From 3bd4cea2cce50d711d2fc79ffff666e88d812ec0 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 15 Oct 2026 14:49:23 +0530 Subject: [PATCH 01/53] feat(capability-registry): let one product span two hosts, and onboard App Automate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product's capabilities all resolve against one `base_url`. App Automate does not fit that: its account-level API (devices, app builds, plan, session records) answers on api-cloud and the Appium hub that drives the device answers on hub-cloud, and neither serves the other's paths. Splitting them into two products would split the `app_session` entity with them — the session the hub creates is the session the REST API reports on, and a caller holding the id would have to know it had changed products to use it. So a product may now declare `hosts: {name: url}` and a capability may name one with `host`. Unset means the product's own `base_url`, so every existing index is unaffected. Override with CAPABILITY_REGISTRY_BASE_URL__HOST_; the `_HOST_` segment is there because the plain `_` form already means an environment, and either reading of a bare `_HUB` would send calls to the wrong origin silently. A host the index does not declare is refused by name rather than falling back, because the fallback returns a 404 that reads like the caller's mistake. `host` stays off the describe surface, as `method` and `path` already do. This is NOT `base_urls`, which is a list of candidates probed to find one region-sharded host. These are several hosts serving the product at once. The App Automate index grows from 9 capabilities to 45: api-cloud device catalogue, private devices, plan and parallel usage, app build upload by link, build listings, session records, session status, mid-session network profiles hub-cloud screenshot, back, orientation, contexts, keyboard state and dismissal, geolocation, file push and pull, key codes and iOS hardware buttons, device properties, and the app lifecycle — launch, close, uninstall, app state, biometric prompts, camera injection and session annotations with three new entities: device, app_build and plan. Every endpoint was probed on a real device before its contract was written, then re-run through the registry's own resolve/bind/auth/egress path on Android 12 and iOS 27. iOS was where the contracts were wrong, every time because behaviour verified on Android had been written up as if it were platform-neutral: - launch_app, terminate_app, uninstall_app, get_app_state and is_app_installed declared only `appId`. iOS requires `bundleId` and answered 400 to all five. - get_geo_location is Android-only; iOS reads its position through get_simulated_location, added here. - get_current_app is Android-only; get_active_app_info is the iOS form and carries the bundleId the lifecycle capabilities need. - hide_keyboard reports success on iOS and leaves the keyboard up. A newline through send_keys closes it; tapping an empty area does not. - press_back works through a standard navigation bar and does nothing on a screen that draws its own header. - set_orientation returns the orientation on Android and null on iOS. - send_keys REPLACES a field's contents; it was documented as appending. read_device_property exposes `getprop` and nothing else: its `pattern` covers the whole executor line, so a command that lists apps or system state cannot be expressed through it and is refused before a request is built. A public phone is shared between customers, and BrowserStack applies a second allow-list of its own on top. egress: a failed body read is no longer reported as an empty success. `fetch` resolves when the headers arrive, so the abort timer can fire while the body is still streaming; that error was swallowed and the call returned its status with a null body. A slow session create therefore came back as a 200 that named no session, while the device really had been allocated — one was left running until its idle timeout. It now reports a failed call, and create_session's guidance says that a start which fails or names no session may still have started one. Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 3163 +++++++++++++++++ src/tools/capability-registry/config.ts | 52 + src/tools/capability-registry/discovery.ts | 2 +- src/tools/capability-registry/egress.ts | 15 +- src/tools/capability-registry/register.ts | 14 +- src/tools/capability-registry/types.ts | 29 + .../tools/capabilityRegistryBodyRead.test.ts | 68 + .../tools/capabilityRegistryNamedHost.test.ts | 118 + 8 files changed, 3455 insertions(+), 6 deletions(-) create mode 100644 capability/appautomate.capability-index.json create mode 100644 tests/tools/capabilityRegistryBodyRead.test.ts create mode 100644 tests/tools/capabilityRegistryNamedHost.test.ts diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json new file mode 100644 index 00000000..3af20dd3 --- /dev/null +++ b/capability/appautomate.capability-index.json @@ -0,0 +1,3163 @@ +{ + "schema_version": 1, + "version": "0.1", + "build_id": "appautomate-demo", + "appautomate": { + "summary": "BrowserStack App Automate: 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, push and pull files, move the simulated GPS, change the network profile, launch, close and uninstall apps, answer biometric prompts and feed images to the camera. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Web browsers are not here, 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.", + "routing_terms": [ + "app automate", + "appium", + "appium session", + "webdriver", + "real device", + "mobile app", + "drive the app", + "tap", + "swipe", + "scroll", + "ui tree", + "page source", + "device list", + "upload app", + "app build", + "parallel sessions", + "plan", + "screenshot", + "session video", + "public link", + "private device", + "network profile", + "launch app", + "close app", + "restart app", + "uninstall app", + "biometric", + "fingerprint", + "face id", + "camera injection", + "qr code", + "annotate", + "keyboard", + "keyboard open", + "home button", + "volume", + "hardware button", + "gps", + "location", + "foreground app", + "adb", + "device property", + "getprop", + "os build" + ], + "base_url": "https://hub-cloud.browserstack.com", + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "create_session", + "method": "POST", + "path": "/wd/hub/session", + "mode": "write", + "entity": "app_session", + "intent": "Start a live Appium session on a real Android or iOS device with an uploaded app \u2014 use this before reading the screen, tapping, typing or swiping in the app.", + "guidance": [ + "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", + "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", + "The session ends itself after idle_timeout seconds with no command (90 by default, 300 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", + "Every session occupies a device and consumes App Automate minutes until it ends: always finish with execute_script setSessionStatus and then delete_session.", + "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", + "device_name and platform_version must be a combination App Automate offers; an unknown pair fails with a session-not-created error naming the capability.", + "Set appium_version to 2.19.0 for any session that will 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.", + "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", + "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", + "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", + "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account." + ], + "body": [ + { + "name": "platform_name", + "type": "string", + "required": true, + "values": [ + "android", + "ios" + ], + "json_path": "/capabilities/alwaysMatch/platformName", + "example": "android" + }, + { + "name": "device_name", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/appium:deviceName", + "description": "Device model exactly as App Automate lists it.", + "example": "Google Pixel 6" + }, + { + "name": "platform_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:platformVersion", + "description": "OS version of that device, as App Automate lists it. Omit to let App Automate pick one for the device.", + "example": "12.0" + }, + { + "name": "app", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/appium:app", + "description": "The bs:// app URL returned by an App Automate upload, or the custom_id given at upload time (the latest app with that id is used).", + "example": "bs://0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "auto_grant_permissions", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/appium:autoGrantPermissions", + "description": "Android only: grant every runtime permission at install so permission dialogs do not appear.", + "example": true + }, + { + "name": "idle_timeout", + "type": "integer", + "minimum": 0, + "maximum": 900, + "default": 90, + "json_path": "/capabilities/alwaysMatch/bstack:options/idleTimeout", + "description": "Seconds the session may sit without a command before App Automate ends it. Raise it when an agent drives the session step by step and may pause to think, or while a human watches; the device is held, and billed, for the whole idle period.", + "example": 300 + }, + { + "name": "appium_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/appiumVersion", + "description": "Appium server version to run. The default is 1.x, which has none of the `mobile:` commands execute_script relies on \u2014 they come back 404 with a list of what that server does support. Set 2.19.0 unless something needs the older server.", + "example": "2.19.0" + }, + { + "name": "project_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", + "description": "Groups sessions on the App Automate dashboard.", + "example": "Checkout flows" + }, + { + "name": "build_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", + "description": "Build the session is filed under on the dashboard.", + "example": "agent-exploration" + }, + { + "name": "session_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", + "description": "Name shown for this session on the dashboard.", + "example": "Add a product to the cart" + }, + { + "name": "interactive_debugging", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/interactiveDebugging", + "description": "Let a person watch and interact with the device from the dashboard while the session runs. Off by default on Android; needs video left on.", + "example": true + }, + { + "name": "video", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/video", + "description": "Record a video of the session, viewable on the dashboard afterwards. On by default; set false to skip recording when the screen shows sensitive data. interactive_debugging stops working if this is false.", + "default": true, + "example": false + }, + { + "name": "automation_name", + "type": "string", + "values": [ + "UiAutomator2", + "XCUITest" + ], + "json_path": "/capabilities/alwaysMatch/appium:automationName", + "description": "Driver to use. Omit to let the hub pick by platform; name it when a session must not fall back to another driver.", + "example": "UiAutomator2" + }, + { + "name": "auto_accept_alerts", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/appium:autoAcceptAlerts", + "description": "iOS only: dismiss system dialogs automatically. Leave false to see and answer permission prompts yourself.", + "example": false + }, + { + "name": "new_command_timeout", + "type": "integer", + "minimum": 0, + "json_path": "/capabilities/alwaysMatch/appium:newCommandTimeout", + "description": "Seconds the Appium server itself waits for the next command before ending the session. Distinct from idle_timeout, which is App Automate's own backstop; the shorter of the two wins.", + "example": 300 + }, + { + "name": "language", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:language", + "description": "Device language code.", + "example": "fr" + }, + { + "name": "locale", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:locale", + "description": "Device locale.", + "example": "fr_CA" + }, + { + "name": "timezone", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/timezone", + "description": "Device time zone, as a city name rather than an offset.", + "example": "Paris" + }, + { + "name": "gps_location", + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?,-?\\d+(\\.\\d+)?$", + "json_path": "/capabilities/alwaysMatch/bstack:options/gpsLocation", + "description": "Starting GPS position as 'latitude,longitude'. Use set_geo_location to move it later.", + "example": "48.8566,2.3522" + }, + { + "name": "network_profile", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkProfile", + "values": [ + "no-network", + "2g-gprs-good", + "2g-gprs-lossy", + "3g-umts-good", + "3g-umts-lossy", + "4g-lte-good", + "4g-lte-advanced-good", + "4g-lte-advanced-lossy" + ], + "description": "Start the session under a named network profile. set_session_network changes it later.", + "example": "3g-umts-good" + }, + { + "name": "network_logs", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkLogs", + "description": "Capture the app's network traffic, readable from the session afterwards.", + "example": true + }, + { + "name": "enable_biometric", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/enableBiometric", + "description": "Instrument the app so a fingerprint, Touch ID or Face ID prompt can be answered during the session. Must be set at start; it cannot be turned on later.", + "example": true + }, + { + "name": "enable_camera_image_injection", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/enableCameraImageInjection", + "description": "Instrument the app so an uploaded image can be fed to its camera. Must be set at start; it cannot be turned on later.", + "example": true + }, + { + "name": "upload_media", + "type": "array", + "json_path": "/capabilities/alwaysMatch/bstack:options/uploadMedia", + "maxItems": 10, + "description": "media:// URLs of files to preload into the device's gallery or storage before the app opens.", + "example": [ + "media://0123456789abcdef0123456789abcdef01234567" + ] + }, + { + "name": "device_tunnel", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/deviceTunnel", + "description": "Open a tunnel to the device. Private (dedicated) devices only; BrowserStack rejects it on shared ones.", + "example": true + } + ], + "returns": [ + "value.sessionId", + "value.capabilities" + ], + "responses": { + "200": { + "description": "Session created.", + "schema": { + "$schema": "NewSessionResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_page_source", + "method": "GET", + "path": "/wd/hub/session/{session_id}/source", + "mode": "read", + "entity": "app_session", + "intent": "Read the full UI tree of the screen currently shown in the app \u2014 use this to see what is on screen and choose what to tap, type into or scroll before acting.", + "guidance": [ + "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.", + "Read it again after every action: the tree is a snapshot, and element ids from find_element go stale once the screen changes.", + "Android bounds are \"[x1,y1][x2,y2]\" in device pixels \u2014 the centre of that box is the coordinate to use with perform_actions.", + "A product-specific attribute is the best locator: resource-id for find_element using \"id\", content-desc for \"accessibility id\".", + "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." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "description": "value.sessionId from create_session.", + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The UI tree as an XML string.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "find_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element", + "mode": "read", + "entity": "app_session", + "intent": "Locate one element on the current screen by id, accessibility id, XPath or UiAutomator selector \u2014 use this to get the element id that click_element and send_keys act on.", + "guidance": [ + "The element id comes back under the W3C key value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", + "Take the selector from get_page_source rather than guessing: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\".", + "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.", + "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\"))." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "id", + "accessibility id", + "xpath", + "class name", + "-android uiautomator", + "-ios predicate string", + "-ios class chain" + ], + "example": "accessibility id" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": "nav-cart" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The matching element reference.", + "schema": { + "$schema": "ElementResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "click_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/click", + "mode": "write", + "entity": "app_session", + "intent": "Tap an element in the app \u2014 use this to press a button, open a menu item, select a list row or focus a field found with find_element.", + "guidance": [ + "element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\".", + "To tap something that has no usable locator, tap its bounds centre with perform_actions instead.", + "Read get_page_source afterwards to confirm the tap had the intended effect \u2014 a 200 only means the tap was delivered.", + "A keyboard covers the lower part of the screen and a tap aimed underneath it lands on the keyboard while still reporting success. When the target sits low, call is_keyboard_shown and then hide_keyboard before tapping." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "value[\"element-6066-11e4-a52e-4f735466cecf\"] from find_element.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Tap delivered; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "send_keys", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/value", + "mode": "write", + "entity": "app_session", + "intent": "Type text into an input field in the app \u2014 use this to fill a search box, login form or any editable field found with find_element.", + "guidance": [ + "REPLACES whatever the field already holds rather than adding to it \u2014 measured on Android with and without refocusing between calls. Read the field back from get_page_source if the resulting value matters, and do not build up a value with successive calls.", + "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", + "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", + "The W3C specification says this appends, and other Appium drivers may; do not rely on either behaviour across platforms without checking. There is no separate way to empty a field on this surface: the standard clear endpoint answers 200 without reliably emptying the field, so typing the value you want is the way to set it.", + "The keyboard is usually left open afterwards: call hide_keyboard before tapping anything below the field, and is_keyboard_shown to confirm it closed.", + "Sending a newline submits the field and closes the keyboard on iOS, which is the most reliable way to dismiss it there." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "body": [ + { + "name": "text", + "type": "string", + "required": true, + "description": "The characters to type.", + "example": "Galaxy S20" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Text typed; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "perform_actions", + "method": "POST", + "path": "/wd/hub/session/{session_id}/actions", + "mode": "write", + "entity": "app_session", + "intent": "Perform touch gestures on the device screen by coordinates \u2014 tap a point, swipe or scroll a list, long-press, or drag \u2014 using W3C pointer actions.", + "guidance": [ + "Coordinates are device pixels 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.", + "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).", + "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's bounds.", + "Long-press: replace the pause duration with 1000 or more.", + "For a scroll without computing coordinates, execute_script with \"mobile: scrollGesture\" is simpler and reports whether more content remains.", + "To go back on an iOS screen that ignores press_back, swipe in from the left edge: a pointer sequence starting at exactly x=0 and moving right in several short steps. A single long move does not register." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "actions", + "type": "array", + "required": true, + "description": "W3C input sources. For touch use one source of type \"pointer\" with parameters {\"pointerType\":\"touch\"} and an actions list of pointerMove / pointerDown / pause / pointerUp items.", + "fields": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "pointer", + "key", + "none" + ] + }, + { + "name": "id", + "type": "string", + "required": true + }, + { + "name": "parameters", + "type": "object" + }, + { + "name": "actions", + "type": "array", + "required": true + } + ], + "example": [ + { + "type": "pointer", + "id": "finger1", + "parameters": { + "pointerType": "touch" + }, + "actions": [ + { + "type": "pointerMove", + "duration": 0, + "x": 540, + "y": 1900 + }, + { + "type": "pointerDown", + "button": 0 + }, + { + "type": "pointerMove", + "duration": 600, + "x": 540, + "y": 700 + }, + { + "type": "pointerUp", + "button": 0 + } + ] + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Gesture performed; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "400": { + "$response": "WebDriverError" + } + } + }, + { + "name": "execute_script", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Run an Appium mobile command or a BrowserStack session command \u2014 scroll a list, press a hardware key, launch or close an app, or mark the session passed or failed.", + "guidance": [ + "Mark the result before ending the session: script 'browserstack_executor: {\"action\":\"setSessionStatus\",\"arguments\":{\"status\":\"passed\",\"reason\":\"Cart shows one item\"}}' with args []. status is passed or failed; a later call overwrites an earlier one, and the status survives delete_session.", + "Keep the setSessionStatus reason to one plain line under 256 characters with no escape sequences such as newlines.", + "Android scroll: script \"mobile: scrollGesture\" with args [{\"left\":100,\"top\":500,\"width\":880,\"height\":1500,\"direction\":\"down\",\"percent\":0.8}] \u2014 value true means more content remains below.", + "Which mobile: commands exist depends on the Appium version the session runs. The default Android version is Appium 1.x, which supports scrollGesture, swipeGesture, flingGesture, dragGesture, longClickGesture, doubleClickGesture and pinch gestures, but NOT mobile: pressKey \u2014 that needs a session started with appium_version 2.x or later.", + "An unsupported command fails with 404 \"unknown command\", and the message lists every mobile: command the session does support \u2014 read it rather than retrying variants." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "Either \"mobile: \" or a string beginning \"browserstack_executor: \" followed by a JSON object with action and arguments.", + "example": "mobile: scrollGesture" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Arguments for the command \u2014 for mobile: commands a one-element array holding an options object; for browserstack_executor an empty array.", + "example": [ + { + "left": 100, + "top": 500, + "width": 880, + "height": 1500, + "direction": "down", + "percent": 0.8 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_window_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "read", + "entity": "app_session", + "intent": "Get the device screen size in pixels \u2014 use this to plan gesture coordinates, or as a cheap keep-alive so an idle session is not ended while deciding the next step.", + "guidance": [ + "On Android the height excludes the system bars, so it can be smaller than a screenshot's height; page-source bounds use this same space." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "Screen rectangle.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "delete_session", + "method": "DELETE", + "path": "/wd/hub/session/{session_id}", + "mode": "write", + "entity": "app_session", + "intent": "End a live App Automate session and release the device \u2014 use this when the flow is finished, after marking the session passed or failed.", + "guidance": [ + "There is no way to resume an ended session; any further step needs create_session again, which reinstalls the app.", + "The session's video, logs and pass/fail status stay on the App Automate dashboard after it ends.", + "Call setSessionStatus through execute_script first \u2014 once the session has ended that route is gone." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Session ended; state is \"success\" and value is empty.", + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "description": "\"success\" when the session was ended." + }, + "value": { + "type": "string", + "description": "Empty string." + } + } + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "list_devices", + "method": "GET", + "path": "/app-automate/devices.json", + "mode": "read", + "entity": "device", + "intent": "List the real Android and iOS devices this account can run an app session on, with the exact device names and OS versions a session accepts.", + "guidance": [ + "The device and os_version strings must be passed to create_session exactly as they appear here; a near miss is rejected rather than substituted.", + "One model appears once per OS version it is offered on, so the same device name repeats across rows.", + "This is what the account is entitled to, not what is free right now \u2014 a listed device can still be busy when a session starts.", + "There is no filtering on the wire: the whole catalogue comes back and the caller narrows it.", + "realMobile is true for physical devices and absent or false for emulators." + ], + "returns": [ + "device", + "os", + "os_version", + "device_tier", + "device_limit", + "realMobile" + ], + "responses": { + "200": { + "description": "Every device the account can use.", + "schema": { + "$schema": "DeviceList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_dedicated_devices", + "method": "GET", + "path": "/app-automate/dedicated_devices.json", + "mode": "read", + "entity": "device", + "intent": "List the account's private (dedicated) devices \u2014 use this to find devices reserved for this team rather than shared with other customers.", + "guidance": [ + "An account without a private device cloud gets an empty array, which is an answer and not an error.", + "Rows come back as the product sends them and carry more fields than the public catalogue; read the keys rather than assuming the public shape.", + "Private devices are the only ones a device tunnel can be opened on." + ], + "returns": [ + "device", + "os", + "os_version" + ], + "responses": { + "200": { + "description": "The account's private devices, or an empty array.", + "schema": { + "$schema": "DedicatedDeviceList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_plan", + "method": "GET", + "path": "/app-automate/plan.json", + "mode": "read", + "entity": "plan", + "intent": "Show the App Automate plan and how many parallel sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "guidance": [ + "A session start blocks and then queues when parallel_sessions_running has reached parallel_sessions_max_allowed, so a start that seems slow is usually this.", + "team_parallel_sessions_max_allowed is the ceiling shared across everyone on the team and can be lower than the account's own maximum.", + "The counts are account-wide and include sessions other people and CI started, not only this caller's." + ], + "returns": [ + "automate_plan", + "parallel_sessions_running", + "parallel_sessions_max_allowed", + "team_parallel_sessions_max_allowed", + "queued_sessions", + "queued_sessions_max_allowed", + "terminal_access" + ], + "responses": { + "200": { + "description": "Plan name and parallel usage.", + "schema": { + "$schema": "Plan" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "upload_app_build", + "method": "POST", + "path": "/app-automate/upload", + "mode": "write", + "entity": "app_build", + "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.", + "guidance": [ + "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.", + "The url must be https and reachable by BrowserStack without credentials; a link behind a login fails on their side, not the caller's.", + "Pass custom_id to give the build a stable name, then later sessions can use that name in place of the bs:// URL and always get the newest build under it.", + "Re-uploading under the same custom_id adds a new build rather than replacing the old one; both stay listed.", + "Builds expire after 30 days, so a bs:// URL held from an earlier run can stop working.", + "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file." + ], + "body": [ + { + "name": "url", + "type": "string", + "required": true, + "format": "uri", + "description": "Public https link BrowserStack downloads the build from.", + "example": "https://example.com/builds/app-release.apk" + }, + { + "name": "custom_id", + "type": "string", + "maxLength": 100, + "description": "Stable name for the build, reusable in place of the bs:// URL.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_url", + "custom_id", + "shareable_id" + ], + "responses": { + "200": { + "description": "The uploaded build.", + "schema": { + "$schema": "AppUpload" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_app_builds", + "method": "GET", + "path": "/app-automate/recent_apps", + "mode": "read", + "entity": "app_build", + "intent": "List the app builds this user has uploaded, newest first, with their bs:// URLs \u2014 use this to reuse an existing build instead of uploading again.", + "guidance": [ + "An account with no uploads answers with a message object or a 404 rather than an empty array; treat both as 'nothing uploaded yet'.", + "This is only the calling user's own uploads \u2014 use list_team_app_builds for everyone on the team.", + "app_url is what create_session takes; app_id is an internal identifier and is not accepted there." + ], + "query": [ + { + "name": "limit", + "type": "integer", + "minimum": 1, + "maximum": 100, + "example": 20, + "description": "How many builds to return." + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "Recently uploaded builds, newest first.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_team_app_builds", + "method": "GET", + "path": "/app-automate/recent_group_apps", + "mode": "read", + "entity": "app_build", + "intent": "List app builds uploaded by anyone on the team, newest first \u2014 use this to find a colleague's or CI's build rather than only your own.", + "guidance": [ + "Same rows as list_app_builds, widened to the whole team; a build you uploaded appears in both.", + "An empty team history answers with a message object or a 404 rather than an empty array." + ], + "query": [ + { + "name": "limit", + "type": "integer", + "minimum": 1, + "maximum": 100, + "example": 20, + "description": "How many builds to return." + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "The team's recently uploaded builds.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_app_builds_by_custom_id", + "method": "GET", + "path": "/app-automate/recent_apps/{custom_id}", + "mode": "read", + "entity": "app_build", + "intent": "List every build uploaded under one custom id, newest first \u2014 use this to resolve a stable build name to the bs:// URL of its latest version.", + "guidance": [ + "The newest build under the id is the first row; passing the custom id itself to create_session has the same effect and is usually simpler.", + "An unknown custom id answers with a message object or a 404, not an empty array." + ], + "path_params": [ + { + "name": "custom_id", + "type": "string", + "required": true, + "description": "The stable name a build was uploaded under.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "Builds under that custom id.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_session", + "method": "GET", + "path": "/app-automate/sessions/{session_id}.json", + "mode": "read", + "entity": "app_session", + "intent": "Get a session's record: its status, device, duration, the shareable public link, the video, and which app build ran \u2014 use this for the links to share or to check how a finished session ended.", + "guidance": [ + "browser_url is the session on the dashboard and needs a BrowserStack login \u2014 this is the link to give someone on the team who wants to watch or review the run.", + "public_url opens without any login, so it is the one to send outside the team; it carries an auth token, so treat it as a secret and do not paste it anywhere public.", + "video_url is only complete once the session has ended; during a run it may be missing or still filling.", + "This works for any session on the account, including ones this caller did not start.", + "status is the status that was set on the session; browserstack_status is what BrowserStack itself observed, and the two can disagree when a run was marked by hand.", + "app_details carries the build that ran, including its custom id, which is how to tell which version a past session used." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "status", + "reason", + "device", + "os", + "os_version", + "duration", + "browser_url", + "public_url", + "video_url", + "build_name", + "project_name", + "created_at", + "app_details", + "browserstack_status", + "hashed_id", + "name" + ], + "responses": { + "200": { + "description": "The session record.", + "schema": { + "$schema": "SessionDetails" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_status", + "method": "PUT", + "path": "/app-automate/sessions/{session_id}.json", + "mode": "write", + "entity": "app_session", + "intent": "Mark a session passed or failed with a reason, so the dashboard shows the outcome \u2014 use this to record the verdict of what was driven on the device.", + "guidance": [ + "Works after the session has ended as well as during it, so the verdict can be set once the result is known.", + "Only 'passed' and 'failed' are accepted; any other value is rejected with a 422.", + "reason is truncated at 255 characters.", + "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run.", + "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." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "values": [ + "passed", + "failed" + ], + "example": "passed" + }, + { + "name": "reason", + "type": "string", + "maxLength": 255, + "description": "Short explanation shown beside the status.", + "example": "Checkout completed and the cart showed the right total" + } + ], + "returns": [ + "status", + "reason", + "hashed_id", + "name", + "device", + "os", + "os_version", + "build_name", + "project_name" + ], + "responses": { + "200": { + "description": "The updated session record.", + "schema": { + "$schema": "SessionStatusUpdate" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_network", + "method": "PUT", + "path": "/app-automate/sessions/{session_id}/update_network.json", + "mode": "write", + "entity": "app_session", + "intent": "Change the network conditions of a running session to a named profile \u2014 use this to see how the app behaves on a slow or absent connection.", + "guidance": [ + "Mid-session changes take a named profile only; custom bandwidth or latency values are rejected with a 422.", + "'reset' restores the device's normal connection.", + "The session must still be running; this cannot be applied to a finished one.", + "To start a session already under a profile, pass network_profile to create_session instead." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "networkProfile", + "type": "string", + "required": true, + "values": [ + "no-network", + "2g-gprs-good", + "2g-gprs-lossy", + "3g-umts-good", + "3g-umts-lossy", + "4g-lte-good", + "4g-lte-advanced-good", + "4g-lte-advanced-lossy", + "reset" + ], + "example": "3g-umts-good" + } + ], + "returns": [ + "message" + ], + "responses": { + "200": { + "description": "Acknowledgement that the profile was applied.", + "schema": { + "$schema": "MessageResponse" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "take_screenshot", + "method": "GET", + "path": "/wd/hub/session/{session_id}/screenshot", + "mode": "read", + "entity": "app_session", + "intent": "Take a screenshot of what is on the device right now, as a base64 PNG \u2014 use this to see the screen as a user would, including anything the UI tree does not describe.", + "guidance": [ + "The image is returned inline as base64 in value and a phone screen runs to hundreds of kilobytes, so fetch it only when the picture is needed and prefer get_page_source for deciding what to act on.", + "Screenshot pixels are the device's full resolution and can be taller than the rectangle get_window_rect reports, because that excludes the system bars; scale before reusing screenshot coordinates for gestures.", + "A screen the app marks secure comes back black, which is the app's doing and not a failure." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded PNG of the screen.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "press_back", + "method": "POST", + "path": "/wd/hub/session/{session_id}/back", + "mode": "write", + "entity": "app_session", + "intent": "Go back one screen \u2014 use this to leave a detail page, close a dialog, or undo a navigation step.", + "guidance": [ + "On Android this is the system back button and is the reliable way back.", + "On iOS this works on a screen that uses a standard navigation bar, where it acts on the bar's own back control. A screen that draws its own header instead is left where it was, and the call still answers 200 \u2014 so confirm from the page source rather than from the status.", + "Pressing back on the first screen of an app can send the app to the background and show the home screen; read the page source afterwards to confirm where you are.", + "The response is null on success and says nothing about whether the screen actually changed.", + "When an iOS screen ignores it, swipe in from the left edge with perform_actions: start at exactly x=0 and move in several short steps. One long move, or a start at x=2, is ignored." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_orientation", + "method": "GET", + "path": "/wd/hub/session/{session_id}/orientation", + "mode": "read", + "entity": "app_session", + "intent": "Read whether the device is currently portrait or landscape.", + "guidance": [ + "The value is upper case, PORTRAIT or LANDSCAPE." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current orientation.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_orientation", + "method": "POST", + "path": "/wd/hub/session/{session_id}/orientation", + "mode": "write", + "entity": "app_session", + "intent": "Rotate the device to portrait or landscape \u2014 use this to test a layout in the other orientation.", + "guidance": [ + "An app that locks its orientation refuses the rotation with a 400 'invalid element state' saying the screen could not be changed; that is the app's choice, not a broken call.", + "The value must be upper case, PORTRAIT or LANDSCAPE.", + "Element positions and the window rectangle change after a rotation, so re-read the page source before acting on coordinates.", + "The response carries the new orientation on Android and null on iOS, so treat a 200 rather than the value as the sign it worked, and read get_orientation if the value matters." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "orientation", + "type": "string", + "required": true, + "values": [ + "PORTRAIT", + "LANDSCAPE" + ], + "example": "LANDSCAPE" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The orientation now in effect.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "list_contexts", + "method": "GET", + "path": "/wd/hub/session/{session_id}/contexts", + "mode": "read", + "entity": "app_session", + "intent": "List the contexts available on screen \u2014 the native app and any web views \u2014 so a hybrid app's web content can be driven.", + "guidance": [ + "NATIVE_APP is always present; a web view appears as a WEBVIEW_ prefixed name only while one is actually on screen.", + "A screen with no web view returns NATIVE_APP alone, which means the content is native and not that something failed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The available context names.", + "schema": { + "$schema": "StringListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_context", + "method": "POST", + "path": "/wd/hub/session/{session_id}/context", + "mode": "write", + "entity": "app_session", + "intent": "Switch between the native app and a web view \u2014 use this when the part of the screen you need to drive is web content inside the app.", + "guidance": [ + "Use a name exactly as list_contexts returned it; an invented WEBVIEW name is rejected.", + "In a web view, selectors change: find_element then takes css selector and the page source is HTML rather than the native UI tree.", + "Switch back to NATIVE_APP before driving native controls again.", + "A web view's name carries the process id of the app that owns it, so it differs between sessions and after the app restarts. Read it from list_contexts each time rather than reusing one seen earlier." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Context name from list_contexts.", + "example": "NATIVE_APP" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "hide_keyboard", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/hide_keyboard", + "mode": "write", + "entity": "app_session", + "intent": "Close the on-screen keyboard \u2014 use this when it covers a button or field you need to tap after typing.", + "guidance": [ + "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", + "On iOS this can be refused by a keyboard the app controls; the fallback is tapping an empty area or the keyboard's own Done key.", + "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", + "On iOS this frequently leaves the keyboard open while still answering successfully, so always confirm with is_keyboard_shown. Two things do close it there: send_keys with a newline, or execute_script with 'mobile: hideKeyboard' and args [{\"keys\":[\"return\"]}]. Tapping an empty area of the screen does not, and neither does that command with no keys or with [\"Done\"], which fail as \"invalid element state\".", + "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when a keyboard was dismissed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_geo_location", + "method": "POST", + "path": "/wd/hub/session/{session_id}/location", + "mode": "write", + "entity": "app_session", + "intent": "Move the device's simulated GPS position \u2014 use this to test location-dependent behaviour without starting a new session.", + "guidance": [ + "The response is a position reading that does not reliably reflect the value just sent: on Android it has come back as the device's previous location. Confirm it afterwards \u2014 with get_geo_location on Android, or get_simulated_location on iOS.", + "latitude, longitude and altitude must be nested under a location object.", + "altitude is required by the wire format even when it does not matter; send 0.", + "The app may cache its last fix, so a change can need the app to request location again before it is visible.", + "Latitude and longitude are applied as sent; altitude is not preserved and reads back as 0, so do not rely on it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "location", + "type": "object", + "required": true, + "description": "The position to move to.", + "fields": [ + { + "name": "latitude", + "type": "number", + "required": true, + "minimum": -90, + "maximum": 90, + "example": 48.8566 + }, + { + "name": "longitude", + "type": "number", + "required": true, + "minimum": -180, + "maximum": 180, + "example": 2.3522 + }, + { + "name": "altitude", + "type": "number", + "required": true, + "example": 0 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "A position reading, which may be the location before the change rather than after it.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_geo_location", + "method": "GET", + "path": "/wd/hub/session/{session_id}/location", + "mode": "read", + "entity": "app_session", + "intent": "Read the device's current simulated GPS position \u2014 use this to confirm a location change took effect.", + "guidance": [ + "Android only \u2014 iOS answers 500, saying the command is not supported. Read the position on iOS with get_simulated_location instead.", + "On Android this is the authoritative reading, and worth making when set_geo_location's own response is in doubt." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current position.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "push_file", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/push_file", + "mode": "write", + "entity": "app_session", + "intent": "Write a file onto the device from base64 content \u2014 use this to put test data, a fixture or a document where the app will read it.", + "guidance": [ + "data must be base64; to push text, encode it first. A file on the caller's own disk cannot be sent as a path, only as its encoded content.", + "Android takes an absolute path such as /sdcard/Download/data.json.", + "iOS takes an app-container path in the form @bundle.id:documents/data.json and can only write inside apps that allow it.", + "The file lasts only as long as the session; the device is wiped afterwards.", + "The response is null on success and does not confirm the file is readable by the app." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Destination path on the device.", + "example": "/sdcard/Download/data.json" + }, + { + "name": "data", + "type": "string", + "required": true, + "description": "Base64-encoded file content.", + "example": "aGVsbG8=" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "pull_file", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/pull_file", + "mode": "read", + "entity": "app_session", + "intent": "Read a file off the device as base64 \u2014 use this to check what the app wrote, such as a log, an export or a saved document.", + "guidance": [ + "The content comes back base64-encoded in value and must be decoded; a large file is returned whole, so pull only what is needed.", + "Paths are the same as for push_file: absolute on Android, @bundle.id:documents/... on iOS.", + "A missing path is a 404 rather than empty content.", + "This is POST because the path travels in the body; it changes nothing on the device." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Path to read on the device.", + "example": "/sdcard/Download/data.json" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded file content.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "press_keycode", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/press_keycode", + "mode": "write", + "entity": "app_session", + "intent": "Press a hardware or system key on an Android device by its key code \u2014 use this for home, enter, delete, volume or any key with no on-screen control.", + "guidance": [ + "Android only. iOS has no equivalent: use press_back to go back, and execute_script with 'mobile: pressButton' for home and the volume keys.", + "Common codes: 3 home, 4 back, 66 enter, 67 delete, 61 tab, 82 menu, 84 search, 187 app switch, 24 volume up, 25 volume down, 26 power.", + "press_back is the clearer way to send 4.", + "The response is null on success and says nothing about what the key did.", + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or 'mobile: pressButton' there." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "keycode", + "type": "integer", + "required": true, + "minimum": 0, + "maximum": 400, + "description": "Android KeyEvent code.", + "example": 66 + }, + { + "name": "metastate", + "type": "integer", + "minimum": 0, + "description": "Modifier bitmask, e.g. 1 for shift.", + "example": 1 + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "405": { + "description": "The command is not implemented on this platform, as \"unknown method\".", + "$response": "WebDriverError" + } + } + }, + { + "name": "get_current_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Find out which app is in the foreground right now \u2014 use this to confirm the app under test is showing and not the home screen or another app.", + "guidance": [ + "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.", + "This is the quickest way to learn the app under test's own package id, which launch_app, terminate_app and uninstall_app all need.", + "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.", + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: getCurrentPackage" + ], + "example": "mobile: getCurrentPackage" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The foreground app's package name.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "405": { + "description": "The command is not implemented on this platform, as \"unknown method\".", + "$response": "WebDriverError" + } + } + }, + { + "name": "launch_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Bring an app to the foreground, starting it if it is not running \u2014 use this to open the app under test, return to it, or switch to another installed app.", + "guidance": [ + "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.", + "Launching an app that is already in the foreground does nothing and still reports success.", + "To restart an app from a clean state, call terminate_app first and then this.", + "Succeeding means the app was activated, not that it finished drawing; read the page source before acting.", + "The app must be installed \u2014 a package that is not there fails rather than installing it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: activateApp" + ], + "example": "mobile: activateApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "terminate_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Close an app and stop it running \u2014 use this to end a session inside the app, or as the first half of a restart.", + "guidance": [ + "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.", + "Returns true when the app was running and false when it was not; false is not an error.", + "The app stays installed and keeps its data; only the running process is stopped.", + "This does not clear state between runs \u2014 relaunching shows the app where it was unless the app itself resets." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: terminateApp" + ], + "example": "mobile: terminateApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when a running app was stopped.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_app_state", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Check whether an app is installed, running or in the foreground \u2014 use this to confirm a launch or a close actually took effect.", + "guidance": [ + "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The value is a number: 0 not installed, 1 installed but not running, 2 running in the background and suspended, 3 running in the background, 4 running in the foreground.", + "4 is the only state in which the app is on screen and can be driven.", + "The same scale applies on both platforms. Sending an app to the background with press_button home leaves it at 2, and launching it again returns it to 4." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: queryAppState" + ], + "example": "mobile: queryAppState" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The app's state as a number from 0 to 4.", + "schema": { + "$schema": "IntegerValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "is_app_installed", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Check whether an app is installed on the device \u2014 use this before launching one, or to confirm an uninstall.", + "guidance": [ + "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "get_app_state answers this and more in one call; prefer it when the running state also matters." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: isAppInstalled" + ], + "example": "mobile: isAppInstalled" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the app is installed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "uninstall_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Remove an app and its data from the device for the rest of the session \u2014 use this to test a first-run or upgrade path from a clean state.", + "guidance": [ + "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The app and everything it stored are gone for the rest of the session and nothing here puts them back; confirm with the user before removing the app under test.", + "It is recoverable only in the sense that the device is wiped when the session ends and the next session installs the app fresh.", + "Returns true when an app was removed and false when it was not installed.", + "Removing the app under test leaves the session on the home screen with nothing to drive." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: removeApp" + ], + "example": "mobile: removeApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when an app was removed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "simulate_biometric", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Answer a fingerprint, Touch ID or Face ID prompt with a success or a failure \u2014 use this to get past biometric authentication in the app.", + "guidance": [ + "The session must have been started with enable_biometric true; it cannot be turned on once the session is running.", + "Call it only while the prompt is actually on screen \u2014 with no prompt showing it fails with BROWSERSTACK_COMMAND_EXECUTION_FAILED.", + "biometricMatch takes \"pass\" or \"fail\"; \"fail\" is how to exercise the app's rejection path.", + "Read the page source afterwards to see what the app did with the answer.", + "Every failure seen so far reports the same BROWSERSTACK_COMMAND_EXECUTION_FAILED regardless of cause, so it does not distinguish 'no prompt on screen' from 'the session was not started with enable_biometric'. Check both." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the biometric action, built as: browserstack_executor: {\"action\":\"biometric\",\"arguments\":{\"biometricMatch\":\"\"}}", + "example": "browserstack_executor: {\"action\":\"biometric\",\"arguments\":{\"biometricMatch\":\"pass\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Acknowledgement that the prompt was answered.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "inject_camera_image", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Make the app's camera see a chosen image instead of the device camera \u2014 use this to feed a QR code, barcode or document to a scanning flow.", + "guidance": [ + "The session must have been started with enable_camera_image_injection true; it cannot be turned on later.", + "imageUrl must be a media:// URL of a file already uploaded to BrowserStack. This surface cannot upload media, because that endpoint takes a file from disk rather than a link \u2014 the URL has to come from elsewhere.", + "An unusable URL fails with BROWSERSTACK_INVALID_IMAGE_URL_PASSED rather than silently showing nothing.", + "Inject before the app opens its camera; a camera already running keeps the previous source." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the cameraImageInjection action, built as: browserstack_executor: {\"action\":\"cameraImageInjection\",\"arguments\":{\"imageUrl\":\"media://\"}}", + "example": "browserstack_executor: {\"action\":\"cameraImageInjection\",\"arguments\":{\"imageUrl\":\"media://0123456789abcdef0123456789abcdef01234567\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Acknowledgement that the image was accepted.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "annotate_session", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Write a note into the session's timeline so it appears beside the video on the dashboard \u2014 use this to label what each step was doing for whoever reviews the run.", + "guidance": [ + "The note is attached at the moment of the call, so it lines up with that point in the video.", + "level takes info, debug, warn or error and changes how the note is shown.", + "This records commentary only; it has no effect on the device or on the session's pass/fail status." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the annotate action, built as: browserstack_executor: {\"action\":\"annotate\",\"arguments\":{\"data\":\"\",\"level\":\"\"}}", + "example": "browserstack_executor: {\"action\":\"annotate\",\"arguments\":{\"data\":\"Tapped Add to cart\",\"level\":\"info\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The note that was recorded.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "is_keyboard_shown", + "method": "GET", + "path": "/wd/hub/session/{session_id}/appium/device/is_keyboard_shown", + "mode": "read", + "entity": "app_session", + "intent": "Check whether the on-screen keyboard is up \u2014 use this before tapping something low on the screen, or to confirm a field really took focus.", + "guidance": [ + "A keyboard covers the lower part of the screen, so a tap aimed at a control under it lands on the keyboard and still reports success. Check here first when the target sits low.", + "True right after typing means the field took focus; false means the tap that was meant to focus it did not land.", + "Use hide_keyboard to close it, then check again rather than assuming it closed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the keyboard is on screen.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "press_button", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Press a hardware button on an iPhone or iPad \u2014 use this for home and the volume keys, which have no on-screen control.", + "guidance": [ + "iOS only; press_keycode is the Android equivalent and the two are not interchangeable.", + "The button names carry no separator: home, volumeup, volumedown.", + "Pressing home sends the app to the background \u2014 bring it back with launch_app, passing bundleId rather than appId, and confirm from the page source that the app is in front again.", + "The response is null on success and says nothing about what the button did." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: pressButton" + ], + "example": "mobile: pressButton" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the button.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "values": [ + "home", + "volumeup", + "volumedown" + ], + "example": "home" + } + ], + "example": [ + { + "name": "home" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_simulated_location", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Read the simulated GPS position on an iPhone or iPad \u2014 use this to confirm a location change took effect.", + "guidance": [ + "iOS only; get_geo_location is the Android equivalent and is refused here.", + "Every field comes back null until a position has been set in this session, which means nothing has been simulated rather than that the device is at zero.", + "After set_geo_location this returns exactly the latitude, longitude and altitude that were sent, so it is the way to confirm what that call's own response does not.", + "altitude reads back as 0 whatever was sent; only latitude and longitude are meaningful." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: getSimulatedLocation" + ], + "example": "mobile: getSimulatedLocation" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The simulated position, with null fields when none has been set.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_active_app_info", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Find out which app is in the foreground on an iPhone or iPad, with its bundle id \u2014 use this to confirm the app under test is showing rather than the home screen.", + "guidance": [ + "iOS only; get_current_app is the Android equivalent and is refused here.", + "bundleId is the value the app lifecycle capabilities need on iOS, so this is how to learn it mid-session.", + "name is often empty; read bundleId rather than relying on it.", + "The answer is an object, where the Android equivalent returns a bare package-name string." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: activeAppInfo" + ], + "example": "mobile: activeAppInfo" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The foreground app.", + "schema": { + "$schema": "ActiveAppInfo" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "read_device_property", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Read one Android system property from the device, such as its OS build or manufacturer \u2014 use this for details the UI does not show.", + "guidance": [ + "Android only. iOS has no adb and the call is rejected there.", + "Only `getprop ` can be expressed: the accepted form is fixed, and a command that lists apps or system state cannot be sent through this capability. A public phone is shared between customers, and commands such as `pm list packages` or `dumpsys` report what other people's sessions left behind.", + "BrowserStack applies its own allow-list on top of this one and accepts only some properties on a shared phone; ro.build.version.release and ro.product.manufacturer are accepted, while a bare getprop is not. A property it refuses comes back as an execution failure rather than an empty value.", + "The device name and OS version are already in the session record, so get_session answers those without this.", + "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "pattern": "^browserstack_executor: \\{\"action\":\"adbShell\",\"arguments\":\\{\"command\":\"getprop [A-Za-z0-9_.\\-]+\"\\}\\}$", + "description": "The executor line carrying a single `getprop ` command. Any other command, or any change to the surrounding form, is refused before the request is sent.", + "example": "browserstack_executor: {\"action\":\"adbShell\",\"arguments\":{\"command\":\"getprop ro.build.version.release\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The property's value as a string.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + } + ], + "schemas": { + "NewSessionResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The session id every other app_session capability takes." + }, + "capabilities": { + "type": "object", + "description": "Open map of the capabilities the device actually started with; keys are capability names." + } + } + } + } + }, + "StringValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "A string, or null where the platform returns no value - set_orientation answers null on iOS." + } + } + }, + "ElementResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + }, + "NullValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "null" + } + } + }, + "AnyValueResponse": { + "type": "object", + "properties": { + "value": { + "description": "Command-specific result: null, a boolean, a string or an object." + } + } + }, + "RectResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + } + }, + "WebDriverErrorBody": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "W3C error code such as \"no such element\" or \"invalid session id\"." + }, + "message": { + "type": "string" + } + } + } + } + }, + "BooleanValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + } + } + }, + "StringListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LocationResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "altitude": { + "type": "number", + "description": "Often a negligible non-zero value such as 5e-324 rather than exactly 0." + } + } + } + } + }, + "DeviceList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Device name, to be passed to create_session exactly as given." + }, + "os": { + "type": "string", + "description": "android or ios." + }, + "os_version": { + "type": "string" + }, + "device_tier": { + "type": "string", + "description": "Tier 1 to Tier 4; tiers group devices by how widely used they are, not by capability." + }, + "device_limit": { + "type": "integer", + "description": "How many sessions the account may run on this model at once." + }, + "realMobile": { + "type": "boolean", + "description": "True for a physical device." + } + } + } + }, + "DedicatedDeviceList": { + "type": "array", + "description": "Private devices, returned as the product sends them; rows carry more fields than the public catalogue.", + "items": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "automate_plan": { + "type": "string" + }, + "parallel_sessions_running": { + "type": "integer", + "description": "Account-wide, including sessions other people and CI started." + }, + "parallel_sessions_max_allowed": { + "type": "integer" + }, + "team_parallel_sessions_max_allowed": { + "type": "integer", + "description": "Ceiling shared by the whole team; can be lower than the account maximum." + }, + "queued_sessions": { + "type": "integer" + }, + "queued_sessions_max_allowed": { + "type": "integer" + }, + "terminal_access": { + "type": "string" + } + } + }, + "AppUpload": { + "type": "object", + "properties": { + "app_url": { + "type": "string", + "description": "The bs:// URL create_session installs." + }, + "custom_id": { + "type": "string" + }, + "shareable_id": { + "type": "string", + "description": "username/custom_id form, usable by teammates." + } + } + }, + "AppBuildList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "app_version": { + "type": "string" + }, + "app_url": { + "type": "string", + "description": "The bs:// URL create_session takes." + }, + "app_id": { + "type": "string", + "description": "Internal identifier; not accepted by create_session." + }, + "custom_id": { + "type": "string" + }, + "shareable_id": { + "type": "string" + }, + "uploaded_at": { + "type": "string" + } + } + } + }, + "AppDetails": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "app_version": { + "type": "string" + }, + "app_url": { + "type": "string" + }, + "app_custom_id": { + "type": "string" + }, + "app_filename": { + "type": "string" + }, + "uploaded_at": { + "type": "string" + } + } + }, + "SessionStatusUpdate": { + "type": "object", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + } + } + } + }, + "description": "The session record after the change, wrapped in automation_session." + }, + "SessionDetails": { + "type": "object", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "duration": { + "type": "integer", + "description": "Seconds; null while the session is still running." + }, + "created_at": { + "type": "string" + }, + "public_url": { + "type": "string", + "description": "Shareable link that needs no login; it carries an auth token, so treat it as a secret." + }, + "video_url": { + "type": "string", + "description": "Complete only once the session has ended." + }, + "browserstack_status": { + "type": "string", + "description": "What BrowserStack observed, which can differ from the status that was set." + }, + "app_details": { + "$schema": "AppDetails" + }, + "browser_url": { + "type": "string", + "description": "The session on the dashboard; needs a BrowserStack login." + } + } + } + }, + "description": "The session record, wrapped in automation_session." + }, + "MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "IntegerValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "integer" + } + } + }, + "ActiveAppInfo": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "bundleId": { + "type": "string" + }, + "pid": { + "type": "integer", + "description": "Process id; a web view context name is built from it." + }, + "name": { + "type": "string", + "description": "Often empty." + } + } + } + } + } + }, + "responses": { + "WebDriverError": { + "description": "W3C WebDriver error; read value.error for the code.", + "schema": { + "$schema": "WebDriverErrorBody" + } + }, + "Unauthorized": { + "description": "The BrowserStack username or access key was rejected." + } + }, + "paging": {}, + "entities": { + "app_session": { + "title": "App Session", + "description": "A live Appium session on one real device running one installed app, driven command by command until it ends.", + "aliases": [ + "appium session", + "app automate session", + "device session", + "live session", + "webdriver session", + "mobile session", + "test session" + ], + "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element, valid only until the screen changes.", + "key_facts": [ + "A session holds a real device and consumes App Automate minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "The app must already be uploaded before a session can start; a build is uploaded by giving a public download link, not by sending a file.", + "Actions are decided from the UI tree: read the page source, act, then read it again to confirm the effect.", + "Each session is recorded with video and logs on the App Automate dashboard, including its passed or failed status.", + "A session's record outlives the session: its status, shareable link and video stay available on the account after the device is released.", + "Apps can be launched, closed and removed during a session, and anything removed comes back only when a later session installs it again." + ], + "capabilities": [ + "create_session", + "get_page_source", + "find_element", + "click_element", + "send_keys", + "perform_actions", + "execute_script", + "get_window_rect", + "delete_session", + "take_screenshot", + "press_back", + "get_orientation", + "set_orientation", + "list_contexts", + "set_context", + "hide_keyboard", + "set_geo_location", + "get_geo_location", + "push_file", + "pull_file", + "press_keycode", + "get_session", + "set_session_status", + "set_session_network", + "get_current_app", + "launch_app", + "terminate_app", + "get_app_state", + "is_app_installed", + "uninstall_app", + "simulate_biometric", + "inject_camera_image", + "annotate_session", + "is_keyboard_shown", + "press_button", + "get_simulated_location", + "get_active_app_info", + "read_device_property" + ] + }, + "device": { + "title": "Device", + "description": "A real phone or tablet model in the BrowserStack cloud, identified by its name and OS version, that a session can run on.", + "aliases": [ + "device", + "real device", + "phone", + "handset", + "mobile device", + "android device", + "ios device", + "iphone", + "tablet", + "private device", + "dedicated device", + "device cloud" + ], + "id_convention": "A device is addressed by its name and OS version as two strings, exactly as the device list spells them; there is no numeric id and no abbreviation is accepted.", + "key_facts": [ + "Public devices are shared with other customers and are taken for the length of a session, so one can be busy even though the account is entitled to it.", + "Private (dedicated) devices are reserved for the team and are the only ones a device tunnel can be opened on.", + "A device's tier groups it by how widely used the model is, not by what it can do.", + "The catalogue changes as models are added and retired, so a device name held from an earlier run can stop being offered." + ], + "capabilities": [ + "list_devices", + "list_dedicated_devices" + ] + }, + "app_build": { + "title": "App Build", + "description": "An uploaded app binary stored on BrowserStack, addressed by a bs:// URL, that a session installs on the device.", + "aliases": [ + "app", + "app build", + "build", + "binary", + "apk", + "aab", + "ipa", + "app upload", + "uploaded app", + "app url" + ], + "id_convention": "app_url is a bs:// URL of 40 hex characters. A build may also carry a custom_id, a name chosen at upload that always resolves to the newest build under it.", + "key_facts": [ + "A build must be uploaded before any session can install it; a file on the caller's own machine cannot be sent through this surface, only a public download link.", + "Builds expire 30 days after upload, so a bs:// URL kept from an earlier run can stop working.", + "Uploading under a custom id that already exists adds another build rather than replacing the old one, and both stay listed.", + "A build uploaded by one person is visible to the whole team." + ], + "capabilities": [ + "upload_app_build", + "list_app_builds", + "list_team_app_builds", + "get_app_builds_by_custom_id" + ] + }, + "plan": { + "title": "Plan", + "description": "The account's App Automate subscription and how much of its parallel-session allowance is in use.", + "aliases": [ + "plan", + "subscription", + "parallel sessions", + "parallels", + "concurrency", + "quota", + "capacity", + "limits", + "usage" + ], + "id_convention": "There is one plan per account; it is not addressed by an id.", + "key_facts": [ + "Parallel-session counts are account-wide and include sessions started by other people and by CI, not only the caller's.", + "A team can have a lower ceiling than the account as a whole.", + "Starting a session past the limit queues it rather than refusing it, which looks like a slow start." + ], + "capabilities": [ + "get_plan" + ] + } + }, + "hosts": { + "api": "https://api-cloud.browserstack.com" + } + } +} \ No newline at end of file diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 8258bd6b..6e413041 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -98,16 +98,68 @@ function credentialsFrom(config: BrowserStackConfig): Credentials { * Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404 * that reads like the caller's problem, when it is our missing configuration. */ +/** + * The host a capability named, as opposed to the product's own. + * + * `_HOST_` is in the override variable deliberately. The environment-suffixed form is + * `CAPABILITY_REGISTRY_BASE_URL__`, so a bare + * `CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HUB` would mean the hub host or the "hub" + * environment depending on which the reader had in mind — and whichever the code chose, + * the other reading sends a call to the wrong origin silently. + * + * A named environment refuses rather than falling back to the declared host, for the same + * reason the product-level resolver does: a preprod session must not reach production + * because nobody defined preprod's hub. + */ +function resolveNamedHost( + product: string, + key: string, + host: string, + source: HostSource | undefined, + environment: string | undefined, +): string { + const trim = (url: string) => url.replace(/\/$/, ""); + const suffix = `${key}_HOST_${host.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`; + const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${suffix}`]; + if (override) return trim(override); + + if (environment) { + throw new InvocationError( + `environment '${environment}' has no '${host}' host for product '${product}'. Set ` + + `CAPABILITY_REGISTRY_BASE_URL_${suffix}.`, + ); + } + + const declared = source?.hosts?.[host]; + if (declared) return trim(declared); + + const known = Object.keys(source?.hosts ?? {}); + throw new InvocationError( + `that capability is served from the '${host}' host, which product '${product}' does ` + + `not declare. ` + + (known.length + ? `Its index defines: ${known.join(", ")}.` + : `Its index defines no named hosts.`) + + ` Set CAPABILITY_REGISTRY_BASE_URL_${suffix} to override.`, + ); +} + export async function resolveBaseUrl( product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport, + host?: string, ): Promise { const key = product.toUpperCase(); const environment = selectedEnvironment(); const trim = (url: string) => url.replace(/\/$/, ""); + // A capability that names a host is asking for one fixed origin, so none of the rungs + // below apply: discovery answers with the product's own host, and the declared + // `base_url` cannot serve this capability's paths at all. + if (host) return resolveNamedHost(product, key, host, source, environment); + const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`]; if (explicit) return trim(explicit); diff --git a/src/tools/capability-registry/discovery.ts b/src/tools/capability-registry/discovery.ts index 04a0ea60..b05ca50f 100644 --- a/src/tools/capability-registry/discovery.ts +++ b/src/tools/capability-registry/discovery.ts @@ -21,7 +21,7 @@ import { Capability, ProductIndex } from "./types.js"; /** What the resolver needs from a product's index to find its host. */ export type HostSource = Pick< ProductIndex, - "base_url" | "base_urls" | "probe_path" | "auth" + "base_url" | "base_urls" | "hosts" | "probe_path" | "auth" > & { capabilities?: Capability[]; }; diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts index 8b056bc4..aee8fb6a 100644 --- a/src/tools/capability-registry/egress.ts +++ b/src/tools/capability-registry/egress.ts @@ -199,7 +199,20 @@ async function readBody(response: Response): Promise { return ``; } - const raw = await response.text().catch(() => ""); + // A FAILED READ IS NOT AN EMPTY BODY. `fetch` resolves as soon as the headers arrive, so + // the abort timer can fire while the body is still streaming. Swallowing that here + // returned the status line with no payload — a slow session create came back as a 200 + // carrying null, which reads as a successful start that forgot to mention the session id. + // The device was really allocated, so the caller then had a session it could not name and + // could not release. Re-throwing lets the transport report the call as failed. + let raw: string; + try { + raw = await response.text(); + } catch (error) { + throw new Error( + `the response body could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } if (!raw.trim()) return null; if (contentType.includes("json")) { diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index e14942f8..f1665716 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -64,7 +64,7 @@ export interface RegistryDeps { * Per-product base URL. Never baked into the artifact — it is environment AND account * specific: tm is region-sharded, so this is resolved per call, not once at startup. */ - baseUrlFor: (product: string) => Promise; + baseUrlFor: (product: string, host?: string) => Promise; credentialsFor: () => Credentials; transport?: Transport; } @@ -138,8 +138,14 @@ export function addCapabilityRegistryToolsFromConfig( registry, // The whole product bundle, not just its host: a region-sharded product declares // several candidates and the probe needs an endpoint from its own capabilities. - baseUrlFor: (product) => - resolveBaseUrl(product, config, registry.index.products[product]), + baseUrlFor: (product, host) => + resolveBaseUrl( + product, + config, + registry.index.products[product], + undefined, + host, + ), // Read per call, not captured: the remote server rebuilds config per session, so a // captured credential would outlive the session it belongs to. credentialsFor: () => ({ @@ -1210,7 +1216,7 @@ export function addCapabilityRegistryTools( const result = await invoke( capability, args, - await deps.baseUrlFor(product), + await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 919076bb..38707cad 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -132,6 +132,23 @@ export interface Capability { name?: string; method: string; path: string; + /** + * Which of the product's named `hosts` serves this one capability, when it is not the + * product's own `base_url`. + * + * A product is normally one host. App Automate is not: the account-level API (devices, + * app builds, plan, session records) answers on one origin and the Appium hub that + * drives the device answers on another, and neither serves the other's paths. Splitting + * them into two products would split one entity too — the session a hub capability + * creates is the session the account-level API reports on — and a caller holding the id + * would have to know it had changed products to use it. + * + * Unset means the product's own host, so every existing index is unaffected. A name that + * `hosts` does not define is refused by name at invocation rather than quietly falling + * back, because the fallback would send the call to a host that cannot serve the path and + * return a 404 that reads like the caller's mistake. + */ + host?: string; mode: Mode; entity: string; path_params?: WireParam[]; @@ -330,6 +347,18 @@ export interface ProductIndex { * (see `discovery.probePath`); declare it when the derived choice would be wrong. */ probe_path?: string; + /** + * Additional hosts this product is served from, by name, for capabilities that declare + * `host`. + * + * DISTINCT FROM `base_urls`, which is a list of CANDIDATES for one host — probed in turn + * so a region-sharded product lands on the account's own region. These are several hosts + * that all serve the product at once, each addressing a different part of it, and the + * capability says which it needs rather than the server discovering it. + * + * `base_url` remains the default for every capability that names no host. + */ + hosts?: Record; capabilities: Capability[]; entities: Record; paging?: Record; diff --git a/tests/tools/capabilityRegistryBodyRead.test.ts b/tests/tools/capabilityRegistryBodyRead.test.ts new file mode 100644 index 00000000..03bcb080 --- /dev/null +++ b/tests/tools/capabilityRegistryBodyRead.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchTransport } from "../../src/tools/capability-registry/egress.js"; + +// `fetch` resolves as soon as the response headers arrive, so a slow body can still be +// streaming when the abort timer fires. That failure used to be swallowed and reported as a +// 200 with no payload — which, for a session create that really did allocate a device, reads +// as "started successfully, but there is no session id", leaving a session nobody can release. + +const headers = { + get: (k: string) => (k === "content-type" ? "application/json" : null), +}; + +describe("a response whose body cannot be read", () => { + it("is reported as a failed call, not as an empty 200", async () => { + vi.stubGlobal("fetch", async () => ({ + status: 200, + headers, + text: async () => { + throw Object.assign(new Error("The operation was aborted."), { + name: "AbortError", + }); + }, + })); + const result = await fetchTransport()( + "POST", + "https://hub.example/session", + {}, + ); + expect(result.status).toBe(0); + expect(result.body).toBeNull(); + expect(result.error).toBeTruthy(); + vi.unstubAllGlobals(); + }); + + it("still treats a genuinely empty body as an empty body", async () => { + // A 204, or any endpoint that answers with nothing, must keep working. + vi.stubGlobal("fetch", async () => ({ + status: 204, + headers, + text: async () => "", + })); + const result = await fetchTransport()( + "DELETE", + "https://hub.example/session/x", + {}, + ); + expect(result.status).toBe(204); + expect(result.body).toBeNull(); + expect(result.error).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("passes a normal JSON body through unchanged", async () => { + vi.stubGlobal("fetch", async () => ({ + status: 200, + headers, + text: async () => '{"value":{"sessionId":"abc"}}', + })); + const result = await fetchTransport()( + "POST", + "https://hub.example/session", + {}, + ); + expect(result.status).toBe(200); + expect(result.body).toEqual({ value: { sessionId: "abc" } }); + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/tools/capabilityRegistryNamedHost.test.ts b/tests/tools/capabilityRegistryNamedHost.test.ts new file mode 100644 index 00000000..08f9523c --- /dev/null +++ b/tests/tools/capabilityRegistryNamedHost.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A product is normally one host. App Automate is not: the account-level API (devices, app +// builds, plan, session records) and the Appium hub that drives the device are different +// origins, and neither serves the other's paths. A capability says which it needs through +// `host`, resolved against the product's `hosts` map. + +const CONFIG = { + "browserstack-username": "ing_Xx", + "browserstack-access-key": "SECRET", +} as any; + +const SOURCE = { + base_url: "https://api-cloud.browserstack.com", + hosts: { hub: "https://hub-cloud.browserstack.com" }, +}; + +const OVERRIDE = "CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HOST_HUB"; + +describe("a capability served from a named host", () => { + beforeEach(() => { + delete process.env[OVERRIDE]; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE; + delete process.env.CAPABILITY_REGISTRY_ENV; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env[OVERRIDE]; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE; + delete process.env.CAPABILITY_REGISTRY_ENV; + }); + + it("resolves to the host it names, not the product's own", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("still gives the product's own host to a capability that names none", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("appautomate", CONFIG, SOURCE)).toBe( + "https://api-cloud.browserstack.com", + ); + }); + + it("trims a trailing slash, so the index can be authored either way", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl( + "appautomate", + CONFIG, + { ...SOURCE, hosts: { hub: "https://hub-cloud.browserstack.com/" } }, + undefined, + "hub", + ), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("lets config override one named host without touching the others", async () => { + process.env[OVERRIDE] = "https://hub-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-preprod.example"); + expect(await resolveBaseUrl("appautomate", CONFIG, SOURCE)).toBe( + "https://api-cloud.browserstack.com", + ); + }); + + it("is NOT overridden by the product-level variable, which addresses a different host", async () => { + // Pointing the product at a preprod API must not silently redirect hub traffic there: + // the hub is a separate deployment and would answer 404 for every /wd/hub path. + process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE = + "https://api-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("refuses a host the index does not declare, and names the ones it does", async () => { + // Falling back to base_url would send the call to a host that cannot serve the path, + // returning a 404 that reads like the caller's mistake. + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + await expect( + resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "terminal"), + ).rejects.toThrow(/'terminal' host.*does not declare.*hub/s); + }); + + it("refuses a named environment that defines no host for it", async () => { + // The same reason the product-level resolver refuses: a preprod session must not reach + // production because nobody configured preprod's hub. + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + await expect( + resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + ).rejects.toThrow(/environment 'preprod' has no 'hub' host/); + }); + + it("takes the environment's host from the _HOST_ variable when one is set", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env[OVERRIDE] = "https://hub-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-preprod.example"); + }); +}); From 929db21a54842b090d44edc07731644457072e69 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 13:21:46 +0530 Subject: [PATCH 02/53] feat(capability-registry): withhold local-machine capabilities on a remote server Some capabilities only mean anything when the person and the process share a computer. One that reads a file the caller names is the clear case: on a remote server that path resolves against the OPERATOR's disk, so honouring it would read one user's files on another's behalf. The capability does not become unreliable there, it becomes wrong. `local_only` is a claim about where a capability can run, as against `disabled`, which withholds it everywhere. The same artifact then serves both deployments: a local server publishes it and a remote one does not. It joins the existing filter, so it inherits the whole `capability_disabled` path: the capability is absent from searchCapability, describeCapability, describeEntity and listProducts, while a caller already holding the handle is told the absence is deliberate rather than hunting for a synonym. It fails closed on any truthy value, for the same reason `disabled` does. REMOTE_MCP is read at call time rather than captured at module load, because the remote server rebuilds its config per session and a captured value would outlive the deployment it described. No index sets the flag yet; this is the mechanism. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/capability-registry/index-loader.ts | 9 ++- src/tools/capability-registry/types.ts | 17 ++++ .../tools/capabilityRegistryLocalOnly.test.ts | 81 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/tools/capabilityRegistryLocalOnly.test.ts diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index d04c76f1..2cef457e 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -7,6 +7,7 @@ */ import { readFileSync } from "node:fs"; +import appConfig from "../../config.js"; import { basename, dirname } from "node:path"; import { Capability, @@ -57,9 +58,15 @@ export function endpointKey(method: string, path: string): string { * FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to * suppress something that cannot work, so an unexpected shape must hide it rather than be * ignored — the opposite default would turn a typo into a silently re-enabled capability. + * + * `local_only` withholds on a remote server only, for a capability that reads the caller's + * own machine: there, the path it would take resolves against the operator's disk instead. + * Read at call time rather than captured, because the remote server builds its config per + * session and a value captured at module load would outlive the deployment it described. */ export function isDisabled(capability: Capability): boolean { - return Boolean(capability.disabled); + if (capability.disabled) return true; + return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP); } export interface LoadedProduct { diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 38707cad..beadf4cf 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -188,6 +188,23 @@ export interface Capability { * suppression must not be ignored for arriving in an unexpected shape. */ disabled?: boolean; + /** + * Withheld when the server is not running on the caller's own machine. + * + * Some capabilities only mean anything when the person and the process share a computer: + * one that reads a file the caller names is the clear case. On a remote server that path + * resolves against the OPERATOR's disk, so honouring it would read one user's files on + * another's behalf — the capability does not become unreliable there, it becomes wrong. + * + * Distinct from `disabled`, which withholds a capability everywhere. This one is a claim + * about where it can run, so the same artifact serves both deployments: a local server + * publishes it and a remote one does not. + * + * FAILS CLOSED in the same way, and takes the same `capability_disabled` path at + * invocation, so a caller holding the handle is told the absence is deliberate rather + * than hunting for a synonym. + */ + local_only?: boolean; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; diff --git a/tests/tools/capabilityRegistryLocalOnly.test.ts b/tests/tools/capabilityRegistryLocalOnly.test.ts new file mode 100644 index 00000000..950c2329 --- /dev/null +++ b/tests/tools/capabilityRegistryLocalOnly.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Some capabilities only mean anything when the person and the process share a computer — +// one that reads a file the caller names is the clear case. On a remote server that path +// resolves against the operator's disk, so the capability is not merely unreliable there, +// it is wrong. `local_only` withholds it on that surface while the same artifact keeps +// publishing it locally. + +const LOCAL = { + name: "read_local_thing", + method: "POST", + path: "/x", + mode: "write", + entity: "e", + local_only: true, +}; +const SHARED = { + name: "read_remote_thing", + method: "GET", + path: "/y", + mode: "read", + entity: "e", +}; +const OFF = { + name: "never_available", + method: "GET", + path: "/z", + mode: "read", + entity: "e", + disabled: true, +}; + +async function loader(remote: boolean) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + return import("../../src/tools/capability-registry/index-loader.js"); +} + +describe("local_only", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("is published on a local server", async () => { + const { isDisabled } = await loader(false); + expect(isDisabled(LOCAL as never)).toBe(false); + }); + + it("is withheld on a remote server", async () => { + const { isDisabled } = await loader(true); + expect(isDisabled(LOCAL as never)).toBe(true); + }); + + it("leaves every other capability alone on both surfaces", async () => { + for (const remote of [false, true]) { + const { isDisabled } = await loader(remote); + expect(isDisabled(SHARED as never)).toBe(false); + } + }); + + it("keeps `disabled` withholding everywhere, local included", async () => { + // The two flags are independent: one is about where it can run, the other about + // whether it runs at all. + for (const remote of [false, true]) { + const { isDisabled } = await loader(remote); + expect(isDisabled(OFF as never)).toBe(true); + } + }); + + it("fails closed on a truthy value that is not a boolean", async () => { + // A flag whose only job is suppression must not be ignored for arriving oddly shaped. + const { isDisabled } = await loader(true); + expect(isDisabled({ ...LOCAL, local_only: "yes" } as never)).toBe(true); + }); + + it("does not withhold when the flag is absent, whatever the surface", async () => { + const { isDisabled } = await loader(true); + expect(isDisabled({ ...LOCAL, local_only: undefined } as never)).toBe( + false, + ); + }); +}); From 6ed4e979ca3e1cacb192d72ca4f1b49bd4885111 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 13:28:12 +0530 Subject: [PATCH 03/53] feat(capability-registry): upload a build that is on the caller's own machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now a capability was always a JSON request, so an endpoint taking the bytes of a file could only be published in whatever reference form it also accepted — for app builds, a public download link. A build sitting on the user's laptop had nowhere to go. A capability may now declare `encoding: "multipart"` and a parameter of type `file`. The bound value is streamed with `openAsBlob` rather than buffered: the cap is 4 GB, and reading that into memory to post it is how a server falls over. The JSON content-type is dropped for those requests, because fetch writes its own with the boundary and leaving the old one on sends a body the product cannot parse under a header claiming it can. THE PATH IS CHECKED IN `bind`, which register.ts runs dry before the write-consent gate. A path that does not exist, or a base directory nobody configured, is therefore refused before the user is asked to approve an upload that was never going to happen, and before anything is read from disk. The rules are `validateUploadPath`'s, already applied to every other upload this server does: confinement to MCP_UPLOAD_BASE_DIR, an extension allow-list, a size cap, and a hidden-directory block that stops ~/.ssh even after symlink resolution. Both refusals name what to do — set the variable and restart, or move the build into the directory, which the message gives as a resolved absolute path. upload_app_build_from_file is a SEPARATE capability rather than a file parameter on upload_app_build, because `local_only` withholds a whole capability: a shared one would have meant either losing the link form on a remote server or publishing a path that names the operator's disk. The link form stays available everywhere. Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 64 +++++++++- src/tools/capability-registry/bind.ts | 56 +++++++++ src/tools/capability-registry/egress.ts | 11 +- src/tools/capability-registry/resolve.ts | 47 +++++++- src/tools/capability-registry/types.ts | 8 ++ .../capabilityRegistryFileUpload.test.ts | 109 ++++++++++++++++++ 6 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 tests/tools/capabilityRegistryFileUpload.test.ts diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json index 3af20dd3..ad41d51f 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/appautomate.capability-index.json @@ -48,7 +48,10 @@ "adb", "device property", "getprop", - "os build" + "os build", + "upload apk", + "upload ipa", + "local build" ], "base_url": "https://hub-cloud.browserstack.com", "auth": { @@ -858,7 +861,8 @@ "Pass custom_id to give the build a stable name, then later sessions can use that name in place of the bs:// URL and always get the newest build under it.", "Re-uploading under the same custom_id adds a new build rather than replacing the old one; both stay listed.", "Builds expire after 30 days, so a bs:// URL held from an earlier run can stop working.", - "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file." + "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file.", + "upload_app_build_from_file takes a build that is on this machine instead, where the server runs beside the user. It is not offered on a remote server, where a path would name the operator's disk rather than theirs." ], "body": [ { @@ -2569,6 +2573,57 @@ "$response": "WebDriverError" } } + }, + { + "name": "upload_app_build_from_file", + "method": "POST", + "path": "/app-automate/upload", + "host": "api", + "mode": "write", + "entity": "app_build", + "encoding": "multipart", + "local_only": true, + "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.", + "guidance": [ + "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.", + "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.", + "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.", + "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.", + "upload_app_build takes a public https link instead and is the one to use when the build is not on this machine.", + "A large build takes minutes, because the call does not return until the whole file has been sent." + ], + "body": [ + { + "name": "file", + "type": "file", + "required": true, + "description": "Path to the build on this machine, inside MCP_UPLOAD_BASE_DIR.", + "example": "/Users/me/builds/app-release.apk" + }, + { + "name": "custom_id", + "type": "string", + "maxLength": 100, + "description": "Stable name for the build, reusable in place of the bs:// URL.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_url", + "custom_id", + "shareable_id" + ], + "responses": { + "200": { + "description": "The uploaded build.", + "schema": { + "$schema": "AppUpload" + } + }, + "401": { + "$response": "Unauthorized" + } + } } ], "schemas": { @@ -3119,7 +3174,7 @@ ], "id_convention": "app_url is a bs:// URL of 40 hex characters. A build may also carry a custom_id, a name chosen at upload that always resolves to the newest build under it.", "key_facts": [ - "A build must be uploaded before any session can install it; a file on the caller's own machine cannot be sent through this surface, only a public download link.", + "A build must be uploaded before any session can install it, either from a public download link or from a file on the machine running this server.", "Builds expire 30 days after upload, so a bs:// URL kept from an earlier run can stop working.", "Uploading under a custom id that already exists adds another build rather than replacing the old one, and both stay listed.", "A build uploaded by one person is visible to the whole team." @@ -3128,7 +3183,8 @@ "upload_app_build", "list_app_builds", "list_team_app_builds", - "get_app_builds_by_custom_id" + "get_app_builds_by_custom_id", + "upload_app_build_from_file" ] }, "plan": { diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts index ef272344..c5894a59 100644 --- a/src/tools/capability-registry/bind.ts +++ b/src/tools/capability-registry/bind.ts @@ -8,6 +8,12 @@ * Grouping removes the collision AND the rename, so a caller sends the spec's own names. */ +import appConfig from "../../config.js"; +import { + APP_BINARY_EXTENSIONS, + MAX_APP_UPLOAD_BYTES, + validateUploadPath, +} from "../../lib/upload-validator.js"; import { InvocationError } from "./index-loader.js"; import { Capability, WireConstraints, WireParam } from "./types.js"; @@ -243,6 +249,28 @@ function coerceType( label = param.name, ): unknown { const expected = param.type; + if (expected === "file") { + // CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path + // that does not exist — or a base directory nobody configured — is refused before the + // user is asked to approve an upload that was never going to happen. + // + // The rules are the ones the server already applies to every other upload, so the + // answer does not depend on which surface asked. + const given = String(value ?? "").trim(); + if (!given) + throw new InvocationError(`'${label}' must be a path to a file`); + try { + return validateUploadPath(given, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); + } catch (error) { + throw new InvocationError( + error instanceof Error ? error.message : String(error), + ); + } + } if (expected === "object" || expected === "array") { // An opaque body object is passed through as given: the spec does not describe its // fields, so validating or reshaping it would mean inventing a contract. @@ -312,6 +340,34 @@ function coerceType( return value; } + // A DECLARED STRING IS NOT A LICENCE TO STRINGIFY ANYTHING. + // + // The untyped case above was fixed because `String(value)` destroyed objects the spec had + // declined to describe. A DECLARED string was left stringifying whatever arrived, and it + // is the worse of the two: an object sent for `create_test_case_comment`'s `comment` was + // serialised to the literal text "[object Object]", the API accepted it, the write + // returned 200, and the agent reported success in good faith. tm exposes no comment + // delete, so that text is now permanently on the case. + // + // A 400 teaches the caller what it got wrong. A 200 that stores "[object Object]" teaches + // it nothing and cannot be undone, so refuse here rather than let it reach the wire. + // + // `null` passes through instead of becoming the five characters "null", which no API has + // ever wanted: several string parameters document null as the way to CLEAR a field — + // `test_plan_id` says "send null or '' to unlink" — and stringifying it silently sent a + // value where the caller meant to send an absence. + if (expected === "string") { + if (value === null || value === undefined) return value; + if (typeof value === "object") { + throw new InvocationError( + `'${label}' must be a string, and was ${Array.isArray(value) ? "a list" : "an object"}. ` + + `Serialising it would send the text ` + + `${JSON.stringify(Array.isArray(value) ? "[object Array]" : "[object Object]")} ` + + `rather than its contents, and the call would succeed with that stored.`, + ); + } + } + const text = String(value); if (param.values && param.values.length > 0) { const allowed = param.values.map((v) => String(v)); diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts index aee8fb6a..8185ec4e 100644 --- a/src/tools/capability-registry/egress.ts +++ b/src/tools/capability-registry/egress.ts @@ -247,8 +247,15 @@ export function fetchTransport(timeoutMs = 45_000): Transport { method, headers, // Only send a body when there IS one: a literal `null` payload with a JSON - // content-type is rejected by several endpoints. - body: body === undefined ? undefined : JSON.stringify(body), + // content-type is rejected by several endpoints. A FormData goes through as it is — + // stringifying it yields "[object FormData]", which uploads nothing and reads as a + // product error rather than ours. + body: + body === undefined + ? undefined + : body instanceof FormData + ? body + : JSON.stringify(body), // A redirect from an authenticated API is usually a login bounce, and following it // turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the // resolver would then read as an empty result set rather than a failure. diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index 98e123ef..2d400d49 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -18,6 +18,8 @@ import { bind, GroupedArguments } from "./bind.js"; import { authHeaders, Credentials, Transport } from "./egress.js"; import { resolveScope, resolveTokenUrl } from "./oauth.js"; import { AuthScheme } from "./types.js"; +import { openAsBlob } from "node:fs"; +import { basename } from "node:path"; import { InvocationError } from "./index-loader.js"; import { Capability } from "./types.js"; @@ -90,6 +92,32 @@ function assertTransportSafe(baseUrl: string): void { ); } +/** + * Turn a bound body into a FormData, streaming any `file` parameter from disk. + * + * `bind` has already canonicalised and checked the path, so what arrives here is a file + * this server is configured to read. The blob is opened rather than buffered: an app build + * runs to gigabytes and reading one into memory to post it is how a server falls over. + */ +async function multipartBody( + capability: Capability, + body: Record | undefined, +): Promise { + const form = new FormData(); + for (const param of capability.body || []) { + const value = body?.[param.name]; + if (value === undefined || value === null) continue; + if (param.type === "file") { + const path = String(value); + const blob = await openAsBlob(path); + form.append(param.name, blob, basename(path)); + } else { + form.append(param.name, String(value)); + } + } + return form; +} + export async function invoke( capability: Capability, args: GroupedArguments, @@ -106,14 +134,25 @@ export async function invoke( // Before binding would be wrong and after sending is too late: a scheme that mints a // token must not spend a round trip on a call that `bind` was going to reject anyway. const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`; - const send = async () => - transport( + const multipart = + capability.encoding === "multipart" + ? await multipartBody(capability, bound.body) + : undefined; + const send = async () => { + const headers = await authHeaders(credentials, auth, product); + if (multipart) { + // fetch writes its own Content-Type with the boundary. Leaving the JSON one on sends + // a body the product cannot parse, under a header that says it should be able to. + delete headers["Content-Type"]; + } + return transport( capability.method, url, - await authHeaders(credentials, auth, product), + headers, bound.query, - bound.body, + multipart ?? bound.body, ); + }; let response = await send(); diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index beadf4cf..b9efe4b6 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -205,6 +205,14 @@ export interface Capability { * than hunting for a synonym. */ local_only?: boolean; + /** + * How the request body is encoded. Absent means JSON, which is every capability today. + * + * `multipart` exists for endpoints that take the bytes of a file rather than a reference + * to one. A capability declaring it must also declare a `file` parameter, and should be + * `local_only`: the path names the caller's own machine. + */ + encoding?: "multipart"; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; diff --git a/tests/tools/capabilityRegistryFileUpload.test.ts b/tests/tools/capabilityRegistryFileUpload.test.ts new file mode 100644 index 00000000..55b76fe2 --- /dev/null +++ b/tests/tools/capabilityRegistryFileUpload.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// A `file` parameter carries the bytes of a build rather than a reference to one. The path +// is checked inside `bind`, which register.ts runs dry BEFORE the write-consent gate — so a +// misplaced file or an unconfigured base directory is refused before the user is asked to +// approve an upload that was never going to happen. + +const CAPABILITY = { + name: "upload_app_build_from_file", + method: "POST", + path: "/app-automate/upload", + mode: "write", + entity: "app_build", + encoding: "multipart", + local_only: true, + body: [ + { name: "file", type: "file", required: true }, + { name: "custom_id", type: "string" }, + ], +}; + +let base: string; +let apk: string; +let outside: string; + +async function bindWith(uploadBaseDir: string | undefined) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ + default: { UPLOAD_BASE_DIR: uploadBaseDir, REMOTE_MCP: false }, + })); + return import("../../src/tools/capability-registry/bind.js"); +} + +describe("a file parameter", () => { + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "uploads-")); + apk = join(base, "app-release.apk"); + writeFileSync(apk, "not really an apk"); + const other = mkdtempSync(join(tmpdir(), "elsewhere-")); + outside = join(other, "app-release.apk"); + writeFileSync(outside, "not really an apk"); + }); + afterEach(() => { + vi.doUnmock("../../src/config.js"); + rmSync(base, { recursive: true, force: true }); + }); + + it("resolves a build inside the configured directory to its canonical path", async () => { + const { bind } = await bindWith(base); + const bound = bind(CAPABILITY as never, { body: { file: apk } }); + expect(String((bound.body as Record).file)).toContain( + "app-release.apk", + ); + }); + + it("refuses when MCP_UPLOAD_BASE_DIR is not set, and says to set it", async () => { + // The agent has to be able to tell the user what to do, so the refusal names the + // variable rather than reporting a generic rejection. + const { bind } = await bindWith(undefined); + expect(() => bind(CAPABILITY as never, { body: { file: apk } })).toThrow( + /MCP_UPLOAD_BASE_DIR is not set/, + ); + }); + + it("refuses a build outside the directory, and names the directory it must be in", async () => { + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: outside } }), + ).toThrow(/must be located inside/); + }); + + it("refuses an extension that is not an app build", async () => { + const notes = join(base, "notes.txt"); + writeFileSync(notes, "hello"); + const { bind } = await bindWith(base); + expect(() => bind(CAPABILITY as never, { body: { file: notes } })).toThrow( + /extension/, + ); + }); + + it("refuses a path that does not exist", async () => { + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: join(base, "gone.apk") } }), + ).toThrow(/File not found/); + }); + + it("refuses a build under a hidden directory", async () => { + // Blocks ~/.ssh and friends even after symlink resolution. + const hidden = join(base, ".secrets"); + mkdirSync(hidden); + const hiddenApk = join(hidden, "app-release.apk"); + writeFileSync(hiddenApk, "x"); + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: hiddenApk } }), + ).toThrow(/hidden directory/); + }); + + it("refuses an empty path instead of reading the working directory", async () => { + const { bind } = await bindWith(base); + expect(() => bind(CAPABILITY as never, { body: { file: " " } })).toThrow( + /must be a path to a file/, + ); + }); +}); From 4634141fa199bcbc32e2db72f0a07a02ae726798 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 13:36:09 +0530 Subject: [PATCH 04/53] fix(capability-registry): a withheld capability answers as absent, not as withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search, describe, describeEntity and listProducts already behave as though a withheld capability does not exist — it is dropped when the registry loads, from the product's capabilities and from the entity records that named it. Invocation was the one place that contradicted them, answering `capability_disabled` and explaining that the capability was real but suppressed here. That explanation describes the artifact rather than this surface, and every action it invites fails: retrying, searching for a synonym, or asking the user to enable something no setting on this deployment turns on. It also discloses the existence of a capability that every other surface is at pains not to mention. So both lookups now raise their ordinary not-found error. The handle reads as absent wherever a caller meets it. The distinction is kept where it is actually useful. InvocationError carries a `withheld` flag, and the telemetry reason still resolves to `capability_disabled`, so how often a withheld handle is reached for stays countable — which is how we learn whether withholding it was right — without the message admitting to it. A genuinely absent name leaves the flag unset, so the two remain distinguishable in the data and indistinguishable to the caller. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/capability-registry/index-loader.ts | 61 ++++++++++------- src/tools/capability-registry/register.ts | 11 +++- .../tools/capabilityRegistryDisabled.test.ts | 66 +++++++++++++------ 3 files changed, 90 insertions(+), 48 deletions(-) diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index 2cef457e..ff892d15 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -25,7 +25,18 @@ import { export class IndexError extends Error {} /** Thrown to the caller as a tool error, so the wording is caller-facing. */ -export class InvocationError extends Error {} +export class InvocationError extends Error { + /** + * Set when the handle exists in the artifact but this surface withholds it. + * + * The MESSAGE deliberately does not say so — a caller is told the capability does not + * exist, because "withheld" invites a retry, a search for synonyms, or a question to the + * user about enabling it, none of which can succeed here. The flag keeps the distinction + * for telemetry, where knowing how often a withheld handle is reached for is how we learn + * whether withholding it was the right call. + */ + withheld?: boolean; +} /** The file inside a product subdirectory, for the earlier nested layout. */ export const INDEX_FILE = "index.json"; @@ -285,26 +296,23 @@ export class CapabilityRegistry { } /** - * The `capability_disabled` message for a handle this build withholds, if it is one. + * Does this build withhold the handle? * - * Says THAT it is disabled and nothing about why. The reason is documentation and lives - * with the decision; repeating it here would put four copies of a paragraph in the - * shipped artifact to serve a path the flag itself makes rare. + * WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean + * rather than a message. Saying "withheld" describes the artifact rather than the + * surface, and every action it invites — retry, hunt for a synonym, ask the user to turn + * it on — fails here. A capability absent from search, describe and every listing should + * read the same way at invocation as it does everywhere else. * - * Returns undefined for anything else, so the caller falls through to its own - * not-found wording. + * The caller raises its own not-found error and marks it `withheld`, so the distinction + * survives in telemetry without reaching the agent. */ - private withheldMatch(handle: string, product?: string): string | undefined { + private isWithheld(handle: string, product?: string): boolean { for (const [owner, withheld] of this.withheld) { if (product && owner !== product) continue; - if (!withheld.has(handle)) continue; - return ( - `capability_disabled: ${handle} is disabled on this surface. This is ` + - `deliberate — it is withheld by the ${owner} index, not missing from it — so ` + - `retrying will not help.` - ); + if (withheld.has(handle)) return true; } - return undefined; + return false; } /** How many capabilities each product withholds. Startup logging only. */ @@ -409,12 +417,13 @@ export class CapabilityRegistry { if (capability) matches.push({ product: owner, capability }); } if (matches.length === 0) { - // WITHHELD IS A DIFFERENT ANSWER FROM ABSENT, and only here. Search cannot surface a - // disabled capability, so reaching this branch means the caller already held the - // exact handle — from an older index, a doc, or a human. "unknown_capability" would - // send it hunting for a synonym that does not exist; the reason ends the attempt. - const off = this.withheldMatch(name, product); - if (off) throw new InvocationError(off); + // A WITHHELD HANDLE ANSWERS THE SAME WAY AS AN ABSENT ONE. Search, describe and the + // listings already behave as though it does not exist, and invocation is no place to + // contradict them: "withheld" is a fact about the artifact, not about this surface, + // and it invites a retry, a hunt for a synonym, or a question to the user about + // enabling something that cannot be enabled here. The error carries `withheld` so the + // attempt is still countable in telemetry. + const withheldHere = this.isWithheld(name, product); // Say whether names are published at all for the product asked about: "no such name" // and "this product does not name its capabilities" need different fixes. @@ -428,7 +437,9 @@ export class CapabilityRegistry { ? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` + `no capability names yet — call those by \`method\` and \`path\` instead.` : " Search again — names come from searchCapability and describeEntity."; - throw new InvocationError(`unknown_capability: ${name}.${hint}`); + const error = new InvocationError(`unknown_capability: ${name}.${hint}`); + if (withheldHere) error.withheld = true; + throw error; } if (matches.length > 1 && !product) { const owners = matches @@ -465,12 +476,12 @@ export class CapabilityRegistry { if (capability) matches.push({ product: name, capability }); } if (matches.length === 0) { - const off = this.withheldMatch(key, product); - if (off) throw new InvocationError(off); - throw new InvocationError( + const error = new InvocationError( `unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` + `searchCapability returned them, placeholders included.`, ); + if (this.isWithheld(key, product)) error.withheld = true; + throw error; } if (matches.length > 1 && !product) { const owners = matches diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index f1665716..b3b42645 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -234,7 +234,12 @@ export function addCapabilityRegistryTools( * below pin each branch against the message its source actually produces; if you reword * a refusal, one of them will tell you. */ - const invocationReason = (message: string): string => { + const invocationReason = (message: string, error?: unknown): string => { + // A WITHHELD HANDLE IS REPORTED TO THE CALLER AS NOT FOUND, so the message cannot be + // what distinguishes it. The flag on the error can, which keeps the refusal countable + // without the wording admitting the capability exists. + if (error instanceof InvocationError && error.withheld) + return "capability_disabled"; if (message.startsWith("unknown_capability:")) return "unknown_capability"; if (message.startsWith("unknown_endpoint:")) return "unknown_endpoint"; // The outcome this PR turns on: 46 of 244 capabilities are withheld, so this is the @@ -1033,7 +1038,7 @@ export function addCapabilityRegistryTools( if (error instanceof InvocationError) return refuse( "describeCapability", - invocationReason(error.message), + invocationReason(error.message, error), error.message, undefined, identity, @@ -1236,7 +1241,7 @@ export function addCapabilityRegistryTools( if (error instanceof InvocationError) return refuse( "invokeCapability", - invocationReason(error.message), + invocationReason(error.message, error), error.message, undefined, identity, diff --git a/tests/tools/capabilityRegistryDisabled.test.ts b/tests/tools/capabilityRegistryDisabled.test.ts index 0f36ff26..80e3f746 100644 --- a/tests/tools/capabilityRegistryDisabled.test.ts +++ b/tests/tools/capabilityRegistryDisabled.test.ts @@ -76,9 +76,9 @@ describe("a disabled capability is absent from the loaded model", () => { it("is gone from the entity's capability list, which describeEntity returns wholesale", () => { const registry = registryWith(product()); - expect( - registry.index.products.tm.entities.attachment.capabilities, - ).toEqual(["list_attachments_v1"]); + expect(registry.index.products.tm.entities.attachment.capabilities).toEqual( + ["list_attachments_v1"], + ); }); it("leaves the original bundle untouched rather than mutating the caller's object", () => { @@ -93,7 +93,9 @@ describe("a disabled capability is absent from the loaded model", () => { }); it("reports nothing, and shares the bundle, when no capability is disabled", () => { - const products = { tm: { summary: "s", capabilities: [LIVE], entities: {} } }; + const products = { + tm: { summary: "s", capabilities: [LIVE], entities: {} }, + }; const registry = registryWith(products); expect(registry.disabledCounts()).toEqual({}); // Untouched products keep object identity — the filter allocates only where it must. @@ -104,9 +106,13 @@ describe("a disabled capability is absent from the loaded model", () => { describe("searchCapability cannot surface one", () => { it("does not return it for a query whose words it matches best", () => { const registry = registryWith(product()); - const hit = searchCapabilities(registry.index.products, "upload attachments", { - product: "tm", - }); + const hit = searchCapabilities( + registry.index.products, + "upload attachments", + { + product: "tm", + }, + ); expect(hit.hits.map((h) => h.capability.name)).not.toContain( "upload_generic_attachments", ); @@ -135,8 +141,12 @@ describe("searchCapability cannot surface one", () => { }); }); -describe("invokeCapability answers `capability_disabled`, not `unknown`", () => { - it("names the reason when the caller already holds the name", () => { +describe("a withheld handle answers the same way as an absent one", () => { + // Search, describe and every listing already behave as though it does not exist. + // Invocation says the same, because "withheld" is a fact about the artifact rather than + // this surface, and it invites a retry or a request to enable something that cannot be + // enabled here. The distinction survives on the error, for telemetry only. + it("tells a caller holding the name that it does not exist", () => { const registry = registryWith(product()); expect(() => registry.byNameLookup("upload_generic_attachments")).toThrow( InvocationError, @@ -145,12 +155,11 @@ describe("invokeCapability answers `capability_disabled`, not `unknown`", () => registry.byNameLookup("upload_generic_attachments"); } catch (error) { const message = (error as Error).message; - expect(message).toContain("capability_disabled"); - // THAT it is disabled, not why. The reason is documentation and lives with the - // decision, not in four copies inside the shipped artifact. - expect(message).toContain("disabled on this surface"); - // The distinction that matters: withheld by the index, not missing from it. - expect(message).not.toContain("unknown_capability"); + expect(message).toContain("unknown_capability"); + expect(message).not.toContain("disabled"); + expect(message).not.toContain("withheld"); + // Countable without being disclosed. + expect((error as InvocationError).withheld).toBe(true); } }); @@ -163,7 +172,20 @@ describe("invokeCapability answers `capability_disabled`, not `unknown`", () => ); throw new Error("expected a refusal"); } catch (error) { - expect((error as Error).message).toContain("capability_disabled"); + const message = (error as Error).message; + expect(message).toContain("unknown_endpoint"); + expect(message).not.toContain("disabled"); + expect((error as InvocationError).withheld).toBe(true); + } + }); + + it("leaves a genuinely absent name unmarked, so the two are told apart in telemetry", () => { + const registry = registryWith(product()); + try { + registry.byNameLookup("no_such_capability"); + throw new Error("expected a refusal"); + } catch (error) { + expect((error as InvocationError).withheld).toBeUndefined(); } }); @@ -217,13 +239,17 @@ describe("the shipped tm index", () => { const { readFileSync } = await import("node:fs"); const raw = JSON.parse(readFileSync(FILE, "utf8")); const off = new Set( - raw.tm.capabilities.filter((c: any) => c.disabled).map((c: any) => c.name), + raw.tm.capabilities + .filter((c: any) => c.disabled) + .map((c: any) => c.name), ); expect(off.size).toBe(WITHHELD_COUNT); for (const name of FILE_BEARING) expect(off.has(name)).toBe(true); // Every destructive capability is withheld, so the tier is now invisible as well as // refused. Nothing that passed is caught up in it. - const destructive = raw.tm.capabilities.filter((c: any) => c.mode === "destructive"); + const destructive = raw.tm.capabilities.filter( + (c: any) => c.mode === "destructive", + ); expect(destructive.every((c: any) => c.disabled)).toBe(true); }); @@ -273,10 +299,10 @@ describe("the shipped tm index", () => { expect(hits.hits.map((h) => h.capability.name)).not.toContain(name); } - // Still refused by name, with the reason. + // Still refused by name — as not found, the same answer every other surface gives. for (const name of FILE_BEARING) { expect(() => registry.byNameLookup(name, "tm")).toThrow( - /capability_disabled/, + /unknown_capability/, ); } }); From 3881984d6a9c6cbf85faaceba48ef57eca8d42b4 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 15:33:47 +0530 Subject: [PATCH 05/53] feat(capability-registry): publish a local-only capability for reference where it cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding it entirely answered the wrong question. An agent asked to upload a build from the user's machine, talking to a remote server, found nothing — and concluded no such route exists, when one does: the user uploads the file themselves and passes back the url. The capability was the natural match for that query and the only place the answer could have come from. So on a remote server a `local_only` capability is now published for reference. It keeps its name, its entity and its intent — the intent is what the search matched on — and loses everything a caller would need to invoke it. The parameters and response shapes are stripped at load, because a model handed a body schema fills it in, and its guidance is replaced by `unavailable_note`: what to do instead, written as an instruction rather than an apology. invokeCapability refuses it with that note attached, under a new reason, `capability_not_invocable`. This is the ONE place describe and invoke diverge, and the code says elsewhere that such divergence is its own bug class — so it is commented as the deliberate exception it is. A capability published to be read and not called is exactly the case the rule did not anticipate. The three states are now distinct, and the tests pin them apart: disabled absent everywhere; answers as unknown local_only, local fully callable local_only, remote found and described, not callable `disabled` continues to win over `local_only`, so a capability carrying both is absent rather than merely uncallable. Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 3 +- src/tools/capability-registry/index-loader.ts | 56 ++++++ src/tools/capability-registry/register.ts | 18 ++ src/tools/capability-registry/types.ts | 18 ++ .../capabilityRegistryReferenceOnly.test.ts | 159 ++++++++++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tests/tools/capabilityRegistryReferenceOnly.test.ts diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json index ad41d51f..2a687faf 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/appautomate.capability-index.json @@ -2623,7 +2623,8 @@ "401": { "$response": "Unauthorized" } - } + }, + "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." } ], "schemas": { diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index ff892d15..382fd248 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -80,6 +80,50 @@ export function isDisabled(capability: Capability): boolean { return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP); } +/** + * Is this capability published for reference only on this surface? + * + * `local_only` on a remote server. The capability is real and the caller may well be asking + * for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing + * it without the means to call it, and with a note saying what to do instead, answers the + * question it was asked. + */ +export function isReferenceOnly(capability: Capability): boolean { + if (capability.disabled) return false; + return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP); +} + +/** + * The reference form: what it is and what to do instead, and nothing to call it with. + * + * The parameters and response shapes go because publishing them invites the attempt — a + * model handed a body schema will fill it in. `intent` stays, because that is what the + * search matched on, and `mode` stays so a reader can still see what kind of operation it + * would have been. + */ +function asReference(capability: Capability): Capability { + const reference: Capability = { ...capability }; + for (const field of [ + "path_params", + "query", + "body", + "responses", + "returns", + "paginated", + "max_page_size", + "encoding", + ] as const) { + delete reference[field]; + } + return { + ...reference, + invocable: false, + guidance: capability.unavailable_note + ? [capability.unavailable_note] + : ["This capability cannot be called on this server."], + }; +} + export interface LoadedProduct { name: string; product: ProductIndex; @@ -251,6 +295,18 @@ export class CapabilityRegistry { // between a disabled name and a live one is still a broken artifact, and skipping // the disabled half would mean re-enabling the flag could fail the load later, // somewhere with less context than here. + if (isReferenceOnly(capability)) { + // PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that + // should find it does, and loses everything a caller would need to invoke it. + const reference = asReference(capability); + enabled.push(reference); + lookup.set( + endpointKey(capability.method, capability.path), + reference, + ); + if (capability.name) names.set(capability.name, reference); + continue; + } if (isDisabled(capability)) { withheld.set( endpointKey(capability.method, capability.path), diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index b3b42645..7b855a2e 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -1176,6 +1176,24 @@ export function addCapabilityRegistryTools( body: input.body, }; + if (capability.invocable === false) { + // THE ONE PLACE describe AND invoke DIVERGE, deliberately. Everywhere else a name + // that describes is a name that invokes, and breaking that is its own bug class — + // but a reference-only capability exists precisely to be read and not called. The + // refusal carries the instruction, because the note is the whole point of it + // still being published. + return refuse( + "invokeCapability", + "capability_not_invocable", + `${handle} cannot be called on this server. ` + + (capability.guidance?.[0] ?? + "Describe it for what to do instead.") + + " Follow that instead of retrying.", + undefined, + identity, + ); + } + if (capability.mode === "destructive") { // Refused before binding, so consent is never sought for something that cannot run. return refuse( diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index b9efe4b6..ac890b40 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -205,6 +205,24 @@ export interface Capability { * than hunting for a synonym. */ local_only?: boolean; + /** + * What to do instead, on a surface where this capability cannot run. + * + * A `local_only` capability is NOT hidden on a remote server: the query that should find + * it still finds it, because an agent asked to upload a file from the user's machine + * needs to learn that the route exists and what replaces it here. What it loses is the + * means to call it — the parameters and response shapes are stripped at load, and + * invocation is refused — so the note is the whole of what remains. + * + * Write it as the instruction the caller should follow, not as an apology. + */ + unavailable_note?: string; + /** + * FALSE when this build publishes the capability for reference but will not call it. + * + * Set by the loader, never authored: it is the surface's answer, not the artifact's. + */ + invocable?: boolean; /** * How the request body is encoded. Absent means JSON, which is every capability today. * diff --git a/tests/tools/capabilityRegistryReferenceOnly.test.ts b/tests/tools/capabilityRegistryReferenceOnly.test.ts new file mode 100644 index 00000000..a0680ee8 --- /dev/null +++ b/tests/tools/capabilityRegistryReferenceOnly.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A `local_only` capability is not hidden on a remote server. The query that should find it +// still finds it, because an agent asked to upload a file from the user's machine needs to +// learn the route exists and what replaces it here. What it loses is the means to call it: +// the parameters and response shapes are stripped, and invocation is refused. +// +// This is the one place describe and invoke diverge. Everywhere else a name that describes +// is a name that invokes; a reference-only capability exists precisely to be read, not +// called. + +const LOCAL_ONLY = { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + entity: "build", + encoding: "multipart", + local_only: true, + unavailable_note: "Upload it yourself and pass the returned url.", + intent: "Upload a build that is on this machine.", + guidance: ["The file must sit inside MCP_UPLOAD_BASE_DIR."], + body: [{ name: "file", type: "file", required: true }], + returns: ["app_url"], + responses: { "200": { description: "ok" } }, + paginated: false, +}; +const ORDINARY = { + name: "upload_from_url", + method: "POST", + path: "/upload-url", + mode: "write", + entity: "build", + body: [{ name: "url", type: "string", required: true }], +}; +const OFF = { + name: "gone", + method: "GET", + path: "/gone", + mode: "read", + entity: "build", + disabled: true, + local_only: true, +}; + +function product(capabilities: unknown[] = [LOCAL_ONLY, ORDINARY, OFF]) { + return { + p: { + summary: "s", + base_url: "https://p.example", + capabilities, + entities: { + build: { + description: "A build", + capabilities: ["upload_from_file", "upload_from_url", "gone"], + }, + }, + }, + }; +} + +async function load(remote: boolean, capabilities?: unknown[]) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + const { CapabilityRegistry } = + await import("../../src/tools/capability-registry/index-loader.js"); + return new CapabilityRegistry({ + schema_version: 1, + build_id: "test", + products: product(capabilities), + } as never); +} + +describe("a local_only capability on a remote server", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("stays in the product listing, so search can still reach it", async () => { + const reg = await load(true); + expect(reg.index.products.p.capabilities.map((c) => c.name)).toContain( + "upload_from_file", + ); + }); + + it("stays named on its entity", async () => { + const reg = await load(true); + expect(reg.index.products.p.entities.build.capabilities).toContain( + "upload_from_file", + ); + }); + + it("keeps the intent the search matched on", async () => { + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.intent).toBe("Upload a build that is on this machine."); + }); + + it("loses everything a caller would need to invoke it", async () => { + // A model handed a body schema fills it in, so the schema goes. + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.body).toBeUndefined(); + expect(capability.responses).toBeUndefined(); + expect(capability.returns).toBeUndefined(); + expect(capability.encoding).toBeUndefined(); + expect(capability.invocable).toBe(false); + }); + + it("replaces its guidance with the note, which is the whole of what is left", async () => { + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance).toEqual([ + "Upload it yourself and pass the returned url.", + ]); + }); + + it("says so even without an authored note, rather than saying nothing", async () => { + const reg = await load(true, [ + { ...LOCAL_ONLY, unavailable_note: undefined }, + ]); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance?.[0]).toMatch(/cannot be called/); + }); +}); + +describe("the same capability on a local server", () => { + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("is invocable, with its parameters intact", async () => { + const reg = await load(false); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.invocable).toBeUndefined(); + expect(capability.body).toHaveLength(1); + expect(capability.guidance?.[0]).toMatch(/MCP_UPLOAD_BASE_DIR/); + }); +}); + +describe("the flags do not blur into each other", () => { + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("`disabled` still hides a capability outright, local_only or not", async () => { + for (const remote of [false, true]) { + const reg = await load(remote); + expect( + reg.index.products.p.capabilities.map((c) => c.name), + ).not.toContain("gone"); + expect(() => reg.byNameLookup("gone", "p")).toThrow(/unknown_capability/); + } + }); + + it("leaves an ordinary capability untouched on both surfaces", async () => { + for (const remote of [false, true]) { + const reg = await load(remote); + const { capability } = reg.byNameLookup("upload_from_url", "p"); + expect(capability.invocable).toBeUndefined(); + expect(capability.body).toHaveLength(1); + } + }); +}); From 73800016e6d50ecc7d54736e4385947083fde281 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 15:55:52 +0530 Subject: [PATCH 06/53] feat(capability-registry): onboard Automate, the web half of the browser and device work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Automate drives an installed app; Automate drives a browser. Same shape, two hosts again — api.browserstack.com for the catalogue, plan and session records, and hub.browserstack.com for WebDriver — so the named-host mechanism carries it with no further change. It is a separate product rather than more capabilities on appautomate: the entity is a browser session, not an app session, and "session", "click" and "screenshot" are shared vocabulary that would make routing between them a coin toss. 32 capabilities: the browser catalogue, plan and session records; sessions on desktop and on real phones; navigation with history; finding one element or many; click, type and clear; reading text, properties, attributes and visibility; page source; cookies; JavaScript; screenshots; window geometry; tabs and frames. Everything was probed on a real browser before its contract was written, and the probing is most of what the guidance says: - get_element_text is EMPTY for an input. What a user typed is a property, so get_element_property on "value" is the one that reads a field. - find_elements answers an empty list and a 200 where find_element answers 404, which is how to test for absence without handling an error. - clear_element genuinely empties a field here, unlike on App Automate where it reports success and changes nothing. Both indexes now say so. - switch_to_frame with null is how to get back to the top-level page, and not doing it is the usual reason an element plainly on screen cannot be found. - On mobile the browser is named after the platform: Chrome on Android is "android", Safari is "iphone" or "ipad". Twelve names, read from the API rather than guessed. - set_window_rect is accepted on Android and refused with a 405 on iOS. Two registry changes the web hub forced, both kept narrow: send_empty_body, per capability, for a parameterless POST. Chrome answers 400 "invalid argument" to one with the body omitted and 200 to the same call carrying `{}`; Appium tolerates the omission, which is why the identical click_element needs this on one product and not the other. Opt-in rather than a rule for every bodyless POST, because 27 of those ship across the indexes and this cannot verify what `{}` does to them. `any` now passes a value through coerceType untouched. It was falling to the string branch, so a frame id of null arrived as "null" and was refused as an invalid argument — a failure that reads like the caller chose the wrong frame. check-contract.py gains an envelope check. A hand-authored index with schema_version "0.1" was refused by the loader, and because loading is a merge across files it took tm, tra and loadtesting down with it. One assertion prevents a total outage from a typo in a new file. Co-Authored-By: Claude Opus 5 (1M context) --- capability/automate.capability-index.json | 2060 +++++++++++++++++++++ scripts/check-contract.py | 28 + src/tools/capability-registry/bind.ts | 7 + src/tools/capability-registry/resolve.ts | 13 +- src/tools/capability-registry/types.ts | 14 + 5 files changed, 2115 insertions(+), 7 deletions(-) create mode 100644 capability/automate.capability-index.json diff --git a/capability/automate.capability-index.json b/capability/automate.capability-index.json new file mode 100644 index 00000000..763bd970 --- /dev/null +++ b/capability/automate.capability-index.json @@ -0,0 +1,2060 @@ +{ + "schema_version": 1, + "version": "0.1", + "build_id": "automate-authored", + "automate": { + "summary": "BrowserStack Automate: 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 and element text, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, move between tabs and iframes, and read cookies. 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 App Automate, 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.", + "routing_terms": [ + "automate", + "browser", + "web", + "website", + "web page", + "chrome", + "firefox", + "safari", + "edge", + "desktop browser", + "mobile browser", + "selenium", + "webdriver", + "navigate", + "url", + "click", + "type", + "scroll", + "iframe", + "tab", + "cookie", + "screenshot", + "console logs", + "network logs", + "har", + "browser session", + "cross browser" + ], + "base_url": "https://hub.browserstack.com", + "hosts": { + "api": "https://api.browserstack.com" + }, + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "list_browsers", + "method": "GET", + "path": "/automate/browsers.json", + "mode": "read", + "entity": "browser", + "intent": "List every operating system, browser and version combination this account can run a web session on, with the exact names create_session expects.", + "guidance": [ + "This is a very large answer \u2014 several thousand rows, one per combination \u2014 and there is no filtering on the wire. Narrow it yourself, and never read it whole into a reply.", + "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.", + "The browser names are lower case and are what create_session takes: chrome, firefox, edge, safari, opera, and chromium on mobile.", + "Pass the strings exactly as they appear; an unlisted combination is refused rather than substituted." + ], + "returns": [ + "browser", + "browser_version", + "os", + "os_version", + "device", + "real_mobile" + ], + "responses": { + "200": { + "description": "Every supported combination.", + "schema": { + "$schema": "BrowserList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_plan", + "method": "GET", + "path": "/automate/plan.json", + "mode": "read", + "entity": "plan", + "intent": "Show the Automate plan and how many parallel browser sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "guidance": [ + "A start past the limit queues rather than failing, which looks like a slow start; this is how to tell the two apart.", + "The counts are account-wide and include sessions other people and CI started.", + "This is the web plan. App Automate has its own, counted separately." + ], + "returns": [ + "automate_plan", + "parallel_sessions_running", + "parallel_sessions_max_allowed", + "team_parallel_sessions_max_allowed", + "queued_sessions", + "queued_sessions_max_allowed", + "terminal_access" + ], + "responses": { + "200": { + "description": "Plan name and parallel usage.", + "schema": { + "$schema": "Plan" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_session", + "method": "GET", + "path": "/automate/sessions/{session_id}.json", + "mode": "read", + "entity": "browser_session", + "intent": "Get a web session's record: status, browser, duration, the shareable link, the video, and the console, network and profiling logs \u2014 use this for the links to share or to review a finished run.", + "guidance": [ + "public_url opens without a BrowserStack login, so it is the link to share outside the team; it carries an auth token and should be treated as a secret.", + "browser_url is the session on the dashboard and needs a login.", + "video_url is only complete once the session has ended.", + "har_logs_url, browser_console_logs_url and browser_profiling_url are present only when the session was started with the matching capability; they are fetched separately and are not part of this answer.", + "Works for any session on the account, including ones this caller did not start." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "status", + "reason", + "browser", + "browser_version", + "os", + "os_version", + "device", + "duration", + "public_url", + "browser_url", + "video_url", + "har_logs_url", + "browser_console_logs_url", + "browser_profiling_url", + "build_name", + "project_name", + "created_at", + "browserstack_status", + "hashed_id", + "name" + ], + "responses": { + "200": { + "description": "The session record.", + "schema": { + "$schema": "SessionDetails" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_status", + "method": "PUT", + "path": "/automate/sessions/{session_id}.json", + "mode": "write", + "entity": "browser_session", + "intent": "Mark a web session passed or failed with a reason, so the dashboard shows the outcome.", + "guidance": [ + "Works after the session has ended as well as during it.", + "Only 'passed' and 'failed' are accepted.", + "reason is truncated at 255 characters.", + "A session left unmarked reads as an abandoned run on the dashboard." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "values": [ + "passed", + "failed" + ], + "example": "passed" + }, + { + "name": "reason", + "type": "string", + "maxLength": 255, + "description": "Short explanation shown beside the status.", + "example": "Cart totalled correctly after adding two items" + } + ], + "returns": [ + "status", + "reason", + "hashed_id", + "name", + "browser", + "os", + "os_version", + "build_name", + "project_name" + ], + "responses": { + "200": { + "description": "The updated session record.", + "schema": { + "$schema": "SessionStatusUpdate" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "create_session", + "method": "POST", + "path": "/wd/hub/session", + "mode": "write", + "entity": "browser_session", + "intent": "Start a browser session on a real desktop or mobile browser and get a session id to drive it with \u2014 use this to open a page and interact with it step by step.", + "guidance": [ + "A DESKTOP target needs browser_name with os and os_version; a MOBILE BROWSER needs browser_name with device_name and real_mobile instead, and takes no browser_version because the browser ships with the OS. Sending both kinds together is refused.", + "ON MOBILE THE BROWSER IS NAMED AFTER THE PLATFORM, not the product: Chrome on an Android phone is \"android\", Safari on an iPhone is \"iphone\" and on an iPad \"ipad\". Samsung Internet is \"samsung\" and Chromium-based mobile browsers are \"chromium\". Asking for \"chrome\" or \"safari\" on a phone is refused.", + "Take every value from list_browsers exactly as it spells them; an unlisted combination is refused rather than substituted.", + "The session id comes back at value.sessionId \u2014 carry it into every other browser_session capability.", + "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes Automate minutes until it ends: always finish with set_session_status and then delete_session.", + "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", + "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors." + ], + "body": [ + { + "name": "browser_name", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/browserName", + "values": [ + "chrome", + "chromeForTesting", + "edge", + "firefox", + "ie", + "opera", + "safari", + "android", + "chromium", + "ipad", + "iphone", + "samsung" + ], + "description": "Lower case except chromeForTesting, exactly as list_browsers spells it. Desktop: chrome, chromeForTesting, edge, firefox, ie, opera, safari. Mobile: android, chromium, ipad, iphone, samsung.", + "example": "chrome" + }, + { + "name": "browser_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/browserVersion", + "description": "Desktop only. Omit for the newest, or for a mobile browser, which has no separate version.", + "example": "latest" + }, + { + "name": "os", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/os", + "description": "Desktop only, paired with os_version. Only Windows and OS X; a mobile browser uses device_name instead.", + "example": "Windows", + "values": [ + "Windows", + "OS X" + ] + }, + { + "name": "os_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/osVersion", + "description": "The desktop OS version, or the mobile device's OS version.", + "example": "11" + }, + { + "name": "device_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/deviceName", + "description": "Mobile browser only, paired with real_mobile and os_version.", + "example": "Samsung Galaxy S23" + }, + { + "name": "real_mobile", + "type": "string", + "values": [ + "true" + ], + "json_path": "/capabilities/alwaysMatch/bstack:options/realMobile", + "description": "Mobile browser only: the string \"true\", not a boolean.", + "example": "true" + }, + { + "name": "resolution", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/resolution", + "description": "Desktop screen resolution. The browser window is smaller than this; read the real size from get_window_rect.", + "example": "1920x1080" + }, + { + "name": "idle_timeout", + "type": "integer", + "minimum": 0, + "maximum": 300, + "default": 90, + "json_path": "/capabilities/alwaysMatch/bstack:options/idleTimeout", + "description": "Seconds the session may sit without a command before it is ended. Raise it when an agent drives the session step by step.", + "example": 300 + }, + { + "name": "video", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/video", + "description": "Record a video of the session. On by default; set false for a page showing sensitive data.", + "example": true + }, + { + "name": "console_logs", + "type": "string", + "values": [ + "disable", + "errors", + "warnings", + "info", + "verbose" + ], + "json_path": "/capabilities/alwaysMatch/bstack:options/consoleLogs", + "description": "Capture the browser console at this level.", + "example": "info" + }, + { + "name": "network_logs", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkLogs", + "description": "Capture network traffic as a HAR, linked from get_session.", + "example": true + }, + { + "name": "project_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", + "description": "Groups sessions on the dashboard.", + "example": "Checkout flows" + }, + { + "name": "build_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", + "description": "Build the session is filed under.", + "example": "agent-exploration" + }, + { + "name": "session_name", + "type": "string", + "maxLength": 255, + "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", + "description": "Name shown for this session.", + "example": "cart totals" + } + ], + "returns": [ + "value.sessionId", + "value.capabilities" + ], + "responses": { + "200": { + "description": "The new session.", + "schema": { + "$schema": "NewSessionResponse" + } + }, + "400": { + "$response": "WebDriverError" + } + } + }, + { + "name": "navigate_to", + "method": "POST", + "path": "/wd/hub/session/{session_id}/url", + "mode": "write", + "entity": "browser_session", + "intent": "Open a URL in the session's browser \u2014 use this to go to the page you want to work on.", + "guidance": [ + "The call returns once the page has loaded, so there is no need to wait afterwards; a slow page is bounded by the page-load timeout rather than by this call.", + "A URL without a scheme is rejected: send the https:// prefix.", + "Navigating away invalidates every element id already held \u2014 find elements again on the new page." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "url", + "type": "string", + "required": true, + "format": "uri", + "description": "Absolute URL including the scheme.", + "example": "https://www.bstackdemo.com/" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_current_url", + "method": "GET", + "path": "/wd/hub/session/{session_id}/url", + "mode": "read", + "entity": "browser_session", + "intent": "Read the URL the browser is on now \u2014 use this to confirm a navigation, a redirect or a form submission went where expected.", + "guidance": [ + "A single-page app can change what is on screen without changing this, so confirm from the page rather than the URL alone." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current URL.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_title", + "method": "GET", + "path": "/wd/hub/session/{session_id}/title", + "mode": "read", + "entity": "browser_session", + "intent": "Read the page title \u2014 the cheapest way to confirm which page is loaded.", + "guidance": [ + "Far cheaper than get_page_source when all that is needed is which page is showing." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The document title.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "go_back", + "method": "POST", + "path": "/wd/hub/session/{session_id}/back", + "mode": "write", + "entity": "browser_session", + "intent": "Go back one entry in the browser's history.", + "guidance": [ + "Takes no parameters of its own.", + "Element ids from the page being left stop working; find them again.", + "Going back past the first page of the session does nothing and still answers 200." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "go_forward", + "method": "POST", + "path": "/wd/hub/session/{session_id}/forward", + "mode": "write", + "entity": "browser_session", + "intent": "Go forward one entry in the browser's history, after going back.", + "guidance": [ + "Takes no parameters of its own.", + "Does nothing when there is nothing to go forward to, and still answers 200." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "refresh_page", + "method": "POST", + "path": "/wd/hub/session/{session_id}/refresh", + "mode": "write", + "entity": "browser_session", + "intent": "Reload the current page \u2014 use this to pick up a change the page does not reflect on its own.", + "guidance": [ + "Takes no parameters of its own.", + "Every element id held becomes stale; find them again.", + "Anything typed and not submitted is lost." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "get_page_source", + "method": "GET", + "path": "/wd/hub/session/{session_id}/source", + "mode": "read", + "entity": "browser_session", + "intent": "Get the current page's HTML \u2014 use this to find selectors, or to read content a screenshot cannot show.", + "guidance": [ + "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title and get_element_text answer most questions far more cheaply.", + "This is the page as the browser has it now, including anything scripts have changed, not the HTML the server sent.", + "Inside a frame this returns that frame's document; switch_to_frame with a null id first to get the top-level page back." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The page HTML.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "find_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element", + "mode": "read", + "entity": "browser_session", + "intent": "Locate one element on the page by CSS selector, XPath or link text \u2014 use this to get the element id that click_element, send_keys and the element readers act on.", + "guidance": [ + "The element id comes back under value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", + "No match is a 404 with error \"no such element\". The element may not have rendered yet, or may be inside a frame \u2014 switch_to_frame first in that case.", + "A css selector is the cheapest and most stable choice; xpath is slower and brittle to markup changes.", + "An id goes stale the moment the page navigates, reloads or re-renders that part of the DOM, and using a stale one answers \"stale element reference\". Find it again rather than holding it across a navigation." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "css selector", + "xpath", + "link text", + "partial link text", + "tag name" + ], + "example": "css selector" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": ".shelf-item__buy-btn" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The matching element reference.", + "schema": { + "$schema": "ElementResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "find_elements", + "method": "POST", + "path": "/wd/hub/session/{session_id}/elements", + "mode": "read", + "entity": "browser_session", + "intent": "Locate every element matching a selector \u2014 use this to count matches, or to work through a list of results, products or rows.", + "guidance": [ + "No match is an EMPTY LIST and a 200, not a 404 \u2014 the opposite of find_element, which is how to test whether something is absent without handling an error.", + "The order is the document's own.", + "Each entry carries the same element-6066 key as find_element." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "css selector", + "xpath", + "link text", + "partial link text", + "tag name" + ], + "example": "css selector" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": ".shelf-item__buy-btn" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every matching element, possibly none.", + "schema": { + "$schema": "ElementListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "click_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/click", + "mode": "write", + "entity": "browser_session", + "intent": "Click an element \u2014 use this for buttons, links, checkboxes and anything else that responds to a click.", + "guidance": [ + "Takes no parameters of its own.", + "The element must be in view and not covered. A click on something another element overlaps answers \"element click intercepted\" and names the element in the way \u2014 scroll it into view with execute_script, or dismiss the overlay first.", + "An element that is not visible answers \"element not interactable\".", + "Success means the click was dispatched, not that the page has finished reacting: read the page or the URL afterwards to confirm." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "send_keys", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/value", + "mode": "write", + "entity": "browser_session", + "intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.", + "guidance": [ + "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", + "Read the result back with get_element_property on \"value\", not get_element_text, which is empty for an input.", + "A \\n at the end submits the form, which is usually quicker than finding and clicking the submit button.", + "The element must be editable; typing into anything else answers \"element not interactable\".", + "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + } + ], + "body": [ + { + "name": "text", + "type": "string", + "required": true, + "description": "The characters to type. End with \\n to submit.", + "example": "BrowserStack" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "clear_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/clear", + "mode": "write", + "entity": "browser_session", + "intent": "Empty an input \u2014 use this before typing, since send_keys adds to what is already there.", + "guidance": [ + "Takes no parameters of its own.", + "Confirm with get_element_property on \"value\", which reads empty afterwards.", + "Clearing also fires the page's own change handlers, so a form may revalidate and show an error about the now-empty field.", + "The element must be editable; anything else answers \"invalid element state\"." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "get_element_text", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/text", + "mode": "read", + "entity": "browser_session", + "intent": "Read the visible text of an element \u2014 use this to check a heading, a price, a label or an error message.", + "guidance": [ + "EMPTY FOR AN INPUT. What a user typed is a property, not text: read it with get_element_property on \"value\".", + "This is the rendered text, so it excludes anything hidden by CSS and collapses whitespace the way the page displays it.", + "For a container it returns the text of everything inside it, which can be far more than expected." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The element's visible text.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_property", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/property/{name}", + "mode": "read", + "entity": "browser_session", + "intent": "Read a live DOM property of an element \u2014 use this for what a field currently holds, or whether a box is checked right now.", + "guidance": [ + "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. get_element_text is empty for an input, and the \"value\" attribute is the markup's initial value rather than what is in the box now.", + "Useful names: value, checked, selected, disabled, innerText, href.", + "A property the element does not have comes back null, not an error." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The DOM property to read.", + "example": "value" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The property's value, or null.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_attribute", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/attribute/{name}", + "mode": "read", + "entity": "browser_session", + "intent": "Read an HTML attribute of an element \u2014 use this for ids, classes, data- attributes and link targets as the markup declares them.", + "guidance": [ + "This is the attribute as written in the HTML, which is NOT what a field currently holds: for that, use get_element_property on \"value\".", + "An attribute the element does not carry comes back null rather than an error, so null means absent, not empty.", + "An attribute a script has changed may still read as the original markup; the property is the live view." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The attribute to read.", + "example": "class" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The attribute's value, or null when absent.", + "schema": { + "$schema": "NullableStringResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "is_element_displayed", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/displayed", + "mode": "read", + "entity": "browser_session", + "intent": "Check whether an element is actually visible on the page \u2014 use this before clicking, or to confirm something appeared or went away.", + "guidance": [ + "False for an element that exists in the DOM but is hidden, zero-sized or has no visible text, which is why find_element succeeding does not mean a click will.", + "It does not say whether the element is scrolled into view or covered by something else; a click can still be intercepted." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the element is visible.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "execute_script", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Run JavaScript in the page \u2014 use this to scroll, read something the element readers cannot reach, or drive a control that resists clicking.", + "guidance": [ + "SCROLLING LIVES HERE: window.scrollTo(0, document.body.scrollHeight) goes to the bottom, and arguments[0].scrollIntoView({block:'center'}) brings an element into view before clicking it.", + "`return` is required to get a value back; without it the answer is null.", + "An element id can be passed in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives as a real element in arguments.", + "It runs with the page's own privileges and can change anything, so a script that alters state is as consequential as a click.", + "A script that throws answers \"javascript error\" with the page's own message." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The JavaScript to run. Use `return` to get a value back.", + "example": "window.scrollTo(0, document.body.scrollHeight); return window.scrollY" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Arguments, available as `arguments` in the script. Send an empty list when there are none.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "take_screenshot", + "method": "GET", + "path": "/wd/hub/session/{session_id}/screenshot", + "mode": "read", + "entity": "browser_session", + "intent": "Take a screenshot of the browser's viewport, as a base64 PNG \u2014 use this to see the page as a user would.", + "guidance": [ + "Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.", + "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with execute_script.", + "get_element_text and get_title answer most checks far more cheaply than reading an image." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded PNG of the viewport.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_window_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "read", + "entity": "browser_session", + "intent": "Read the browser window's size and position \u2014 use this to learn the real viewport before planning anything that depends on layout.", + "guidance": [ + "The window is smaller than the screen resolution asked for at session start, because of the browser's own chrome; this is the real figure." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The window rectangle.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_window_rect", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "write", + "entity": "browser_session", + "intent": "Resize or move the browser window \u2014 use this to check a layout at a particular width.", + "guidance": [ + "The response is the rectangle actually applied, which can differ from the one asked for: a window cannot exceed the screen, and some sizes are adjusted.", + "Resizing re-lays out the page, so element positions change and anything measured before is stale.", + "A real mobile browser is not uniformly resizable: an Android device accepts the call, while iOS refuses it with a 405. Read the size back from get_window_rect rather than assuming either." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "width", + "type": "integer", + "minimum": 0, + "example": 1200 + }, + { + "name": "height", + "type": "integer", + "minimum": 0, + "example": 900 + }, + { + "name": "x", + "type": "integer", + "example": 0 + }, + { + "name": "y", + "type": "integer", + "example": 0 + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The rectangle now in effect.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "maximize_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/maximize", + "mode": "write", + "entity": "browser_session", + "intent": "Maximise the browser window \u2014 use this to give the page the most room before reading a wide layout.", + "guidance": [ + "Takes no parameters of its own.", + "Page layout changes, so anything measured before is stale.", + "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The rectangle after maximising.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "list_window_handles", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/handles", + "mode": "read", + "entity": "browser_session", + "intent": "List the open tabs and windows \u2014 use this to find a tab a link opened, before switching to it.", + "guidance": [ + "The first handle is the one the session started in. A new tab appears at the end.", + "A handle is opaque and only valid within this session.", + "Commands keep going to the tab last switched to, even after a link opens another one \u2014 a click that opens a tab does not move the session into it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every open tab or window.", + "schema": { + "$schema": "StringListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "switch_to_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window", + "mode": "write", + "entity": "browser_session", + "intent": "Point the session at a different tab or window \u2014 use this after a link opens one, so later commands act on it.", + "guidance": [ + "Use a handle exactly as list_window_handles returned it; an invented one answers \"no such window\".", + "Element ids from the previous tab do not carry over.", + "Switching does not close anything; the other tab stays open." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "handle", + "type": "string", + "required": true, + "description": "Handle from list_window_handles.", + "example": "2FFCF2904DC789DD82BACEF48E43AE93" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "open_new_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/new", + "mode": "write", + "entity": "browser_session", + "intent": "Open a new tab or window \u2014 use this to work on two pages at once.", + "guidance": [ + "The new handle comes back in the response, but the session does NOT move into it: call switch_to_window next.", + "The new tab starts blank; navigate_to after switching." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "type", + "type": "string", + "values": [ + "tab", + "window" + ], + "example": "tab", + "description": "Open a tab or a separate window." + } + ], + "returns": [ + "value.handle", + "value.type" + ], + "responses": { + "200": { + "description": "The new tab's handle.", + "schema": { + "$schema": "NewWindowResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "switch_to_frame", + "method": "POST", + "path": "/wd/hub/session/{session_id}/frame", + "mode": "write", + "entity": "browser_session", + "intent": "Move into an iframe, or back out to the top-level page \u2014 use this when the content you need is inside an embedded frame.", + "guidance": [ + "SEND null TO GET BACK TO THE TOP-LEVEL PAGE. Until you do, every find_element and the page source apply to the frame only, which is the usual reason an element that is plainly on screen cannot be found.", + "An index counts the frames in document order from 0, and an out-of-range one answers 404 \"no such frame\".", + "A frame can also be entered by passing an element id found for the iframe itself.", + "Element ids do not survive a frame switch.", + "Send the index as a number and null as a real null. A quoted \"0\" or \"null\" is refused with \"invalid argument\" rather than treated as a frame." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "id", + "type": "any", + "required": true, + "description": "The frame's index as a NUMBER, or an element reference for the iframe, or null to return to the top-level page. The type matters: sent as text, each of these is refused as an invalid argument.", + "example": 0 + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_cookies", + "method": "GET", + "path": "/wd/hub/session/{session_id}/cookie", + "mode": "read", + "entity": "browser_session", + "intent": "Read the cookies for the current page \u2014 use this to check a login or consent cookie was set.", + "guidance": [ + "Only cookies visible to the current page's domain; navigating elsewhere gives a different set.", + "An empty list means none for this domain, not an error.", + "A session that has not navigated anywhere yet has no cookies at all." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The cookies for this domain.", + "schema": { + "$schema": "CookieListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "delete_session", + "method": "DELETE", + "path": "/wd/hub/session/{session_id}", + "mode": "write", + "entity": "browser_session", + "intent": "End a browser session and release the browser \u2014 use this when the flow is finished, after marking it passed or failed.", + "guidance": [ + "Mark the outcome with set_session_status FIRST; an unmarked session reads as abandoned on the dashboard.", + "The session record, video and logs survive and stay readable with get_session.", + "A session already ended answers 404, which means it is gone rather than that something failed.", + "Every session consumes Automate minutes until it ends, so end it even when the run failed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + } + ], + "schemas": { + "StringValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "NullableStringResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Null when the attribute is absent." + } + } + }, + "BooleanValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + } + } + }, + "NullValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "null" + } + } + }, + "AnyValueResponse": { + "type": "object", + "properties": { + "value": { + "description": "Whatever the call produced; the shape depends on what was asked for." + } + } + }, + "StringListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ElementResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + }, + "ElementListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string" + } + } + } + } + } + }, + "RectResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + } + }, + "NewWindowResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "handle": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + }, + "CookieListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "path": { + "type": "string" + }, + "secure": { + "type": "boolean" + }, + "httpOnly": { + "type": "boolean" + }, + "expiry": { + "type": "integer" + } + } + } + } + } + }, + "NewSessionResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Carry this into every other browser_session capability." + }, + "capabilities": { + "type": "object", + "description": "What the hub negotiated, including the browser version actually given." + } + } + } + } + }, + "WebDriverErrorBody": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "The WebDriver error code, such as \"no such element\"." + }, + "message": { + "type": "string" + }, + "stacktrace": { + "type": "string" + } + } + } + } + }, + "BrowserList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "browser": { + "type": "string", + "description": "Lower case, as create_session takes it." + }, + "browser_version": { + "type": "string", + "description": "Null for a mobile browser, which ships with the OS." + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Null for a desktop combination." + }, + "real_mobile": { + "type": "boolean", + "description": "True for a real phone or tablet." + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "automate_plan": { + "type": "string" + }, + "parallel_sessions_running": { + "type": "integer", + "description": "Account-wide, including other people's sessions." + }, + "parallel_sessions_max_allowed": { + "type": "integer" + }, + "team_parallel_sessions_max_allowed": { + "type": "integer" + }, + "queued_sessions": { + "type": "integer" + }, + "queued_sessions_max_allowed": { + "type": "integer" + }, + "terminal_access": { + "type": "string" + } + } + }, + "SessionStatusUpdate": { + "type": "object", + "description": "The session record after the change.", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "browser_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Set for a mobile browser, null for a desktop one." + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + } + } + } + } + }, + "SessionDetails": { + "type": "object", + "description": "The session record, wrapped in automation_session.", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "browser_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Set for a mobile browser, null for a desktop one." + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "duration": { + "type": "integer", + "description": "Seconds; null while the session is still running." + }, + "created_at": { + "type": "string" + }, + "public_url": { + "type": "string", + "description": "Shareable link needing no login; carries an auth token, so treat it as a secret." + }, + "browser_url": { + "type": "string", + "description": "The session on the dashboard; needs a login." + }, + "video_url": { + "type": "string", + "description": "Complete only once the session has ended." + }, + "har_logs_url": { + "type": "string", + "description": "Present only when the session was started with network logs." + }, + "browser_console_logs_url": { + "type": "string", + "description": "Present only when console logs were asked for." + }, + "browser_profiling_url": { + "type": "string" + }, + "browserstack_status": { + "type": "string", + "description": "What BrowserStack observed, which can differ from the status that was set." + } + } + } + } + } + }, + "responses": { + "WebDriverError": { + "description": "A WebDriver failure, naming the error code and the browser's own message.", + "schema": { + "$schema": "WebDriverErrorBody" + } + }, + "Unauthorized": { + "description": "The credentials were rejected.", + "schema": { + "$schema": "WebDriverErrorBody" + } + } + }, + "paging": {}, + "entities": { + "browser_session": { + "title": "Browser Session", + "description": "A live browser on one operating system, driven page by page until it ends.", + "aliases": [ + "browser session", + "automate session", + "web session", + "selenium session", + "webdriver session", + "browser", + "session", + "test session" + ], + "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element and is valid only until the page navigates or that part of the page re-renders.", + "key_facts": [ + "A session holds a browser and consumes Automate minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "Element ids go stale the moment the page navigates, reloads or re-renders, and a stale one is refused rather than silently acting on the wrong thing.", + "Console and network logs must be asked for when the session starts; they cannot be turned on later.", + "Each session is recorded with video and its record outlives it, including the shareable link and the passed or failed status.", + "Everything inside an iframe is invisible until the session is switched into it, and invisible again until it is switched back out." + ], + "capabilities": [ + "get_session", + "set_session_status", + "create_session", + "navigate_to", + "get_current_url", + "get_title", + "go_back", + "go_forward", + "refresh_page", + "get_page_source", + "find_element", + "find_elements", + "click_element", + "send_keys", + "clear_element", + "get_element_text", + "get_element_property", + "get_element_attribute", + "is_element_displayed", + "execute_script", + "take_screenshot", + "get_window_rect", + "set_window_rect", + "maximize_window", + "list_window_handles", + "switch_to_window", + "open_new_window", + "switch_to_frame", + "get_cookies", + "delete_session" + ] + }, + "browser": { + "title": "Browser", + "description": "One operating system, browser and version combination a session can run on.", + "aliases": [ + "browser", + "browser version", + "os", + "platform", + "combination", + "browser list", + "capability", + "desktop browser", + "mobile browser" + ], + "id_convention": "A combination is addressed by its browser name with either os and os_version, or device and os_version for a mobile browser; there is no id, and the strings must match the list exactly.", + "key_facts": [ + "Several thousand combinations exist, so the list is large and has to be narrowed by the caller.", + "A mobile browser is named after its platform rather than its product, so the name that works on a phone is not the one a person would say.", + "A mobile browser is the one that ships with the device's OS, so it has no separate version to choose.", + "The set changes as browser versions are released and retired, so a combination held from an earlier run can stop being offered." + ], + "capabilities": [ + "list_browsers" + ] + }, + "plan": { + "title": "Plan", + "description": "The account's Automate subscription and how much of its parallel-session allowance is in use.", + "aliases": [ + "plan", + "subscription", + "parallel sessions", + "parallels", + "concurrency", + "quota", + "capacity", + "limits", + "usage" + ], + "id_convention": "There is one plan per account; it is not addressed by an id.", + "key_facts": [ + "Parallel-session counts are account-wide and include sessions started by other people and by CI.", + "A team can have a lower ceiling than the account as a whole.", + "Starting past the limit queues the session rather than refusing it, which looks like a slow start.", + "Web and mobile-app sessions are counted against separate plans." + ], + "capabilities": [ + "get_plan" + ] + } + } + } +} \ No newline at end of file diff --git a/scripts/check-contract.py b/scripts/check-contract.py index 56240ec1..244adee4 100755 --- a/scripts/check-contract.py +++ b/scripts/check-contract.py @@ -15,8 +15,36 @@ """ import json, sys, collections +# The envelope is three fields and every shipped index gets them right, so nothing checked +# it — until a hand-authored file arrived with `schema_version: "0.1"` and the loader +# refused it. Loading is a MERGE across files, so that one file took every product down +# with it: tm, tra and loadtesting became unavailable because a new index had a typo. Cheap +# to assert, and the failure it prevents is total. +SCHEMA_VERSION = 1 + + +def check_envelope(path, d): + problems = [] + if d.get('schema_version') != SCHEMA_VERSION: + problems.append( + f"schema_version is {json.dumps(d.get('schema_version'))}, and the server reads " + f"{SCHEMA_VERSION} — it will refuse this file AND every other index alongside it") + for field in ('version', 'build_id'): + if not isinstance(d.get(field), str) or not d[field].strip(): + problems.append(f"{field} must be a non-empty string, and is {json.dumps(d.get(field))}") + products = [k for k in d if k not in ('schema_version', 'version', 'build_id')] + if len(products) != 1: + problems.append(f"expected exactly one product key, found {products}") + if problems: + print(f"\n{path}: unloadable envelope") + for problem in problems: + print(f" {problem}") + sys.exit(1) + + def load(p): d = json.load(open(p)) + check_envelope(p, d) return d, d[list(k for k in d if k not in ('schema_version','version','build_id'))[0]] def resolve(tm, node, seen=None, path=''): diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts index c5894a59..7202ad0c 100644 --- a/src/tools/capability-registry/bind.ts +++ b/src/tools/capability-registry/bind.ts @@ -249,6 +249,13 @@ function coerceType( label = param.name, ): unknown { const expected = param.type; + if (expected === "any") { + // PASSES THROUGH UNTOUCHED, which is the whole point of declaring it. A polymorphic + // parameter reached the string branch and arrived as text: WebDriver's frame id takes a + // number, an element reference or null, and `null` sent as "null" is refused as an + // invalid argument — a failure that reads like the caller chose the wrong frame. + return value; + } if (expected === "file") { // CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path // that does not exist — or a base directory nobody configured — is refused before the diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index 2d400d49..833e0031 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -145,13 +145,12 @@ export async function invoke( // a body the product cannot parse, under a header that says it should be able to. delete headers["Content-Type"]; } - return transport( - capability.method, - url, - headers, - bound.query, - multipart ?? bound.body, - ); + const payload = + multipart ?? + (bound.body === undefined && capability.send_empty_body + ? {} + : bound.body); + return transport(capability.method, url, headers, bound.query, payload); }; let response = await send(); diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index ac890b40..21e0fa3f 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -231,6 +231,20 @@ export interface Capability { * `local_only`: the path names the caller's own machine. */ encoding?: "multipart"; + /** + * Send `{}` rather than no body at all, for an endpoint that takes no parameters. + * + * Normally a capability with nothing to send sends nothing, which is right for a REST API + * and wrong for W3C WebDriver: Chrome answers 400 "invalid argument" to a parameterless + * POST with the body omitted and 200 to the same call carrying `{}`. Appium tolerates the + * omission, which is why the identical capability needs this on Automate and not on App + * Automate. + * + * Opt-in per capability rather than a rule for every bodyless POST, because 27 of those + * exist across the shipped indexes and changing what reaches their products is not + * something this can verify. + */ + send_empty_body?: boolean; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; From da1bf6f13f575c7c181c271d061757e1ed8211a9 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 16:20:13 +0530 Subject: [PATCH 07/53] chore(capability-registry): publish only what the eval suite exercises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapping the 144 eval cases onto the two indexes leaves 21 capabilities no case reaches: on App Automate the device features — GPS, network profile, biometric, camera injection, file transfer, uninstall — and on Automate the DOM readers and window geometry. Those are withheld for now, so the published surface is the one under test rather than everything that was authored. 33 of 46 stay live on App Automate and 24 of 32 on Automate. This is a deliberate narrowing, not a quality judgement: all 78 were probed against real devices and browsers, and each withheld record keeps its contract and guidance intact, so re-enabling one is a single flag. It does cost something, and the cost is worth stating. Reading a screen is now screenshot-only on both products: get_page_source is withheld on App Automate, where it was the way an agent saw the whole UI tree, and get_page_source, get_element_text, get_element_attribute and is_element_displayed are withheld on Automate, where they were how an agent found a selector and checked a result without an image. The eval suite asserts visually, so it does not miss them; an agent doing the same work will pay for every look. Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 39 +++++++++++++------- capability/automate.capability-index.json | 24 ++++++++---- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json index 2a687faf..0b5c3cab 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/appautomate.capability-index.json @@ -342,7 +342,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "find_element", @@ -702,7 +703,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "delete_session", @@ -1201,7 +1203,8 @@ "$response": "Unauthorized" } }, - "host": "api" + "host": "api", + "disabled": true }, { "name": "take_screenshot", @@ -1539,7 +1542,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "get_geo_location", @@ -1573,7 +1577,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "push_file", @@ -1626,7 +1631,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "pull_file", @@ -1671,7 +1677,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "press_keycode", @@ -2075,7 +2082,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "uninstall_app", @@ -2148,7 +2156,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "simulate_biometric", @@ -2201,7 +2210,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "inject_camera_image", @@ -2253,7 +2263,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "annotate_session", @@ -2304,7 +2315,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "is_keyboard_shown", @@ -2464,7 +2476,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "get_active_app_info", diff --git a/capability/automate.capability-index.json b/capability/automate.capability-index.json index 763bd970..0325f20f 100644 --- a/capability/automate.capability-index.json +++ b/capability/automate.capability-index.json @@ -648,7 +648,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "find_element", @@ -946,7 +947,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "get_element_property", @@ -1044,7 +1046,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "is_element_displayed", @@ -1085,7 +1088,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "execute_script", @@ -1209,7 +1213,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "set_window_rect", @@ -1271,7 +1276,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "maximize_window", @@ -1310,7 +1316,8 @@ "$response": "WebDriverError" } }, - "send_empty_body": true + "send_empty_body": true, + "disabled": true }, { "name": "list_window_handles", @@ -1517,7 +1524,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "delete_session", From 29c2509451ff8100f5fa98d87210d7d39e21f5fb Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 16:24:41 +0530 Subject: [PATCH 08/53] chore: bump version to 2.3.0 A minor rather than a patch: this adds capabilities and two products, and changes no existing contract. 2.2.2 is already released, so a beta built on it would sort below what is on `latest` and anyone tracking `@beta` would be handed something older than the stable release. The Beta Release workflow derives `-beta.` from this field and increments the counter itself, so this is the only version that needs setting by hand. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- server.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64ff5f77..2673a26c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@browserstack/mcp-server", - "version": "2.2.2", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@browserstack/mcp-server", - "version": "2.2.2", + "version": "2.3.0", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 9f648042..b8dfde5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@browserstack/mcp-server", - "version": "2.2.2", + "version": "2.3.0", "description": "BrowserStack's Official MCP Server", "mcpName": "io.github.browserstack/mcp-server", "main": "dist/index.js", diff --git a/server.json b/server.json index 4a4a89f2..18c2ca6f 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/browserstack/mcp-server", "source": "github" }, - "version": "2.2.2", + "version": "2.3.0", "packages": [ { "registryType": "npm", "identifier": "@browserstack/mcp-server", - "version": "2.2.2", + "version": "2.3.0", "transport": { "type": "stdio" }, From 0f13e2749a43b5faa5996372b8fe7af3b18de264 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 17:13:08 +0530 Subject: [PATCH 09/53] fix(automate): a newline does not submit a form on Safari for iOS The guidance said a trailing \n submits, which is what the specification implies and what desktop Chrome and Chrome for Android both do. On Safari for iOS it does not: 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. Found by eval case E105, which is the first thing in that suite to catch a published claim rather than confirm one. Clicking the form's submit button works, and so does arguments[0].form.submit() through execute_script with the field as the argument; both are named in the guidance because both were measured. App Automate's own send_keys keeps its line about a newline closing the keyboard on iOS. That was measured against a native text field, which is a different surface from a web form that happens to share the platform's name. Co-Authored-By: Claude Opus 5 (1M context) --- capability/automate.capability-index.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capability/automate.capability-index.json b/capability/automate.capability-index.json index 0325f20f..f362209a 100644 --- a/capability/automate.capability-index.json +++ b/capability/automate.capability-index.json @@ -820,7 +820,7 @@ "guidance": [ "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", "Read the result back with get_element_property on \"value\", not get_element_text, which is empty for an input.", - "A \\n at the end submits the form, which is usually quicker than finding and clicking the submit button.", + "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.", "The element must be editable; typing into anything else answers \"element not interactable\".", "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." ], From 8a94122211b85135fb2bd4bb5a9e6d4c256748e4 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 18:09:50 +0530 Subject: [PATCH 10/53] feat(capability-registry): find_elements on App Automate, and three corrections the eval run found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_elements was on Automate and missing from App Automate, which left no way to ask whether an element is absent: find_element answers that question with a 404, so a caller had to catch an error to learn a fact. Verified on a device before authoring — many matches, one match, and none, where none is an empty list and a 200. The web index's wording about an empty list not proving absence does NOT carry over. Through this surface it can be trusted: a strategy App Automate does not accept is refused before the request is sent, and an iOS strategy aimed at an Android screen comes back as a 400 "invalid selector" rather than as nothing found. The raw endpoint does answer an unsupported strategy with an empty list; the declared enum means a caller never reaches that. Three corrections, each from an eval case rather than a probe: resolution takes one of a fixed set per operating system, not arbitrary dimensions. 1920x1080 is accepted on Windows 11 and 2560x1440 is refused outright with "not compatible with the chosen OS version", and no session starts. The viewport that results is around 930x860, well short of the screen, so the request is not the usable area. (E085) launch_app answers 400 when an app genuinely cannot be brought forward, and get_app_state reads 4 once it really is in front. Sampled too early it can report the home screen mid-switch — which is what led me to accuse the capability of reporting false success before checking. It does not. (E067) get_app_state's backgrounded value is 3 or 2 rather than one number: the system suspends an app a moment after it stops being visible, so anything below 4 means not on screen. (E060) Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 88 +++++++++++++++++++- capability/automate.capability-index.json | 5 +- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json index 0b5c3cab..ac35b6cb 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/appautomate.capability-index.json @@ -356,7 +356,8 @@ "The element id comes back under the W3C key value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", "Take the selector from get_page_source rather than guessing: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\".", "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.", - "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\"))." + "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\")).", + "find_elements answers the same query with a list and never a 404, which is how to ask whether something is present without treating absence as a failure." ], "path_params": [ { @@ -1808,7 +1809,7 @@ "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.", "Launching an app that is already in the foreground does nothing and still reports success.", "To restart an app from a clean state, call terminate_app first and then this.", - "Succeeding means the app was activated, not that it finished drawing; read the page source before acting.", + "Succeeding means the app was activated, not that it finished drawing, so give it a moment and confirm before acting. An app that cannot be brought forward at all answers 400 rather than reporting success, and get_app_state reads 4 once it really is in front \u2014 sampled too early it can still report the home screen mid-switch.", "The app must be installed \u2014 a package that is not there fails rather than installing it." ], "path_params": [ @@ -1953,7 +1954,7 @@ "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", "The value is a number: 0 not installed, 1 installed but not running, 2 running in the background and suspended, 3 running in the background, 4 running in the foreground.", "4 is the only state in which the app is on screen and can be driven.", - "The same scale applies on both platforms. Sending an app to the background with press_button home leaves it at 2, and launching it again returns it to 4." + "The same scale applies on both platforms. Sending an app to the background leaves it at 3 or 2 \u2014 the system suspends it a moment after it stops being visible \u2014 so treat anything below 4 as not on screen rather than waiting for one number. Launching it again returns it to 4." ], "path_params": [ { @@ -2638,6 +2639,67 @@ } }, "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." + }, + { + "name": "find_elements", + "method": "POST", + "path": "/wd/hub/session/{session_id}/elements", + "mode": "read", + "entity": "app_session", + "intent": "Find every element matching a selector \u2014 use this to count matches, work through a list, or check whether something is present at all.", + "guidance": [ + "No match is an EMPTY LIST and a 200, where find_element answers 404. That makes this the way to test whether something is there without handling an error.", + "An empty list can be trusted to mean absent: a strategy this surface does not accept is refused before the request is sent, and an iOS strategy aimed at an Android screen comes back as a 400 \"invalid selector\" rather than as nothing found.", + "Each entry carries the same element-6066-11e4-a52e-4f735466cecf key as find_element, and the ids go stale when the screen changes just as readily.", + "The order is the UI tree's own, so it follows the screen's layout rather than anything meaningful to the app.", + "Use find_element when exactly one match is expected: its 404 says plainly that the screen is not what you thought." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "id", + "accessibility id", + "xpath", + "class name", + "-android uiautomator", + "-ios predicate string", + "-ios class chain" + ], + "example": "accessibility id" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": "nav-cart" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every matching element, possibly none.", + "schema": { + "$schema": "ElementListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } } ], "schemas": { @@ -3065,6 +3127,23 @@ } } } + }, + "ElementListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + } } }, "responses": { @@ -3139,7 +3218,8 @@ "press_button", "get_simulated_location", "get_active_app_info", - "read_device_property" + "read_device_property", + "find_elements" ] }, "device": { diff --git a/capability/automate.capability-index.json b/capability/automate.capability-index.json index f362209a..976dc5bc 100644 --- a/capability/automate.capability-index.json +++ b/capability/automate.capability-index.json @@ -246,7 +246,8 @@ "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes Automate minutes until it ends: always finish with set_session_status and then delete_session.", "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", - "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors." + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured." ], "body": [ { @@ -317,7 +318,7 @@ "name": "resolution", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/resolution", - "description": "Desktop screen resolution. The browser window is smaller than this; read the real size from get_window_rect.", + "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. The browser window is much smaller than the screen either way, so read the real size rather than assuming it.", "example": "1920x1080" }, { From 3ea63466f68eea674120b3870e1b9c1073a513b2 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 18:17:48 +0530 Subject: [PATCH 11/53] chore(capability-registry): publish get_page_source and get_window_rect again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding these two did not cost coverage, it cost RECOVERY. Running the eval suite without them, four cases failed and stayed failing — E047 three times, E058 four — and in every instance the capability under test was working. The failures were wrong selectors and assumed screen bounds, and neither could be diagnosed, because the only move available was to guess again and watch the answer come back empty. With them published, both passed on the first attempt. The page source showed the product ids are add-to-cart-11, -12 and -16 in this build, where I had been guessing -1, -13 and -17 from a build probed earlier; the scroll had worked every time. get_window_rect returned 1080x2400, which is what the gestures had been assuming all along. So the argument is not that reading a screen is expensive without them. It is that an agent cannot recover from a wrong selector or an unknown viewport at all: page source is how a locator is discovered and how a failed one is debugged, and the window rectangle is what every coordinate gesture is measured against. 36 of 47 now publish on App Automate and 26 of 32 on Automate. The 17 still withheld are genuinely peripheral — GPS, biometric, camera injection, file transfer, cookies, element text, window resizing — and none of them stand between a caller and understanding what went wrong. Co-Authored-By: Claude Opus 5 (1M context) --- capability/appautomate.capability-index.json | 6 ++---- capability/automate.capability-index.json | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/capability/appautomate.capability-index.json b/capability/appautomate.capability-index.json index ac35b6cb..e380c7fc 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/appautomate.capability-index.json @@ -342,8 +342,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "find_element", @@ -704,8 +703,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "delete_session", diff --git a/capability/automate.capability-index.json b/capability/automate.capability-index.json index 976dc5bc..b5f53c57 100644 --- a/capability/automate.capability-index.json +++ b/capability/automate.capability-index.json @@ -649,8 +649,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "find_element", @@ -1214,8 +1213,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "set_window_rect", From a07080392ad9dc95b3cf310d3b48405642ba79ea Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 19:03:55 +0530 Subject: [PATCH 12/53] refactor(capability-registry): name the products for what they control, and strip the examples back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appautomate and automate are BrowserStack's own product names, and they say nothing about what the capabilities do. mobiledevicecontrol drives an installed app on a real phone; devicecontrol drives a browser on a desktop or a phone. The product key comes from the filename, so the `product` enum on describeCapability and invokeCapability follows without being edited. The marketing names stay in the summaries and the routing terms, because they are what a person says and what the plan, the minutes and the dashboard are still called. Dropping them would make the products unfindable by the words customers use. Each summary now names the other product, so a router can tell an app from a page. The hand-written App Automate tools under src/tools/appautomate* are untouched. They are a separate surface that happens to share the old name. THE EXAMPLES CARRIED REAL SESSION ARTEFACTS. An element reference and a window handle from a live run had been copied in as the canonical examples for those parameters — meaningless to a reader, and one account's internals shipped to npm. Both are now zeroed placeholders that still show the shape. The rest came from BrowserStack's own demo shop: bstackdemo.com as the example URL, .shelf-item__buy-btn and nav-cart as example selectors, a product name as the text to type. An agent reading those learns one test site rather than the shape of a selector, so they are now example.com, #add-to-cart and cart-button. Device, OS and browser names stay real — those have to match the catalogue exactly, so a plausible-looking placeholder there would be worse than none. Co-Authored-By: Claude Opus 5 (1M context) --- ...on => devicecontrol.capability-index.json} | 42 ++++++++++--------- ...mobiledevicecontrol.capability-index.json} | 25 ++++++----- .../tools/capabilityRegistryNamedHost.test.ts | 26 ++++++------ 3 files changed, 49 insertions(+), 44 deletions(-) rename capability/{automate.capability-index.json => devicecontrol.capability-index.json} (96%) rename capability/{appautomate.capability-index.json => mobiledevicecontrol.capability-index.json} (98%) diff --git a/capability/automate.capability-index.json b/capability/devicecontrol.capability-index.json similarity index 96% rename from capability/automate.capability-index.json rename to capability/devicecontrol.capability-index.json index b5f53c57..8d3141dd 100644 --- a/capability/automate.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -2,8 +2,8 @@ "schema_version": 1, "version": "0.1", "build_id": "automate-authored", - "automate": { - "summary": "BrowserStack Automate: 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 and element text, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, move between tabs and iframes, and read cookies. 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 App Automate, 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.", + "devicecontrol": { + "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 and element text, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, move between tabs and iframes, and read cookies. 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.", "routing_terms": [ "automate", "browser", @@ -31,7 +31,9 @@ "network logs", "har", "browser session", - "cross browser" + "cross browser", + "device control", + "browser control" ], "base_url": "https://hub.browserstack.com", "hosts": { @@ -204,7 +206,7 @@ "type": "string", "maxLength": 255, "description": "Short explanation shown beside the status.", - "example": "Cart totalled correctly after adding two items" + "example": "The page behaved as expected" } ], "returns": [ @@ -364,14 +366,14 @@ "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", "description": "Groups sessions on the dashboard.", - "example": "Checkout flows" + "example": "Web checkout" }, { "name": "build_name", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", "description": "Build the session is filed under.", - "example": "agent-exploration" + "example": "nightly" }, { "name": "session_name", @@ -379,7 +381,7 @@ "maxLength": 255, "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", "description": "Name shown for this session.", - "example": "cart totals" + "example": "sign in and add to basket" } ], "returns": [ @@ -425,7 +427,7 @@ "required": true, "format": "uri", "description": "Absolute URL including the scheme.", - "example": "https://www.bstackdemo.com/" + "example": "https://www.example.com/products" } ], "returns": [ @@ -691,7 +693,7 @@ "type": "string", "required": true, "description": "The selector itself, interpreted according to using.", - "example": ".shelf-item__buy-btn" + "example": "#add-to-cart" } ], "returns": [ @@ -748,7 +750,7 @@ "type": "string", "required": true, "description": "The selector itself, interpreted according to using.", - "example": ".shelf-item__buy-btn" + "example": ".product-card" } ], "returns": [ @@ -791,7 +793,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" } ], "returns": [ @@ -836,7 +838,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" } ], "body": [ @@ -845,7 +847,7 @@ "type": "string", "required": true, "description": "The characters to type. End with \\n to submit.", - "example": "BrowserStack" + "example": "hello" } ], "returns": [ @@ -888,7 +890,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" } ], "returns": [ @@ -931,7 +933,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" } ], "returns": [ @@ -974,7 +976,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" }, { "name": "name", @@ -1023,14 +1025,14 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" }, { "name": "name", "type": "string", "required": true, "description": "The attribute to read.", - "example": "class" + "example": "href" } ], "returns": [ @@ -1072,7 +1074,7 @@ "type": "string", "required": true, "description": "Element id from find_element.", - "example": "f.31006BCEEF25A5A489371DAD0726B51F.d.B5F92408.e.13" + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" } ], "returns": [ @@ -1379,7 +1381,7 @@ "type": "string", "required": true, "description": "Handle from list_window_handles.", - "example": "2FFCF2904DC789DD82BACEF48E43AE93" + "example": "00000000000000000000000000000000" } ], "returns": [ diff --git a/capability/appautomate.capability-index.json b/capability/mobiledevicecontrol.capability-index.json similarity index 98% rename from capability/appautomate.capability-index.json rename to capability/mobiledevicecontrol.capability-index.json index e380c7fc..4ee1a3ea 100644 --- a/capability/appautomate.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -2,8 +2,8 @@ "schema_version": 1, "version": "0.1", "build_id": "appautomate-demo", - "appautomate": { - "summary": "BrowserStack App Automate: 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, push and pull files, move the simulated GPS, change the network profile, launch, close and uninstall apps, answer biometric prompts and feed images to the camera. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Web browsers are not here, 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.", + "mobiledevicecontrol": { + "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, push and pull files, move the simulated GPS, change the network profile, launch, close and uninstall apps, answer biometric prompts and feed images to the camera. 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.", "routing_terms": [ "app automate", "appium", @@ -51,7 +51,10 @@ "os build", "upload apk", "upload ipa", - "local build" + "local build", + "mobile device control", + "device control", + "drive a device" ], "base_url": "https://hub-cloud.browserstack.com", "auth": { @@ -72,7 +75,7 @@ "The session ends itself after idle_timeout seconds with no command (90 by default, 300 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", "Every session occupies a device and consumes App Automate minutes until it ends: always finish with execute_script setSessionStatus and then delete_session.", "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", - "device_name and platform_version must be a combination App Automate offers; an unknown pair fails with a session-not-created error naming the capability.", + "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", "Set appium_version to 2.19.0 for any session that will 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.", "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", @@ -145,21 +148,21 @@ "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", "description": "Groups sessions on the App Automate dashboard.", - "example": "Checkout flows" + "example": "Mobile checkout" }, { "name": "build_name", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", "description": "Build the session is filed under on the dashboard.", - "example": "agent-exploration" + "example": "nightly" }, { "name": "session_name", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", "description": "Name shown for this session on the dashboard.", - "example": "Add a product to the cart" + "example": "sign in and add to basket" }, { "name": "interactive_debugging", @@ -387,7 +390,7 @@ "type": "string", "required": true, "description": "The selector itself, interpreted according to using.", - "example": "nav-cart" + "example": "cart-button" } ], "returns": [ @@ -483,7 +486,7 @@ "type": "string", "required": true, "description": "The characters to type.", - "example": "Galaxy S20" + "example": "hello" } ], "returns": [ @@ -1121,7 +1124,7 @@ "type": "string", "maxLength": 255, "description": "Short explanation shown beside the status.", - "example": "Checkout completed and the cart showed the right total" + "example": "The app behaved as expected" } ], "returns": [ @@ -2681,7 +2684,7 @@ "type": "string", "required": true, "description": "The selector itself, interpreted according to using.", - "example": "nav-cart" + "example": "cart-button" } ], "returns": [ diff --git a/tests/tools/capabilityRegistryNamedHost.test.ts b/tests/tools/capabilityRegistryNamedHost.test.ts index 08f9523c..a231ac4a 100644 --- a/tests/tools/capabilityRegistryNamedHost.test.ts +++ b/tests/tools/capabilityRegistryNamedHost.test.ts @@ -15,19 +15,19 @@ const SOURCE = { hosts: { hub: "https://hub-cloud.browserstack.com" }, }; -const OVERRIDE = "CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HOST_HUB"; +const OVERRIDE = "CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL_HOST_HUB"; describe("a capability served from a named host", () => { beforeEach(() => { delete process.env[OVERRIDE]; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL; delete process.env.CAPABILITY_REGISTRY_ENV; vi.resetModules(); }); afterEach(() => { delete process.env[OVERRIDE]; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL; delete process.env.CAPABILITY_REGISTRY_ENV; }); @@ -35,14 +35,14 @@ describe("a capability served from a named host", () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); expect( - await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), ).toBe("https://hub-cloud.browserstack.com"); }); it("still gives the product's own host to a capability that names none", async () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl("appautomate", CONFIG, SOURCE)).toBe( + expect(await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE)).toBe( "https://api-cloud.browserstack.com", ); }); @@ -52,7 +52,7 @@ describe("a capability served from a named host", () => { await import("../../src/tools/capability-registry/config.js"); expect( await resolveBaseUrl( - "appautomate", + "mobiledevicecontrol", CONFIG, { ...SOURCE, hosts: { hub: "https://hub-cloud.browserstack.com/" } }, undefined, @@ -66,9 +66,9 @@ describe("a capability served from a named host", () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); expect( - await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), ).toBe("https://hub-preprod.example"); - expect(await resolveBaseUrl("appautomate", CONFIG, SOURCE)).toBe( + expect(await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE)).toBe( "https://api-cloud.browserstack.com", ); }); @@ -76,12 +76,12 @@ describe("a capability served from a named host", () => { it("is NOT overridden by the product-level variable, which addresses a different host", async () => { // Pointing the product at a preprod API must not silently redirect hub traffic there: // the hub is a separate deployment and would answer 404 for every /wd/hub path. - process.env.CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE = + process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL = "https://api-preprod.example"; const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); expect( - await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), ).toBe("https://hub-cloud.browserstack.com"); }); @@ -91,7 +91,7 @@ describe("a capability served from a named host", () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); await expect( - resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "terminal"), + resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "terminal"), ).rejects.toThrow(/'terminal' host.*does not declare.*hub/s); }); @@ -102,7 +102,7 @@ describe("a capability served from a named host", () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); await expect( - resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), ).rejects.toThrow(/environment 'preprod' has no 'hub' host/); }); @@ -112,7 +112,7 @@ describe("a capability served from a named host", () => { const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); expect( - await resolveBaseUrl("appautomate", CONFIG, SOURCE, undefined, "hub"), + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), ).toBe("https://hub-preprod.example"); }); }); From 6782f7ee319f39ab934a566142e6600c61851033 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 19:24:22 +0530 Subject: [PATCH 13/53] fix(capability): stop the indexes pointing at capabilities they withhold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold agent run against the published surface found every one of these. Each is a line that sends a caller to something it cannot reach, which costs a round trip and then a guess. - the product summaries advertised cookies and element text on devicecontrol, and file transfer, GPS, network profiles, uninstall, biometrics and the camera on mobiledevicecontrol. All of those are withheld; a summary is read before anything else, so it was the first thing to mislead. - four capabilities referred the caller to `get_element_text`, which is withheld. The reads that answer the same question — `get_title`, `get_element_property` — say it instead, and the one line explaining why an input has no text now explains it without naming a capability that is not there. - `create_session` guidance said idle_timeout is "300 at most" against a declared maximum of 900, so the contract and the prose disagreed about the same number. - `create_session` and `execute_script` told the caller to mark a result with the raw `browserstack_executor` setSessionStatus action. `set_session_status` is the capability for that; the executor form is now named only for the case it is still needed, from inside a running session. - `list_apps` claimed uninstall_app needs the package id it returns. uninstall_app is withheld too. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 10 +++++----- capability/mobiledevicecontrol.capability-index.json | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 8d3141dd..3a039a2f 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "0.1", "build_id": "automate-authored", "devicecontrol": { - "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 and element text, find elements by CSS or XPath, click, type, clear, run JavaScript, screenshot the viewport, resize the window, move between tabs and iframes, and read cookies. 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.", + "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.", "routing_terms": [ "automate", "browser", @@ -626,7 +626,7 @@ "entity": "browser_session", "intent": "Get the current page's HTML \u2014 use this to find selectors, or to read content a screenshot cannot show.", "guidance": [ - "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title and get_element_text answer most questions far more cheaply.", + "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title answers most questions far more cheaply.", "This is the page as the browser has it now, including anything scripts have changed, not the HTML the server sent.", "Inside a frame this returns that frame's document; switch_to_frame with a null id first to get the top-level page back." ], @@ -821,7 +821,7 @@ "intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.", "guidance": [ "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", - "Read the result back with get_element_property on \"value\", not get_element_text, which is empty for an input.", + "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text.", "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.", "The element must be editable; typing into anything else answers \"element not interactable\".", "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." @@ -960,7 +960,7 @@ "entity": "browser_session", "intent": "Read a live DOM property of an element \u2014 use this for what a field currently holds, or whether a box is checked right now.", "guidance": [ - "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. get_element_text is empty for an input, and the \"value\" attribute is the markup's initial value rather than what is in the box now.", + "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. The \"value\" attribute is the markup's initial value rather than what is in the box now, and an input carries no text.", "Useful names: value, checked, selected, disabled, innerText, href.", "A property the element does not have comes back null, not an error." ], @@ -1156,7 +1156,7 @@ "guidance": [ "Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.", "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with execute_script.", - "get_element_text and get_title answer most checks far more cheaply than reading an image." + "get_title and get_element_property answer most checks far more cheaply than reading an image." ], "path_params": [ { diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 4ee1a3ea..84641cf5 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "0.1", "build_id": "appautomate-demo", "mobiledevicecontrol": { - "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, push and pull files, move the simulated GPS, change the network profile, launch, close and uninstall apps, answer biometric prompts and feed images to the camera. 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.", + "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.", "routing_terms": [ "app automate", "appium", @@ -72,8 +72,8 @@ "guidance": [ "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", - "The session ends itself after idle_timeout seconds with no command (90 by default, 300 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", - "Every session occupies a device and consumes App Automate minutes until it ends: always finish with execute_script setSessionStatus and then delete_session.", + "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", + "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", "Set appium_version to 2.19.0 for any session that will 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.", @@ -615,8 +615,8 @@ "entity": "app_session", "intent": "Run an Appium mobile command or a BrowserStack session command \u2014 scroll a list, press a hardware key, launch or close an app, or mark the session passed or failed.", "guidance": [ - "Mark the result before ending the session: script 'browserstack_executor: {\"action\":\"setSessionStatus\",\"arguments\":{\"status\":\"passed\",\"reason\":\"Cart shows one item\"}}' with args []. status is passed or failed; a later call overwrites an earlier one, and the status survives delete_session.", - "Keep the setSessionStatus reason to one plain line under 256 characters with no escape sequences such as newlines.", + "set_session_status is the capability for marking a session passed or failed, and works after the session has ended too. Only reach for the browserstack_executor setSessionStatus action here when something has to happen from inside the running session.", + "Keep any executor reason to one plain line under 256 characters with no escape sequences such as newlines.", "Android scroll: script \"mobile: scrollGesture\" with args [{\"left\":100,\"top\":500,\"width\":880,\"height\":1500,\"direction\":\"down\",\"percent\":0.8}] \u2014 value true means more content remains below.", "Which mobile: commands exist depends on the Appium version the session runs. The default Android version is Appium 1.x, which supports scrollGesture, swipeGesture, flingGesture, dragGesture, longClickGesture, doubleClickGesture and pinch gestures, but NOT mobile: pressKey \u2014 that needs a session started with appium_version 2.x or later.", "An unsupported command fails with 404 \"unknown command\", and the message lists every mobile: command the session does support \u2014 read it rather than retrying variants." @@ -1750,7 +1750,7 @@ "intent": "Find out which app is in the foreground right now \u2014 use this to confirm the app under test is showing and not the home screen or another app.", "guidance": [ "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.", - "This is the quickest way to learn the app under test's own package id, which launch_app, terminate_app and uninstall_app all need.", + "This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.", "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.", "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there." ], From d9163afb61b9563c21c766093ee4e97eecc2cd24 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 19:24:33 +0530 Subject: [PATCH 14/53] fix(capability-registry): keep the wire fields out of describeCapability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler built its answer by spreading the capability and removing the route, so every field since added to `Capability` has been published to callers by default. `host: "api"` was in a shipped describe response — a caller cannot choose which of a product's origins serves a route, and showing it invites reasoning about plumbing it has no say in. Counted across the indexes: `disabled` on 64 capabilities, `host` on 15, `send_empty_body` on 6, `encoding` on 1, `local_only` on 1. `invocable` stays, and is the one of these that is not plumbing. Presence in the index means published, which is not the same as callable: a reference-only entry is published precisely so the search that should find it still does, and stripping the flag would leave that fact in a prose guidance line alone. The reference-only tests assert against the registry, so none of this was visible to them. The new tests go through the registered handler, which is what a caller sees; reverting the destructure fails two of them on the exact `host: "api"` that was reported. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/capability-registry/register.ts | 31 +++- .../capabilityRegistryDescribeSurface.test.ts | 165 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/tools/capabilityRegistryDescribeSurface.test.ts diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 7b855a2e..0a8b4283 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -1013,8 +1013,37 @@ export function addCapabilityRegistryTools( // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers, // and spreading the capability would leak them through whenever the resolved value // is absent. - const { responses: unresolved, method, path, ...contract } = capability; + // + // THE WIRE FIELDS GO WITH IT, for the same reason the route does: they describe how + // WE reach the product, not anything the caller supplies or acts on. `host` names + // which of the product's origins serves this one capability; `encoding` and + // `send_empty_body` say how the request body is framed; `local_only` and `disabled` + // are claims about which surface publishes it, already answered by the capability + // being present at all. A contract that shows them invites a caller to reason about + // plumbing it cannot influence — and `host: "api"` was doing exactly that in a + // shipped describe response. + // + // `invocable` STAYS, and is the one of these that is not plumbing. Presence says the + // capability is published, which is not the same as callable: a reference-only entry + // is published precisely so search can still find it and say what to do instead. + // Stripping the flag would leave that fact only in a prose guidance line. + const { + responses: unresolved, + method, + path, + host, + encoding, + send_empty_body, + local_only, + disabled, + ...contract + } = capability; void unresolved; + void host; + void encoding; + void send_empty_body; + void local_only; + void disabled; return ok({ // No build_id here either, same reason. `product` stays: describeCapability // resolves by NAME and its product argument is optional, so the answer has to diff --git a/tests/tools/capabilityRegistryDescribeSurface.test.ts b/tests/tools/capabilityRegistryDescribeSurface.test.ts new file mode 100644 index 00000000..88436c35 --- /dev/null +++ b/tests/tools/capabilityRegistryDescribeSurface.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// What describeCapability is allowed to say about a capability. +// +// The contract an agent reads has to be about the call it is going to make. Fields that +// describe how THIS server reaches the product — which origin serves the route, how the +// body is framed, which surface publishes it — are not the caller's to reason about, and +// `host: "api"` reached a shipped describe response before anyone noticed. The handler +// spreads the capability, so every field added to the type lands on that surface by +// default: this file is the test that makes adding one a deliberate act. +// +// The existing reference-only tests read the REGISTRY, so they could not have caught it. +// These go through the registered handler, which is what a caller actually sees. + +/** Everything the handler must strip before answering. */ +const WIRE = [ + "host", + "encoding", + "send_empty_body", + "local_only", + "disabled", + "method", + "path", +] as const; + +const CAPABILITIES = [ + { + name: "list_devices", + method: "GET", + path: "/devices.json", + host: "api", + mode: "read", + entity: "device", + intent: "List the devices this account can use.", + returns: ["device", "os"], + responses: { "200": { description: "ok" } }, + }, + { + name: "start_session", + method: "POST", + path: "/session", + mode: "write", + entity: "session", + send_empty_body: true, + intent: "Start a session.", + }, + { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + entity: "build", + encoding: "multipart", + local_only: true, + unavailable_note: "Upload it yourself and pass the returned url.", + intent: "Upload a build that is on this machine.", + body: [{ name: "file", type: "file", required: true }], + }, +]; + +function index() { + return { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + hosts: { api: "https://api.example" }, + capabilities: CAPABILITIES, + entities: { + device: { description: "A device", capabilities: ["list_devices"] }, + session: { + description: "A session", + capabilities: ["start_session"], + }, + build: { description: "A build", capabilities: ["upload_from_file"] }, + }, + }, + }, + }; +} + +async function describeHandler(remote: boolean) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + const { CapabilityRegistry } = + await import("../../src/tools/capability-registry/index-loader.js"); + const { addCapabilityRegistryTools } = + await import("../../src/tools/capability-registry/register.js"); + const handlers: Record = {}; + // The registered-tool seam, not a stand-in for it: whatever the real server would be + // handed is what gets called here. + const server = { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + }; + addCapabilityRegistryTools( + server as never, + { + registry: new CapabilityRegistry(index() as never), + } as never, + ); + return async (name: string) => + JSON.parse( + (await handlers.describeCapability({ name, product: "p" }, {} as never)) + .content[0].text, + ); +} + +describe("describeCapability keeps the plumbing off the contract", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("answers list_devices without naming the origin that serves it", async () => { + const describe_ = await describeHandler(false); + const doc = await describe_("list_devices"); + // The capability DOES declare a host — the point is that it does not come back. + expect(CAPABILITIES[0].host).toBe("api"); + expect(doc.host).toBeUndefined(); + expect(doc.name).toBe("list_devices"); + expect(doc.intent).toBeTruthy(); + }); + + it("strips every wire field from every capability it publishes", async () => { + const describe_ = await describeHandler(false); + for (const c of CAPABILITIES) { + const doc = await describe_(c.name); + for (const field of WIRE) + expect(doc[field], `${c.name}.${field}`).toBeUndefined(); + } + }); + + it("still says what the caller supplies and reads", async () => { + const describe_ = await describeHandler(false); + const doc = await describe_("list_devices"); + expect(doc.returns).toEqual(["device", "os"]); + expect(doc.responses).toBeDefined(); + expect(doc.mode).toBe("read"); + expect(doc.entity).toBe("device"); + }); + + // The one of these fields that is the caller's business. Presence in the index means + // published; it does not mean callable, and a reference-only entry is published exactly + // so the search that should find it still does. + it("keeps invocable:false, which is the one flag a caller acts on", async () => { + const describe_ = await describeHandler(true); + const doc = await describe_("upload_from_file"); + expect(doc.invocable).toBe(false); + expect(doc.guidance).toEqual([ + "Upload it yourself and pass the returned url.", + ]); + // and it is still stripped of the means to try + expect(doc.body).toBeUndefined(); + expect(doc.encoding).toBeUndefined(); + }); + + it("leaves invocable off a capability that can be called", async () => { + const describe_ = await describeHandler(false); + expect((await describe_("list_devices")).invocable).toBeUndefined(); + expect((await describe_("upload_from_file")).invocable).toBeUndefined(); + }); +}); From e8941185f12aae7f4a063bebd2aed0819df9a6d8 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 16 Oct 2026 19:38:56 +0530 Subject: [PATCH 15/53] fix(capability-registry): stop products claiming words they do not own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate causes, one symptom: listProducts put a clash in front of the user over a word nothing in the request was about, and the question it generated — "devicecontrol or mobiledevicecontrol or tm or tra?" — was unanswerable. OUR OWN NAME WAS ROUTING VOCABULARY. The camelCase split in `terms` runs before stopword filtering, so "BrowserStack" arrived as `browser` + `stack`, and `browser` is a devicecontrol entity and a tm configuration alias. "Which Android apps have I uploaded to BrowserStack?" therefore clashed over `browser` across four products. It reached past the requests that name us, too: the tm and tra summaries say "BrowserStack", so both became permanent claimants of `browser`, and "what browsers can I use" — which names us nowhere — clashed four ways over a noun only devicecontrol models. The brand is now removed after the split and the fold, so one pattern covers "BrowserStack", "browserstack", "browser stack" and `bstack`. A SUMMARY SENTENCE THAT DISOWNS A WORD WAS READ AS CLAIMING IT. Each of these summaries carries a sentence saying what is NOT in it and where to go instead, which is the most useful sentence in the paragraph: "Browsers are not here — those are device control" made mobiledevicecontrol a claimant of `browser`, and "Mobile APPS are not here" made devicecontrol a claimant of `app`. Both products were then offered to the user for a word their own summary disowns. The claimant pass now skips disclaiming sentences; since prose cannot introduce a term, dropping one can only narrow a clash, never misdirect it. Measured over the 198 search-eval and 77 routing-eval queries plus nine paraphrases that name us: 28 queries that clashed now settle, 14 clashes narrow to fewer products, and no query's settled product changed. One goes the other way — "before I build the run from this folder, show me which cases the selection resolves to" was settling on tra, because `build` is a tra entity and was the only single-owner term in the sentence; it is now an honest clash whose candidates include tm, which is the product the fixture expects. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/capability-registry/search.ts | 54 +++++- tests/tools/capabilityRoutingAnchors.test.ts | 190 ++++++++++++++++--- 2 files changed, 220 insertions(+), 24 deletions(-) diff --git a/src/tools/capability-registry/search.ts b/src/tools/capability-registry/search.ts index 953dccdf..9a7b1e93 100644 --- a/src/tools/capability-registry/search.ts +++ b/src/tools/capability-registry/search.ts @@ -130,11 +130,33 @@ export function isBrowseQuery(query?: string): boolean { return q === "*" || q === "all"; } +/** + * OUR OWN NAME, WHICH IS NOT ROUTING VOCABULARY. + * + * Every product here is BrowserStack's, so the word distinguishes none of them — it is a + * stopword in the strict sense. It could not be spelled as one, because the camelCase + * split above runs first and turns "BrowserStack" into `browser` + `stack`, two words that + * ARE vocabulary: `browser` is a devicecontrol entity and an alias on a tm configuration. + * + * So the company's name claimed a product's noun. "Which Android apps have I uploaded to + * BrowserStack?" clashed over `browser` across four products, and the question put to the + * user — devicecontrol or mobiledevicecontrol or tm or tra — was unanswerable, because no + * word in the request was really about browsers at all. It reached further than queries + * that name us: the tm and tra summaries say "BrowserStack", so those two became claimants + * of `browser` permanently, and "what browsers can I use" clashed four ways on a word only + * devicecontrol models. + * + * Matched after the split and the fold, so one pattern covers "BrowserStack", "browserstack" + * and "browser stack". `bstack` is the same name in the shorthand the logs and repos use. + */ +const BRAND = /\bbrowser\s*stack\b|\bbstack\b/g; + export function terms(text: string | undefined): string[] { return [ ...(text || "") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .toLowerCase() + .replace(BRAND, " ") .matchAll(WORD), ] .map((match) => match[0]) @@ -614,6 +636,25 @@ export interface ProductAmbiguity { * answered the question and there is nothing to ask. Only queries whose every recognised * word is shared are genuinely undecided. */ +/** + * The words that do disclaiming work in a product summary. + * + * Deliberately a list of markers rather than anything resembling negation parsing: the + * sentences this has to catch are written to one pattern ("X is not here", "neither is Y", + * "unlike Test Management", "rather than a browser"), and a looser rule would start + * discarding prose that claims. + */ +const DISCLAIMS = + /\b(?:not|never|neither|nor|unlike|rather than|instead of|except)\b/i; + +/** A summary with its disclaiming sentences removed, for the claimant pass only. */ +function claimingSentences(summary: string | undefined): string { + return (summary ?? "") + .split(/(?<=[.!?])\s+/) + .filter((sentence) => !DISCLAIMS.test(sentence)) + .join(" "); +} + export function ambiguousProducts( products: Record, query: string | undefined, @@ -667,10 +708,21 @@ export function ambiguousProducts( // product already models catches that, and refusing to mine NEW terms out of it is what // keeps the prose from taking over: a summary is a paragraph of verbs and nouns written // to sell the product, and every word in it would otherwise become routing vocabulary. + // + // AND ONLY FROM A SENTENCE THAT CLAIMS. These summaries route deliberately — each one + // carries a sentence saying what is NOT in it and where to go instead, which is the most + // useful sentence in the paragraph and was being read as the opposite of what it says. + // "Browsers are not here — those are device control" made mobiledevicecontrol a claimant + // of `browser`, and "Mobile APPS are not here" made devicecontrol a claimant of `app`. + // Both products were then offered to the user for a word their own summary disowns. + // + // Dropping a sentence can only ever narrow a clash, never misdirect one: prose cannot + // introduce a term, so the worst case is a clash that goes unreported — and a product + // that genuinely handles something does not say so in a negated sentence. const summaries = Object.fromEntries( Object.entries(products).map(([product, index]) => [ product, - ` ${fold(index.summary ?? "")} `, + ` ${fold(claimingSentences(index.summary))} `, ]), ); for (const [key, claimants] of owners) { diff --git a/tests/tools/capabilityRoutingAnchors.test.ts b/tests/tools/capabilityRoutingAnchors.test.ts index 6d5b04ed..811503fa 100644 --- a/tests/tools/capabilityRoutingAnchors.test.ts +++ b/tests/tools/capabilityRoutingAnchors.test.ts @@ -1,9 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fileURLToPath } from "node:url"; -import { productAnchors, ambiguousProducts } from "../../src/tools/capability-registry/search.js"; +import { + productAnchors, + ambiguousProducts, + terms, +} from "../../src/tools/capability-registry/search.js"; import { readFileSync } from "node:fs"; -const load = (p: string) => { const j = JSON.parse(readFileSync(`capability/${p}.capability-index.json`, "utf8")); return j[p]; }; +const load = (p: string) => { + const j = JSON.parse( + readFileSync(`capability/${p}.capability-index.json`, "utf8"), + ); + return j[p]; +}; const products = { tm: load("tm"), tra: load("tra") } as any; describe("productAnchors", () => { @@ -26,8 +35,14 @@ describe("productAnchors", () => { }); describe("ambiguity still fires on the shared nouns", () => { - for (const q of ["What did the last report say?", "Show me the runs", "which project"]) - it(q, () => expect(ambiguousProducts(products, q).products.length).toBeGreaterThan(1)); + for (const q of [ + "What did the last report say?", + "Show me the runs", + "which project", + ]) + it(q, () => + expect(ambiguousProducts(products, q).products.length).toBeGreaterThan(1), + ); }); describe("searchCapability withholds the product names", () => { @@ -41,7 +56,9 @@ describe("searchCapability withholds the product names", () => { "src/tools/capability-registry/register.ts", "utf8", ); - const search = src.slice(src.indexOf("tools.searchCapability = server.tool")); + const search = src.slice( + src.indexOf("tools.searchCapability = server.tool"), + ); const arg = search.slice(0, search.indexOf("routing_token:")); expect(arg).toMatch(/product: z\s*\.string\(\)/); expect(arg).not.toMatch(/product: productArg\(\)/); @@ -79,17 +96,20 @@ describe("the user_words gate", () => { }); it("judges the user's words, not the agent's rewrite of them", async () => { - const { ambiguousProducts } = await import( - "../../src/tools/capability-registry/search.js" - ); + const { ambiguousProducts } = + await import("../../src/tools/capability-registry/search.js"); const { readFileSync } = await import("node:fs"); const src = readFileSync( "src/tools/capability-registry/register.ts", "utf8", ); // The paraphrase is what used to be judged, and it is the caller's to choose. - expect(src).toMatch(/const subject = user_words\?\.trim\(\) \? user_words : query;/); - expect(src).toMatch(/ambiguousProducts\(registry\.index\.products, subject\)/); + expect(src).toMatch( + /const subject = user_words\?\.trim\(\) \? user_words : query;/, + ); + expect(src).toMatch( + /ambiguousProducts\(registry\.index\.products, subject\)/, + ); expect(ambiguousProducts).toBeTypeOf("function"); }); @@ -104,7 +124,9 @@ describe("the user_words gate", () => { // nothing to leak between sessions. expect(src).toMatch(/const token = signRouting\(/); expect(src).toMatch(/const routed = readRouting\(routing_token\)/); - expect(src).toMatch(/routed\?\.verdict === "clash" \|\| routed\?\.verdict === "blank"/); + expect(src).toMatch( + /routed\?\.verdict === "clash" \|\| routed\?\.verdict === "blank"/, + ); // AND NOTHING IS REMEMBERED. A set of live tokens and a last-verdict variable are // both per-connection state; under one process serving several sessions the second // lets one client's listing satisfy another client's gate. @@ -122,15 +144,20 @@ describe("a lone decisive word settles, and says so", () => { // caller used to learn only that nothing needed asking — and then searched the other // product, which is the same wrong turn arrived at quietly. it("names the product its words point at, and the words", () => { - const verdict = ambiguousProducts(products, "Show me all datasets in the project"); + const verdict = ambiguousProducts( + products, + "Show me all datasets in the project", + ); expect(verdict.products).toEqual([]); expect(verdict.settled).toBe("tm"); expect(verdict.because).toContain("dataset"); }); it("leaves `settled` unset when the words genuinely clash", () => { - expect(ambiguousProducts(products, "Delete the failed results from this run.").settled) - .toBeUndefined(); + expect( + ambiguousProducts(products, "Delete the failed results from this run.") + .settled, + ).toBeUndefined(); }); }); @@ -148,8 +175,10 @@ describe("a summary claims the words its product really uses", () => { }); it("still lets a word only one product uses decide", () => - expect(ambiguousProducts(products, "Show me all datasets in the project").products) - .toEqual([])); + expect( + ambiguousProducts(products, "Show me all datasets in the project") + .products, + ).toEqual([])); }); describe("a request with no product vocabulary at all", () => { @@ -169,20 +198,28 @@ describe("a request with no product vocabulary at all", () => { }); it("is not claimed for a request whose words DO name something", () => - expect(ambiguousProducts(products, "What did the last report say?").unknown) - .toBeUndefined()); + expect( + ambiguousProducts(products, "What did the last report say?").unknown, + ).toBeUndefined()); }); describe("routing_terms let a product claim its own words", () => { it("gives tra the flakiness vocabulary no index models as an entity", () => { - expect(ambiguousProducts(products, "Which tests are flaky?").unknown).toBeUndefined(); - expect(productAnchors(products, "Which tests are flaky?").products).toEqual(["tra"]); + expect( + ambiguousProducts(products, "Which tests are flaky?").unknown, + ).toBeUndefined(); + expect(productAnchors(products, "Which tests are flaky?").products).toEqual( + ["tra"], + ); }); it("stops an organisational word both products use from settling it", () => { // `workspace` was tm's alone and decided three prompts on its own; both products // have one, so it decides nothing now. - const verdict = ambiguousProducts(products, "Show me all the projects in our workspace."); + const verdict = ambiguousProducts( + products, + "Show me all the projects in our workspace.", + ); expect(verdict.settled).toBeUndefined(); expect(verdict.products).toEqual(["tm", "tra"]); }); @@ -196,7 +233,10 @@ describe("a clash announced by listProducts binds the next search", () => { // words that caused the clash. it("mints a token on the clash and on a blank request", async () => { const { readFileSync } = await import("node:fs"); - const src = readFileSync("src/tools/capability-registry/register.ts", "utf8"); + const src = readFileSync( + "src/tools/capability-registry/register.ts", + "utf8", + ); const listing = src.slice( src.indexOf("tools.listProducts = server.tool("), src.indexOf("tools.searchCapability = server.tool("), @@ -222,7 +262,10 @@ describe("words that point at another product are evidence, not noise", () => { it("guards the mismatch on the product, not merely on settling at all", async () => { const { readFileSync } = await import("node:fs"); - const src = readFileSync("src/tools/capability-registry/register.ts", "utf8"); + const src = readFileSync( + "src/tools/capability-registry/register.ts", + "utf8", + ); expect(src).toMatch( /const mismatch =\s*\n?\s*routable && !!ambiguity\.settled && ambiguity\.settled !== product/, ); @@ -237,3 +280,104 @@ describe("words that point at another product are evidence, not noise", () => { expect(reopened).not.toMatch(/!ambiguity\.settled &&/); }); }); + +// --------------------------------------------------------------------------------------- +// Two ways a product came to claim a word it does not own. Both were found by a cold agent +// run against the published surface, and both produced the same symptom: a clash put to the +// user over a word nothing in the request was about. + +const four = { + devicecontrol: load("devicecontrol"), + mobiledevicecontrol: load("mobiledevicecontrol"), + tm: load("tm"), + tra: load("tra"), +} as any; + +describe("our own name is not routing vocabulary", () => { + // The camelCase split runs before stopword filtering, so "BrowserStack" arrived as + // `browser` + `stack` — and `browser` is a devicecontrol entity and a tm configuration + // alias. The company's name was claiming a product's noun. + for (const spelling of [ + "BrowserStack", + "browserstack", + "browser stack", + "bstack", + ]) + it(`${JSON.stringify(spelling)} yields no routing terms`, () => + expect(terms(`upload a build to ${spelling}`)).toEqual([ + "upload", + "build", + ])); + + it("still reads `browser` when the caller names one alongside us", () => + expect(terms("which browsers can I use on BrowserStack")).toEqual([ + "browsers", + "use", + ])); + + it("leaves a real mention of browsers intact", () => + expect(terms("open a browser")).toContain("browser")); + + it("the reported request no longer clashes over `browser`", () => { + const a = ambiguousProducts( + four, + "Which Android apps have I uploaded to BrowserStack?", + ); + // It may still be ambiguous — `app` is genuinely shared — but not over this. + expect(a.terms).not.toContain("browser"); + expect(a.terms).not.toContain("stack"); + }); + + it("stops a summary that names the company claiming `browser` by doing so", () => { + // The reach of it. tm and tra say "BrowserStack" in their summaries and nothing else + // in either is about browsers, so both became permanent claimants of the word — and + // "what browsers can I use", which names us nowhere, clashed four ways over a noun + // only devicecontrol models. Synthetic, so the two causes stay apart: this product + // set has no disclaiming sentence in it. + const synthetic = { + owner: { + summary: "Drives a browser.", + base_url: "https://a.example", + capabilities: [], + entities: { browser: { description: "A browser", capabilities: [] } }, + }, + bystander: { + summary: "A BrowserStack product about something else entirely.", + base_url: "https://b.example", + capabilities: [], + entities: { widget: { description: "A widget", capabilities: [] } }, + }, + } as any; + const a = ambiguousProducts(synthetic, "what browsers can I use"); + expect(a.products).not.toContain("bystander"); + expect(a.settled).toBe("owner"); + }); +}); + +describe("a summary sentence that disowns a word does not claim it", () => { + // "Browsers are not here — those are device control" and "Mobile APPS are not here: + // those are mobile device control" are the most useful sentences in either summary, and + // the claimant pass was reading each as its own opposite. + it("mobiledevicecontrol does not claim `browser`", () => { + expect( + ambiguousProducts(four, "what browsers can I use").products, + ).not.toContain("mobiledevicecontrol"); + }); + + it("devicecontrol does not claim `app`", () => { + const a = ambiguousProducts(four, "which apps have I uploaded"); + expect(a.terms).toContain("app"); + expect(a.products).not.toContain("devicecontrol"); + }); + + it("leaves the claim that the sentence actually makes", () => { + // Same two summaries, the positive sentences: devicecontrol does drive browsers and + // mobiledevicecontrol does hold app builds. + expect( + ambiguousProducts(four, "what browsers can I use").products, + ).toContain("devicecontrol"); + expect( + ambiguousProducts(four, "which apps have I uploaded").products, + ).toContain("mobiledevicecontrol"); + }); +}); From 8445466ce37358600bfc12c95c2ae2f0e634ac46 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 19 Oct 2026 12:23:46 +0530 Subject: [PATCH 16/53] feat(capability-registry): let a capability declare that we narrow its response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `list_browsers` was published and unusable. `/automate/browsers.json` returns every operating system, browser and version combination the account can run — 5,251 rows, 604 KB — and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come back byte-identical to the bare call, which is measured, not assumed. Its own guidance told the caller to "narrow it yourself", which is the one thing it had no means to do. 604 KB of JSON is most of a context window spent to learn that Chrome runs on Windows 11. So a capability may now declare `narrowable`, and invokeCapability grows a `select` argument — `where`, `distinct`, `fields`, `limit` — applied to the response after the call. Three rules keep it from becoming a second API: 1. Opt-in. `select` on a capability that declares nothing is refused, so the untouched-response promise holds everywhere it held before. 2. Only the declared fields, and an undeclared one is refused BY NAME with the set listed — a caller that guesses `platform` learns the vocabulary instead of silently receiving everything. 3. The answer reports it: `narrowed: {from, to, by}`. A smaller body that does not announce itself cannot be told from a product that returned less than it has. It is not a query language and should not grow into one. Equality, membership, distinct tuples, projection, a cap — anything beyond that is a reason to look for an endpoint that filters, not to add an operator. Narrowing is applied to a 2xx only: a 4xx body is the diagnosis, and a selection matching none of its keys would empty it. Against production, through the registered tool: bare call 603,879 bytes distinct browser 259 bytes 5251 -> 12 distinct os, os_version 1,473 bytes 5251 -> 41 Chrome versions on Windows 11 136 bytes 5251 -> 5 does Safari 18.0 run on Sequoia? 2 bytes 5251 -> 0 (it is 18.4) The guidance was also wrong where it was not impossible: it named six browser values and the API returns twelve, omitting ie, samsung, android, iphone, ipad and chromeForTesting. It now says what the endpoint does not do, how to ask what values exist, that `os` is capitalised in two of its four spellings, and that rows arrive oldest-first so a `limit` is the wrong way to find the newest build — it hands back Chrome 37 for Windows 11. Co-Authored-By: Claude Opus 5 (1M context) --- .../devicecontrol.capability-index.json | 23 +- src/tools/capability-registry/narrow.ts | 205 +++++++++++++++ src/tools/capability-registry/register.ts | 56 +++- src/tools/capability-registry/types.ts | 26 ++ tests/tools/capabilityRegistryNarrow.test.ts | 240 +++++++++++++++++ .../capabilityRegistryNarrowInvoke.test.ts | 244 ++++++++++++++++++ 6 files changed, 787 insertions(+), 7 deletions(-) create mode 100644 src/tools/capability-registry/narrow.ts create mode 100644 tests/tools/capabilityRegistryNarrow.test.ts create mode 100644 tests/tools/capabilityRegistryNarrowInvoke.test.ts diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 3a039a2f..30b54cc3 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -52,10 +52,12 @@ "entity": "browser", "intent": "List every operating system, browser and version combination this account can run a web session on, with the exact names create_session expects.", "guidance": [ - "This is a very large answer \u2014 several thousand rows, one per combination \u2014 and there is no filtering on the wire. Narrow it yourself, and never read it whole into a reply.", + "The endpoint accepts no filtering on the wire \u2014 every query parameter is ignored, and a filtered call comes back byte-identical to the bare one \u2014 so pass `select` instead and never fetch this whole. The full answer is several thousand rows; `select` with `distinct` turns the usual question into a few hundred bytes.", + "To ask what values EXIST, use distinct rather than reading rows: distinct ['browser'] names every browser, distinct ['os','os_version'] every platform, distinct ['device'] every mobile device. To check one combination, put it in `where` and see whether anything comes back.", "A desktop row carries os, os_version, browser and browser_version, with device null. A mobile row carries device and real_mobile true instead, and its browser_version is null because the browser ships with the OS.", - "The browser names are lower case and are what create_session takes: chrome, firefox, edge, safari, opera, and chromium on mobile.", - "Pass the strings exactly as they appear; an unlisted combination is refused rather than substituted." + "os is spelled Windows, OS X, android and ios \u2014 two capitalised and two not, in the same column \u2014 and `where` matches case-insensitively, so either spelling finds them. Browser names are lower case apart from chromeForTesting.", + "Rows come back in the product's own order, which is OLDEST VERSION FIRST, and `limit` takes the first N of them \u2014 so a limit is the wrong way to find the newest build and will hand back Chrome 37 for Windows 11. Ask for the versions with distinct and no limit (a hundred-odd strings, under 3 KB) and read the end of the list.", + "Pass the strings into create_session exactly as they appear here; an unlisted combination is refused rather than substituted." ], "returns": [ "browser", @@ -76,7 +78,18 @@ "$response": "Unauthorized" } }, - "host": "api" + "host": "api", + "narrowable": { + "fields": [ + "os", + "os_version", + "browser", + "browser_version", + "device", + "real_mobile" + ], + "note": "The endpoint takes no parameters of its own, so this narrowing is applied to the response after the call." + } }, { "name": "get_plan", @@ -2066,4 +2079,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/tools/capability-registry/narrow.ts b/src/tools/capability-registry/narrow.ts new file mode 100644 index 00000000..dc92c2d1 --- /dev/null +++ b/src/tools/capability-registry/narrow.ts @@ -0,0 +1,205 @@ +/** + * Narrowing a response the product will not narrow itself. + * + * invokeCapability makes one request and hands back what came off the wire. That is the + * right default and it stays the default: a caller reasoning about a product's answer + * should be reasoning about the product's answer. This is the declared exception. + * + * It exists because of `list_browsers`. `/automate/browsers.json` returns every operating + * system, browser and version combination the account can run — 5,251 rows and 604 KB — + * and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come + * back byte-identical to the bare call. Its guidance told the caller to "narrow it + * yourself", which it had no means to do; the only way to use the capability was to not be + * an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome + * runs on Windows 11. + * + * WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design: + * + * 1. A capability must declare `narrowable` before any of this is reachable. Nothing is + * applied to a capability that did not ask for it, so the untouched-response promise + * is intact everywhere it was intact before. + * 2. Only the fields it names may be used, and a field outside that set is refused BY + * NAME with the set listed — the same way an unknown host is refused. A caller that + * guesses `platform` learns what the fields are instead of silently getting everything. + * 3. The result says it was narrowed, from how many rows, and by what. A smaller answer + * that does not announce itself is indistinguishable from a product that returned less + * than it has, which is the failure this must not introduce. + * + * It is NOT a query language and should not grow into one. Equality, membership, distinct + * tuples, projection, a cap. Anything a caller cannot express here is a reason to look for + * a product endpoint that filters, not a reason to add an operator. + */ + +import { Capability } from "./types.js"; +import { InvocationError } from "./index-loader.js"; + +/** What the caller asked for. Every field is optional; an empty selection narrows nothing. */ +export interface Selection { + /** + * Field → the value it must take, or any of several. + * + * Strings compare case-insensitively. The product's own spellings are not consistent — + * `Windows` and `OS X` are capitalised, `android` and `ios` are not, in the same column + * of the same response — so an exact match would refuse half the corpus on a technicality + * the caller cannot see from the field name. + */ + where?: Record; + /** Return one row per distinct combination of these fields, and nothing else. */ + distinct?: string[]; + /** Keep only these fields on each row. */ + fields?: string[]; + /** At most this many rows, applied last. */ + limit?: number; +} + +export interface Narrowing { + /** Rows the product sent. */ + from: number; + /** Rows being handed back. */ + to: number; + /** Echoed so the answer carries the question — a narrowed body is not self-describing. */ + by: Selection; +} + +function isSelectionEmpty(selection: Selection): boolean { + return ( + !selection.where && + !selection.distinct && + !selection.fields && + selection.limit === undefined + ); +} + +/** The array inside a body, by the capability's declared path. */ +function rowsAt( + body: unknown, + path: string | undefined, +): unknown[] | undefined { + let here: unknown = body; + for (const step of (path ?? "").split(".").filter(Boolean)) { + if (typeof here !== "object" || here === null) return undefined; + here = (here as Record)[step]; + } + return Array.isArray(here) ? here : undefined; +} + +/** The body with its array replaced, leaving every sibling key as the product sent it. */ +function withRows( + body: unknown, + path: string | undefined, + rows: unknown[], +): unknown { + const steps = (path ?? "").split(".").filter(Boolean); + if (steps.length === 0) return rows; + const clone: Record = { + ...(body as Record), + }; + let here = clone; + for (const step of steps.slice(0, -1)) { + here[step] = { ...(here[step] as Record) }; + here = here[step] as Record; + } + here[steps[steps.length - 1]] = rows; + return clone; +} + +function sameValue(cell: unknown, wanted: unknown): boolean { + if (typeof cell === "string" && typeof wanted === "string") + return cell.toLowerCase() === wanted.toLowerCase(); + // `real_mobile` is true or null, and a caller who writes "true" means true. + if (typeof cell === "boolean" && typeof wanted === "string") + return String(cell) === wanted.toLowerCase(); + if (cell === null || cell === undefined) + return wanted === null || wanted === "null"; + return cell === wanted; +} + +function assertKnownFields( + used: string[], + allowed: string[], + what: string, +): void { + const unknown = used.filter((field) => !allowed.includes(field)); + if (unknown.length > 0) + throw new InvocationError( + `${what} names ${unknown.map((f) => `'${f}'`).join(", ")}, which this capability ` + + `cannot be narrowed on. The fields it accepts are: ${allowed.join(", ")}.`, + ); +} + +/** + * Apply a selection to a response, or refuse it. + * + * Returns `undefined` when there is nothing to do — no selection, or a capability that + * declares none — so the caller hands back the original body and the default holds. + */ +export function narrowResponse( + capability: Capability, + body: unknown, + selection: Selection | undefined, +): { body: unknown; narrowing: Narrowing } | undefined { + if (!selection || isSelectionEmpty(selection)) return undefined; + + const declared = capability.narrowable; + if (!declared) + throw new InvocationError( + "that capability does not support `select`: it returns the product's response as " + + "sent. describeCapability names the ones that do, under `narrowable`.", + ); + + const allowed = declared.fields; + assertKnownFields(Object.keys(selection.where ?? {}), allowed, "`where`"); + assertKnownFields(selection.distinct ?? [], allowed, "`distinct`"); + assertKnownFields(selection.fields ?? [], allowed, "`fields`"); + + const rows = rowsAt(body, declared.path); + // A body that is not the shape the capability promised is handed back whole. Narrowing + // an error payload, or a response that changed shape, would hide exactly the thing the + // caller needs to see. + if (!rows) return undefined; + + let out = rows; + + for (const [field, wanted] of Object.entries(selection.where ?? {})) { + const wantedValues = Array.isArray(wanted) ? wanted : [wanted]; + out = out.filter((row) => { + const cell = (row as Record)?.[field]; + return wantedValues.some((value) => sameValue(cell, value)); + }); + } + + if (selection.distinct?.length) { + const seen = new Set(); + const unique: unknown[] = []; + for (const row of out) { + const picked = Object.fromEntries( + selection.distinct.map((field) => [ + field, + (row as Record)?.[field], + ]), + ); + const key = JSON.stringify(picked); + if (seen.has(key)) continue; + seen.add(key); + unique.push(picked); + } + out = unique; + } else if (selection.fields?.length) { + out = out.map((row) => + Object.fromEntries( + selection.fields!.map((field) => [ + field, + (row as Record)?.[field], + ]), + ), + ); + } + + if (selection.limit !== undefined) + out = out.slice(0, Math.max(0, selection.limit)); + + return { + body: withRows(body, declared.path, out), + narrowing: { from: rows.length, to: out.length, by: selection }, + }; +} diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 0a8b4283..7c4174f6 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -35,6 +35,7 @@ import { ResponseSelection, resolveResponses, } from "./index-loader.js"; +import { narrowResponse } from "./narrow.js"; import { invoke } from "./resolve.js"; import { ambiguousProducts, @@ -1093,7 +1094,9 @@ export function addCapabilityRegistryTools( "as given — that is the handle. Only when a result carries no `name` (some products " + "do not publish them yet) pass `method` and `path` instead, exactly as returned. " + "Arguments go in path_params / query / body under the spec's own names. One call " + - "makes exactly one request and returns the product's own response untouched; when " + + "makes exactly one request and returns the product's own response as sent — unless " + + "you pass `select`, which only capabilities whose contract carries `narrowable` " + + "accept, and which reports what it narrowed. When " + "`completed` is false there is another page, which you fetch by sending the " + "capability's own page parameter. If the mode is 'write' you MUST ask the user " + "first, then resend with user_permission='granted' and a change_summary; both are " + @@ -1148,6 +1151,35 @@ export function addCapabilityRegistryTools( .string() .optional() .describe("What will change. Required for writes."), + select: z + .object({ + where: z + .record(z.string(), z.any()) + .optional() + .describe( + "Field -> the value it must take, or an array of accepted values. Strings " + + "match case-insensitively.", + ), + distinct: z + .array(z.string()) + .optional() + .describe( + "Return one row per distinct combination of these fields and nothing else — " + + "the cheapest way to ask what values exist.", + ), + fields: z + .array(z.string()) + .optional() + .describe("Keep only these fields on each row."), + limit: z.number().optional().describe("At most this many rows."), + }) + .optional() + .describe( + "ONLY for a capability whose contract carries `narrowable`, which names the " + + "fields this may use; on any other capability it is refused. Those endpoints " + + "take no filtering parameters of their own, so this is applied to the response " + + "here, and the result reports what it narrowed and from how many rows.", + ), }, { title: "Invoke Capability", @@ -1274,6 +1306,20 @@ export function addCapabilityRegistryTools( registry.index.products[product]?.auth, product, ); + // NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller + // needs whole, and a selection that happened to match none of its keys would + // quietly empty it. `narrowResponse` refuses an unsupported `select` by throwing, + // which lands in the InvocationError branch below with the accepted fields listed. + const narrowed = result.ok + ? narrowResponse(capability, result.http_response.body, input.select) + : undefined; + const answer = narrowed + ? { + ...result, + http_response: { ...result.http_response, body: narrowed.body }, + narrowed: narrowed.narrowing, + } + : result; // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than // throwing, so `success` keeps its tool-level meaning and these two fields carry // whether the product call worked. Status 0 means it could not be reached. @@ -1282,8 +1328,14 @@ export function addCapabilityRegistryTools( upstream_ok: result.ok, upstream_status: result.http_response.status, change_summary: redact(input.change_summary), + ...(narrowed + ? { + narrowed_from: narrowed.narrowing.from, + narrowed_to: narrowed.narrowing.to, + } + : {}), }); - return ok(result); + return ok(answer); } catch (error) { if (error instanceof InvocationError) return refuse( diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 21e0fa3f..84f899d1 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -245,6 +245,32 @@ export interface Capability { * something this can verify. */ send_empty_body?: boolean; + /** + * This capability's array response may be narrowed by the server, on these fields. + * + * THE PRODUCT CANNOT DO IT. Declaring this is a statement that the endpoint offers no + * filtering of its own — not that ours is more convenient. `/automate/browsers.json` + * returns 5,251 rows and ignores every query parameter it is given, so a caller asking + * which browsers run on Windows had to receive 604 KB and find out itself. + * + * Where the wire DOES filter, declare the parameters and let the product filter: a + * server-side pass still transfers the whole response and still costs the round trip, + * and it moves the contract off the product and onto us. + * + * Unset — which is every other capability — means `select` is refused outright and the + * response is handed back exactly as sent. + */ + narrowable?: { + /** The only fields `select` may name. Anything else is refused with this list. */ + fields: string[]; + /** + * Dotted path to the array inside the body; omitted when the body IS the array. + * Siblings of the array are preserved, so an envelope survives the narrowing. + */ + path?: string; + /** One line for the contract: why this is here rather than on the wire. */ + note?: string; + }; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; diff --git a/tests/tools/capabilityRegistryNarrow.test.ts b/tests/tools/capabilityRegistryNarrow.test.ts new file mode 100644 index 00000000..6a29af95 --- /dev/null +++ b/tests/tools/capabilityRegistryNarrow.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { narrowResponse } from "../../src/tools/capability-registry/narrow.js"; +import { InvocationError } from "../../src/tools/capability-registry/index-loader.js"; +import { Capability } from "../../src/tools/capability-registry/types.js"; + +// Narrowing exists for one shape of endpoint: one that returns everything and filters +// nothing. These tests pin the three rules that keep it from becoming a query language — +// opt-in, fields declared, and the narrowing reported — plus the cases where it must +// decline to touch the body at all. + +const ROWS = [ + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "131.0", + device: null, + real_mobile: null, + }, + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "130.0", + device: null, + real_mobile: null, + }, + { + os: "Windows", + os_version: "10", + browser: "firefox", + browser_version: "133.0", + device: null, + real_mobile: null, + }, + { + os: "OS X", + os_version: "Sequoia", + browser: "safari", + browser_version: "18.0", + device: null, + real_mobile: null, + }, + { + os: "ios", + os_version: "26", + browser: "chromium", + browser_version: null, + device: "iPhone 17", + real_mobile: true, + }, +]; + +const FIELDS = [ + "os", + "os_version", + "browser", + "browser_version", + "device", + "real_mobile", +]; + +const browsers = { + name: "list_browsers", + method: "GET", + path: "/automate/browsers.json", + mode: "read", + entity: "browser", + narrowable: { fields: FIELDS }, +} as Capability; + +const plain = { + name: "list_things", + method: "GET", + path: "/things", + mode: "read", + entity: "thing", +} as Capability; + +const rowsOf = (r: ReturnType) => + r!.body as Record[]; + +describe("a capability must opt in", () => { + it("refuses `select` on one that did not", () => { + expect(() => + narrowResponse(plain, ROWS, { where: { os: "Windows" } }), + ).toThrow(InvocationError); + }); + + it("says where the ones that do are named", () => { + expect(() => narrowResponse(plain, ROWS, { limit: 1 })).toThrow( + /describeCapability.*narrowable/s, + ); + }); + + it("does nothing at all without a selection", () => { + expect(narrowResponse(browsers, ROWS, undefined)).toBeUndefined(); + expect(narrowResponse(browsers, ROWS, {})).toBeUndefined(); + }); +}); + +describe("only the declared fields", () => { + for (const [label, selection] of [ + ["where", { where: { platform: "Windows" } }], + ["distinct", { distinct: ["platform"] }], + ["fields", { fields: ["platform"] }], + ] as const) + it(`refuses an undeclared field in ${label}, by name and with the set`, () => { + // A caller that guesses learns the vocabulary instead of silently getting everything. + expect(() => narrowResponse(browsers, ROWS, selection)).toThrow( + /'platform'.*os, os_version, browser, browser_version, device, real_mobile/s, + ); + }); +}); + +describe("what it actually does", () => { + it("filters on equality", () => { + const out = narrowResponse(browsers, ROWS, { + where: { browser: "chrome" }, + })!; + expect(out.narrowing).toEqual({ + from: 5, + to: 2, + by: { where: { browser: "chrome" } }, + }); + expect(rowsOf(out).every((r) => r.browser === "chrome")).toBe(true); + }); + + it("matches a string case-insensitively, because the product's own column is not consistent", () => { + // `Windows` and `OS X` are capitalised, `android` and `ios` are not, in one column. + expect( + narrowResponse(browsers, ROWS, { where: { os: "windows" } })!.narrowing + .to, + ).toBe(3); + expect( + narrowResponse(browsers, ROWS, { where: { os: "IOS" } })!.narrowing.to, + ).toBe(1); + }); + + it("accepts several values for one field", () => { + const out = narrowResponse(browsers, ROWS, { + where: { browser: ["safari", "chromium"] }, + })!; + expect(out.narrowing.to).toBe(2); + }); + + it("reads real_mobile written as a string, which is how it arrives from a model", () => { + expect( + narrowResponse(browsers, ROWS, { where: { real_mobile: "true" } })! + .narrowing.to, + ).toBe(1); + }); + + it("matches a null cell, so `which rows have no device` is askable", () => { + expect( + narrowResponse(browsers, ROWS, { where: { device: null } })!.narrowing.to, + ).toBe(4); + }); + + it("returns distinct combinations and nothing else", () => { + const out = narrowResponse(browsers, ROWS, { + distinct: ["os", "browser"], + })!; + expect(rowsOf(out)).toEqual([ + { os: "Windows", browser: "chrome" }, + { os: "Windows", browser: "firefox" }, + { os: "OS X", browser: "safari" }, + { os: "ios", browser: "chromium" }, + ]); + }); + + it("projects fields without deduplicating", () => { + const out = narrowResponse(browsers, ROWS, { fields: ["browser"] })!; + expect(rowsOf(out)).toHaveLength(5); + expect(rowsOf(out)[0]).toEqual({ browser: "chrome" }); + }); + + it("caps with limit, last", () => { + const out = narrowResponse(browsers, ROWS, { + where: { os: "Windows" }, + limit: 2, + })!; + expect(out.narrowing).toMatchObject({ from: 5, to: 2 }); + }); + + it("combines where and distinct, which is the shape that makes this worth having", () => { + const out = narrowResponse(browsers, ROWS, { + where: { os: "Windows", browser: "chrome" }, + distinct: ["browser_version"], + })!; + expect(rowsOf(out)).toEqual([ + { browser_version: "131.0" }, + { browser_version: "130.0" }, + ]); + }); +}); + +describe("the answer says it was narrowed", () => { + it("reports the row count it started from and the selection used", () => { + const by = { where: { os: "Windows" }, distinct: ["browser"] }; + const out = narrowResponse(browsers, ROWS, by)!; + // A smaller answer that does not announce itself cannot be told apart from a product + // that returned less than it has. + expect(out.narrowing.from).toBe(5); + expect(out.narrowing.to).toBe(2); + expect(out.narrowing.by).toEqual(by); + }); +}); + +describe("bodies it declines to touch", () => { + it("hands back a body that is not an array", () => { + expect( + narrowResponse(browsers, { message: "Unauthorized" }, { limit: 1 }), + ).toBeUndefined(); + }); + + it("finds an array nested under a declared path and keeps its siblings", () => { + const nested = { + ...browsers, + narrowable: { fields: FIELDS, path: "data.rows" }, + } as Capability; + const body = { info: { next: null }, data: { rows: ROWS, total: 5 } }; + const out = narrowResponse(nested, body, { where: { browser: "safari" } })!; + expect(out.narrowing.to).toBe(1); + const got = out.body as typeof body; + expect(got.info).toEqual({ next: null }); + expect(got.data.total).toBe(5); + expect(got.data.rows).toHaveLength(1); + // and the original is untouched + expect(body.data.rows).toHaveLength(5); + }); + + it("hands back a body whose declared path is not there", () => { + const nested = { + ...browsers, + narrowable: { fields: FIELDS, path: "data.rows" }, + } as Capability; + expect(narrowResponse(nested, { data: {} }, { limit: 1 })).toBeUndefined(); + }); +}); diff --git a/tests/tools/capabilityRegistryNarrowInvoke.test.ts b/tests/tools/capabilityRegistryNarrowInvoke.test.ts new file mode 100644 index 00000000..8cf8b5cd --- /dev/null +++ b/tests/tools/capabilityRegistryNarrowInvoke.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// `select` reaching the response, through the registered tool rather than the module it +// calls. The unit tests next door pin the narrowing itself; these pin the wiring — that a +// selection arrives, that the answer reports it, and that an error body is left alone. + +const ROWS = [ + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "131.0", + device: null, + }, + { + os: "Windows", + os_version: "11", + browser: "firefox", + browser_version: "133.0", + device: null, + }, + { + os: "ios", + os_version: "26", + browser: "chromium", + browser_version: null, + device: "iPhone 17", + }, +]; + +function tools(body: unknown, status = 200) { + const index = { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "list_browsers", + method: "GET", + path: "/browsers.json", + mode: "read", + entity: "browser", + intent: "List combinations.", + narrowable: { + fields: [ + "os", + "os_version", + "browser", + "browser_version", + "device", + ], + }, + }, + { + name: "list_plain", + method: "GET", + path: "/plain.json", + mode: "read", + entity: "browser", + intent: "List things, unnarrowable.", + }, + ], + entities: { + browser: { + description: "A combination", + capabilities: ["list_browsers", "list_plain"], + }, + }, + }, + }, + }; + const handlers: Record = {}; + const server = { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + }; + addCapabilityRegistryTools( + server as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async () => ({ status, body }) as never, + } as never, + ); + return handlers; +} + +const call = async (handlers: Record, input: unknown) => + JSON.parse( + (await handlers.invokeCapability(input, {} as never)).content[0].text, + ); + +describe("invokeCapability applies a selection", () => { + it("narrows the body and says so", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + select: { where: { os: "windows" }, distinct: ["browser"] }, + }); + expect(out.http_response.body).toEqual([ + { browser: "chrome" }, + { browser: "firefox" }, + ]); + expect(out.narrowed).toEqual({ + from: 3, + to: 2, + by: { where: { os: "windows" }, distinct: ["browser"] }, + }); + }); + + it("leaves the body alone, and says nothing, without a selection", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + }); + expect(out.http_response.body).toEqual(ROWS); + expect(out.narrowed).toBeUndefined(); + }); + + it("refuses a selection on a capability that declares none", async () => { + const out = await call(tools(ROWS), { + name: "list_plain", + product: "p", + select: { limit: 1 }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/does not support `select`/); + }); + + it("refuses a field the capability did not declare, naming the set", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + select: { where: { platform: "Windows" } }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/'platform'/); + expect(out.error).toMatch(/os, os_version, browser/); + }); + + it("does not narrow an error body, which is the diagnosis", async () => { + // A selection matching none of a 401's keys would quietly empty it. + const out = await call(tools({ message: "Unauthorized" }, 401), { + name: "list_browsers", + product: "p", + select: { where: { browser: "chrome" } }, + }); + expect(out.ok).toBe(false); + expect(out.http_response.body).toEqual({ message: "Unauthorized" }); + expect(out.narrowed).toBeUndefined(); + }); + + it("still makes exactly one request", async () => { + const sent: unknown[] = []; + const index = new CapabilityRegistry({ + schema_version: 1, + build_id: "t", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "list_browsers", + method: "GET", + path: "/browsers.json", + mode: "read", + entity: "browser", + narrowable: { fields: ["browser"] }, + }, + ], + entities: { + browser: { description: "b", capabilities: ["list_browsers"] }, + }, + }, + }, + } as never); + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find( + (a) => typeof a === "function", + ); + return { name: args[0] }; + }, + } as never, + { + registry: index, + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async (...args: unknown[]) => { + sent.push(args); + return { status: 200, body: ROWS } as never; + }, + } as never, + ); + await call(handlers, { + name: "list_browsers", + product: "p", + select: { distinct: ["browser"] }, + }); + expect(sent).toHaveLength(1); + // and nothing from `select` reached the wire, where it would be ignored anyway + expect(JSON.stringify(sent[0])).not.toMatch(/distinct/); + }); +}); + +describe("the contract advertises it", () => { + it("describeCapability publishes `narrowable`, which is the caller's business", async () => { + const handlers = tools(ROWS); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "list_browsers", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.narrowable.fields).toContain("browser"); + expect(doc.host).toBeUndefined(); + }); + + it("says nothing about it on a capability that has none", async () => { + const handlers = tools(ROWS); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "list_plain", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.narrowable).toBeUndefined(); + }); +}); From cd1a98eb73969472faeb195de083eeacfcab8167 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 19 Oct 2026 12:58:04 +0530 Subject: [PATCH 17/53] chore(capability): stamp the two new indexes the way the format says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Provenance.build_id` is documented as `_`, which is what the generated indexes carry — loadtesting `43eba70_2026-09-02T14:26:51Z`, tm `cb63a4a5_2026-09-16T18:02:57Z`. These two were hand-authored and were stamped `automate-authored` and `appautomate-demo`, which are not that shape, still carry the pre-rename product names, and in the second case say "demo" about an index that ships on npm and answers against production. The stamp names the commit whose CONTENT it describes, not the commit that writes the stamp — the same relationship a generator has to its source, and it means `git show :capability/.capability-index.json` reproduces the file the id refers to. Nothing in the repo writes build_id; every reference reads it. `version` goes 0.1 -> 1.0 with it. The field is the dotted content-generation counter and is documented as absent in the pre-release shape, so a version that is present and below 1 says two contradictory things about an index that is shipping. loadtesting's first release is 1.0. check-contract.py only asserted a non-empty string, which is why neither was caught. Content is byte-identical either side of this; only the envelope moved. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 4 ++-- capability/mobiledevicecontrol.capability-index.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 30b54cc3..271f9130 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -1,7 +1,7 @@ { "schema_version": 1, - "version": "0.1", - "build_id": "automate-authored", + "version": "1.0", + "build_id": "8445466_2026-10-19T06:53:46Z", "devicecontrol": { "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.", "routing_terms": [ diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 84641cf5..26c02595 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -1,7 +1,7 @@ { "schema_version": 1, - "version": "0.1", - "build_id": "appautomate-demo", + "version": "1.0", + "build_id": "6782f7e_2026-10-16T13:54:22Z", "mobiledevicecontrol": { "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.", "routing_terms": [ @@ -3311,4 +3311,4 @@ "api": "https://api-cloud.browserstack.com" } } -} \ No newline at end of file +} From 0013fedd58b11d894b243df08a5459d26c1683f2 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 21 Oct 2026 12:55:14 +0530 Subject: [PATCH 18/53] fix(capability): say what the iOS keyboard actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a real iPhone, three routes, text in the field before each: hide_keyboard 400 keyboard up field kept mobile: hideKeyboard keys ["Done"] 400 keyboard up field kept perform_actions tap above the keyboard 200 keyboard up field kept Both 400s are the same error — "invalid element state", "Did not know how to dismiss the keyboard" — and the guidance described that as the opposite: it said hide_keyboard "frequently leaves the keyboard open while still answering successfully, so always confirm with is_keyboard_shown". A caller following that writes a success path with a check after it, and takes an error branch instead. A second line recommended, as the iOS fallback, "tapping an empty area or the keyboard's own Done key" — both of which fail, and both of which the NEXT line in the same block already said fail. Two guidance lines disagreeing about one capability; the wrong one is gone. What was missing is the cost of the route that does work. keys ["return"], and a newline through send_keys, close the keyboard BY PRESSING RETURN: the field is submitted and reset to its placeholder and the value moves wherever the app sends it. Both were published as ways to dismiss the keyboard with no note that they change the app. The keys argument also names a key on the APP'S own keyboard, which is why ["Done"] fails on a keyboard that only offers Return — so the working route is not portable between apps either. So: on iOS there may be no way to put the keyboard away without submitting, and the guidance now says that outright rather than listing fallbacks that do not work. send_keys carried a related error. It led with "REPLACES whatever the field already holds rather than adding to it — measured on Android", and a later line hedged that other platforms may append. iOS appends: typing twice left routeA, then routeArouteB, then routeArouteBrouteC in one field. The headline is now platform-split and the hedge is replaced by the measurement. Since nothing on this surface can empty a field, an iOS field typed into twice cannot be corrected. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobiledevicecontrol.capability-index.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 26c02595..92b3318b 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -459,12 +459,12 @@ "entity": "app_session", "intent": "Type text into an input field in the app \u2014 use this to fill a search box, login form or any editable field found with find_element.", "guidance": [ - "REPLACES whatever the field already holds rather than adding to it \u2014 measured on Android with and without refocusing between calls. Read the field back from get_page_source if the resulting value matters, and do not build up a value with successive calls.", + "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.", "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", - "The W3C specification says this appends, and other Appium drivers may; do not rely on either behaviour across platforms without checking. There is no separate way to empty a field on this surface: the standard clear endpoint answers 200 without reliably emptying the field, so typing the value you want is the way to set it.", - "The keyboard is usually left open afterwards: call hide_keyboard before tapping anything below the field, and is_keyboard_shown to confirm it closed.", - "Sending a newline submits the field and closes the keyboard on iOS, which is the most reliable way to dismiss it there." + "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.", + "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.", + "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." ], "path_params": [ { @@ -1449,9 +1449,11 @@ "intent": "Close the on-screen keyboard \u2014 use this when it covers a button or field you need to tap after typing.", "guidance": [ "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", - "On iOS this can be refused by a keyboard the app controls; the fallback is tapping an empty area or the keyboard's own Done key.", "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", - "On iOS this frequently leaves the keyboard open while still answering successfully, so always confirm with is_keyboard_shown. Two things do close it there: send_keys with a newline, or execute_script with 'mobile: hideKeyboard' and args [{\"keys\":[\"return\"]}]. Tapping an empty area of the screen does not, and neither does that command with no keys or with [\"Done\"], which fail as \"invalid element state\".", + "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.", + "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.", + "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.", + "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.", "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." ], "path_params": [ From 78faaeebeeaff3dcf4c641a097458a5b020a103a Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 21 Oct 2026 22:39:54 +0530 Subject: [PATCH 19/53] fix(capability): say WHERE the app id goes, and which scroll actually works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guidance defects the QA eval run caught (E136, E137, E110). THE APP ID GOES INSIDE `args`. launch_app, terminate_app and get_app_state are execute/sync wrappers whose body is `script` and `args`, and the args parameter already carried the right example. The guidance opened with "Android takes appId and iOS takes bundleId — send exactly the one for the platform", which says WHICH key but never WHERE it goes, and an agent reading it sent the key as a top-level body field: unknown body: bundleId. accepted: args, script missing required parameter(s): args, script Both are refusals this surface raises before anything is sent, and neither is the 400 the old line warned about — so the guidance described a failure the caller was not going to get while staying silent about the one it was. It now shows the args object literally for both platforms and names both refusals. press_button pointed at launch_app with the same half-instruction and is corrected to match. SCROLLING: the guidance led with window.scrollTo(0, document.body.scrollHeight) as the way to reach the bottom. On Chrome for Android that can stop short and leave a footer off screen while still reporting success — measured. scrollIntoView on the element works there, so the element form now leads and the page form is given with the platform caveat attached. press_back was checked and NOT changed: its guidance already says the iOS call "still answers 200 — so confirm from the page source rather than from the status", which is exactly what the failing cases hit. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 2 +- capability/mobiledevicecontrol.capability-index.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 271f9130..0e408b2e 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -1114,7 +1114,7 @@ "entity": "browser_session", "intent": "Run JavaScript in the page \u2014 use this to scroll, read something the element readers cannot reach, or drive a control that resists clicking.", "guidance": [ - "SCROLLING LIVES HERE: window.scrollTo(0, document.body.scrollHeight) goes to the bottom, and arguments[0].scrollIntoView({block:'center'}) brings an element into view before clicking it.", + "SCROLLING LIVES HERE, and the reliable form is the ELEMENT one: pass the element in args and call arguments[0].scrollIntoView({block:'center'}). window.scrollTo(0, document.body.scrollHeight) reaches the bottom on a desktop browser, but on Chrome for Android it can stop short and leave a footer off screen while still reporting success \u2014 so scroll the thing you actually want rather than the page, and confirm with a screenshot or the page source.", "`return` is required to get a value back; without it the answer is null.", "An element id can be passed in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives as a real element in arguments.", "It runs with the page's own privileges and can change anything, so a script that alters state is as consequential as a click.", diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 92b3318b..68c251eb 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -1809,7 +1809,7 @@ "entity": "app_session", "intent": "Bring an app to the foreground, starting it if it is not running \u2014 use this to open the app under test, return to it, or switch to another installed app.", "guidance": [ - "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", "Launching an app that is already in the foreground does nothing and still reports success.", "To restart an app from a clean state, call terminate_app first and then this.", "Succeeding means the app was activated, not that it finished drawing, so give it a moment and confirm before acting. An app that cannot be brought forward at all answers 400 rather than reporting success, and get_app_state reads 4 once it really is in front \u2014 sampled too early it can still report the home screen mid-switch.", @@ -1882,7 +1882,7 @@ "entity": "app_session", "intent": "Close an app and stop it running \u2014 use this to end a session inside the app, or as the first half of a restart.", "guidance": [ - "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", "Returns true when the app was running and false when it was not; false is not an error.", "The app stays installed and keeps its data; only the running process is stopped.", "This does not clear state between runs \u2014 relaunching shows the app where it was unless the app itself resets." @@ -1954,7 +1954,7 @@ "entity": "app_session", "intent": "Check whether an app is installed, running or in the foreground \u2014 use this to confirm a launch or a close actually took effect.", "guidance": [ - "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", "The value is a number: 0 not installed, 1 installed but not running, 2 running in the background and suspended, 3 running in the background, 4 running in the foreground.", "4 is the only state in which the app is on screen and can be driven.", "The same scale applies on both platforms. Sending an app to the background leaves it at 3 or 2 \u2014 the system suspends it a moment after it stops being visible \u2014 so treat anything below 4 as not on screen rather than waiting for one number. Launching it again returns it to 4." @@ -2367,7 +2367,7 @@ "guidance": [ "iOS only; press_keycode is the Android equivalent and the two are not interchangeable.", "The button names carry no separator: home, volumeup, volumedown.", - "Pressing home sends the app to the background \u2014 bring it back with launch_app, passing bundleId rather than appId, and confirm from the page source that the app is in front again.", + "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 from the page source that the app is in front again.", "The response is null on success and says nothing about what the button did." ], "path_params": [ From 39a68457646607f8795dbe01a38629aba3e3e960 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 21 Oct 2026 23:16:14 +0530 Subject: [PATCH 20/53] fix(capability): the iOS keyboard submit is the app's doing, not Return's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting guidance shipped earlier today. It said the only things that close an iOS keyboard "closes it BY SUBMITTING ... Both press Return, so the field is submitted", which names the wrong cause, and it implied dismissal itself may be impossible. Measured on an iPad Pro 11 / iOS 26 with the same app: the iPad keyboard carries a "Hide keyboard" key, and execute_script 'mobile: hideKeyboard' with args [{"keys":["Hide keyboard"]}] closes it cleanly — 200, is_keyboard_shown false, no Return pressed. The field still committed: Text Output changed from "Waiting for text input." to the typed value. So the commit follows the field ending editing, whichever way that happens, and is the app's behaviour rather than anything about Return or about the driver. Three corrections follow. `mobile: hideKeyboard` DOES work on iOS, given a key the keyboard actually has — the earlier text read as though nothing worked. The keys available differ by device class, iPad carrying a dismiss key where an iPhone of this kind offers only the return key, so the guidance now says to read the keyboard from get_page_source and pick from what is really there instead of guessing; that also tells a caller in advance when the only option is one that submits. And whether the field survives is named as the app's business, not the keyboard's. Also four routes measured on iPhone and all four ruled out as non-submitting dismissals: set_orientation leaves it up, backgrounding brings it back with the app, a swipe over the keyboard does nothing, and tapping the nav-bar back button closes it by tearing the screen down and discarding the text. Co-Authored-By: Claude Opus 5 (1M context) --- capability/mobiledevicecontrol.capability-index.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 68c251eb..32bbd2b7 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -464,7 +464,7 @@ "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", "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.", "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.", - "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." + "Sending a newline closes the keyboard on iOS by pressing Return, which an app that commits on editing-ended will treat as a submit \u2014 the field resets and the value moves wherever the app sends it. The commit is the app's doing, not the newline's: dismissing the same field any other way can commit it too. See hide_keyboard." ], "path_params": [ { @@ -1451,9 +1451,9 @@ "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", "On 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.", - "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.", - "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.", - "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.", + "What DOES close it on iOS is execute_script with 'mobile: hideKeyboard' and a key the keyboard actually has, in args [{\"keys\":[\"\"]}]. An iPad keyboard carries a \"Hide keyboard\" key and that closes it cleanly \u2014 200, is_keyboard_shown false, no Return pressed. An iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", + "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. Read the keyboard from get_page_source first and pick from the keys that are really there rather than guessing \u2014 that also tells you in advance when the only option is one that submits. Omitting keys fails too, and tapping an empty area above the keyboard with perform_actions returns 200 and changes nothing.", + "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 measured: closing an iPad keyboard with \"Hide keyboard\" 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 hide_keyboard works normally.", "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." ], "path_params": [ From bdb7dad842f7359f4697148d57cf091229df0fdb Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 21 Oct 2026 23:24:15 +0530 Subject: [PATCH 21/53] fix(capability): language and locale land on the device or the app, by platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `language` and `locale` were described as "Device language code" and "Device locale", which is right on one platform and wrong on the other — and the wrong one is the one a tester checked. On iOS they do not touch the device: Settings and General stay in English, and the code applies to the app under test at launch. Reading the description, an agent reports the device language changed, and the case is marked failed against a capability that did what it was asked. Measured on a Samsung Galaxy S25 / Android 15 with language de and locale DE, on an app that actually ships German: create_session returned 200 with no warnings, the app rendered German throughout ("Wikipedia durchsuchen", "In den Nachrichten", tabs "Entdecken / Meine Listen / Verlauf / In der Nähe", dates as 21.10.2026), and Android's OWN permission dialog — elements under package="android", not the app — came up in German too. So on Android it is the device, and the app follows. Two further things the run established, both now in the guidance. Nothing is visible unless the app ships that language, so a 200 says nothing and the strings have to be read back from get_page_source. And the device locale cannot be confirmed from inside the session at all: read_device_property refuses getprop persist.sys.locale and ro.product.locale with "BROWSERSTACK_UNSUPPORTED_ADB_SHELL_COMMAND ... not supported on BrowserStack currently", while ro.build.version.release and ro.product.manufacturer work — a per-property allow-list, not a broken capability. That is recorded on read_device_property as well, since it will bite anyone reading properties. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobiledevicecontrol.capability-index.json | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 32bbd2b7..45fccf4f 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -82,7 +82,9 @@ "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", - "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account." + "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", + "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back from get_page_source rather than trusting the call's 200.", + "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen." ], "body": [ { @@ -209,15 +211,15 @@ "name": "language", "type": "string", "json_path": "/capabilities/alwaysMatch/appium:language", - "description": "Device language code.", - "example": "fr" + "description": "Language code applied at session start. On Android it sets the DEVICE language \u2014 the system's own dialogs render in it \u2014 and the app follows. On iOS it does NOT change the device: Settings stays in English, and the code is applied to the app under test at launch instead.", + "example": "de" }, { "name": "locale", "type": "string", "json_path": "/capabilities/alwaysMatch/appium:locale", - "description": "Device locale.", - "example": "fr_CA" + "description": "Region applied with `language`, same split: the device on Android, the app under test on iOS. Android takes a bare region code such as DE.", + "example": "DE" }, { "name": "timezone", @@ -2549,7 +2551,8 @@ "Only `getprop ` can be expressed: the accepted form is fixed, and a command that lists apps or system state cannot be sent through this capability. A public phone is shared between customers, and commands such as `pm list packages` or `dumpsys` report what other people's sessions left behind.", "BrowserStack applies its own allow-list on top of this one and accepts only some properties on a shared phone; ro.build.version.release and ro.product.manufacturer are accepted, while a bare getprop is not. A property it refuses comes back as an execution failure rather than an empty value.", "The device name and OS version are already in the session record, so get_session answers those without this.", - "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form." + "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form.", + "The allowed property list is narrow and locale is not on it: getprop persist.sys.locale and getprop ro.product.locale both come back HTTP 500, \"BROWSERSTACK_UNSUPPORTED_ADB_SHELL_COMMAND ... is not supported on BrowserStack currently\". ro.build.version.release and ro.product.manufacturer do work, so treat the refusal as a per-property allow-list rather than a broken capability." ], "path_params": [ { From 587fa19093636b84fc35352c85ba3042b826e248 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 12:13:56 +0530 Subject: [PATCH 22/53] chore(capability): withhold get_page_source on devicecontrol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested: stop publishing a way to read a web page's HTML. Browser only — mobiledevicecontrol keeps its copy, because there it is the ONLY way to read anything on screen (47 authored capabilities, none of which read an element's text or attributes) and six published capabilities point callers at it. On the browser side the content stays reachable: execute_script returns the same markup, get_element_property reads innerText and a field's live value, and get_title and get_current_url answer the cheap questions. So this removes the named route, not the access — worth saying plainly, because the capability was also the documented way to DISCOVER a selector on an unfamiliar page, and guessed selectors fail silently. execute_script's guidance now carries that job: the whole-document and single-element forms, which to prefer, and why guessing instead is a mistake. Repaired with it, so nothing points at a capability that is no longer published: the product summary advertised "read the page HTML" in its list of what the session side can do, and get_title's first guidance line compared itself to get_page_source by name. Entity lists need no edit — the loader prunes withheld names from them, verified through describeEntity (30 authored, 24 listed, no phantoms). A sweep of all five indexes for guidance naming a withheld capability now returns one hit, and it is pre-existing and not ours: tm's create_template points at get_template, which tm withholds. devicecontrol goes 26 published to 25. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 0e408b2e..9ac22729 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "1.0", "build_id": "8445466_2026-10-19T06:53:46Z", "devicecontrol": { - "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.", + "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, 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.", "routing_terms": [ "automate", "browser", @@ -499,7 +499,7 @@ "entity": "browser_session", "intent": "Read the page title \u2014 the cheapest way to confirm which page is loaded.", "guidance": [ - "Far cheaper than get_page_source when all that is needed is which page is showing." + "The cheapest way to confirm which page is showing, and enough on its own for most navigation checks." ], "path_params": [ { @@ -664,7 +664,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "find_element", @@ -1115,6 +1116,7 @@ "intent": "Run JavaScript in the page \u2014 use this to scroll, read something the element readers cannot reach, or drive a control that resists clicking.", "guidance": [ "SCROLLING LIVES HERE, and the reliable form is the ELEMENT one: pass the element in args and call arguments[0].scrollIntoView({block:'center'}). window.scrollTo(0, document.body.scrollHeight) reaches the bottom on a desktop browser, but on Chrome for Android it can stop short and leave a footer off screen while still reporting success \u2014 so scroll the thing you actually want rather than the page, and confirm with a screenshot or the page source.", + "READING THE PAGE'S MARKUP LIVES HERE TOO: 'return document.documentElement.outerHTML' for the whole document, or 'return arguments[0].outerHTML' with an element in args for one part of it. Prefer the element form \u2014 a real page runs to tens of kilobytes and the whole document is rarely what is wanted. This is also how to discover a selector on a page you have not seen: read the markup around the area you care about rather than guessing a selector, which fails silently when it matches nothing.", "`return` is required to get a value back; without it the answer is null.", "An element id can be passed in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives as a real element in arguments.", "It runs with the page's own privileges and can change anything, so a script that alters state is as consequential as a click.", From 8df3db119b53b6ce881e65e8b997ce5eb90c1bb2 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 12:17:18 +0530 Subject: [PATCH 23/53] feat(capability): hand the user a live-view link when a session starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: the live session view does not open automatically. It cannot — and the guidance never said so, which is the part we can fix. invokeCapability makes an HTTP request and returns the response; it has no channel to open a browser on the caller's machine, and on the remote server there is no browser to open at all. runAppLiveSession feels different because it is a local tool built to shell out, which is also why agents have been reaching for App Live when asked to start an App Automate session. What was missing is the achievable version. create_session returns only value.sessionId — no link of any kind — and the links live on get_session, so an agent that stops at create_session leaves the user with nothing to click. Both products now say to call get_session straight after and give back browser_url in the same reply, to offer public_url only when sharing outside the team since it carries an auth token, and never to claim a live view was opened. mobiledevicecontrol gets a second line, because watching has a precondition that cannot be met late: interactive_debugging has to be set true in create_session itself. It cannot be switched on once the session is running, it is off by default on Android, and it needs video left on. An agent that learns this after the fact has to throw the session away and start again, so the guidance says to ask up front when someone is going to follow along. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 3 ++- capability/mobiledevicecontrol.capability-index.json | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 9ac22729..a1ae341e 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -262,7 +262,8 @@ "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", - "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured." + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured.", + "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." ], "body": [ { diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 45fccf4f..ba31496b 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -84,7 +84,9 @@ "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back from get_page_source rather than trusting the call's 200.", - "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen." + "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.", + "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.", + "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." ], "body": [ { From 26449876308aba4dc6658eaf2bc9b42f34c13b3c Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 12:39:06 +0530 Subject: [PATCH 24/53] fix(capability): name all three ways to get a bs:// url, and what the helper does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader of the remote refusal checked it against the remote server's own code and was right on both counts: the upload goes straight to /app-automate/upload and answers with a bs:// url, and there is no S3 anywhere in it — no presigned url, no bucket, no second call. (The presigned-upload flow that exists in this repo belongs to Load Testing's script upload, which is a different product and a different shape; it is the likely source of the confusion.) The note was accurate but thin in two ways. It said the asset-upload helper "will write out" the request, which reads as though calling it performs the upload. It does not: that tool reads no file and makes no HTTP call at all — it returns the curl command as text, and a HUMAN has to run it. An agent that waits for a bs:// from the helper waits forever. And it offered one route where there are three. Reusing a build already on the account is the cheapest and the only one needing nothing from the user, which is how every mobile session in this week's testing actually got its app, and it was not mentioned anywhere near the refusal. The note now lists all three in order: list_app_builds for a build uploaded before (they last 30 days), upload_app_build for a public https link, and only then the by-hand curl. upload_app_build says the same about there being no intermediate storage, and gains a line pointing at list_app_builds before uploading at all; list_app_builds says it is the first thing to try on a remote server. Co-Authored-By: Claude Opus 5 (1M context) --- capability/mobiledevicecontrol.capability-index.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index ba31496b..cef14157 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -864,13 +864,14 @@ "entity": "app_build", "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.", "guidance": [ - "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.", + "Only the download-link form can be used here. The same endpoint also accepts a multipart upload carrying the file itself, which this surface cannot send \u2014 so for a build that exists only on the caller's own machine, give them that multipart request to run themselves and take the bs:// url it returns. The upload answers with the bs:// url directly: there is no intermediate storage, no presigned url and no second call. There is no way to hand the bytes over from here.", "The url must be https and reachable by BrowserStack without credentials; a link behind a login fails on their side, not the caller's.", "Pass custom_id to give the build a stable name, then later sessions can use that name in place of the bs:// URL and always get the newest build under it.", "Re-uploading under the same custom_id adds a new build rather than replacing the old one; both stay listed.", "Builds expire after 30 days, so a bs:// URL held from an earlier run can stop working.", "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file.", - "upload_app_build_from_file takes a build that is on this machine instead, where the server runs beside the user. It is not offered on a remote server, where a path would name the operator's disk rather than theirs." + "upload_app_build_from_file takes a build that is on this machine instead, where the server runs beside the user. It is not offered on a remote server, where a path would name the operator's disk rather than theirs.", + "Before uploading at all, check list_app_builds \u2014 a build from an earlier run is still usable for 30 days, and on a remote server reusing one is the only route that needs nothing from the user." ], "body": [ { @@ -917,7 +918,8 @@ "guidance": [ "An account with no uploads answers with a message object or a 404 rather than an empty array; treat both as 'nothing uploaded yet'.", "This is only the calling user's own uploads \u2014 use list_team_app_builds for everyone on the team.", - "app_url is what create_session takes; app_id is an internal identifier and is not accepted there." + "app_url is what create_session takes; app_id is an internal identifier and is not accepted there.", + "On a remote server this is the first thing to try: uploading from a path is not offered there, so an existing build is the only app a session can install without either a public link or the user running an upload by hand." ], "query": [ { @@ -2646,7 +2648,7 @@ "$response": "Unauthorized" } }, - "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." + "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. Three routes get you a bs:// url instead, cheapest first. (1) list_app_builds or list_team_app_builds, if the build was uploaded before: builds last 30 days and are reusable as they are. (2) upload_app_build, if the build sits at a public https link \u2014 the only fully automatic route. (3) 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 has to run that command, and it answers with the bs:// url directly from /app-automate/upload. There is no intermediate storage and no second step. Take the bs:// url back from them and pass it as the app for create_session." }, { "name": "find_elements", From ee0404606463eb3c2d52f2f2f91302e21674f13f Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 13:27:18 +0530 Subject: [PATCH 25/53] refactor(capability-registry): guidance splits by surface, replacing unavailable_note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unavailable_note` was a single string that REPLACED a capability's guidance on a remote server. That threw away lines still true there — the accepted file types, how long a large upload takes — and invited them to be restated inside the note, where the two copies could drift apart. It also had to carry everything in one paragraph, which in searchCapability sat beside rows whose guidance is short scannable lines; and search is where it is read, since guidance comes back on every row, before the caller picks anything. `guidance` now takes either form. A plain array is shared, which is what every shipped index already uses and what the other four keep. The object form splits it: guidance: { shared: [...], true wherever this runs local: [...], only where the server sits beside the user remote: [...], only on a remote server } The loader folds the applicable half into one array at load, so nothing downstream meets the object — the three readers that walk guidance go through a `guidanceLines` accessor that checks rather than asserts. Which surface this is stays ours to know. The split is not cosmetic. On a remote server, advice about the caller's own filesystem is not merely useless but WRONG: it would have an agent tell the user to configure a machine they are not on. upload_app_build_from_file is the case — four lines about MCP_UPLOAD_BASE_DIR and path rules that must not travel, three that must (file types, upload duration, the two alternative routes), and four that exist only remotely. Previously the first four reached nobody remote and the middle three were dropped and partly re-typed into the note. The remote half says only what to do instead. It no longer explains that the server is not on the user's machine: invokeCapability's refusal already opens with " cannot be called on this server", so the guidance need not repeat it, and a capability should describe what to do rather than where the code is running. asReference keeps its fallback for the case where nothing applies on this surface — no shared and no remote lines — so a published-but-uncallable capability never appears with no explanation at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobiledevicecontrol.capability-index.json | 30 ++++++---- src/tools/capability-registry/index-loader.ts | 48 +++++++++++++-- src/tools/capability-registry/register.ts | 8 +-- src/tools/capability-registry/search.ts | 10 +++- src/tools/capability-registry/types.ts | 60 ++++++++++++------- .../capabilityRegistryDescribeSurface.test.ts | 2 +- .../capabilityRegistryReferenceOnly.test.ts | 43 +++++++++++-- 7 files changed, 154 insertions(+), 47 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index cef14157..9c8afca4 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -2608,14 +2608,25 @@ "encoding": "multipart", "local_only": true, "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.", - "guidance": [ - "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.", - "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.", - "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.", - "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.", - "upload_app_build takes a public https link instead and is the one to use when the build is not on this machine.", - "A large build takes minutes, because the call does not return until the whole file has been sent." - ], + "guidance": { + "shared": [ + "Only .apk, .aab and .ipa are accepted.", + "A large build takes minutes, because the upload does not return until the whole file has been sent.", + "upload_app_build takes a public https link instead, and list_app_builds reuses a build already on the account \u2014 builds last 30 days." + ], + "local": [ + "The file must sit inside the directory MCP_UPLOAD_BASE_DIR names. When that is not set the upload is refused and nothing is sent: tell the user to set it to the folder holding their builds and restart the server.", + "When it is set but the build is somewhere else, the refusal names the directory the file has to be inside. Tell the user to move it there rather than trying another path.", + "No part of the path may be a hidden folder, so a build under a dot-directory has to be moved out of it.", + "The path is checked before anything is uploaded and before the user is asked to approve, so a refusal costs nothing and means the file was never read." + ], + "remote": [ + "Cheapest route first: list_app_builds or list_team_app_builds, if the build was uploaded before.", + "Then upload_app_build, if the build sits at a public https link \u2014 the only route that needs nothing from the user.", + "Otherwise the user uploads it themselves. The asset-upload helper PRINTS a curl command and nothing more \u2014 it reads no file and sends no request \u2014 so the user runs that command, and it answers with the bs:// url directly from /app-automate/upload. There is no intermediate storage and no second step.", + "Take the bs:// url back from them and pass it as the app for create_session." + ] + }, "body": [ { "name": "file", @@ -2647,8 +2658,7 @@ "401": { "$response": "Unauthorized" } - }, - "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. Three routes get you a bs:// url instead, cheapest first. (1) list_app_builds or list_team_app_builds, if the build was uploaded before: builds last 30 days and are reusable as they are. (2) upload_app_build, if the build sits at a public https link \u2014 the only fully automatic route. (3) 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 has to run that command, and it answers with the bs:// url directly from /app-automate/upload. There is no intermediate storage and no second step. Take the bs:// url back from them and pass it as the app for create_session." + } }, { "name": "find_elements", diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index 382fd248..f4e35b62 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -115,15 +115,52 @@ function asReference(capability: Capability): Capability { ] as const) { delete reference[field]; } + // Guidance is NOT touched here. `withSurfaceGuidance` has already folded in + // `remote_guidance` — which is where a reference-only capability says what to do + // instead — and it runs for every capability, not only this branch. + // `withSurfaceGuidance` has already run, so guidance is a plain array here — and for a + // reference-only capability its `remote` half is where "what to do instead" lives. + const named = Array.isArray(capability.guidance) + ? capability.guidance.length > 0 + : false; return { ...reference, invocable: false, - guidance: capability.unavailable_note - ? [capability.unavailable_note] - : ["This capability cannot be called on this server."], + ...(named + ? {} + : { guidance: ["This capability cannot be called on this server."] }), }; } +/** + * Flatten `guidance` to the lines that apply to the surface doing the loading. + * + * A capability may say three things about how to use it: what is true everywhere, what is + * true only where the server sits beside the user, and what is true only on a remote one. + * Only one of the last two can ever apply, so the caller is handed exactly that and never + * has to work out which half was meant for them. + * + * COMPOSED, NOT REPLACED. An earlier shape put the remote instruction in a single + * `unavailable_note` that replaced the guidance outright, which threw away lines still true + * there — the accepted file types, how long a large upload takes — and invited them to be + * restated in the note, where the two copies could drift. Shared lines are now written once. + * + * The object form is flattened away here, so no reader downstream sees anything but a plain + * array, and which surface this is stays ours to know rather than a fact the caller acts on. + */ +function withSurfaceGuidance(capability: Capability): Capability { + const g = capability.guidance; + if (!g || Array.isArray(g)) return capability; + const lines = [ + ...(g.shared ?? []), + ...(appConfig.REMOTE_MCP ? (g.remote ?? []) : (g.local ?? [])), + ]; + const next: Capability = { ...capability }; + if (lines.length) next.guidance = lines; + else delete next.guidance; + return next; +} + export interface LoadedProduct { name: string; product: ProductIndex; @@ -290,11 +327,14 @@ export class CapabilityRegistry { const withheld = new Map(); const enabled: Capability[] = []; - for (const capability of bundle.capabilities) { + for (let capability of bundle.capabilities) { // UNIQUENESS IS CHECKED OVER EVERY CAPABILITY, disabled ones included. A clash // between a disabled name and a live one is still a broken artifact, and skipping // the disabled half would mean re-enabling the flag could fail the load later, // somewhere with less context than here. + // Surface guidance is folded in for EVERY capability, before any of the branches + // below, so a reference-only one and an ordinary one are composed the same way. + capability = withSurfaceGuidance(capability); if (isReferenceOnly(capability)) { // PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that // should find it does, and loses everything a caller would need to invoke it. diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 7c4174f6..dea2f7ba 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -46,7 +46,7 @@ import { terms, vocabularyOf, } from "./search.js"; -import { Mode } from "./types.js"; +import { Mode, guidanceLines } from "./types.js"; export const PERMISSION_VALUES = ["not_asked", "granted", "denied"] as const; @@ -888,8 +888,8 @@ export function addCapabilityRegistryTools( mode: capability.mode, entity: capability.entity, ...(capability.intent ? { intent: capability.intent } : {}), - ...(capability.guidance?.length - ? { guidance: capability.guidance } + ...(guidanceLines(capability.guidance).length + ? { guidance: guidanceLines(capability.guidance) } : {}), })), ...rest, @@ -1247,7 +1247,7 @@ export function addCapabilityRegistryTools( "invokeCapability", "capability_not_invocable", `${handle} cannot be called on this server. ` + - (capability.guidance?.[0] ?? + (guidanceLines(capability.guidance)[0] ?? "Describe it for what to do instead.") + " Follow that instead of retrying.", undefined, diff --git a/src/tools/capability-registry/search.ts b/src/tools/capability-registry/search.ts index 9a7b1e93..56cc7c7a 100644 --- a/src/tools/capability-registry/search.ts +++ b/src/tools/capability-registry/search.ts @@ -12,7 +12,13 @@ * capability needing an id it cannot possibly have yet. */ -import { Capability, EntityDoc, Mode, ProductIndex } from "./types.js"; +import { + Capability, + EntityDoc, + Mode, + ProductIndex, + guidanceLines, +} from "./types.js"; /** * Every non-alphanumeric character separates, `_` included. @@ -429,7 +435,7 @@ function score( // `test_cases_count`); gating it on an identity match instead made a field reachable // only through returns unreachable, which is worse. [(capability.returns || []).join(" "), 1], - [(capability.guidance || []).join(" "), 1], + [guidanceLines(capability.guidance).join(" "), 1], // Parameter names, their descriptions, and their enum values — 330 descriptions and 34 // value lists that the artifact already carries and nothing was reading. The vocabulary // a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 84f899d1..f755de21 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -118,6 +118,43 @@ export interface ResponseDoc extends ComponentRef { } /** A capability, keyed by the endpoint it exposes. There is deliberately no name. */ +/** + * How to call the endpoint correctly, optionally split by which surface is serving it. + * + * A plain array is shared — true wherever this runs, which is almost everything, and the + * form every shipped index already uses. The object form adds lines that are true on only + * one surface: `local` where the server sits beside the user, `remote` where it does not. + * `shared` is the same list the plain array would have been, so a capability can carry + * facts of both kinds without repeating any of them. + * + * The loader folds the applicable half into one array before anything reads it, so a + * caller is handed exactly the guidance that applies to the server it is talking to and + * never has to work out which half was meant for it. The split itself is ours to know: + * these keys do not survive into what describeCapability or searchCapability return. + * + * WHY A LINE IS SURFACE-SPECIFIC RATHER THAN SHARED: on a remote server, advice about the + * caller's own filesystem is not merely useless but wrong — it would have an agent tell the + * user to configure a machine they are not on. And for a `local_only` capability, `remote` + * is where "what to do instead" lives, which searchCapability puts in front of the caller + * before they pick anything, let alone call it. + */ +export type Guidance = + | string[] + | { shared?: string[]; local?: string[]; remote?: string[] }; + +/** + * The guidance lines as a plain array, whichever form was authored. + * + * The loader flattens the object form at load, so everything downstream genuinely does hold + * an array — this exists so that is checked rather than asserted, and so a capability read + * straight from a file (a test, a checker) behaves the same as one from the registry. + * Without the surface to decide between them, only the shared lines can be returned. + */ +export function guidanceLines(guidance?: Guidance): string[] { + if (!guidance) return []; + return Array.isArray(guidance) ? guidance : (guidance.shared ?? []); +} + export interface Capability { /** * The caller-facing handle — the snake-cased `operationId`, unique within its product. @@ -155,14 +192,8 @@ export interface Capability { query?: WireParam[]; body?: WireParam[]; intent?: string; - /** - * How to call the endpoint correctly. - * - * ABSENT from the released artifact — the export dropped it, and the shape-change note - * does not say whether that was intended. Kept optional and still scored by search so the - * server works either way; nothing may depend on it being present. - */ - guidance?: string[]; + /** How to call it; see `Guidance` for the local/remote split. */ + guidance?: Guidance; /** Allowlisted row fields. Absent when `shape` is "discovered". */ returns?: string[]; /** "discovered" when the product declares no response schema for this operation. */ @@ -205,18 +236,7 @@ export interface Capability { * than hunting for a synonym. */ local_only?: boolean; - /** - * What to do instead, on a surface where this capability cannot run. - * - * A `local_only` capability is NOT hidden on a remote server: the query that should find - * it still finds it, because an agent asked to upload a file from the user's machine - * needs to learn that the route exists and what replaces it here. What it loses is the - * means to call it — the parameters and response shapes are stripped at load, and - * invocation is refused — so the note is the whole of what remains. - * - * Write it as the instruction the caller should follow, not as an apology. - */ - unavailable_note?: string; + /** * FALSE when this build publishes the capability for reference but will not call it. * diff --git a/tests/tools/capabilityRegistryDescribeSurface.test.ts b/tests/tools/capabilityRegistryDescribeSurface.test.ts index 88436c35..6856b906 100644 --- a/tests/tools/capabilityRegistryDescribeSurface.test.ts +++ b/tests/tools/capabilityRegistryDescribeSurface.test.ts @@ -52,8 +52,8 @@ const CAPABILITIES = [ entity: "build", encoding: "multipart", local_only: true, - unavailable_note: "Upload it yourself and pass the returned url.", intent: "Upload a build that is on this machine.", + guidance: { remote: ["Upload it yourself and pass the returned url."] }, body: [{ name: "file", type: "file", required: true }], }, ]; diff --git a/tests/tools/capabilityRegistryReferenceOnly.test.ts b/tests/tools/capabilityRegistryReferenceOnly.test.ts index a0680ee8..605332b6 100644 --- a/tests/tools/capabilityRegistryReferenceOnly.test.ts +++ b/tests/tools/capabilityRegistryReferenceOnly.test.ts @@ -17,9 +17,12 @@ const LOCAL_ONLY = { entity: "build", encoding: "multipart", local_only: true, - unavailable_note: "Upload it yourself and pass the returned url.", intent: "Upload a build that is on this machine.", - guidance: ["The file must sit inside MCP_UPLOAD_BASE_DIR."], + guidance: { + shared: ["Only .apk and .ipa are accepted."], + local: ["The file must sit inside MCP_UPLOAD_BASE_DIR."], + remote: ["Upload it yourself and pass the returned url."], + }, body: [{ name: "file", type: "file", required: true }], returns: ["app_url"], responses: { "200": { description: "ok" } }, @@ -106,17 +109,43 @@ describe("a local_only capability on a remote server", () => { expect(capability.invocable).toBe(false); }); - it("replaces its guidance with the note, which is the whole of what is left", async () => { + it("gives the remote half, keeps the shared lines, drops the local ones", async () => { + // The whole point of the split: a fact true on both surfaces is written once and still + // reaches a remote caller, while advice about the user's own disk — which here would be + // wrong, not merely useless — does not. const reg = await load(true); const { capability } = reg.byNameLookup("upload_from_file", "p"); expect(capability.guidance).toEqual([ + "Only .apk and .ipa are accepted.", "Upload it yourself and pass the returned url.", ]); }); - it("says so even without an authored note, rather than saying nothing", async () => { + it("gives the local half on a local server, and never the remote one", async () => { + const reg = await load(false); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance).toEqual([ + "Only .apk and .ipa are accepted.", + "The file must sit inside MCP_UPLOAD_BASE_DIR.", + ]); + }); + + it("flattens the object away, so no reader downstream meets it", async () => { + for (const remote of [true, false]) { + const reg = await load(remote); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(Array.isArray(capability.guidance), `remote=${remote}`).toBe(true); + } + }); + + it("says so when nothing at all applies here, rather than saying nothing", async () => { + // No shared lines and no remote ones: the capability would otherwise be published with + // no explanation of why it cannot be called. const reg = await load(true, [ - { ...LOCAL_ONLY, unavailable_note: undefined }, + { + ...LOCAL_ONLY, + guidance: { local: ["The file must sit inside MCP_UPLOAD_BASE_DIR."] }, + }, ]); const { capability } = reg.byNameLookup("upload_from_file", "p"); expect(capability.guidance?.[0]).toMatch(/cannot be called/); @@ -131,7 +160,9 @@ describe("the same capability on a local server", () => { const { capability } = reg.byNameLookup("upload_from_file", "p"); expect(capability.invocable).toBeUndefined(); expect(capability.body).toHaveLength(1); - expect(capability.guidance?.[0]).toMatch(/MCP_UPLOAD_BASE_DIR/); + expect((capability.guidance as string[]).join(" ")).toMatch( + /MCP_UPLOAD_BASE_DIR/, + ); }); }); From 13cc246230f6c2d9c7c8a85ae1039bbb70751990 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 14:06:04 +0530 Subject: [PATCH 26/53] fix(capability): measure the two keyboard tricks PR 485 uses, and record both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 485 dismisses the iOS keyboard with a cascade, two steps of which this guidance did not mention: offering several key names in one call, and a drag that starts ABOVE the keyboard rather than inside it. The second mattered because an earlier measurement here swiped from y=556 on a keyboard whose top is y=553 — inside it — so "a swipe does not dismiss it" was recorded without the gesture having been given a fair run. Both are now measured on an iPhone 17 / iOS 26, keyboard bounds read from the page source rather than guessed. Several candidates in one call is ACCEPTED: ["Done","done","Hide keyboard", "Dismiss"] is not rejected as malformed and costs nothing. It also makes no difference here — the same 400, because this keyboard carries none of those four and its only action key is Return. So it is worth offering and is not a way round the problem, which is what the guidance now says. The drag does not dismiss it either, with the correct geometry: pressing at (195, 433), 120px above the keyboard top, and moving down through 463, 493, 523 and 543 in four short steps answered without error and changed nothing — keyboard still up, field still holding its text, nothing submitted. That form works on a screen whose scroll view dismisses on drag; this app's does not. PR 485's own test fixture expects this failure too, asserting the message a caller gets when both the Done key and the drag have been tried and the control is still covered. So the earlier finding stands, for a better reason than it was first recorded. The guidance now rules out gestures explicitly rather than mentioning only the tap. Co-Authored-By: Claude Opus 5 (1M context) --- capability/mobiledevicecontrol.capability-index.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 9c8afca4..8436fb36 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -1458,7 +1458,8 @@ "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", "On 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.", "What DOES close it on iOS is execute_script with 'mobile: hideKeyboard' and a key the keyboard actually has, in args [{\"keys\":[\"\"]}]. An iPad keyboard carries a \"Hide keyboard\" key and that closes it cleanly \u2014 200, is_keyboard_shown false, no Return pressed. An iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", - "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. Read the keyboard from get_page_source first and pick from the keys that are really there rather than guessing \u2014 that also tells you in advance when the only option is one that submits. Omitting keys fails too, and tapping an empty area above the keyboard with perform_actions returns 200 and changes nothing.", + "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. SEVERAL CANDIDATES MAY BE LISTED IN ONE CALL \u2014 [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"] is accepted and costs nothing \u2014 but it still returns the same 400 when the keyboard carries none of them, so it is a convenience and not a way round the problem. Read the keyboard from get_page_source and pick from the keys that are really there; that also tells you in advance when the only option is one that submits.", + "GESTURES DO NOT DISMISS IT. Tapping an empty area above the keyboard returns 200 and changes nothing, and so does a drag downward over it \u2014 including one that starts well above the keyboard's top and moves through it in short steps, which is the form that works on a screen whose scroll view dismisses on drag. This app's does not, and neither gesture moved it.", "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 measured: closing an iPad keyboard with \"Hide keyboard\" 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 hide_keyboard works normally.", "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." ], From 167431fbaac455e0fdb9fbaa3c4e18dce0bb3f60 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 14:25:29 +0530 Subject: [PATCH 27/53] feat(capability): clear_element on mobile, and the claim it disproves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_keys said flatly that emptying a field was impossible here: "the standard clear endpoint answers 200 without reliably emptying it… on iOS… there is no way to correct a field you have already typed into." That was an unqualified generalisation and it is wrong. Measured on an iPhone 16 / iOS 18.6: the endpoint emptied the field. It is the same W3C route the browser product already publishes — /session/{id}/element/{id}/clear — so this is a capability record, not new plumbing, and it carries send_empty_body to match the browser's copy. It matters almost entirely on iOS. On Android send_keys REPLACES what is in the field and press_keycode 67 is delete, so clearing first is rarely needed; on iOS send_keys APPENDS, press_keycode is a 405, and until now a field typed into twice could not be corrected at all within a session. Its guidance leads with the thing that will bite: the 200 says the call was accepted, not that the field changed, so the field has to be read back from get_page_source. That is also what its declared 200 response now says. The first draft of this record reused send_keys' response wholesale and so described "Text typed" on a capability that types nothing — the copied-response mistake the authoring rules name specifically. The shape is genuinely shared, so it still points at NullValueResponse; only the description is its own. Found by the run that tested it, which read its own contract and reported both faults before I did. Co-Authored-By: Claude Opus 5 (1M context) --- .../mobiledevicecontrol.capability-index.json | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 8436fb36..e2771b97 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -466,7 +466,7 @@ "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.", "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", - "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.", + "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 from get_page_source rather than trusting the status.", "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.", "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." ], @@ -508,6 +508,51 @@ } } }, + { + "name": "clear_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/clear", + "mode": "write", + "entity": "app_session", + "intent": "Empty an editable field in the app \u2014 use this before typing when the field already holds something and you need to replace rather than add to it.", + "guidance": [ + "Takes no parameters of its own.", + "CONFIRM IT ACTUALLY EMPTIED THE FIELD by reading the field back from get_page_source: this endpoint can answer 200 without the field changing, and the status alone does not tell you which happened.", + "Needed far more on iOS than on Android: send_keys REPLACES the field's contents on Android, so clearing first is usually unnecessary there, while on iOS it APPENDS and this is the only way to take back what is already in the field.", + "The element must be an editable field \u2014 EditText on Android, TextField on iOS. Anything else answers \"invalid element state\".", + "The element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\"." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "element-6066-11e4-a52e-4f735466cecf" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Field emptied; value is null. The null says the call was accepted, not that the field changed \u2014 read the field back to know that.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, { "name": "perform_actions", "method": "POST", @@ -3240,7 +3285,8 @@ "get_simulated_location", "get_active_app_info", "read_device_property", - "find_elements" + "find_elements", + "clear_element" ] }, "device": { From 0fe631b952e9a3660e6f913b810038eaae2dd92e Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 14:35:57 +0530 Subject: [PATCH 28/53] feat(capability): wrap the jobs execute_script was doing, and stop publishing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running arbitrary JavaScript in a page, or an arbitrary Appium mobile: command, is not a capability — it is a hole through the capability surface. Anything reachable that way is reachable without a contract, without a declared mode, and without anything to search for. The jobs we actually pointed callers at it for are now capabilities in their own right, and execute_script is withheld on both products. Four new ones, each a fixed-script wrapper of the kind the seven mobile lifecycle capabilities already were: scroll_to_element devicecontrol scrollIntoView on an element scroll_page devicecontrol the page itself, by distance or to the end scroll_gesture mobiledevicecontrol mobile: scrollGesture, Android only dismiss_keyboard_key mobiledevicecontrol mobile: hideKeyboard with keys that exist scroll_to_element leads deliberately: window.scrollTo stops short on Chrome for Android and leaves a footer off screen while reporting success, which is how E110 failed, and the element form was measured to work there first try. scroll_page says so rather than pretending the two are equivalent. dismiss_keyboard_key turns this week's measured workaround into a contract: the plain hide_keyboard is a 400 on iOS, the key has to be one the keyboard really carries, several may be offered at once, and pressing Return submits. Withholding execute_script on BROWSER reopened a hole 587fa19 made, which pointed markup reading at it after get_page_source went. The answer is scoped reads, not a page dump: get_element_text, get_element_attribute and is_element_displayed were authored but withheld and are now published. A caller asks for one element's text and gets one element's text. Everything that named execute_script now names a capability instead: click_element and take_screenshot point at scroll_to_element, send_keys at click_element, hide_keyboard at dismiss_keyboard_key, create_session and perform_actions and delete_session at their replacements, and set_session_status drops the executor line. Both product summaries lose it too — devicecontrol advertised "run JavaScript" and "scrolling is done through execute_script". A sweep of all five indexes for guidance naming a withheld capability returns one hit, pre-existing and not ours: tm's create_template points at get_template. devicecontrol 25 -> 29 published, mobiledevicecontrol 37 -> 38. Co-Authored-By: Claude Opus 5 (1M context) --- .../devicecontrol.capability-index.json | 131 +++++++++++++-- .../mobiledevicecontrol.capability-index.json | 153 ++++++++++++++++-- 2 files changed, 261 insertions(+), 23 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index a1ae341e..d1cc0ceb 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "1.0", "build_id": "8445466_2026-10-19T06:53:46Z", "devicecontrol": { - "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, 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.", + "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, screenshot the viewport, resize the window, and move between tabs and iframes. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are mobile device control, which drives an installed app rather than a browser. Scrolling is done with scroll_to_element or scroll_page, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.", "routing_terms": [ "automate", "browser", @@ -792,7 +792,7 @@ "intent": "Click an element \u2014 use this for buttons, links, checkboxes and anything else that responds to a click.", "guidance": [ "Takes no parameters of its own.", - "The element must be in view and not covered. A click on something another element overlaps answers \"element click intercepted\" and names the element in the way \u2014 scroll it into view with execute_script, or dismiss the overlay first.", + "The element must be in view and not covered. A click on something another element overlaps answers \"element click intercepted\" and names the element in the way \u2014 scroll it into view with scroll_to_element, or dismiss the overlay first.", "An element that is not visible answers \"element not interactable\".", "Success means the click was dispatched, not that the page has finished reacting: read the page or the URL afterwards to confirm." ], @@ -837,7 +837,7 @@ "guidance": [ "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text.", - "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.", + "A \\n at the end submits the form on a desktop browser and on Chrome for Android, and is quicker than finding the submit button. IT DOES NOT SUBMIT ON SAFARI FOR iOS \u2014 the text lands in the field and the page stays where it is, whether the newline is sent with the text or on its own. There, click the form's submit button with click_element \u2014 scroll_to_element first if it sits below the fold.", "The element must be editable; typing into anything else answers \"element not interactable\".", "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." ], @@ -964,8 +964,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "get_element_property", @@ -1063,8 +1062,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "is_element_displayed", @@ -1105,8 +1103,114 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } + }, + { + "name": "scroll_to_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Bring an element into view \u2014 use this before clicking or screenshotting something below the fold, since both fail or miss when the target is off screen.", + "guidance": [ + "THE RELIABLE FORM ON EVERY PLATFORM. Prefer this to scrolling the page by a distance: it works out where the element is for you, and it is the only form measured to work on Chrome for Android, where scrolling the page to its height can stop short and leave the target off screen.", + "The element_id goes in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives in the script as arguments[0]; find_element gives you the id.", + "Returns the element's position after scrolling, so a caller can confirm it really is in view rather than trusting the status.", + "A click on a covered element answers \"element click intercepted\" \u2014 scroll it into view with this first." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "Leave as the default; it scrolls arguments[0] into the middle of the viewport and reports where it ended up.", + "example": "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object holding the element reference.", + "example": [ + { + "element-6066-11e4-a52e-4f735466cecf": "" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "scroll_page", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Scroll the page itself by a distance or to the bottom \u2014 use this when there is no element to aim at, such as reaching a footer or loading more of an infinite list.", + "guidance": [ + "PREFER scroll_to_element when you have an element. On Chrome for Android this form can stop short and leave a footer off screen while still reporting success \u2014 measured \u2014 so confirm from the returned position rather than the status.", + "The default script goes to the bottom. Replace document.body.scrollHeight with a pixel offset to move by a fixed amount instead.", + "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The scroll to run. The default goes to the bottom of the page and reports where it landed.", + "example": "window.scrollTo(0, document.body.scrollHeight); return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } }, { "name": "execute_script", @@ -1160,7 +1264,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "take_screenshot", @@ -1171,7 +1276,7 @@ "intent": "Take a screenshot of the browser's viewport, as a base64 PNG \u2014 use this to see the page as a user would.", "guidance": [ "Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.", - "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with execute_script.", + "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with scroll_to_element.", "get_title and get_element_property answer most checks far more cheaply than reading an image." ], "path_params": [ @@ -2027,7 +2132,9 @@ "open_new_window", "switch_to_frame", "get_cookies", - "delete_session" + "delete_session", + "scroll_to_element", + "scroll_page" ] }, "browser": { diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index e2771b97..cb3e37a1 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -76,7 +76,7 @@ "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", - "Set appium_version to 2.19.0 for any session that will 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.", + "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", @@ -565,7 +565,7 @@ "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).", "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's bounds.", "Long-press: replace the pause duration with 1000 or more.", - "For a scroll without computing coordinates, execute_script with \"mobile: scrollGesture\" is simpler and reports whether more content remains.", + "For a scroll without computing coordinates, scroll_gesture is simpler on Android and reports whether more content remains; on iOS compute the swipe here.", "To go back on an iOS screen that ignores press_back, swipe in from the left edge: a pointer sequence starting at exactly x=0 and moving right in several short steps. A single long move does not register." ], "path_params": [ @@ -656,6 +656,135 @@ } } }, + { + "name": "scroll_gesture", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Scroll a region of the screen in a direction \u2014 use this to move through a list or page without working out start and end coordinates yourself.", + "guidance": [ + "ANDROID ONLY. On iOS this is a 404 \"unknown command\"; use perform_actions there and compute the swipe yourself.", + "The returned value is true when more content remains in that direction and false at the end, which is the cheap way to know whether to scroll again.", + "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", + "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "Leave as the default.", + "example": "mobile: scrollGesture" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object: the rectangle to scroll inside, the direction, and how far.", + "example": [ + { + "left": 100, + "top": 500, + "width": 880, + "height": 1500, + "direction": "down", + "percent": 0.8 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "dismiss_keyboard_key", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on iOS, where the plain hide_keyboard is refused.", + "guidance": [ + "THIS IS THE ONE THAT WORKS ON iOS. hide_keyboard answers 400 \"invalid element state\" there; this presses a key the keyboard actually carries instead.", + "The key must EXIST on that keyboard or this returns the same 400: read the keyboard from get_page_source and pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", + "Several candidates may be listed in one call and it costs nothing, but it still fails when the keyboard carries none of them \u2014 measured with [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"].", + "PRESSING RETURN SUBMITS. An app that commits when the field stops being edited will commit however the keyboard is closed, so check the field afterwards rather than assuming only the keyboard moved.", + "Confirm with is_keyboard_shown; on Android use hide_keyboard instead, which works normally." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "Leave as the default.", + "example": "mobile: hideKeyboard" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object whose keys field lists the key names to try, in order.", + "example": [ + { + "keys": [ + "Done", + "done", + "Hide keyboard", + "Dismiss" + ] + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, { "name": "execute_script", "method": "POST", @@ -719,7 +848,8 @@ "500": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "get_window_rect", @@ -767,7 +897,7 @@ "guidance": [ "There is no way to resume an ended session; any further step needs create_session again, which reinstalls the app.", "The session's video, logs and pass/fail status stay on the App Automate dashboard after it ends.", - "Call setSessionStatus through execute_script first \u2014 once the session has ended that route is gone." + "Mark the result with set_session_status first \u2014 it also works after the session has ended, so this is an ordering preference rather than a last chance." ], "path_params": [ { @@ -1148,8 +1278,7 @@ "Works after the session has ended as well as during it, so the verdict can be set once the result is known.", "Only 'passed' and 'failed' are accepted; any other value is rejected with a 422.", "reason is truncated at 255 characters.", - "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run.", - "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." + "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run." ], "path_params": [ { @@ -1502,7 +1631,7 @@ "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", "On 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.", - "What DOES close it on iOS is execute_script with 'mobile: hideKeyboard' and a key the keyboard actually has, in args [{\"keys\":[\"\"]}]. An iPad keyboard carries a \"Hide keyboard\" key and that closes it cleanly \u2014 200, is_keyboard_shown false, no Return pressed. An iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", + "What DOES close it on iOS is dismiss_keyboard_key, which presses a key the keyboard actually has. An iPad keyboard carries a \"Hide keyboard\" key and that closes it cleanly \u2014 200, is_keyboard_shown false, no Return pressed. An iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. SEVERAL CANDIDATES MAY BE LISTED IN ONE CALL \u2014 [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"] is accepted and costs nothing \u2014 but it still returns the same 400 when the keyboard carries none of them, so it is a convenience and not a way round the problem. Read the keyboard from get_page_source and pick from the keys that are really there; that also tells you in advance when the only option is one that submits.", "GESTURES DO NOT DISMISS IT. Tapping an empty area above the keyboard returns 200 and changes nothing, and so does a drag downward over it \u2014 including one that starts well above the keyboard's top and moves through it in short steps, which is the form that works on a screen whose scroll view dismisses on drag. This app's does not, and neither gesture moved it.", "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 measured: closing an iPad keyboard with \"Hide keyboard\" 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 hide_keyboard works normally.", @@ -1744,11 +1873,11 @@ "entity": "app_session", "intent": "Press a hardware or system key on an Android device by its key code \u2014 use this for home, enter, delete, volume or any key with no on-screen control.", "guidance": [ - "Android only. iOS has no equivalent: use press_back to go back, and execute_script with 'mobile: pressButton' for home and the volume keys.", + "Android only. iOS has no equivalent: use press_back to go back, and press_button for home and the volume keys.", "Common codes: 3 home, 4 back, 66 enter, 67 delete, 61 tab, 82 menu, 84 search, 187 app switch, 24 volume up, 25 volume down, 26 power.", "press_back is the clearer way to send 4.", "The response is null on success and says nothing about what the key did.", - "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or 'mobile: pressButton' there." + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or press_button there." ], "path_params": [ { @@ -1806,7 +1935,7 @@ "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.", "This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.", "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.", - "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there." + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use get_active_app_info there." ], "path_params": [ { @@ -3286,7 +3415,9 @@ "get_active_app_info", "read_device_property", "find_elements", - "clear_element" + "clear_element", + "scroll_gesture", + "dismiss_keyboard_key" ] }, "device": { From 89372481269422596ae7b8533e9bba834a1b0cf4 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 14:38:48 +0530 Subject: [PATCH 29/53] fix(capability): pin every wrapper's script, and give iOS its own scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withholding execute_script did nothing on its own, and an automated security review caught it. The four wrappers sit on the SAME path, /execute/sync, and each took the script as a caller-supplied string — so anyone could call scroll_to_element with any JavaScript at all and reach exactly the capability that had just been removed. scroll_page's guidance made it worse by inviting the caller to edit the script. The seven lifecycle wrappers already did this correctly, pinning their script with `values`, and read_device_property pins its executor line with a `pattern`. I did not follow the pattern that was already there. All four now pin theirs, which bind enforces before the request is built: an arbitrary script is refused with the list of what is allowed. Every published /execute/sync wrapper across both products is now pinned — twelve of twelve. scroll_page keeps its range of behaviour without a mutable script: the script is fixed and args chooses, taking "bottom", "top", or a pixel count. Separately, iOS had been left pointing at hand-computed perform_actions because mobile: scrollGesture is UiAutomator2 and answers 404 there. scroll_view wraps XCUITest's mobile: scroll, so each platform has a scroll capability and the two name each other: direction scrolling, or predicateString with toVisible to stop when a known control appears, which beats guessing a distance. Counts: devicecontrol 29 published, mobiledevicecontrol 39. Co-Authored-By: Claude Opus 5 (1M context) --- .../devicecontrol.capability-index.json | 22 +++-- .../mobiledevicecontrol.capability-index.json | 84 +++++++++++++++++-- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index d1cc0ceb..9b1fbd5d 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -1131,8 +1131,11 @@ "name": "script", "type": "string", "required": true, - "description": "Leave as the default; it scrolls arguments[0] into the middle of the viewport and reports where it ended up.", - "example": "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};" + "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", + "example": "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};", + "values": [ + "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};" + ] }, { "name": "args", @@ -1170,7 +1173,7 @@ "intent": "Scroll the page itself by a distance or to the bottom \u2014 use this when there is no element to aim at, such as reaching a footer or loading more of an infinite list.", "guidance": [ "PREFER scroll_to_element when you have an element. On Chrome for Android this form can stop short and leave a footer off screen while still reporting success \u2014 measured \u2014 so confirm from the returned position rather than the status.", - "The default script goes to the bottom. Replace document.body.scrollHeight with a pixel offset to move by a fixed amount instead.", + "Choose what it does with args: \"bottom\", \"top\", or a pixel count. The script itself is fixed and cannot be changed.", "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end." ], "path_params": [ @@ -1186,15 +1189,20 @@ "name": "script", "type": "string", "required": true, - "description": "The scroll to run. The default goes to the bottom of the page and reports where it landed.", - "example": "window.scrollTo(0, document.body.scrollHeight); return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};" + "description": "Fixed. The only accepted value; anything else is refused before the request is sent. What it does is chosen by args, not by editing this.", + "example": "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};", + "values": [ + "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};" + ] }, { "name": "args", "type": "array", "required": true, - "description": "Always empty.", - "example": [] + "description": "Exactly one value: \"bottom\", \"top\", or a number of pixels to scroll by (negative scrolls up).", + "example": [ + "bottom" + ] } ], "returns": [ diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index cb3e37a1..d1cefb58 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -565,7 +565,7 @@ "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).", "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's bounds.", "Long-press: replace the pause duration with 1000 or more.", - "For a scroll without computing coordinates, scroll_gesture is simpler on Android and reports whether more content remains; on iOS compute the swipe here.", + "For a scroll without computing coordinates, scroll_gesture is simpler on Android and reports whether more content remains; on iOS use scroll_view; compute a swipe here only when neither fits.", "To go back on an iOS screen that ignores press_back, swipe in from the left edge: a pointer sequence starting at exactly x=0 and moving right in several short steps. A single long move does not register." ], "path_params": [ @@ -664,7 +664,7 @@ "entity": "app_session", "intent": "Scroll a region of the screen in a direction \u2014 use this to move through a list or page without working out start and end coordinates yourself.", "guidance": [ - "ANDROID ONLY. On iOS this is a 404 \"unknown command\"; use perform_actions there and compute the swipe yourself.", + "ANDROID ONLY. On iOS this is a 404 \"unknown command\"; scroll_view is the iOS counterpart.", "The returned value is true when more content remains in that direction and false at the end, which is the cheap way to know whether to scroll again.", "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." @@ -682,8 +682,11 @@ "name": "script", "type": "string", "required": true, - "description": "Leave as the default.", - "example": "mobile: scrollGesture" + "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", + "example": "mobile: scrollGesture", + "values": [ + "mobile: scrollGesture" + ] }, { "name": "args", @@ -720,6 +723,69 @@ } } }, + { + "name": "scroll_view", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "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.", + "guidance": [ + "iOS ONLY, and the counterpart of scroll_gesture: this is a 404 \"unknown command\" on Android, where scroll_gesture is the one that works.", + "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.", + "Pass an element in args to scroll inside that view rather than the whole screen; without one it scrolls the first scrollable view it finds, which may not be the one you meant.", + "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know.", + "Confirm from get_page_source that the screen actually moved \u2014 a 200 only says the gesture was delivered." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: scroll" + ], + "example": "mobile: scroll", + "description": "Fixed. The only accepted value; anything else is refused before the request is sent." + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object: a direction, or a predicateString with toVisible, optionally with an element to scroll inside.", + "example": [ + { + "direction": "down" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, { "name": "dismiss_keyboard_key", "method": "POST", @@ -747,8 +813,11 @@ "name": "script", "type": "string", "required": true, - "description": "Leave as the default.", - "example": "mobile: hideKeyboard" + "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", + "example": "mobile: hideKeyboard", + "values": [ + "mobile: hideKeyboard" + ] }, { "name": "args", @@ -3417,7 +3486,8 @@ "find_elements", "clear_element", "scroll_gesture", - "dismiss_keyboard_key" + "dismiss_keyboard_key", + "scroll_view" ] }, "device": { From 433a0040aa0e0cae51e55f7862772e29d5b3bb88 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 14:40:47 +0530 Subject: [PATCH 30/53] chore(capability): drop the plumbing talk from the pinned script parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Fixed. The only accepted value; anything else is refused before the request is sent." describes our enforcement, not the parameter, and a caller cannot act on it. The closed set is already declared: `values` carries it, describeCapability returns it, and a wrong value comes back naming what was allowed. Saying it again in prose only adds a second place to be wrong. The seven wrappers that were already pinned carry NO description on their script at all — values and example say the whole thing — so these five now match. The args descriptions stay, because those describe data the caller chooses. Also removes "The script itself is fixed and cannot be changed" from scroll_page's guidance, for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- capability/devicecontrol.capability-index.json | 4 +--- capability/mobiledevicecontrol.capability-index.json | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 9b1fbd5d..a4dfd509 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -1131,7 +1131,6 @@ "name": "script", "type": "string", "required": true, - "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", "example": "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};", "values": [ "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};" @@ -1173,7 +1172,7 @@ "intent": "Scroll the page itself by a distance or to the bottom \u2014 use this when there is no element to aim at, such as reaching a footer or loading more of an infinite list.", "guidance": [ "PREFER scroll_to_element when you have an element. On Chrome for Android this form can stop short and leave a footer off screen while still reporting success \u2014 measured \u2014 so confirm from the returned position rather than the status.", - "Choose what it does with args: \"bottom\", \"top\", or a pixel count. The script itself is fixed and cannot be changed.", + "Choose what it does with args: \"bottom\", \"top\", or a pixel count.", "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end." ], "path_params": [ @@ -1189,7 +1188,6 @@ "name": "script", "type": "string", "required": true, - "description": "Fixed. The only accepted value; anything else is refused before the request is sent. What it does is chosen by args, not by editing this.", "example": "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};", "values": [ "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};" diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index d1cefb58..323c53d3 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -682,7 +682,6 @@ "name": "script", "type": "string", "required": true, - "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", "example": "mobile: scrollGesture", "values": [ "mobile: scrollGesture" @@ -753,8 +752,7 @@ "values": [ "mobile: scroll" ], - "example": "mobile: scroll", - "description": "Fixed. The only accepted value; anything else is refused before the request is sent." + "example": "mobile: scroll" }, { "name": "args", @@ -813,7 +811,6 @@ "name": "script", "type": "string", "required": true, - "description": "Fixed. The only accepted value; anything else is refused before the request is sent.", "example": "mobile: hideKeyboard", "values": [ "mobile: hideKeyboard" From e57d48927ccb295f62764d581c6ff6b16d6b3f7a Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 15:14:36 +0530 Subject: [PATCH 31/53] fix(capability): correct the five scroll and keyboard wrappers from a live run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five were exercised on real sessions: desktop Chrome, Chrome for Android and Safari on iPhone for the browser pair; a Galaxy S24, an iPhone and an iPad for the mobile three. What held is unchanged; what did not is corrected here. scroll_page: on Safari the position in the "bottom" call's own response is the requested position, not the resting one — it comes back equal to scrollHeight, which no resting position can be. The guidance now says to read the position again with args 0. The script also takes the larger of the body's and the document's height, and the end-of-page check allows a couple of pixels, since a scaled phone screen reports fractional positions. scroll_to_element: the Android caveat that leaned on an earlier stop-short is dropped — scrolling the page reached the bottom on Chrome for Android this time, so the element form is preferred for not depending on the page's reported height rather than for being the only one that works. scroll_gesture: iOS refuses it with 405 "unknown method", not 404. Its boolean is not an end-of-content signal: the first scroll of a freshly opened list answered false while the screen moved and more content followed, and every later scroll answered true. scroll_view: Android does know mobile: scroll, with a different shape — it answers 400 naming strategy and selector as missing, not 404. The element to scroll inside is elementId. It answers 200 on a screen with nothing scrollable. dismiss_keyboard_key: behaved as written on both an iPhone (400, keys absent, field untouched) and an iPad ("Hide keyboard" closes it, 200). No change. Co-Authored-By: Claude Fable 5.1 --- capability/devicecontrol.capability-index.json | 10 +++++----- capability/mobiledevicecontrol.capability-index.json | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index a4dfd509..978788d6 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -1113,7 +1113,7 @@ "entity": "browser_session", "intent": "Bring an element into view \u2014 use this before clicking or screenshotting something below the fold, since both fail or miss when the target is off screen.", "guidance": [ - "THE RELIABLE FORM ON EVERY PLATFORM. Prefer this to scrolling the page by a distance: it works out where the element is for you, and it is the only form measured to work on Chrome for Android, where scrolling the page to its height can stop short and leave the target off screen.", + "THE RELIABLE FORM ON EVERY PLATFORM. Prefer this to scrolling the page by a distance: it works out where the element is for you, and it does not depend on the page reporting its height correctly, which scroll_page does.", "The element_id goes in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives in the script as arguments[0]; find_element gives you the id.", "Returns the element's position after scrolling, so a caller can confirm it really is in view rather than trusting the status.", "A click on a covered element answers \"element click intercepted\" \u2014 scroll it into view with this first." @@ -1171,9 +1171,9 @@ "entity": "browser_session", "intent": "Scroll the page itself by a distance or to the bottom \u2014 use this when there is no element to aim at, such as reaching a footer or loading more of an infinite list.", "guidance": [ - "PREFER scroll_to_element when you have an element. On Chrome for Android this form can stop short and leave a footer off screen while still reporting success \u2014 measured \u2014 so confirm from the returned position rather than the status.", + "PREFER scroll_to_element when you have an element: it works out where the target is for you. Use this form only when there is nothing to aim at.", "Choose what it does with args: \"bottom\", \"top\", or a pixel count.", - "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end." + "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end: at rest at the bottom, scrollY + innerHeight equals scrollHeight within a couple of pixels \u2014 a scaled phone screen reports fractional positions, so do not demand exact equality. ON SAFARI THE POSITION IN THE \"bottom\" CALL'S OWN RESPONSE IS WHERE IT WAS ASKED TO GO, NOT WHERE IT STOPPED \u2014 it comes back equal to scrollHeight, which no resting position can be. Call again with args 0 to read the real position; that second reading is the one to check." ], "path_params": [ { @@ -1188,9 +1188,9 @@ "name": "script", "type": "string", "required": true, - "example": "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};", + "example": "var a=arguments[0]; var h=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight); if(a==='bottom'){window.scrollTo(0,h);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)};", "values": [ - "var a=arguments[0]; if(a==='bottom'){window.scrollTo(0,document.body.scrollHeight);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:document.body.scrollHeight};" + "var a=arguments[0]; var h=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight); if(a==='bottom'){window.scrollTo(0,h);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)};" ] }, { diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 323c53d3..8844216e 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -664,8 +664,8 @@ "entity": "app_session", "intent": "Scroll a region of the screen in a direction \u2014 use this to move through a list or page without working out start and end coordinates yourself.", "guidance": [ - "ANDROID ONLY. On iOS this is a 404 \"unknown command\"; scroll_view is the iOS counterpart.", - "The returned value is true when more content remains in that direction and false at the end, which is the cheap way to know whether to scroll again.", + "ANDROID ONLY. On iOS this is a 405 \"unknown method\"; scroll_view is the iOS counterpart.", + "The returned value is true when the view can still scroll further in that direction and false when it cannot \u2014 but the FIRST scroll of a freshly opened list can answer false while the screen moved and more content followed, so treat false as a hint and confirm from the page source before deciding you are at the end.", "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." ], @@ -730,11 +730,11 @@ "entity": "app_session", "intent": "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.", "guidance": [ - "iOS ONLY, and the counterpart of scroll_gesture: this is a 404 \"unknown command\" on Android, where scroll_gesture is the one that works.", + "iOS ONLY, and the counterpart of scroll_gesture. On Android the same command exists with a different shape and answers 400 \"invalid argument\" naming strategy and selector as missing; use scroll_gesture there.", "Scroll by direction with {\"direction\":\"down\"}, or stop when something comes into view with {\"predicateString\":\"name == 'Submit'\", \"toVisible\": true} \u2014 the second is the reliable way to reach a known control without guessing how far to go.", - "Pass an element 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.", + "Pass elementId in args to scroll inside that view rather than the whole screen; without one it scrolls the first scrollable view it finds, which may not be the one you meant.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know.", - "Confirm from get_page_source that the screen actually moved \u2014 a 200 only says the gesture was delivered." + "Confirm from get_page_source that the screen actually moved \u2014 this answers 200 even on a screen with nothing scrollable, so a 200 only says the gesture was delivered." ], "path_params": [ { @@ -758,7 +758,7 @@ "name": "args", "type": "array", "required": true, - "description": "Exactly one object: a direction, or a predicateString with toVisible, optionally with an element to scroll inside.", + "description": "Exactly one object: a direction, or a predicateString with toVisible, optionally with elementId naming the view to scroll inside.", "example": [ { "direction": "down" From c9ac311152cc4eb0287d8f65fb42155af53795b3 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 15:36:50 +0530 Subject: [PATCH 32/53] fix(capability): hide_keyboard fails on an iPhone, not on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on three devices in one pass: a Galaxy S24 closes the keyboard and answers true; an iPad Pro closes it and answers true; an iPhone 16 answers 400 "Did not know how to dismiss the keyboard". The difference is the keyboard, not the platform — the iPad carries a "Hide keyboard" key and the iPhone offers only Return, which is not pressed for the caller. hide_keyboard, dismiss_keyboard_key and send_keys all said "iOS" where they meant "iPhone". They now name the device, and dismiss_keyboard_key's intent no longer claims to be the only way on iOS. Co-Authored-By: Claude Fable 5.1 --- .../mobiledevicecontrol.capability-index.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 8844216e..0028d1b6 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -467,7 +467,7 @@ "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", "clear_element empties a field, and on iOS \u2014 where this APPENDS \u2014 it is the only way to take back what is already there. It answers 200 whether or not the field changed, so read the field back from get_page_source rather than trusting the status.", - "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.", + "The keyboard is usually left open afterwards. On Android and on an iPad, hide_keyboard closes it. On an iPhone it cannot be closed without submitting, so read hide_keyboard's guidance before planning a tap below the field.", "Sending a newline closes the keyboard on iOS by pressing Return, which an app that commits on editing-ended will treat as a submit \u2014 the field resets and the value moves wherever the app sends it. The commit is the app's doing, not the newline's: dismissing the same field any other way can commit it too. See hide_keyboard." ], "path_params": [ @@ -790,13 +790,13 @@ "path": "/wd/hub/session/{session_id}/execute/sync", "mode": "write", "entity": "app_session", - "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on iOS, where the plain hide_keyboard is refused.", + "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on an iPhone, where the plain hide_keyboard is refused.", "guidance": [ - "THIS IS THE ONE THAT WORKS ON iOS. hide_keyboard answers 400 \"invalid element state\" there; this presses a key the keyboard actually carries instead.", + "THIS IS THE ONE FOR AN iPHONE. hide_keyboard answers 400 \"invalid element state\" there, while on an iPad and on Android it works as it is; this presses a key the keyboard actually carries instead.", "The key must EXIST on that keyboard or this returns the same 400: read the keyboard from get_page_source and pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", "Several candidates may be listed in one call and it costs nothing, but it still fails when the keyboard carries none of them \u2014 measured with [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"].", "PRESSING RETURN SUBMITS. An app that commits when the field stops being edited will commit however the keyboard is closed, so check the field afterwards rather than assuming only the keyboard moved.", - "Confirm with is_keyboard_shown; on Android use hide_keyboard instead, which works normally." + "Confirm with is_keyboard_shown; on Android and on an iPad use hide_keyboard instead, which works normally." ], "path_params": [ { @@ -1696,11 +1696,11 @@ "guidance": [ "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", - "On 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.", - "What DOES close it on iOS is dismiss_keyboard_key, which presses a key the keyboard actually has. An iPad keyboard carries a \"Hide keyboard\" key and that closes it cleanly \u2014 200, is_keyboard_shown false, no Return pressed. An iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", + "WHETHER THIS WORKS ON iOS DEPENDS ON THE KEYBOARD, NOT THE PLATFORM. On an iPad it closes the keyboard and answers 200 true, because that keyboard carries a \"Hide keyboard\" key. On an iPhone it FAILS rather than quietly doing nothing: 400 \"invalid element state\", message \"Did not know how to dismiss the keyboard\", because the only key that closes an iPhone keyboard of this kind is Return and that is not pressed for you. The keyboard stays up and the field keeps what was typed, so treat the 400 as the normal answer on an iPhone and not as a broken call.", + "On an iPhone, dismiss_keyboard_key presses a key the keyboard actually has, but an iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. SEVERAL CANDIDATES MAY BE LISTED IN ONE CALL \u2014 [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"] is accepted and costs nothing \u2014 but it still returns the same 400 when the keyboard carries none of them, so it is a convenience and not a way round the problem. Read the keyboard from get_page_source and pick from the keys that are really there; that also tells you in advance when the only option is one that submits.", "GESTURES DO NOT DISMISS IT. Tapping an empty area above the keyboard returns 200 and changes nothing, and so does a drag downward over it \u2014 including one that starts well above the keyboard's top and moves through it in short steps, which is the form that works on a screen whose scroll view dismisses on drag. This app's does not, and neither gesture moved it.", - "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 measured: closing an iPad keyboard with \"Hide keyboard\" 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 hide_keyboard works normally.", + "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 closing an iPad keyboard this way still moved the text out of the field. So do not expect any dismissal to be side-effect free; check the app's own behaviour. On Android it closes the keyboard, answers true, and leaves the field as it was.", "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." ], "path_params": [ From a66aaae851e97ceec668cf5f0abe5aefe439f116 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 15:46:16 +0530 Subject: [PATCH 33/53] feat(capability): let a capability declare fallbacks, and give hide_keyboard its iPhone chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An iPhone keyboard has no dismiss key, so the plain hide_keyboard is refused with 400 and the agent was left to discover dismiss_keyboard_key and guess which keys exist. Vishalan's hand-written tool runs a chain for this; the registry had no way to say one. `fallbacks` is that way, authored in the index rather than coded per capability: each step names a sibling capability in the same product, the status that triggers it, and the fixed body to send. Only the original call's path parameters travel. A step runs when the LATEST response carries its status, the chain stops at the first 2xx, and a chain that matched nothing leaves the plain refusal untouched. Every attempt is reported under fallback.attempts with its status and the body it sent, so the caller can see which key closed the keyboard. describeCapability shows the chain, so what may be pressed is in the contract, not a surprise. Two things no guidance could make right are refused in code: a step naming a destructive capability, and one naming a capability this surface will not call. The caller's consent covers the rest — it was given for the capability whose contract the chain is. hide_keyboard now declares Done-type keys, then Return. On an iPhone 16 one call went 400 → 400 → 200 and the keyboard closed; on an iPad and on Android the plain call succeeds and the chain never runs. Return is a submit in many apps, which the guidance says, and which the attempts list lets the caller check for. Co-Authored-By: Claude Fable 5.1 --- .../mobiledevicecontrol.capability-index.json | 56 +++- src/tools/capability-registry/fallback.ts | 97 ++++++ src/tools/capability-registry/register.ts | 41 ++- src/tools/capability-registry/types.ts | 27 ++ .../tools/capabilityRegistryFallback.test.ts | 278 ++++++++++++++++++ 5 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 src/tools/capability-registry/fallback.ts create mode 100644 tests/tools/capabilityRegistryFallback.test.ts diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 0028d1b6..9924ab36 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -792,11 +792,11 @@ "entity": "app_session", "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on an iPhone, where the plain hide_keyboard is refused.", "guidance": [ - "THIS IS THE ONE FOR AN iPHONE. hide_keyboard answers 400 \"invalid element state\" there, while on an iPad and on Android it works as it is; this presses a key the keyboard actually carries instead.", + "hide_keyboard already presses these keys on an iPhone when its plain call is refused \u2014 Done-type keys first, then Return \u2014 and reports each attempt. Call this directly to press exactly the key you choose: Return alone, or every key but Return when a submit must not happen.", "The key must EXIST on that keyboard or this returns the same 400: read the keyboard from get_page_source and pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", "Several candidates may be listed in one call and it costs nothing, but it still fails when the keyboard carries none of them \u2014 measured with [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"].", "PRESSING RETURN SUBMITS. An app that commits when the field stops being edited will commit however the keyboard is closed, so check the field afterwards rather than assuming only the keyboard moved.", - "Confirm with is_keyboard_shown; on Android and on an iPad use hide_keyboard instead, which works normally." + "Confirm with is_keyboard_shown; on Android and on an iPad hide_keyboard closes it on its own." ], "path_params": [ { @@ -1694,11 +1694,11 @@ "entity": "app_session", "intent": "Close the on-screen keyboard \u2014 use this when it covers a button or field you need to tap after typing.", "guidance": [ - "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.", + "On a plain success the response is true when a keyboard was dismissed and false when none was showing; false is not an error. A success reached through the chain below carries the key press's own null value instead.", "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", - "WHETHER THIS WORKS ON iOS DEPENDS ON THE KEYBOARD, NOT THE PLATFORM. On an iPad it closes the keyboard and answers 200 true, because that keyboard carries a \"Hide keyboard\" key. On an iPhone it FAILS rather than quietly doing nothing: 400 \"invalid element state\", message \"Did not know how to dismiss the keyboard\", because the only key that closes an iPhone keyboard of this kind is Return and that is not pressed for you. The keyboard stays up and the field keeps what was typed, so treat the 400 as the normal answer on an iPhone and not as a broken call.", - "On an iPhone, dismiss_keyboard_key presses a key the keyboard actually has, but an iPhone keyboard of this kind usually offers only the return key, and pressing that is also a submit.", - "The keys argument names a key on the APP'S OWN keyboard and fails when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. SEVERAL CANDIDATES MAY BE LISTED IN ONE CALL \u2014 [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"] is accepted and costs nothing \u2014 but it still returns the same 400 when the keyboard carries none of them, so it is a convenience and not a way round the problem. Read the keyboard from get_page_source and pick from the keys that are really there; that also tells you in advance when the only option is one that submits.", + "ON AN iPHONE THE PLAIN CALL IS REFUSED AND A CHAIN RUNS INSTEAD. That keyboard has no dismiss key, so the product answers 400 \"Did not know how to dismiss the keyboard\"; this then presses a key the keyboard actually carries \u2014 Done, Hide keyboard or Dismiss where one exists, and failing that Return. On an iPad the plain call closes it, because that keyboard carries \"Hide keyboard\"; on Android it closes it; neither runs the chain.", + "THE RESPONSE LISTS EVERY ATTEMPT WITH ITS STATUS under fallback.attempts, so read which one closed it. When the last attempt's keys include Return, Return is what was pressed \u2014 and in many apps that is a submit: a search runs, a form goes. Read the field and the screen back before assuming only the keyboard moved.", + "When no attempt answers 200 the keyboard stays up and the last 400 is the answer: the app offers no way to close it from outside, which a person on the phone would hit too. dismiss_keyboard_key is the same key press on its own, for trying exactly the key you choose \u2014 Return alone, or everything but Return.", "GESTURES DO NOT DISMISS IT. Tapping an empty area above the keyboard returns 200 and changes nothing, and so does a drag downward over it \u2014 including one that starts well above the keyboard's top and moves through it in short steps, which is the form that works on a screen whose scroll view dismisses on drag. This app's does not, and neither gesture moved it.", "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 closing an iPad keyboard this way still moved the text out of the field. So do not expect any dismissal to be side-effect free; check the app's own behaviour. On Android it closes the keyboard, answers true, and leaves the field as it was.", "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." @@ -1711,6 +1711,50 @@ "example": "0123456789abcdef0123456789abcdef01234567" } ], + "fallbacks": [ + { + "when": { + "status": 400 + }, + "capability": "dismiss_keyboard_key", + "body": { + "script": "mobile: hideKeyboard", + "args": [ + { + "keys": [ + "Done", + "done", + "Hide keyboard", + "Dismiss" + ] + } + ] + } + }, + { + "when": { + "status": 400 + }, + "capability": "dismiss_keyboard_key", + "body": { + "script": "mobile: hideKeyboard", + "args": [ + { + "keys": [ + "Return", + "return", + "Go", + "go", + "Next", + "next", + "Search", + "search" + ] + } + ] + } + } + ], "returns": [ "value" ], diff --git a/src/tools/capability-registry/fallback.ts b/src/tools/capability-registry/fallback.ts new file mode 100644 index 00000000..bb676f33 --- /dev/null +++ b/src/tools/capability-registry/fallback.ts @@ -0,0 +1,97 @@ +/** + * Try the next thing when the product refuses, as the index says to. + * + * ONE REQUEST, ONE RESPONSE is still the rule for every capability that declares nothing + * here. A `fallbacks` list is the one authored exception: when THIS call answers a given + * status, the index knows a sibling capability that may do the job, and names it with the + * body to send. The chain exists for hide_keyboard on an iPhone, where the plain call + * answers 400 because the keyboard has no dismiss key, and the only way on is to press one + * of its own keys — Done where it exists, Return where it does not. + * + * The caller's consent covers the chain: it was given for the capability invoked, and the + * chain is that capability's published contract. What a step may do — press Return, which + * an app may treat as submit — is therefore the capability's guidance to state, not + * something this module decides. It does refuse two things no guidance could make right: a + * step naming a destructive capability, and one naming a capability this surface will not + * call. + * + * Every attempt is reported, in order, with the status it got, so the caller knows which + * step did the work and can read the screen back afterwards. + */ + +import { GroupedArguments } from "./bind.js"; +import { InvocationError } from "./index-loader.js"; +import { InvokeResult } from "./resolve.js"; +import { Capability } from "./types.js"; + +export interface Attempt { + capability: string; + status: number; + /** The authored body the step sent, so the caller can see which keys were tried. */ + body?: Record; +} + +export interface FallbackOutcome { + /** The last response: the first 2xx, or the final refusal. */ + result: InvokeResult; + /** Present only when the chain ran; the first entry is the original call. */ + attempts?: Attempt[]; +} + +/** Makes one request for one capability — `invoke`, with the deps already bound. */ +export type Runner = ( + capability: Capability, + args: GroupedArguments, +) => Promise; + +/** Resolves a step's name within the product of the capability being invoked. */ +export type Lookup = (name: string) => Capability; + +function handle(capability: Capability): string { + return capability.name || `${capability.method} ${capability.path}`; +} + +export async function invokeWithFallbacks( + capability: Capability, + args: GroupedArguments, + run: Runner, + lookup: Lookup, +): Promise { + let result = await run(capability, args); + const chain = capability.fallbacks ?? []; + if (result.ok || chain.length === 0) return { result }; + + const attempts: Attempt[] = [ + { capability: handle(capability), status: result.http_response.status }, + ]; + for (const step of chain) { + // Matched against the LATEST status, not the first: a step that answers something + // the next step does not name ends the chain there, with that answer. + if (result.http_response.status !== step.when.status) continue; + const next = lookup(step.capability); + if (next.mode === "destructive") + throw new InvocationError( + `${handle(capability)} names ${step.capability} as a fallback, which is destructive and cannot run`, + ); + if (next.invocable === false) + throw new InvocationError( + `${handle(capability)} names ${step.capability} as a fallback, which cannot be called on this server`, + ); + // Only the path parameters travel: the step's body and query are the index's own, so + // a caller cannot steer the chain into sending something the contract never showed. + result = await run(next, { + path_params: args.path_params, + query: step.query, + body: step.body, + }); + attempts.push({ + capability: step.capability, + status: result.http_response.status, + ...(step.body ? { body: step.body } : {}), + }); + if (result.ok) break; + } + // A chain that matched nothing is not a chain that ran: the caller sees the plain + // refusal, as it would from a capability that declares no fallbacks at all. + return attempts.length > 1 ? { result, attempts } : { result }; +} diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index dea2f7ba..31258606 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -36,6 +36,7 @@ import { resolveResponses, } from "./index-loader.js"; import { narrowResponse } from "./narrow.js"; +import { invokeWithFallbacks } from "./fallback.js"; import { invoke } from "./resolve.js"; import { ambiguousProducts, @@ -1297,14 +1298,22 @@ export function addCapabilityRegistryTools( } } - const result = await invoke( + // The base URL is resolved per target rather than once: a fallback step may sit + // on a different named host from the capability that declared it. + const { result, attempts } = await invokeWithFallbacks( capability, args, - await deps.baseUrlFor(product, capability.host), - deps.credentialsFor(), - transport, - registry.index.products[product]?.auth, - product, + async (target, targetArgs) => + invoke( + target, + targetArgs, + await deps.baseUrlFor(product, target.host), + deps.credentialsFor(), + transport, + registry.index.products[product]?.auth, + product, + ), + (name) => registry.byNameLookup(name, product).capability, ); // NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller // needs whole, and a selection that happened to match none of its keys would @@ -1313,13 +1322,18 @@ export function addCapabilityRegistryTools( const narrowed = result.ok ? narrowResponse(capability, result.http_response.body, input.select) : undefined; - const answer = narrowed - ? { - ...result, - http_response: { ...result.http_response, body: narrowed.body }, - narrowed: narrowed.narrowing, - } - : result; + const answer = { + ...(narrowed + ? { + ...result, + http_response: { ...result.http_response, body: narrowed.body }, + narrowed: narrowed.narrowing, + } + : result), + // Only when the chain ran: a capability without `fallbacks` answers exactly as + // before, and one whose first call succeeded has nothing to report. + ...(attempts ? { fallback: { attempts } } : {}), + }; // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than // throwing, so `success` keeps its tool-level meaning and these two fields carry // whether the product call worked. Status 0 means it could not be reached. @@ -1328,6 +1342,7 @@ export function addCapabilityRegistryTools( upstream_ok: result.ok, upstream_status: result.http_response.status, change_summary: redact(input.change_summary), + ...(attempts ? { fallback_steps: attempts.length - 1 } : {}), ...(narrowed ? { narrowed_from: narrowed.narrowing.from, diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index f755de21..48bbcd96 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -155,6 +155,18 @@ export function guidanceLines(guidance?: Guidance): string[] { return Array.isArray(guidance) ? guidance : (guidance.shared ?? []); } +/** One step of a capability's `fallbacks` chain. */ +export interface Fallback { + /** Runs when the latest response carried this status. */ + when: { status: number }; + /** The sibling capability to call, by published name, in the same product. */ + capability: string; + /** The body to send it — authored, never the caller's. */ + body?: Record; + /** Query parameters to send it, if it takes any. */ + query?: Record; +} + export interface Capability { /** * The caller-facing handle — the snake-cased `operationId`, unique within its product. @@ -291,6 +303,21 @@ export interface Capability { /** One line for the contract: why this is here rather than on the wire. */ note?: string; }; + /** + * What to try next when the product refuses THIS call, in order. + * + * The one authored exception to one-request-one-response. Each step names a sibling + * capability in the same product and the fixed body to send it; the original call's + * path parameters are carried over, nothing else. A step runs only when the LATEST + * response has the status it names, and the chain stops at the first 2xx. + * + * Exists for hide_keyboard on an iPhone: the keyboard has no dismiss key, so the plain + * call is refused, and the only way on is to press one of the keys it does carry. The + * caller's consent covers the chain, because the chain is this capability's published + * contract — so what a step may do (press Return, which an app may treat as submit) is + * the capability's guidance to state. Every attempt is reported with its status. + */ + fallbacks?: Fallback[]; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; diff --git a/tests/tools/capabilityRegistryFallback.test.ts b/tests/tools/capabilityRegistryFallback.test.ts new file mode 100644 index 00000000..0a1684a3 --- /dev/null +++ b/tests/tools/capabilityRegistryFallback.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// A capability may declare `fallbacks`: when its own call answers a named status, a sibling +// is called with an authored body, and the chain stops at the first 2xx. hide_keyboard on an +// iPhone is the case: the plain call is refused because the keyboard has no dismiss key, so +// the index says to press Done-type keys, then Return. These pin the wiring — what is sent, +// in what order, what stops it, and that the caller is told every step. + +const SESSION = "0123456789abcdef0123456789abcdef01234567"; +const DONE = { keys: ["Done", "done", "Hide keyboard", "Dismiss"] }; +const RETURN = { keys: ["Return", "return"] }; + +function index(fallbacks?: unknown[], extra: Record = {}) { + return { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "hide_keyboard", + method: "POST", + path: "/session/{session_id}/hide", + mode: "write", + entity: "app_session", + intent: "Close the keyboard.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + ...(fallbacks ? { fallbacks } : {}), + ...extra, + }, + { + name: "dismiss_keyboard_key", + method: "POST", + path: "/session/{session_id}/execute", + mode: "write", + entity: "app_session", + intent: "Press one of the keyboard's own keys.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + body: [ + { + name: "script", + type: "string", + required: true, + values: ["mobile: hideKeyboard"], + }, + { name: "args", type: "array", required: true }, + ], + }, + { + name: "delete_everything", + method: "DELETE", + path: "/session/{session_id}", + mode: "destructive", + entity: "app_session", + intent: "Gone.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + }, + ], + entities: { + app_session: { + description: "A session", + capabilities: [ + "hide_keyboard", + "dismiss_keyboard_key", + "delete_everything", + ], + }, + }, + }, + }, + }; +} + +const CHAIN = [ + { + when: { status: 400 }, + capability: "dismiss_keyboard_key", + body: { script: "mobile: hideKeyboard", args: [DONE] }, + }, + { + when: { status: 400 }, + capability: "dismiss_keyboard_key", + body: { script: "mobile: hideKeyboard", args: [RETURN] }, + }, +]; + +/** A transport that answers each call from a queue and records what it was sent. */ +function harness(statuses: number[], fallbacks?: unknown[]) { + const sent: { url: string; body: unknown }[] = []; + const queue = [...statuses]; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index(fallbacks) as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async ( + _m: string, + url: string, + _h: unknown, + _q: unknown, + body: unknown, + ) => { + sent.push({ url, body }); + const status = queue.shift() ?? 599; + return { + status, + body: + status < 300 + ? { value: null } + : { value: { error: "invalid element state" } }, + } as never; + }, + } as never, + ); + const call = async (input: Record) => + JSON.parse( + ( + await handlers.invokeCapability( + { + name: "hide_keyboard", + product: "p", + path_params: { session_id: SESSION }, + user_permission: "granted", + change_summary: "close the keyboard", + ...input, + }, + {} as never, + ) + ).content[0].text, + ); + return { sent, call, handlers }; +} + +describe("a capability with fallbacks", () => { + it("runs the chain when its own call is refused, and stops at the first success", async () => { + const { sent, call } = harness([400, 400, 200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(out.http_response.status).toBe(200); + expect(sent.map((s) => s.url)).toEqual([ + `https://p.example/session/${SESSION}/hide`, + `https://p.example/session/${SESSION}/execute`, + `https://p.example/session/${SESSION}/execute`, + ]); + // the step's body is the index's, carrying the original path parameter only + expect(sent[1].body).toEqual({ + script: "mobile: hideKeyboard", + args: [DONE], + }); + expect(sent[2].body).toEqual({ + script: "mobile: hideKeyboard", + args: [RETURN], + }); + }); + + it("tells the caller every attempt, in order, with what each sent", async () => { + const { call } = harness([400, 400, 200], CHAIN); + const out = await call({}); + expect(out.fallback.attempts).toEqual([ + { capability: "hide_keyboard", status: 400 }, + { + capability: "dismiss_keyboard_key", + status: 400, + body: { script: "mobile: hideKeyboard", args: [DONE] }, + }, + { + capability: "dismiss_keyboard_key", + status: 200, + body: { script: "mobile: hideKeyboard", args: [RETURN] }, + }, + ]); + }); + + it("stops early when a step succeeds, sending nothing more", async () => { + const { sent, call } = harness([400, 200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(sent).toHaveLength(2); + expect(out.fallback.attempts).toHaveLength(2); + }); + + it("hands back the last refusal when every step fails, with the whole chain listed", async () => { + const { sent, call } = harness([400, 400, 400], CHAIN); + const out = await call({}); + expect(out.ok).toBe(false); + expect(out.http_response.status).toBe(400); + expect(sent).toHaveLength(3); + expect(out.fallback.attempts.map((a: any) => a.status)).toEqual([ + 400, 400, 400, + ]); + }); + + it("does not run on a status the chain does not name", async () => { + // A 500 is not "the keyboard has no dismiss key"; pressing keys at it would be a guess. + const { sent, call } = harness([500], CHAIN); + const out = await call({}); + expect(out.ok).toBe(false); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("ends the chain when a step answers something the next step does not name", async () => { + const { sent, call } = harness([400, 500], CHAIN); + const out = await call({}); + expect(out.http_response.status).toBe(500); + expect(sent).toHaveLength(2); + }); + + it("says nothing about fallbacks when the first call succeeds", async () => { + const { sent, call } = harness([200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("still makes exactly one request for a capability that declares none", async () => { + const { sent, call } = harness([400]); + const out = await call({}); + expect(out.ok).toBe(false); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("refuses a step that names a destructive capability", async () => { + const { sent, call } = harness( + [400, 200], + [{ when: { status: 400 }, capability: "delete_everything" }], + ); + const out = await call({}); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/delete_everything.*destructive/); + expect(sent).toHaveLength(1); + }); + + it("refuses a step that names a capability the index does not have", async () => { + const { call } = harness( + [400, 200], + [{ when: { status: 400 }, capability: "no_such_thing" }], + ); + const out = await call({}); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/unknown_capability: no_such_thing/); + }); +}); + +describe("the contract shows the chain", () => { + it("describeCapability publishes `fallbacks`, so a caller knows what may be pressed", async () => { + const { handlers } = harness([], CHAIN); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "hide_keyboard", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.fallbacks).toHaveLength(2); + expect(doc.fallbacks[1].body.args[0].keys).toContain("Return"); + }); +}); From 34629d5ab71e421772c128e84f5e4a1123ebacfb Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 15:56:01 +0530 Subject: [PATCH 34/53] fix(capability): publish set_window_rect and maximize_window, which E085 needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window a browser session opens is about half the screen's width — 945x1012 on a 1920x1080 screen — and nothing published could change it: both capabilities that do were withheld as untested, while the product summary still said it could resize the window. That is ISSUE-24 in full; the resolution refusal E085 hits is a platform fact (2560x1440 is not a Windows 11 screen size) and was already in the contract. Measured on Chrome 155 / Windows 11: maximize_window fills the screen (1936x1048 at -8,-8, the frame past the edges, as Windows does); an exact size is applied exactly; a size larger than the screen is clamped to roughly the screen rather than refused (2560x1440 → 1940x1100). The guidance on both, on get_window_rect and on create_session's resolution now says which call sizes the window and which sets the screen. delete_session's declared 200 was the W3C null; the hub actually answers {state: "success", status: 0, value: ""} on both products, which two separate runs flagged. A SessionDeletedResponse schema says so and `returns` matches. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 43 +++++++++++++------ .../mobiledevicecontrol.capability-index.json | 33 ++++++++------ 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 978788d6..90a325c2 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -262,7 +262,7 @@ "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", - "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured.", + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. It sets the screen, not the window \u2014 the browser opens at about half the screen's width (945x1012 on 1920x1080), so maximize_window or set_window_rect is the call that sizes the window, and get_window_rect is the figure to report.", "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." ], "body": [ @@ -334,7 +334,7 @@ "name": "resolution", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/resolution", - "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. The browser window is much smaller than the screen either way, so read the real size rather than assuming it.", + "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. This sets the SCREEN, not the window: the browser opens at about half the screen's width, so call maximize_window or set_window_rect afterwards for the size you actually want, and read get_window_rect for the real figure.", "example": "1920x1080" }, { @@ -1316,7 +1316,7 @@ "entity": "browser_session", "intent": "Read the browser window's size and position \u2014 use this to learn the real viewport before planning anything that depends on layout.", "guidance": [ - "The window is smaller than the screen resolution asked for at session start, because of the browser's own chrome; this is the real figure." + "The window is smaller than the screen resolution asked for at session start \u2014 it opens at about half the screen's width, 945x1012 on a 1920x1080 screen \u2014 and this is the real figure. Use maximize_window or set_window_rect to change it, then read this again." ], "path_params": [ { @@ -1352,7 +1352,8 @@ "entity": "browser_session", "intent": "Resize or move the browser window \u2014 use this to check a layout at a particular width.", "guidance": [ - "The response is the rectangle actually applied, which can differ from the one asked for: a window cannot exceed the screen, and some sizes are adjusted.", + "The response is the rectangle actually applied, which can differ from the one asked for: a size larger than the screen is clamped to roughly the screen rather than refused \u2014 2560x1440 on a 1920x1080 screen came back 1940x1100, the frame sitting just past the edges \u2014 while a size that fits is applied exactly. Read the applied rectangle, not the request.", + "This is how to get the wide window a screen resolution alone does not give: the window opens at about half the screen's width, and resolution at session start sets the screen, not the window. maximize_window fills the screen without naming a size.", "Resizing re-lays out the page, so element positions change and anything measured before is stale.", "A real mobile browser is not uniformly resizable: an Android device accepts the call, while iOS refuses it with a 405. Read the size back from get_window_rect rather than assuming either." ], @@ -1404,8 +1405,7 @@ "404": { "$response": "WebDriverError" } - }, - "disabled": true + } }, { "name": "maximize_window", @@ -1415,9 +1415,10 @@ "entity": "browser_session", "intent": "Maximise the browser window \u2014 use this to give the page the most room before reading a wide layout.", "guidance": [ - "Takes no parameters of its own.", + "Takes no parameters of its own. The window opens at about half the screen's width whatever resolution the session asked for; this is what fills it \u2014 on a 1920x1080 screen the result is 1936x1048 at (-8,-8), the frame sitting just past the screen edges, which is normal for Windows.", "Page layout changes, so anything measured before is stale.", - "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it." + "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it.", + "The response carries the rectangle now applied, the same as get_window_rect would report." ], "path_params": [ { @@ -1444,8 +1445,7 @@ "$response": "WebDriverError" } }, - "send_empty_body": true, - "disabled": true + "send_empty_body": true }, { "name": "list_window_handles", @@ -1677,13 +1677,14 @@ } ], "returns": [ + "state", "value" ], "responses": { "200": { - "description": "Null on success.", + "description": "state is \"success\" and value an empty string; the body is the hub's own, not the W3C null.", "schema": { - "$schema": "NullValueResponse" + "$schema": "SessionDeletedResponse" } }, "404": { @@ -2069,6 +2070,24 @@ } } } + }, + "SessionDeletedResponse": { + "type": "object", + "description": "What the hub returns when a session is closed. Not the W3C null: state says whether it closed and value is an empty string.", + "properties": { + "state": { + "type": "string", + "description": "\"success\" when the session was closed." + }, + "status": { + "type": "integer", + "description": "The hub's own legacy status code; 0 on success." + }, + "value": { + "type": "string", + "description": "Always empty on success." + } + } } }, "responses": { diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 9924ab36..109d367f 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -974,23 +974,14 @@ } ], "returns": [ + "state", "value" ], "responses": { "200": { - "description": "Session ended; state is \"success\" and value is empty.", + "description": "state is \"success\" and value an empty string; the body is the hub's own, not the W3C null.", "schema": { - "type": "object", - "properties": { - "state": { - "type": "string", - "description": "\"success\" when the session was ended." - }, - "value": { - "type": "string", - "description": "Empty string." - } - } + "$schema": "SessionDeletedResponse" } }, "404": { @@ -3449,6 +3440,24 @@ } } } + }, + "SessionDeletedResponse": { + "type": "object", + "description": "What the hub returns when a session is closed. Not the W3C null: state says whether it closed and value is an empty string.", + "properties": { + "state": { + "type": "string", + "description": "\"success\" when the session was closed." + }, + "status": { + "type": "integer", + "description": "The hub's own legacy status code; 0 on success." + }, + "value": { + "type": "string", + "description": "Always empty on success." + } + } } }, "responses": { From c570283fc85371c2af33d64c61bd20a1911d4c56 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 15:59:07 +0530 Subject: [PATCH 35/53] revert(capability): keep set_window_rect and maximize_window withheld ISSUE-24 (the browser window opens at about half the screen) is a known issue being tracked on its own; publishing the two window capabilities to close it was scope this branch was not asked for. Both go back to withheld, and the guidance that pointed at them goes back with them so nothing names a capability that is not there. The delete_session contract fix from the same commit stays: the hub answers {state, status, value: ""} on both products, which is unrelated. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 90a325c2..8d8d2b1b 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -262,7 +262,7 @@ "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", - "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. It sets the screen, not the window \u2014 the browser opens at about half the screen's width (945x1012 on 1920x1080), so maximize_window or set_window_rect is the call that sizes the window, and get_window_rect is the figure to report.", + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured.", "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." ], "body": [ @@ -334,7 +334,7 @@ "name": "resolution", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/resolution", - "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. This sets the SCREEN, not the window: the browser opens at about half the screen's width, so call maximize_window or set_window_rect afterwards for the size you actually want, and read get_window_rect for the real figure.", + "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. The browser window is much smaller than the screen either way, so read the real size rather than assuming it.", "example": "1920x1080" }, { @@ -1316,7 +1316,7 @@ "entity": "browser_session", "intent": "Read the browser window's size and position \u2014 use this to learn the real viewport before planning anything that depends on layout.", "guidance": [ - "The window is smaller than the screen resolution asked for at session start \u2014 it opens at about half the screen's width, 945x1012 on a 1920x1080 screen \u2014 and this is the real figure. Use maximize_window or set_window_rect to change it, then read this again." + "The window is smaller than the screen resolution asked for at session start, because of the browser's own chrome; this is the real figure." ], "path_params": [ { @@ -1352,8 +1352,7 @@ "entity": "browser_session", "intent": "Resize or move the browser window \u2014 use this to check a layout at a particular width.", "guidance": [ - "The response is the rectangle actually applied, which can differ from the one asked for: a size larger than the screen is clamped to roughly the screen rather than refused \u2014 2560x1440 on a 1920x1080 screen came back 1940x1100, the frame sitting just past the edges \u2014 while a size that fits is applied exactly. Read the applied rectangle, not the request.", - "This is how to get the wide window a screen resolution alone does not give: the window opens at about half the screen's width, and resolution at session start sets the screen, not the window. maximize_window fills the screen without naming a size.", + "The response is the rectangle actually applied, which can differ from the one asked for: a window cannot exceed the screen, and some sizes are adjusted.", "Resizing re-lays out the page, so element positions change and anything measured before is stale.", "A real mobile browser is not uniformly resizable: an Android device accepts the call, while iOS refuses it with a 405. Read the size back from get_window_rect rather than assuming either." ], @@ -1405,7 +1404,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "maximize_window", @@ -1415,10 +1415,9 @@ "entity": "browser_session", "intent": "Maximise the browser window \u2014 use this to give the page the most room before reading a wide layout.", "guidance": [ - "Takes no parameters of its own. The window opens at about half the screen's width whatever resolution the session asked for; this is what fills it \u2014 on a 1920x1080 screen the result is 1936x1048 at (-8,-8), the frame sitting just past the screen edges, which is normal for Windows.", + "Takes no parameters of its own.", "Page layout changes, so anything measured before is stale.", - "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it.", - "The response carries the rectangle now applied, the same as get_window_rect would report." + "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it." ], "path_params": [ { @@ -1445,7 +1444,8 @@ "$response": "WebDriverError" } }, - "send_empty_body": true + "send_empty_body": true, + "disabled": true }, { "name": "list_window_handles", From bccf477a6805fe2f11a5bf3fc7486ad1702cda84 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 16:38:28 +0530 Subject: [PATCH 36/53] fix(live): say the Live tools are manual, and name the session that can be driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the remote evals an agent asked to tap, type or click chose runAppLiveSession or runBrowserLiveSession over a capability session, got a manual session, and reported that its tools "can start it but can't tap anything in it" (E031, E032, E036, E038, E138; E021, E022, E027 the same way). Neither description said the session was for a person, and neither pointed anywhere else. Both now say so in a sentence, and — only while the capability registry is enabled, so a server without it never advertises tools it lacks — name the mobiledevicecontrol / devicecontrol session through invokeCapability as the one for the agent to operate. Co-Authored-By: Claude Fable 5.1 --- src/tools/applive.ts | 13 +++- src/tools/live.ts | 11 ++- .../liveToolsPointAtCapabilities.test.ts | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/tools/liveToolsPointAtCapabilities.test.ts diff --git a/src/tools/applive.ts b/src/tools/applive.ts index a8e159a3..47ad005f 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -6,6 +6,7 @@ import { startSession } from "./applive-utils/start-session.js"; import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; /** * Launches an App Live Session on BrowserStack. @@ -81,9 +82,19 @@ export default function addAppLiveTools( ) { const tools: Record = {}; + // A Live session is for a PERSON. The agent cannot tap, type, read the screen or take a + // screenshot in it, and every eval where an agent was asked to operate an app and chose + // this tool ended with "my tools can start it but can't tap anything in it". The + // description says so, and names the surface that can — only when that surface is on. + const description = + "Open a manual App Live session for the user to operate by hand. It cannot be driven by the agent." + + (capabilityRegistryEnabled() + ? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability." + : ""); + tools.runAppLiveSession = server.tool( "runAppLiveSession", - "Use this tool when user wants to manually check their app on a particular mobile device using BrowserStack's cloud infrastructure. Can be used to debug crashes, slow performance, etc.", + description, { desiredPhone: z .string() diff --git a/src/tools/live.ts b/src/tools/live.ts index fa1ac4bc..6ad480d6 100644 --- a/src/tools/live.ts +++ b/src/tools/live.ts @@ -5,6 +5,7 @@ import { startBrowserSession } from "./live-utils/start-session.js"; import { PlatformType } from "./live-utils/types.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; import globalConfig from "../config.js"; // Define the schema shape @@ -124,9 +125,17 @@ export default function addBrowserLiveTools( ) { const tools: Record = {}; + // Same rule as runAppLiveSession: Live is for a person, and an agent that picks it for a + // click-by-locator task ends up reporting that Live cannot click by locator. + const description = + "Open a manual Live browser session (desktop or mobile) for the user to operate by hand. It cannot be driven by the agent." + + (capabilityRegistryEnabled() + ? " To click, type or read the page yourself, use a devicecontrol session via invokeCapability." + : ""); + tools.runBrowserLiveSession = server.tool( "runBrowserLiveSession", - "Launch a BrowserStack Live session (desktop or mobile).", + description, LiveArgsShape, { title: "Run Browser Live Session", diff --git a/tests/tools/liveToolsPointAtCapabilities.test.ts b/tests/tools/liveToolsPointAtCapabilities.test.ts new file mode 100644 index 00000000..e78df913 --- /dev/null +++ b/tests/tools/liveToolsPointAtCapabilities.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A Live session is for a person. In the evals, an agent asked to tap, type or click chose +// runAppLiveSession / runBrowserLiveSession over a capability session and then reported +// that Live cannot be driven. The description now says so up front, and names the surface +// that can — but only while that surface is registered, so a server with the registry off +// never advertises tools it does not have. + +function captureDescriptions() { + const described: Record = {}; + const server = { + tool: (name: string, description: string) => { + described[name] = description; + return { name }; + }, + server: { getClientVersion: () => ({ name: "t", version: "0" }) }, + }; + return { server, described }; +} + +describe("the Live tools say what they are for", () => { + beforeEach(() => { + vi.resetModules(); + }); + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_DISABLED; + }); + + it("call themselves manual and say the agent cannot drive them", async () => { + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + for (const name of ["runAppLiveSession", "runBrowserLiveSession"]) { + expect(described[name]).toMatch(/manual/); + expect(described[name]).toMatch(/cannot be driven/); + } + }); + + it("point at the capability session while the registry is on", async () => { + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + expect(described.runAppLiveSession).toMatch(/mobiledevicecontrol/); + expect(described.runBrowserLiveSession).toMatch(/devicecontrol session/); + expect(described.runAppLiveSession).toMatch(/invokeCapability/); + }); + + it("do not name a surface that is not registered", async () => { + process.env.CAPABILITY_REGISTRY_DISABLED = "true"; + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + expect(described.runAppLiveSession).not.toMatch(/invokeCapability/); + expect(described.runBrowserLiveSession).not.toMatch(/invokeCapability/); + // the manual warning stays regardless + expect(described.runAppLiveSession).toMatch(/cannot be driven/); + }); +}); From 113007f583afddc804e0aac0b557d1de3601c96f Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 22 Oct 2026 19:53:27 +0530 Subject: [PATCH 37/53] fix(capability): say that scroll_gesture and scroll_view are the swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent asked to swipe a carousel searched for "swipe", landed on perform_actions, had no element bounds to aim it with, and stopped. The two gesture wrappers already do it with a direction of left or right and no coordinates; their intents only said "scroll a list or page". Both now say swipe and name left/right, and scroll_gesture says which way the content moves — down brings what is below into view, so right brings the next card. Co-Authored-By: Claude Fable 5.1 --- capability/mobiledevicecontrol.capability-index.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 109d367f..2d8c1ee1 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -662,10 +662,11 @@ "path": "/wd/hub/session/{session_id}/execute/sync", "mode": "write", "entity": "app_session", - "intent": "Scroll a region of the screen in a direction \u2014 use this to move through a list or page without working out start and end coordinates yourself.", + "intent": "Swipe or scroll a region of the screen in a direction \u2014 use this to move through a list or page, or to swipe left and right through a carousel of cards, without working out start and end coordinates yourself.", "guidance": [ "ANDROID ONLY. On iOS this is a 405 \"unknown method\"; scroll_view is the iOS counterpart.", "The returned value is true when the view can still scroll further in that direction and false when it cannot \u2014 but the FIRST scroll of a freshly opened list can answer false while the screen moved and more content followed, so treat false as a hint and confirm from the page source before deciding you are at the end.", + "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right: a rectangle covering the carousel's band of the screen, taken from get_window_rect, is enough \u2014 no element bounds are needed, and perform_actions is the harder way to do the same thing. DIRECTION NAMES WHERE THE CONTENT COMES FROM, as with a list: down brings what is below into view (the finger moves up), so right brings the NEXT card and left the previous one.", "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." ], @@ -691,7 +692,7 @@ "name": "args", "type": "array", "required": true, - "description": "Exactly one object: the rectangle to scroll inside, the direction, and how far.", + "description": "Exactly one object: the rectangle to scroll inside, the direction (up, down, left or right), and how far.", "example": [ { "left": 100, @@ -728,10 +729,11 @@ "path": "/wd/hub/session/{session_id}/execute/sync", "mode": "write", "entity": "app_session", - "intent": "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.", + "intent": "Swipe or scroll a scrollable view on iOS in a direction, or until a named element is visible \u2014 use this to move through a list or a long screen, or to swipe left and right through a carousel.", "guidance": [ "iOS ONLY, and the counterpart of scroll_gesture. On Android the same command exists with a different shape and answers 400 \"invalid argument\" naming strategy and selector as missing; use scroll_gesture there.", "Scroll by direction with {\"direction\":\"down\"}, or stop when something comes into view with {\"predicateString\":\"name == 'Submit'\", \"toVisible\": true} \u2014 the second is the reliable way to reach a known control without guessing how far to go.", + "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right, optionally with elementId naming the carousel; no coordinates are needed.", "Pass elementId in args to scroll inside that view rather than the whole screen; without one it scrolls the first scrollable view it finds, which may not be the one you meant.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know.", "Confirm from get_page_source that the screen actually moved \u2014 this answers 200 even on a screen with nothing scrollable, so a 200 only says the gesture was delivered." From 454fbb5e80a1f3798f6f34ab56585fbd60bd8d49 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:20:34 +0530 Subject: [PATCH 38/53] feat(capability): read the screen an element at a time on mobile, and withhold the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mobiledevicecontrol had one way to see the screen: get_page_source, the whole UI tree, 60–100 KB on a real app — more than an agent can take in, as every run this week said. find_element returned ids and nothing read them, so a swipe could not be aimed and "did the screen change?" could not be answered. PR 485 reached the same conclusion from the other side: its agents never see the tree; it compares two trees internally and reports the difference. Three plain W3C readers are published, in the shape the browser product already has: get_element_text, get_element_rect, get_element_attribute. Measured on a Galaxy S24 and an iPhone 16 — every Android bounds string matched its rect; iOS answers label/value and a text field's placeholder when empty. Two things the run corrected before publishing: an absent attribute is the string "null" on Android, and get_window_rect's Android height includes the status bar. get_page_source is withheld, as it already was on the browser product, and the nineteen guidance lines that leaned on it now say which reader to use. A full flow on an iPhone — tap, type, hide_keyboard through its chain, read the label — completed with the readers alone and nothing missing. Co-Authored-By: Claude Fable 5.1 --- .../mobiledevicecontrol.capability-index.json | 207 +++++++++++++++--- 1 file changed, 177 insertions(+), 30 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index 2d8c1ee1..e3474c61 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "1.0", "build_id": "6782f7e_2026-10-16T13:54:22Z", "mobiledevicecontrol": { - "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.", + "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.", "routing_terms": [ "app automate", "appium", @@ -74,7 +74,7 @@ "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", - "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", + "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.", "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", @@ -83,7 +83,7 @@ "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", - "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back from get_page_source rather than trusting the call's 200.", + "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back with get_element_text rather than trusting the call's 200.", "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen.", "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.", "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." @@ -321,11 +321,7 @@ "entity": "app_session", "intent": "Read the full UI tree of the screen currently shown in the app \u2014 use this to see what is on screen and choose what to tap, type into or scroll before acting.", "guidance": [ - "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.", - "Read it again after every action: the tree is a snapshot, and element ids from find_element go stale once the screen changes.", - "Android bounds are \"[x1,y1][x2,y2]\" in device pixels \u2014 the centre of that box is the coordinate to use with perform_actions.", - "A product-specific attribute is the best locator: resource-id for find_element using \"id\", content-desc for \"accessibility id\".", - "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." + "Not published: the whole tree runs to tens of kilobytes, more than a caller can read. Find the elements you need with find_element or find_elements and read them with get_element_text, get_element_rect and get_element_attribute." ], "path_params": [ { @@ -349,7 +345,8 @@ "404": { "$response": "WebDriverError" } - } + }, + "disabled": true }, { "name": "find_element", @@ -360,8 +357,8 @@ "intent": "Locate one element on the current screen by id, accessibility id, XPath or UiAutomator selector \u2014 use this to get the element id that click_element and send_keys act on.", "guidance": [ "The element id comes back under the W3C key value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", - "Take the selector from get_page_source rather than guessing: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\".", - "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.", + "Locators by platform: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\", visible text \u2192 xpath such as //*[@text='Submit'] on Android or -ios predicate string label == 'Submit' on iOS. Once you hold an element, get_element_attribute reads its ids and get_element_text what it says.", + "No match is a 404 with error \"no such element\" \u2014 the screen may have changed, or the element may be off-screen and need a scroll first; find it again after the scroll. \"No such element\" is also the cheapest way to confirm something is NOT on screen.", "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\")).", "find_elements answers the same query with a list and never a 404, which is how to ask whether something is present without treating absence as a failure." ], @@ -421,8 +418,8 @@ "intent": "Tap an element in the app \u2014 use this to press a button, open a menu item, select a list row or focus a field found with find_element.", "guidance": [ "element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\".", - "To tap something that has no usable locator, tap its bounds centre with perform_actions instead.", - "Read get_page_source afterwards to confirm the tap had the intended effect \u2014 a 200 only means the tap was delivered.", + "To tap something that has no usable locator, take the centre of a nearby element's get_element_rect and tap the coordinates with perform_actions instead.", + "Confirm the tap had the intended effect \u2014 a 200 only means it was delivered: find an element the new screen should carry, or read a label with get_element_text before and after.", "A keyboard covers the lower part of the screen and a tap aimed underneath it lands on the keyboard while still reporting success. When the target sits low, call is_keyboard_shown and then hide_keyboard before tapping." ], "path_params": [ @@ -463,10 +460,10 @@ "entity": "app_session", "intent": "Type text into an input field in the app \u2014 use this to fill a search box, login form or any editable field found with find_element.", "guidance": [ - "WHETHER THIS REPLACES OR APPENDS DEPENDS ON THE PLATFORM: on Android it replaces whatever the field already holds, and on iOS it appends to it. Typing twice into one iOS field therefore leaves both values joined. Read the field back from get_page_source whenever the resulting value matters.", + "WHETHER THIS REPLACES OR APPENDS DEPENDS ON THE PLATFORM: on Android it replaces whatever the field already holds, and on iOS it appends to it. Typing twice into one iOS field therefore leaves both values joined. Read the field back with get_element_text whenever the resulting value matters.", "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", - "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.", - "clear_element empties a field, and on iOS \u2014 where this APPENDS \u2014 it is the only way to take back what is already there. It answers 200 whether or not the field changed, so read the field back from get_page_source rather than trusting the status.", + "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 check is_keyboard_shown, and compare get_element_rect of the next target with the screen's lower part, before the next tap.", + "clear_element empties a field, and on iOS \u2014 where this APPENDS \u2014 it is the only way to take back what is already there. It answers 200 whether or not the field changed, so read the field back with get_element_text rather than trusting the status.", "The keyboard is usually left open afterwards. On Android and on an iPad, hide_keyboard closes it. On an iPhone it cannot be closed without submitting, so read hide_keyboard's guidance before planning a tap below the field.", "Sending a newline closes the keyboard on iOS by pressing Return, which an app that commits on editing-ended will treat as a submit \u2014 the field resets and the value moves wherever the app sends it. The commit is the app's doing, not the newline's: dismissing the same field any other way can commit it too. See hide_keyboard." ], @@ -517,7 +514,7 @@ "intent": "Empty an editable field in the app \u2014 use this before typing when the field already holds something and you need to replace rather than add to it.", "guidance": [ "Takes no parameters of its own.", - "CONFIRM IT ACTUALLY EMPTIED THE FIELD by reading the field back from get_page_source: this endpoint can answer 200 without the field changing, and the status alone does not tell you which happened.", + "CONFIRM IT ACTUALLY EMPTIED THE FIELD by reading it back with get_element_text: this endpoint can answer 200 without the field changing, and the status alone does not tell you which happened.", "Needed far more on iOS than on Android: send_keys REPLACES the field's contents on Android, so clearing first is usually unnecessary there, while on iOS it APPENDS and this is the only way to take back what is already in the field.", "The element must be an editable field \u2014 EditText on Android, TextField on iOS. Anything else answers \"invalid element state\".", "The element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\"." @@ -561,9 +558,9 @@ "entity": "app_session", "intent": "Perform touch gestures on the device screen by coordinates \u2014 tap a point, swipe or scroll a list, long-press, or drag \u2014 using W3C pointer actions.", "guidance": [ - "Coordinates are device pixels 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.", + "Coordinates are device pixels on Android and points on iOS \u2014 the space get_element_rect and get_window_rect report in; take them from there rather than from a screenshot, whose height can include the system bars.", "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).", - "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's bounds.", + "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's get_element_rect. For a plain scroll or swipe, scroll_gesture (Android) and scroll_view (iOS) need no coordinates at all.", "Long-press: replace the pause duration with 1000 or more.", "For a scroll without computing coordinates, scroll_gesture is simpler on Android and reports whether more content remains; on iOS use scroll_view; compute a swipe here only when neither fits.", "To go back on an iOS screen that ignores press_back, swipe in from the left edge: a pointer sequence starting at exactly x=0 and moving right in several short steps. A single long move does not register." @@ -665,7 +662,7 @@ "intent": "Swipe or scroll a region of the screen in a direction \u2014 use this to move through a list or page, or to swipe left and right through a carousel of cards, without working out start and end coordinates yourself.", "guidance": [ "ANDROID ONLY. On iOS this is a 405 \"unknown method\"; scroll_view is the iOS counterpart.", - "The returned value is true when the view can still scroll further in that direction and false when it cannot \u2014 but the FIRST scroll of a freshly opened list can answer false while the screen moved and more content followed, so treat false as a hint and confirm from the page source before deciding you are at the end.", + "The returned value is true when the view can still scroll further in that direction and false when it cannot \u2014 but the FIRST scroll of a freshly opened list can answer false while the screen moved and more content followed, so treat false as a hint and confirm by finding the element you expect before deciding you are at the end.", "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right: a rectangle covering the carousel's band of the screen, taken from get_window_rect, is enough \u2014 no element bounds are needed, and perform_actions is the harder way to do the same thing. DIRECTION NAMES WHERE THE CONTENT COMES FROM, as with a list: down brings what is below into view (the finger moves up), so right brings the NEXT card and left the previous one.", "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." @@ -736,7 +733,7 @@ "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right, optionally with elementId naming the carousel; no coordinates are needed.", "Pass elementId in args to scroll inside that view rather than the whole screen; without one it scrolls the first scrollable view it finds, which may not be the one you meant.", "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know.", - "Confirm from get_page_source that the screen actually moved \u2014 this answers 200 even on a screen with nothing scrollable, so a 200 only says the gesture was delivered." + "Confirm the screen actually moved \u2014 find the element you were scrolling toward, or compare get_element_rect of one element before and after \u2014 since this answers 200 even on a screen with nothing scrollable: a 200 only says the gesture was delivered." ], "path_params": [ { @@ -795,7 +792,7 @@ "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on an iPhone, where the plain hide_keyboard is refused.", "guidance": [ "hide_keyboard already presses these keys on an iPhone when its plain call is refused \u2014 Done-type keys first, then Return \u2014 and reports each attempt. Call this directly to press exactly the key you choose: Return alone, or every key but Return when a submit must not happen.", - "The key must EXIST on that keyboard or this returns the same 400: read the keyboard from get_page_source and pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", + "The key must EXIST on that keyboard or this returns the same 400: list the keyboard's keys with find_elements using class name XCUIElementTypeKey and read each with get_element_text, then pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", "Several candidates may be listed in one call and it costs nothing, but it still fails when the keyboard carries none of them \u2014 measured with [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"].", "PRESSING RETURN SUBMITS. An app that commits when the field stops being edited will commit however the keyboard is closed, so check the field afterwards rather than assuming only the keyboard moved.", "Confirm with is_keyboard_shown; on Android and on an iPad hide_keyboard closes it on its own." @@ -927,7 +924,7 @@ "entity": "app_session", "intent": "Get the device screen size in pixels \u2014 use this to plan gesture coordinates, or as a cheap keep-alive so an idle session is not ended while deciding the next step.", "guidance": [ - "On Android the height excludes the system bars, so it can be smaller than a screenshot's height; page-source bounds use this same space." + "On Android the height is the WHOLE screen including the status bar \u2014 2340 on a Galaxy S24 whose usable area starts 91px down \u2014 so the top strip of that rectangle is not the app's. On iOS it is the app's window in points." ], "path_params": [ { @@ -1455,7 +1452,7 @@ "entity": "app_session", "intent": "Take a screenshot of what is on the device right now, as a base64 PNG \u2014 use this to see the screen as a user would, including anything the UI tree does not describe.", "guidance": [ - "The image is returned inline as base64 in value and a phone screen runs to hundreds of kilobytes, so fetch it only when the picture is needed and prefer get_page_source for deciding what to act on.", + "The image is returned inline as base64 in value and a phone screen runs to hundreds of kilobytes, so fetch it only when the picture is needed and prefer find_element with get_element_text for deciding what to act on.", "Screenshot pixels are the device's full resolution and can be taller than the rectangle get_window_rect reports, because that excludes the system bars; scale before reusing screenshot coordinates for gestures.", "A screen the app marks secure comes back black, which is the app's doing and not a failure." ], @@ -1491,8 +1488,8 @@ "intent": "Go back one screen \u2014 use this to leave a detail page, close a dialog, or undo a navigation step.", "guidance": [ "On Android this is the system back button and is the reliable way back.", - "On iOS this works on a screen that uses a standard navigation bar, where it acts on the bar's own back control. A screen that draws its own header instead is left where it was, and the call still answers 200 \u2014 so confirm from the page source rather than from the status.", - "Pressing back on the first screen of an app can send the app to the background and show the home screen; read the page source afterwards to confirm where you are.", + "On iOS this works on a screen that uses a standard navigation bar, where it acts on the bar's own back control. A screen that draws its own header instead is left where it was, and the call still answers 200 \u2014 so confirm by finding an element of the screen you expect rather than from the status.", + "Pressing back on the first screen of an app can send the app to the background and show the home screen; find one of the app's own elements afterwards to confirm where you are \u2014 \"no such element\" means it went to the background.", "The response is null on success and says nothing about whether the screen actually changed.", "When an iOS screen ignores it, swipe in from the left edge with perform_actions: start at exactly x=0 and move in several short steps. One long move, or a start at x=2, is ignored." ], @@ -1562,7 +1559,7 @@ "guidance": [ "An app that locks its orientation refuses the rotation with a 400 'invalid element state' saying the screen could not be changed; that is the app's choice, not a broken call.", "The value must be upper case, PORTRAIT or LANDSCAPE.", - "Element positions and the window rectangle change after a rotation, so re-read the page source before acting on coordinates.", + "Element positions and the window rectangle change after a rotation, so re-read get_element_rect and get_window_rect before acting on coordinates.", "The response carries the new orientation on Android and null on iOS, so treat a 200 rather than the value as the sign it worked, and read get_orientation if the value matters." ], "path_params": [ @@ -1643,7 +1640,7 @@ "intent": "Switch between the native app and a web view \u2014 use this when the part of the screen you need to drive is web content inside the app.", "guidance": [ "Use a name exactly as list_contexts returned it; an invented WEBVIEW name is rejected.", - "In a web view, selectors change: find_element then takes css selector and the page source is HTML rather than the native UI tree.", + "In a web view, selectors change: find_element then takes css selector and get_element_text reads the page's text rather than a native label.", "Switch back to NATIVE_APP before driving native controls again.", "A web view's name carries the process id of the app that owns it, so it differs between sessions and after the app restarts. Read it from list_contexts each time rather than reusing one seen earlier." ], @@ -2458,7 +2455,7 @@ "The session must have been started with enable_biometric true; it cannot be turned on once the session is running.", "Call it only while the prompt is actually on screen \u2014 with no prompt showing it fails with BROWSERSTACK_COMMAND_EXECUTION_FAILED.", "biometricMatch takes \"pass\" or \"fail\"; \"fail\" is how to exercise the app's rejection path.", - "Read the page source afterwards to see what the app did with the answer.", + "Afterwards, find the element the app shows on success or on failure to see what it did with the answer.", "Every failure seen so far reports the same BROWSERSTACK_COMMAND_EXECUTION_FAILED regardless of cause, so it does not distinguish 'no prompt on screen' from 'the session was not started with enable_biometric'. Check both." ], "path_params": [ @@ -2651,7 +2648,7 @@ "guidance": [ "iOS only; press_keycode is the Android equivalent and the two are not interchangeable.", "The button names carry no separator: home, volumeup, volumedown.", - "Pressing home sends the app to the background \u2014 bring it back with launch_app, whose args object takes bundleId rather than appId on iOS, and confirm from the page source that the app is in front again.", + "Pressing home sends the app to the background \u2014 bring it back with launch_app, whose args object takes bundleId rather than appId on iOS, and confirm the app is in front again by finding one of its elements.", "The response is null on success and says nothing about what the button did." ], "path_params": [ @@ -2998,6 +2995,144 @@ "$response": "WebDriverError" } } + }, + { + "name": "get_element_text", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/text", + "mode": "read", + "entity": "app_session", + "intent": "Read the visible text of an element on the screen \u2014 use this to check a label, a title, a price, a field's current contents or an error message without reading the whole screen.", + "guidance": [ + "THIS IS HOW TO READ THE SCREEN: find_element gives the id, this gives what it says \u2014 one line per call, nothing to wade through.", + "On Android it is the text of the view; a container answers with an empty string, not its children's text, so aim at the TextView itself (class name android.widget.TextView, or the element by its own text).", + "On iOS it is the element's label or value \u2014 a text field answers with what it currently holds, or its placeholder when empty.", + "To confirm an action did something, read the same element before and after, or find the text you expect with find_element by text and treat \"no such element\" as the answer." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The element's visible text.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/rect", + "mode": "read", + "entity": "app_session", + "intent": "Get an element's position and size on the screen \u2014 use this to aim a swipe, drag or tap with perform_actions, or to tell whether something sits under the keyboard.", + "guidance": [ + "The coordinates are in the same space perform_actions uses: device pixels on Android, points on iOS \u2014 so x + width / 2 and y + height / 2 is the element's centre, ready to press.", + "One call per element: the coordinates to aim at, without reading the whole screen.", + "An element that is off screen still has a rect \u2014 one outside get_window_rect's size \u2014 so compare the two before tapping; scroll_gesture or scroll_view brings it on." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value.x", + "value.y", + "value.width", + "value.height" + ], + "responses": { + "200": { + "description": "The element's rectangle on screen.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_attribute", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/attribute/{name}", + "mode": "read", + "entity": "app_session", + "intent": "Read one attribute of an element \u2014 use this for its resource id, content description, enabled, checked, selected or displayed state, or on iOS its name, label, value or visibility.", + "guidance": [ + "The attribute names are the platform's own: on Android text, content-desc, resource-id, class, package, bounds, checked, checkable, clickable, enabled, focused, selected, displayed, scrollable, long-clickable, password; on iOS name, label, value, type, enabled, visible, accessible, focused, index, rect.", + "Booleans come back as the strings \"true\" and \"false\", not JSON booleans, so compare as text.", + "An attribute the element does not carry answers the STRING \"null\" on Android \u2014 not a JSON null and not an error \u2014 so compare as text; measured on an ImageView's resource-id.", + "For the element's text use get_element_text and for its position get_element_rect; this is for everything else." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The attribute to read, as named in the UI tree.", + "example": "content-desc" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The attribute's value as a string; an absent one is the string \"null\" on Android.", + "schema": { + "$schema": "NullableStringResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } } ], "schemas": { @@ -3460,6 +3595,15 @@ "description": "Always empty on success." } } + }, + "NullableStringResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Null when the attribute is absent." + } + } } }, "responses": { @@ -3536,6 +3680,9 @@ "get_active_app_info", "read_device_property", "find_elements", + "get_element_text", + "get_element_rect", + "get_element_attribute", "clear_element", "scroll_gesture", "dismiss_keyboard_key", From aab850006d9f14c6c0369d307b9883495c1a6870 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:30:46 +0530 Subject: [PATCH 39/53] fix(capability): on the user's own machine, open the live view for them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the server has opened a browser since PR #308 (May 2026): the App Live tools print an `open ""` line and a local agent runs it. Now that agents start App Automate sessions instead, nothing printed such a line, so "the live view used to open" stopped — the routing got better and the convenience went with it. create_session on both products now carries surface-specific guidance: on the local surface, call get_session and run open / xdg-open on browser_url (with interactive_debugging true when the person should tap as well as watch); on the remote surface, the existing line that no browser can be opened. Measured on a local Claude session: create_session → get_session → `open `, and the dashboard came up. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 31 ++++++++----- .../mobiledevicecontrol.capability-index.json | 45 +++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 8d8d2b1b..fc015cac 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -253,18 +253,25 @@ "mode": "write", "entity": "browser_session", "intent": "Start a browser session on a real desktop or mobile browser and get a session id to drive it with \u2014 use this to open a page and interact with it step by step.", - "guidance": [ - "A DESKTOP target needs browser_name with os and os_version; a MOBILE BROWSER needs browser_name with device_name and real_mobile instead, and takes no browser_version because the browser ships with the OS. Sending both kinds together is refused.", - "ON MOBILE THE BROWSER IS NAMED AFTER THE PLATFORM, not the product: Chrome on an Android phone is \"android\", Safari on an iPhone is \"iphone\" and on an iPad \"ipad\". Samsung Internet is \"samsung\" and Chromium-based mobile browsers are \"chromium\". Asking for \"chrome\" or \"safari\" on a phone is refused.", - "Take every value from list_browsers exactly as it spells them; an unlisted combination is refused rather than substituted.", - "The session id comes back at value.sessionId \u2014 carry it into every other browser_session capability.", - "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes Automate minutes until it ends: always finish with set_session_status and then delete_session.", - "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", - "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", - "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", - "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured.", - "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." - ], + "guidance": { + "shared": [ + "A DESKTOP target needs browser_name with os and os_version; a MOBILE BROWSER needs browser_name with device_name and real_mobile instead, and takes no browser_version because the browser ships with the OS. Sending both kinds together is refused.", + "ON MOBILE THE BROWSER IS NAMED AFTER THE PLATFORM, not the product: Chrome on an Android phone is \"android\", Safari on an iPhone is \"iphone\" and on an iPad \"ipad\". Samsung Internet is \"samsung\" and Chromium-based mobile browsers are \"chromium\". Asking for \"chrome\" or \"safari\" on a phone is refused.", + "Take every value from list_browsers exactly as it spells them; an unlisted combination is refused rather than substituted.", + "The session id comes back at value.sessionId \u2014 carry it into every other browser_session capability.", + "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes Automate minutes until it ends: always finish with set_session_status and then delete_session.", + "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", + "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured." + ], + "local": [ + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the Automate dashboard \u2014 it needs a BrowserStack login in that browser." + ], + "remote": [ + "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." + ] + }, "body": [ { "name": "browser_name", diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index e3474c61..d12adcb8 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -69,25 +69,32 @@ "mode": "write", "entity": "app_session", "intent": "Start a live Appium session on a real Android or iOS device with an uploaded app \u2014 use this before reading the screen, tapping, typing or swiping in the app.", - "guidance": [ - "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", - "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", - "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", - "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", - "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", - "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", - "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", - "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", - "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", - "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", - "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", - "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", - "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", - "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back with get_element_text rather than trusting the call's 200.", - "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen.", - "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.", - "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." - ], + "guidance": { + "shared": [ + "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", + "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", + "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", + "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", + "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", + "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", + "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", + "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", + "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", + "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", + "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", + "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back with get_element_text rather than trusting the call's 200.", + "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen.", + "Someone can only WATCH AND INTERACT if interactive_debugging was set true in this call \u2014 it cannot be turned on once the session is running, it is off by default on Android, and it needs video left on. Ask for it up front when a person is going to follow along, because the alternative is restarting the session." + ], + "local": [ + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the App Automate dashboard \u2014 it needs a BrowserStack login in that browser. Set interactive_debugging true in this call when they should be able to tap as well as watch; without it the view is watch-only." + ], + "remote": [ + "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." + ] + }, "body": [ { "name": "platform_name", From c79cd01d24c3e09f7cc2b2928fb07d1a1dc4d003 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:44:59 +0530 Subject: [PATCH 40/53] fix(capability): clear_element holds on every browser; property reads do not on Safari for iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_element measured on desktop Chrome, Chrome for Android and Safari on iPhone: 200 everywhere and the field empty afterwards. The confirm step the guidance recommended, get_element_property on value, is where iOS differs: the XCUITest driver answers 405 "unknown method" to any property read, while get_element_attribute with name value returns the live contents there — the reverse of the desktop rule the attribute guidance stated as universal. All three records now say which read works where. Co-Authored-By: Claude Fable 5.1 --- capability/devicecontrol.capability-index.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index fc015cac..1f1746b9 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -895,8 +895,9 @@ "entity": "browser_session", "intent": "Empty an input \u2014 use this before typing, since send_keys adds to what is already there.", "guidance": [ + "WORKS ON EVERY BROWSER: desktop Chrome, Chrome for Android and Safari on iPhone all answer 200 and leave the field empty; typing afterwards starts from nothing.", "Takes no parameters of its own.", - "Confirm with get_element_property on \"value\", which reads empty afterwards.", + "Confirm with get_element_property on \"value\", which reads empty afterwards \u2014 on Safari for iOS, where property reads answer 405, use get_element_attribute with name value instead. Measured empty after clearing on desktop Chrome, Chrome for Android and Safari on iPhone.", "Clearing also fires the page's own change handlers, so a form may revalidate and show an error about the now-empty field.", "The element must be editable; anything else answers \"invalid element state\"." ], @@ -981,6 +982,7 @@ "entity": "browser_session", "intent": "Read a live DOM property of an element \u2014 use this for what a field currently holds, or whether a box is checked right now.", "guidance": [ + "NOT ON SAFARI FOR iOS: the driver there answers 405 \"unknown method\" to every property read. On an iPhone or iPad read get_element_attribute with name value instead, which returns what the field holds right now; on desktop browsers and Chrome for Android this is the live view and the attribute is the markup.", "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. The \"value\" attribute is the markup's initial value rather than what is in the box now, and an input carries no text.", "Useful names: value, checked, selected, disabled, innerText, href.", "A property the element does not have comes back null, not an error." @@ -1030,7 +1032,7 @@ "entity": "browser_session", "intent": "Read an HTML attribute of an element \u2014 use this for ids, classes, data- attributes and link targets as the markup declares them.", "guidance": [ - "This is the attribute as written in the HTML, which is NOT what a field currently holds: for that, use get_element_property on \"value\".", + "On desktop browsers and Chrome for Android this is the attribute as written in the HTML, which is NOT what a field currently holds: for that, use get_element_property on \"value\". On Safari for iOS it is the other way round \u2014 get_element_property answers 405 there, and this call with name value returns what the field holds right now.", "An attribute the element does not carry comes back null rather than an error, so null means absent, not empty.", "An attribute a script has changed may still read as the original markup; the property is the live view." ], From c23ca03f59862be3f8a4ddfda1fab6c5a26a1344 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:45:18 +0530 Subject: [PATCH 41/53] fix(capability): send_keys names the iOS read-back too Co-Authored-By: Claude Fable 5.1 --- capability/devicecontrol.capability-index.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 1f1746b9..fa217005 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -843,7 +843,7 @@ "intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.", "guidance": [ "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", - "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text.", + "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text. On Safari for iOS, where property reads answer 405, use get_element_attribute with name value instead.", "A \\n at the end submits the form on a desktop browser and on Chrome for Android, and is quicker than finding the submit button. IT DOES NOT SUBMIT ON SAFARI FOR iOS \u2014 the text lands in the field and the page stays where it is, whether the newline is sent with the text or on its own. There, click the form's submit button with click_element \u2014 scroll_to_element first if it sits below the fold.", "The element must be editable; typing into anything else answers \"element not interactable\".", "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." From d30f9bfd127e55742c20430286afd7f2d25d059c Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:54:04 +0530 Subject: [PATCH 42/53] fix(capability): say that App Live's app store is not App Automate's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user asked to upload an app, was asked which product, said "exploratory testing", and the agent uploaded through App Live — whose store a device session cannot see. Measured: the same account lists two builds under /app-automate/recent_apps and none under /app-live/recent_apps. Nothing had said so. upload_app_build and upload_app_build_from_file now lead with it and say to upload here whenever the app is going to be driven by capability calls; the product summary no longer claims a build can "never" come from a file on disk (only true on the remote surface) and names the separate store; runAppLiveSession says its upload goes to App Live's own store, which agent-driven sessions cannot use. Co-Authored-By: Claude Fable 5.1 --- .../mobiledevicecontrol.capability-index.json | 84 ++++++++++--------- src/tools/applive.ts | 2 +- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index d12adcb8..a96634e2 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -3,58 +3,60 @@ "version": "1.0", "build_id": "6782f7e_2026-10-16T13:54:22Z", "mobiledevicecontrol": { - "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.", + "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; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", "routing_terms": [ + "adb", + "annotate", "app automate", + "app build", + "app for automation", "appium", "appium session", - "webdriver", - "real device", - "mobile app", + "biometric", + "camera injection", + "close app", + "device control", + "device list", + "device property", + "drive a device", "drive the app", - "tap", - "swipe", - "scroll", - "ui tree", + "face id", + "fingerprint", + "foreground app", + "getprop", + "gps", + "hardware button", + "home button", + "keyboard", + "keyboard open", + "launch app", + "local build", + "location", + "mobile app", + "mobile device control", + "network profile", + "os build", "page source", - "device list", - "upload app", - "app build", "parallel sessions", "plan", - "screenshot", - "session video", - "public link", "private device", - "network profile", - "launch app", - "close app", + "public link", + "qr code", + "real device", "restart app", + "screenshot", + "scroll", + "session video", + "swipe", + "tap", + "ui tree", "uninstall app", - "biometric", - "fingerprint", - "face id", - "camera injection", - "qr code", - "annotate", - "keyboard", - "keyboard open", - "home button", - "volume", - "hardware button", - "gps", - "location", - "foreground app", - "adb", - "device property", - "getprop", - "os build", "upload apk", + "upload app", + "upload app for device", "upload ipa", - "local build", - "mobile device control", - "device control", - "drive a device" + "volume", + "webdriver" ], "base_url": "https://hub-cloud.browserstack.com", "auth": { @@ -1100,8 +1102,9 @@ "path": "/app-automate/upload", "mode": "write", "entity": "app_build", - "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.", + "intent": "Upload an app build to App Automate \u2014 the store device sessions install from, separate from App Live's \u2014 from a public download link, and get back the bs:// URL a session installs. Use this to make a new .apk, .aab or .ipa available to the devices an agent drives.", "guidance": [ + "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", "Only the download-link form can be used here. The same endpoint also accepts a multipart upload carrying the file itself, which this surface cannot send \u2014 so for a build that exists only on the caller's own machine, give them that multipart request to run themselves and take the bs:// url it returns. The upload answers with the bs:// url directly: there is no intermediate storage, no presigned url and no second call. There is no way to hand the bytes over from here.", "The url must be https and reachable by BrowserStack without credentials; a link behind a login fails on their side, not the caller's.", "Pass custom_id to give the build a stable name, then later sessions can use that name in place of the bs:// URL and always get the newest build under it.", @@ -2892,6 +2895,7 @@ "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.", "guidance": { "shared": [ + "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", "Only .apk, .aab and .ipa are accepted.", "A large build takes minutes, because the upload does not return until the whole file has been sent.", "upload_app_build takes a public https link instead, and list_app_builds reuses a build already on the account \u2014 builds last 30 days." diff --git a/src/tools/applive.ts b/src/tools/applive.ts index 47ad005f..8c941efe 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -87,7 +87,7 @@ export default function addAppLiveTools( // this tool ended with "my tools can start it but can't tap anything in it". The // description says so, and names the surface that can — only when that surface is on. const description = - "Open a manual App Live session for the user to operate by hand. It cannot be driven by the agent." + + "Open a manual App Live session for the user to operate by hand. It cannot be driven by the agent, and it uploads the app to App Live's own store, which agent-driven device sessions cannot use." + (capabilityRegistryEnabled() ? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability." : ""); From 8a3f98260733f683ff9cbf7de92ddfa317584dc0 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 11:57:54 +0530 Subject: [PATCH 43/53] fix(capability): route only on words a published capability answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routing_terms carried words for capabilities this surface withholds — biometrics, camera injection, GPS, network profiles, annotation, uninstall, adb, the UI tree on mobile; cookies on the browser product. A request using one of them was routed to a product that then had nothing for it. Dropped 13 terms on mobiledevicecontrol and one on devicecontrol; everything left maps to a published capability. Co-Authored-By: Claude Fable 5.1 --- capability/devicecontrol.capability-index.json | 1 - .../mobiledevicecontrol.capability-index.json | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index fa217005..b45ffb41 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -25,7 +25,6 @@ "scroll", "iframe", "tab", - "cookie", "screenshot", "console logs", "network logs", diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index a96634e2..adc97827 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -5,43 +5,32 @@ "mobiledevicecontrol": { "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; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", "routing_terms": [ - "adb", - "annotate", "app automate", "app build", "app for automation", "appium", "appium session", - "biometric", - "camera injection", "close app", "device control", "device list", "device property", "drive a device", "drive the app", - "face id", - "fingerprint", "foreground app", "getprop", - "gps", "hardware button", "home button", "keyboard", "keyboard open", "launch app", "local build", - "location", "mobile app", "mobile device control", - "network profile", "os build", - "page source", "parallel sessions", "plan", "private device", "public link", - "qr code", "real device", "restart app", "screenshot", @@ -49,8 +38,6 @@ "session video", "swipe", "tap", - "ui tree", - "uninstall app", "upload apk", "upload app", "upload app for device", From 2dd5a1679bf48fc236a808d8cd93ebf3b9842e49 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:05:50 +0530 Subject: [PATCH 44/53] feat(capability): ask the routing question in the user's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Which product do you mean — mobiledevicecontrol or tm?" puts our keys to a person. Each product now carries a `title` ("Mobile Device Control (App Automate)", "Test Management", …) which the clash question, its note and the senses use, and which rides on the product listing. The key stays the handle every call takes, so nothing an agent sends changes; a product without a title still reads as its key. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 1 + capability/loadtesting.capability-index.json | 5867 +- .../mobiledevicecontrol.capability-index.json | 1 + capability/tm.capability-index.json | 99948 ++++++++-------- capability/tra.capability-index.json | 63167 +++++----- src/tools/capability-registry/register.ts | 12 +- src/tools/capability-registry/types.ts | 8 + .../capabilityRegistryProductTitle.test.ts | 89 + 8 files changed, 84604 insertions(+), 84489 deletions(-) create mode 100644 tests/tools/capabilityRegistryProductTitle.test.ts diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index b45ffb41..98a31eca 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -3,6 +3,7 @@ "version": "1.0", "build_id": "8445466_2026-10-19T06:53:46Z", "devicecontrol": { + "title": "Device Control (Automate)", "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, screenshot the viewport, resize the window, and move between tabs and iframes. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are mobile device control, which drives an installed app rather than a browser. Scrolling is done with scroll_to_element or scroll_page, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.", "routing_terms": [ "automate", diff --git a/capability/loadtesting.capability-index.json b/capability/loadtesting.capability-index.json index 13299bb7..106f1684 100644 --- a/capability/loadtesting.capability-index.json +++ b/capability/loadtesting.capability-index.json @@ -1,3001 +1,3002 @@ { - "schema_version": 1, - "version": "1.0", - "build_id": "43eba70_2026-09-02T14:26:51Z", - "loadtesting": { - "summary": "Load and performance testing: run k6, JMeter, Gatling and Locust load tests at scale. List and inspect projects and load tests; create, update, start, stop and monitor runs; read run reports, AI insights and historical trends; compare runs; and check VU-hour quota and cost estimates. Test definitions, runs and their results live here. Account plan and billing do not — only VU-hour entitlement is exposed, via quota. There is no capability to delete a load test or schedule recurring runs — for those, point the user to the web dashboard. Each capability returns its full result in one call; reuse a result within a task rather than re-fetching the same data.", - "base_url": "https://load-api.browserstack.com", - "auth": { - "type": "http", - "scheme": "basic" - }, - "capabilities": [ - { - "name": "getLoadTestPlatformStatus", - "method": "GET", - "path": "/api/v1/agent/platform/status", - "mode": "read", - "entity": "platform", - "intent": "Check whether the Load Testing service is operational — use this if calls are failing or to confirm availability before starting work.", - "guidance": [ - "Always returns HTTP 200; read platformState (operational|degraded), not the status code.", - "Shallow dependency probe (database, redis) — it does not check a specific test or run." - ], - "returns": [ - "platformState", - "components", - "checkedAt" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestPlatformStatusData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "listLoadTestProjects", - "method": "GET", - "path": "/api/v1/agent/projects", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "search", - "type": "string", - "description": "Filter projects by name (<=200 chars)." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - } - ], - "intent": "List the caller's load-testing projects, most recently active first — use this to find a projectId or answer 'what projects do I have?'", - "guidance": [ - "projectId from here is required by listLoadTests and createLoadTest.", - "To find a project by name, pass search= so the server matches it in one call — do NOT page the whole project list to find one. Paging every project is the single largest source of redundant calls on this surface.", - "NEVER call this repeatedly with limit=1: it returns the same first project every time and makes no progress. To find one project use search=; to browse, make ONE call with a larger limit and page with cursor only if the user wants more.", - "search is group-scoped and may match projects in more than one project space; when it returns several, disambiguate by the projectId on each row — do not assume the first.", - "You already have this turn's results — reuse them; do not re-issue an identical call you already made.", - "Only enumerate with cursor + limit when the user actually wants the full list; nextCursor is opaque." - ], - "returns": [ - "projects", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListLoadTestProjectsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "listLoadTests", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/loadTests", - "mode": "read", - "entity": "loadTest", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "testType", - "type": "string", - "values": [ - "plu", - "blu", - "hybrid" - ], - "description": "Filter by test type." - }, - { - "name": "framework", - "type": "string", - "values": [ - "k6", - "jmeter", - "gatling", - "locust" - ], - "description": "Filter by framework." - }, - { - "name": "tag", - "type": "string", - "description": "Filter by tag." - }, - { - "name": "search", - "type": "string", - "description": "Filter by test name." - }, - { - "name": "updatedSince", - "type": "string", - "description": "ISO-8601 timestamp; only tests updated after it. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - } - ], - "intent": "List the load tests in a project — use this to find a testId or browse tests, optionally filtered by type, framework, tag or recent activity.", - "guidance": [ - "Resolve projectId via listLoadTestProjects first.", - "To find a test by name, pass search= so the server matches it in one call — do not page the whole project and match names yourself. In a large project this is the difference between one call and many.", - "If several tests match the name, disambiguate by lastRun / updatedSince — do not guess. A descriptive or auto-generated name (e.g. Automation_lts_*) is a real test, not noise; never silently drop it in favour of one that merely sounds more relevant.", - "The numeric testId returned here is what getLoadTest and startLoadTestRun need — not a runId.", - "lastRun summarises the most recent execution when present." - ], - "returns": [ - "tests", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListLoadTestsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } + "schema_version": 1, + "version": "1.0", + "build_id": "43eba70_2026-09-02T14:26:51Z", + "loadtesting": { + "title": "Load Testing", + "summary": "Load and performance testing: run k6, JMeter, Gatling and Locust load tests at scale. List and inspect projects and load tests; create, update, start, stop and monitor runs; read run reports, AI insights and historical trends; compare runs; and check VU-hour quota and cost estimates. Test definitions, runs and their results live here. Account plan and billing do not \u2014 only VU-hour entitlement is exposed, via quota. There is no capability to delete a load test or schedule recurring runs \u2014 for those, point the user to the web dashboard. Each capability returns its full result in one call; reuse a result within a task rather than re-fetching the same data.", + "base_url": "https://load-api.browserstack.com", + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "getLoadTestPlatformStatus", + "method": "GET", + "path": "/api/v1/agent/platform/status", + "mode": "read", + "entity": "platform", + "intent": "Check whether the Load Testing service is operational \u2014 use this if calls are failing or to confirm availability before starting work.", + "guidance": [ + "Always returns HTTP 200; read platformState (operational|degraded), not the status code.", + "Shallow dependency probe (database, redis) \u2014 it does not check a specific test or run." + ], + "returns": [ + "platformState", + "components", + "checkedAt" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "searchLoadTests", - "method": "GET", - "path": "/api/v1/agent/loadTests/search", - "mode": "read", - "entity": "loadTest", - "query": [ - { - "name": "name", - "type": "string", - "description": "Test name to search for (substring match, case-insensitive); required.", - "example": "checkout" - }, - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - } - ], - "intent": "Find a load test by name across ALL of the caller's projects — use this to resolve a named test to a testId without knowing which project it is in.", - "guidance": [ - "Group-scoped: unlike listLoadTests it needs no projectId. Prefer it to resolve a named test in one call instead of paging every project.", - "Each row carries testId, projectId, name, testType and framework — disambiguate same-named tests in different projects by projectId.", - "Returns one row per test (its latest version); the numeric testId is what getLoadTest and startLoadTestRun need.", - "hasMore:true means more matches exist beyond the returned limit — there is NO cursor and no page 2; narrow the name query or raise limit to surface them (results are the top matches, not a paginated list)." - ], - "returns": [ - "tests", - "hasMore" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "SearchLoadTestsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestProjectTrends", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/trends", - "mode": "read", - "entity": "trend", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "windowRuns", - "type": "integer", - "description": "How many recent runs to aggregate (1-100, default 10)." - }, - { - "name": "aggregation", - "type": "string", - "values": [ - "perRun" - ], - "description": "Aggregation granularity. Only perRun is supported today." - }, - { - "name": "verdictFilter", - "type": "string", - "values": [ - "passed", - "failed", - "all" - ], - "description": "Restrict to runs of this verdict." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Show how a project's load-test metrics trend across recent runs — use this for 'is performance getting better or worse across this project?'", - "guidance": [ - "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", - "windowRuns bounds how many recent runs are aggregated.", - "Returns the whole windowed series across metrics in one call — request all needed metrics together; do not call once per metric or re-fetch the same window." - ], - "returns": [ - "metrics" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestProjectTrendsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" + { + "properties": { + "data": { + "$schema": "GetLoadTestPlatformStatusData" } + } } - }, - { - "name": "listProjectLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/runs", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "status", - "type": "string", - "values": [ - "active", - "terminal", - "all" - ], - "description": "Filter by lifecycle state." - }, - { - "name": "verdict", - "type": "string", - "values": [ - "passed", - "failed", - "error" - ], - "description": "Filter by run verdict." - }, - { - "name": "startedBy", - "type": "string", - "description": "Filter by the user id that started the run." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "hadSlaBreach", - "type": "boolean", - "description": "Filter to runs that did / did not breach SLA." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." - } - ], - "intent": "List the execution history across every load test in a project — newest first, paginated. Use for 'what ran last week in this project?'.", - "guidance": [ - "Returns run metadata only, not metrics — use getLoadTestRunReport for a run's KPIs.", - "Each run carries a tags array — its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from its test. Filter with the tag query param.", - "Page with cursor + limit; nextCursor is opaque.", - "For a single test's history use /loadTests/{testId}/runs; for currently-live runs use /loadTests/runs/active.", - "One call returns the page of run history — reuse it for follow-ups and page with cursor only when more rows are needed; do not re-list the same window." - ], - "returns": [ - "runs", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListProjectLoadTestRunsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "listLoadTestProjects", + "method": "GET", + "path": "/api/v1/agent/projects", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "search", + "type": "string", + "description": "Filter projects by name (<=200 chars)." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + } + ], + "intent": "List the caller's load-testing projects, most recently active first \u2014 use this to find a projectId or answer 'what projects do I have?'", + "guidance": [ + "projectId from here is required by listLoadTests and createLoadTest.", + "To find a project by name, pass search= so the server matches it in one call \u2014 do NOT page the whole project list to find one. Paging every project is the single largest source of redundant calls on this surface.", + "NEVER call this repeatedly with limit=1: it returns the same first project every time and makes no progress. To find one project use search=; to browse, make ONE call with a larger limit and page with cursor only if the user wants more.", + "search is group-scoped and may match projects in more than one project space; when it returns several, disambiguate by the projectId on each row \u2014 do not assume the first.", + "You already have this turn's results \u2014 reuse them; do not re-issue an identical call you already made.", + "Only enumerate with cursor + limit when the user actually wants the full list; nextCursor is opaque." + ], + "returns": [ + "projects", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "listActiveLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/active", - "mode": "read", - "entity": "run", - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-200, default 50).", - "minimum": 1, - "maximum": 200 - }, - { - "name": "minElapsedSec", - "type": "integer", - "description": "Only runs elapsed at least this many seconds." - }, - { - "name": "projectId", - "type": "integer", - "description": "Restrict to a project." - }, - { - "name": "userId", - "type": "integer", - "description": "Restrict to runs started by a user." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." - } - ], - "intent": "List load-test runs currently executing — use this for 'what's running right now?' or to find a runId to stop or monitor.", - "guidance": [ - "Returns non-terminal runs across the account.", - "runId here is the UUID needed by getLoadTestRunStatus and stopLoadTestRun.", - "Each run carries a tags array — its own run-level tags if set, otherwise the tags inherited from its test. Filter with the tag query param.", - "vuHoursBurnedSoFar reflects consumption at the moment of the call." - ], - "returns": [ - "runs" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListActiveLoadTestRunsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestRunStatus", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/status", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "example": "b1e2c3d4-...", - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - } - ], - "intent": "Poll the live status and progress of a run — use this to wait for a run to finish or check elapsed/remaining time.", - "guidance": [ - "jobId is the run UUID from startLoadTestRun or listLoadTestRuns.", - "Respect pollAfterSeconds between polls instead of tight-looping.", - "slaBreachFlags surfaces threshold breaches as they trip.", - "For 'is it done?' / 'how much time is left?', this status call is sufficient — do not fetch the run report to answer a status question." - ], - "returns": [ - "status", - "elapsedSec", - "remainingSec", - "pollAfterSeconds", - "slaBreachFlags" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestRunStatusData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestInsightSummary", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/insights", - "mode": "read", - "entity": "insight", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "sections", - "type": "string", - "values": [ - "summary", - "rootCause", - "recommendations", - "evidence" - ], - "description": "Comma-separated subset of insight sections (default summary)." - } - ], - "intent": "Get the AI insight (summary, root cause, recommendations) for a completed run — use this for 'why did this run regress or fail?'", - "guidance": [ - "This is the canonical answer to a 'summarize this run' / 'give me a summary' request: return ONLY these AI insight summary points and stop. Do NOT also call getLoadTestRunReport or append KPIs / web vitals / transactions unless the user explicitly asks for them.", - "Only meaningful after the run is terminal; a running job returns an in_progress state, not a report.", - "May return 503 if the insight backend is unavailable — retry later. If no insight exists for the run, fall back to a brief status + headline KPIs from getLoadTestRunReport, not the full report." - ], - "returns": [ - "state", - "status", - "report" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestInsightSummaryData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - }, - "503": { - "$response": "ServiceUnavailable" - } - } - }, - { - "name": "getLoadTestRunReport", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/report", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "detail", - "type": "string", - "values": [ - "aggregate", - "per-txn", - "full" - ], - "description": "Level of detail (default aggregate)." - }, - { - "name": "view", - "type": "string", - "values": [ - "summary", - "detail", - "full" - ], - "description": "Response view." - }, - { - "name": "topN", - "type": "integer", - "description": "Top-N transactions/errors to include (1-100, default 10)." - }, - { - "name": "byteCap", - "type": "integer", - "description": "Hard response size cap in KB (1-256, default 128). Sets meta.truncated when hit." - }, - { - "name": "groupBy", - "type": "string", - "description": "Rollup dimension (url/browser/location/transaction/step)." - }, - { - "name": "slaOnly", - "type": "boolean", - "description": "Return only SLA-related data." - }, - { - "name": "errorCategory", - "type": "string", - "description": "Comma-separated error categories — any combination of: 5xx, 4xx, timeout, connection, assertion (e.g. \"5xx,4xx\")." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound; slices the report to data in this time window. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound for the time-window slice. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Get the full metrics report for a completed run — KPIs, SLA verdicts, per-transaction and error breakdowns. Use this to answer 'how did this run perform?'", - "guidance": [ - "Only valid for a terminal run.", - "Start with detail=aggregate; escalate to per-txn or full only when you need transaction- or network-level detail — full is large.", - "groupBy and errorCategory narrow the payload; byteCap hard-caps the response (max 256 KB).", - "metrics accepts dotted names or @ aliases; see getLoadTestMetricsManifest for what applies to this test type.", - "Answer a scoped question with the narrowest slice instead of the whole report: one metric → metrics=; error breakdown → errorCategory= (or slaOnly=true); a time window → sinceIso/untilIso; slowest transaction → detail=per-txn with groupBy=transaction and topN.", - "One call with the right params returns everything for that question — do not re-fetch the same run with the same params. Fetch the full report (view=full / detail=full) only when the user explicitly asks for the raw or complete report.", - "A plain 'summarize this run' / 'give me a summary' request is answered by getLoadTestInsightSummary, NOT this endpoint. Only call this when the user asks for specific KPIs, SLA verdicts, web vitals, transactions, or the raw/complete report.", - "durationSec is the run's wall-clock length in WHOLE SECONDS. When presenting it to the user, render it as minutes and seconds (e.g. 330 -> '5m 30s', 90 -> '1m 30s', 45 -> '45s'), not as a raw seconds count; keep the raw seconds only for calculations." - ], - "returns": [ - "runId", - "testType", - "status", - "testName", - "durationSec", - "kpis", - "slaVerdicts", - "transactions", - "errorsByCategory", - "meta" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestRunReportData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "stopLoadTestRun", - "method": "POST", - "path": "/api/v1/agent/loadTests/runs/{jobId}/stop", - "mode": "write", - "entity": "run", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId) to stop." - } - ], - "intent": "Stop a running load test — use this to abort an execution that is running or queued.", - "guidance": [ - "Idempotent: stopping an already-terminal run returns alreadyTerminal:true, not an error.", - "Returns status:'stopping'; poll getLoadTestRunStatus to confirm it reaches a terminal state." - ], - "returns": [ - "runId", - "status", - "stoppedAt" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "StopLoadTestRunData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestQuota", - "method": "GET", - "path": "/api/v1/agent/loadTests/quota", - "mode": "read", - "entity": "quota", - "intent": "Check the account's remaining VU-hours and concurrency headroom — use this before starting runs to confirm capacity.", - "guidance": [ - "Account-scoped from the caller's credentials; takes no parameters.", - "Pair with estimateLoadTestRunCost to check a specific run fits before starting it." - ], - "returns": [ - "plan", - "vuHours", - "concurrency" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestQuotaData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "estimateLoadTestRunCost", - "method": "POST", - "path": "/api/v1/agent/loadTests/estimate", - "mode": "read", - "entity": "quota", - "body": [ - { - "name": "testId", - "type": "integer", - "required": true, - "description": "The test to price.", - "example": 1234 - }, - { - "name": "overrides", - "type": "object", - "description": "Hypothetical run configuration to price instead of the saved one.", - "fields": [ - { - "name": "vus", - "type": "integer" - }, - { - "name": "durationSec", - "type": "integer" - } - ] - } - ], - "intent": "Estimate the VU-hour cost of running a test before starting it — use this to answer 'how much will this run cost / will it fit in my quota?'", - "guidance": [ - "Read-only despite being a POST — nothing is started.", - "fitsInQuota / remainingAfterEstimate compare the estimate against current quota.", - "For the same check at start time, call startLoadTestRun with dryRun:true.", - "Always use this for a cost or VU-hour estimate of a hypothetical run — it models ramp-up. Do not compute VU-hours by hand from VUs × duration.", - "An API (plu) run is billed at a full load-generator pod's capacity (e.g. 1000 VUs for k6), not the VUs it actually uses, and carries a minimum billing floor (5 minutes) — so a small or short plu run can estimate far higher than VUs × duration would suggest. That is expected; report the returned estimatedVuHours as-is rather than second-guessing it as an error.", - "The estimate is for the test's OWN type: a browser (blu) run costs about 10x an API (plu) run at the same VUs and duration (browser VUs carry a 10x weight). So the number depends heavily on whether the test is plu, blu or hybrid — always state which type the estimate is for. When the user asks about a hypothetical run without fixing the type, do not silently inherit the type of whatever test you priced against: say the type explicitly, and if it is genuinely open, give both the plu and blu figures (they differ ~10x) or ask which they mean." - ], - "returns": [ - "estimatedVuHours", - "estimationBasis", - "rangeBasis", - "rangeExplanation", - "fitsInQuota", - "remainingAfterEstimate" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "EstimateLoadTestRunCostData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "compareLoadTestRuns", - "method": "POST", - "path": "/api/v1/agent/loadTests/compare", - "mode": "read", - "entity": "report", - "body": [ - { - "name": "baselineRunId", - "type": "string", - "required": true, - "description": "Run UUID to compare against.", - "format": "uuid", - "example": "6f1d2c9a-7b3e-4a10-9c22-0e5f8a1b2c3d" - }, - { - "name": "candidateRunId", - "type": "string", - "required": true, - "description": "Run UUID being evaluated; must differ from baselineRunId.", - "format": "uuid", - "example": "b2e4f6a8-1c3d-4e5f-8a90-2b4c6d8e0f11" - }, - { - "name": "dimensions", - "type": "array", - "description": "Which delta dimensions to compute (any of kpi, transaction, sla).", - "values": [ - "kpi", - "transaction", - "sla" - ] - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. latency.p95, @vitals). Filters kpiDeltas; the first entry with a per-transaction value also picks the transaction metric. Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "groupBy", - "type": "string", - "description": "Axis for transaction deltas (default transaction = request name).", - "values": [ - "transaction", - "label", - "threadGroup", - "scenario" - ] - }, - { - "name": "topN", - "type": "integer", - "description": "Top-N deltas to include (1-100)." - }, - { - "name": "regressedOnly", - "type": "boolean", - "description": "Return only regressed transaction rows." - }, - { - "name": "pctChangeMin", - "type": "number", - "description": "Minimum absolute percent change for a KPI or transaction row to be included." - } - ], - "intent": "Compare two executions (runs) of the SAME load test and surface the deltas — the regression check ('did the latest run regress vs the previous one?'). Not for comparing different tests: runs of different tests are rejected (DIFFERENT_TESTS). For a trend across more than two runs use getLoadTestHistoricalTrends.", - "guidance": [ - "Pick both runs from listLoadTestRuns for ONE testId, filtered to finished runs (status=terminal). The older run is the baseline, the newer the candidate.", - "'Compare the latest run' means the latest finished run (candidate) vs the finished run before it (baseline).", - "Both runs must be finished and must share the same test types — otherwise the call fails with VALIDATION_ERROR. Runs of different tests fail with DIFFERENT_TESTS; do not retry with other tests' runs.", - "Use this to compare two runs — it returns per-KPI, per-transaction and SLA-verdict deltas from the same data as the Compare Runs page. Do not fetch both run reports and diff them yourself.", - "pctChange is signed (candidate vs baseline); direction/regressed already account for whether higher or lower is better — report those, do not re-derive them. direction \"unknown\" (regressed null) means one or both runs have no value for that metric: report it as missing data, never as no change.", - "Transaction deltas are computed on one metric: the first metrics entry that has a per-transaction value (latency.*, errors.rate, throughput.*), else latency.p95. groupBy picks the transaction axis.", - "regressedOnly + pctChangeMin filter to material regressions; regressedOnly applies to transactionDeltas, pctChangeMin to kpiDeltas and transactionDeltas.", - "Check warnings before reporting: they name metrics with no data on a run, runs whose SLA thresholds were not evaluated, SLA definitions that changed between the runs, and requested metrics compare cannot serve (e.g. engine.*). Surface any warning to the user." - ], - "returns": [ - "baseline", - "candidate", - "kpiDeltas", - "transactionDeltas", - "slaVerdictChanges", - "warnings" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "CompareLoadTestRunsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "createLoadTest", - "method": "POST", - "path": "/api/v1/agent/loadTests", - "mode": "write", - "entity": "loadTest", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "maxLength": 255, - "description": "Test name. Max 255 characters — truncate before sending; a longer name is rejected with a 400.", - "example": "checkout-baseline" - }, - { - "name": "testType", - "type": "string", - "required": true, - "description": "plu = API load (server-side, non-browser); blu = real-browser load; hybrid = BOTH in one test (one PLU leg + one BLU leg, supplied via children). These tokens are INTERNAL wire values — send them here but never show them to the user; the user-facing labels are 'API' (plu), 'Browser' (blu) and 'Hybrid' (hybrid). All three are valid, user-selectable options — hybrid is not a rare/advanced variant, offer it alongside the other two.", - "values": [ - "plu", - "blu", - "hybrid" - ] - }, - { - "name": "framework", - "type": "string", - "required": true, - "description": "The framework MUST match testType (the backend rejects a mismatched pair). Offer ONLY the frameworks for the already-chosen testType — read them from valuesByTestType[testType] below — and NEVER present the full `values` list across types. API (plu): k6, jmeter, gatling, locust. Browser (blu): playwright, selenium, webdriverio, nightwatch, lcncnightwatch. For a hybrid test this top-level framework is the Browser leg; the API leg's framework goes in children[].", - "scopedBy": "testType", - "valuesByTestType": { - "plu": [ - "k6", - "jmeter", - "gatling", - "locust" - ], - "blu": [ - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ], - "hybrid": [ - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ] - }, - "values": [ - "k6", - "jmeter", - "gatling", - "locust", - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ] - }, - { - "name": "projectId", - "type": "integer", - "description": "Target project. Provide this OR projectName, not both." - }, - { - "name": "projectName", - "type": "string", - "description": "Target project by name; created if it does not exist. Provide this OR projectId." - }, - { - "name": "config", - "type": "object", - "description": "Test configuration incl. scriptRef {source: s3|s3_upload|existing_zip_id|sample, value|identifier}, loadProfile (constant|ramping|iterations|throughput), vus, vuRamp, durationSec, iterations, targetRps, maxVus, loadGeneratorLocations, slaThresholds, envVarKeys, tags. See the load-profile guidance for which fields each loadProfile needs (iterations + throughput are PLU-only). To supply a script from a local file or URL, use the pendingScriptUpload flow, then source='s3'. To create a test from the platform's built-in sample (no upload), use scriptRef:{source:'sample'} — for Selenium also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber)." - }, - { - "name": "children", - "type": "array", - "description": "Child scenarios for a hybrid test. A hybrid is exactly one PLU (API) leg + one BLU (browser) leg — each leg carries its own framework + scriptRef (a sample or an uploaded script). Send the BLU leg as the top-level testType='blu'/framework/config and put the PLU leg in children[] (one entry, testType='plu' with its framework + config.scriptRef). The backend requires exactly one PLU and one BLU leg and rejects any other composition." - }, - { - "name": "idempotencyKey", - "type": "string", - "description": "Dedupe key so a retried create does not duplicate the test." - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - }, - { - "name": "pendingScriptUpload", - "type": "object", - "description": "Request a presigned upload URL instead of creating; returns uploadUrl + s3Key to PUT the script to, then call create again with config.scriptRef.source='s3_upload'.", - "fields": [ - { - "name": "framework", - "type": "string" - }, - { - "name": "expectedContentType", - "type": "string" - } - ] - } - ], - "intent": "Create a new load test in a project — use this to define a test before running it. JSON body only (no multipart).", - "guidance": [ - "PRECONDITION — do NOT call this endpoint until THREE things are known: (1) testType (plu/blu/hybrid), (2) framework, (3) the script (a built-in sample or the user's own upload). These are MANDATORY and NOT defaultable — never infer or default them. If the user's request does not state one of the three, ASK the user for the missing ones and WAIT for their answer before creating. 'create a test' with no type/framework/script named means all three are missing → ask all three; do not silently pick k6/plu/sample. Only vus, duration, region and load profile are defaultable (see the DEFAULTS line) — the three above are not.", - "'plu' / 'blu' / 'hybrid' are INTERNAL wire values — send them in the request, but NEVER show them to the user. Do not print them in questions, option labels, confirmations, summaries, or prose (no 'Browser (BLU)', no '(plu+blu)'). User-facing labels are: plu → 'API', blu → 'Browser', hybrid → 'Hybrid (both API and Browser)'. Frameworks (k6, jmeter, selenium, playwright, …) are real product names and ARE shown to the user.", - "When you ASK the user to pick testType or framework, present the FULL, exact option set — never drop, merge, or cross-contaminate options. testType: offer all THREE, using the user-facing labels above — 'API', 'Browser', and 'Hybrid'; do not omit Hybrid. framework (ask AFTER testType is chosen, and show only that type's frameworks — the authoritative per-type list is the `framework.valuesByTestType[testType]` array; present exactly those, each as its OWN separate option, and NEVER the full cross-type `values` list): for an API test offer exactly k6, jmeter, gatling AND locust (all four — do not drop locust); for a Browser test offer exactly playwright, selenium, webdriverio AND nightwatch (all four — never list the API frameworks under a Browser test, and never merge selenium and playwright into one option). For a Hybrid test, collect an API leg (its framework + script) AND a Browser leg (its framework + script) and assemble them per the children field — Hybrid needs both legs, so it takes more than the three questions a single-type test does.", - "Provide exactly one of projectId or projectName; projectName creates the project if absent.", - "Script upload is two-phase: send pendingScriptUpload to get a presigned uploadUrl + s3Key, PUT the file, then call create again with the scriptRef source and s3Key returned in the response's nextStep.", - "To spin up a test from a built-in sample with NO file handling (the dashboard's 'use sample script'), pass scriptRef:{source:'sample'} — the platform's bundled sample for the given framework is used. For Selenium, also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber) to pick the binding. testType and framework are still needed; the load profile defaults to constant 1 VU / 30s (see below).", - "Pass idempotencyKey so a retried create does not duplicate the test.", - "Only THREE things must be known before creating, and each is taken from the user's request when they've stated it — ask ONLY for the ones still missing, and ask about nothing else: (1) testType — plu (API load), blu (real-browser load) or hybrid (both); (2) framework — k6 / jmeter / gatling / locust for plu, playwright / selenium / webdriverio / nightwatch for blu; (3) the script — a built-in sample (scriptRef:{source:'sample'}; for Selenium also pass scriptRef.subType) OR the user's own script via the pendingScriptUpload flow. Do not guess a framework/testType, but do not re-ask for one the user already gave.", - "There is no clone capability. To duplicate a test, getLoadTest the source and copy its full config into this create — vuRamp/vus, durationSec, loadGeneratorLocations and slaThresholds included; nothing is inherited from the source, so anything you omit is dropped.", - "DEFAULTS apply ONLY after the three mandatory fields (testType, framework, script) are known — they never substitute for a missing one of those. Everything BEYOND those three — do not ask about it; apply the default unless the user explicitly specifies otherwise: loadProfile='constant', config.vus=1, config.durationSec=30, region us-east-1 (omit loadGeneratorLocations so the backend defaults it), and NO advanced features (do not enable log capture, response capture, artifacts, thresholds, etc.). Send config.vus=1 + config.durationSec=30 with no vuRamp for the default constant profile.", - "Only when the user explicitly asks for a different load shape, set config.loadProfile: 'ramping' (config.vuRamp: array of {vus, durationSec} stages; ramp up/down is inferred from adjacent VU deltas), 'iterations' (PLU only — config.iterations = fixed run count, plus config.vus), or 'throughput' (PLU and only JMeter/Gatling/locust/k6 — config.targetRps + config.maxVus + config.durationSec). A stage's own 'type' is NOT honored — every stage persists as a hold.", - "testType='hybrid' REQUIRES children[] with the PLU leg (top-level is the BLU leg). NEVER call create with testType='hybrid' and no children[] — it is rejected 'hybrid testType requires children[]'. If you don't yet have both legs' framework+script, ASK for them first; do not submit a hybrid without children to 'see what happens'.", - "On a 400, FIX the payload per the error message and only then resend — NEVER resubmit an unchanged payload (add children[] for hybrid, shorten name to <=255, supply testType+framework). Do not call getLoadTest after a FAILED create; there is nothing to fetch. Repeatedly retrying the same rejected body is the main source of wasted create calls.", - "Always send idempotencyKey, and reuse the SAME key across retries of one logical create, so a late success can't create a duplicate test." - ], - "returns": [ - "testId", - "name", - "projectId", - "version", - "dashboardLink" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "CreateLoadTestData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "startLoadTestRun", - "method": "POST", - "path": "/api/v1/agent/loadTests/{testId}/runs", - "mode": "write", - "entity": "run", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "overrides", - "type": "object", - "description": "Per-run overrides.", - "fields": [ - { - "name": "vus", - "type": "integer" - }, - { - "name": "durationSec", - "type": "integer" - }, - { - "name": "loadGeneratorLocations", - "type": "array" - }, - { - "name": "envOverrides", - "type": "object" - } - ] - }, - { - "name": "dryRun", - "type": "boolean", - "description": "Return the VU-hour estimate and quota fit without starting a run." - }, - { - "name": "idempotencyKey", - "type": "string", - "description": "Dedupe key so a retried start does not launch a duplicate run." - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - } - ], - "intent": "Start a run of an existing load test — use this to execute a test, optionally overriding VUs, duration or locations.", - "guidance": [ - "testId is the numeric test id, not a run id.", - "dryRun:true returns the VU-hour estimate and quota fit without starting anything.", - "On success returns runId (UUID) + dashboardLink; poll getLoadTestRunStatus with the runId.", - "A run without dryRun generates real load and consumes VU-hours. Unless the user has already asked to run it now, dryRun:true first, show the estimate/quota fit and the parameters that will be used (VUs, duration, target), and start the real run only after the user confirms — do not treat your own confirmation as the user's." - ], - "returns": [ - "runId", - "status", - "startedAt", - "dashboardLink", - "estimatedVuHoursRange" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "StartLoadTestRunData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" + { + "properties": { + "data": { + "$schema": "ListLoadTestProjectsData" } + } } - }, - { - "name": "listLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/runs", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "status", - "type": "string", - "values": [ - "active", - "terminal", - "all" - ], - "description": "Lifecycle filter (default all)." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "verdict", - "type": "string", - "description": "Filter by run verdict." - }, - { - "name": "startedBy", - "type": "integer", - "description": "Filter by the user who started the run." - }, - { - "name": "hadSlaBreach", - "type": "boolean", - "description": "Only runs that breached an SLA." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from the test when it has none of its own)." - } - ], - "intent": "List the execution history of a test — use this to find past runs of a specific test or to get a runId.", - "guidance": [ - "testId is numeric; runId values returned are UUIDs.", - "Each run carries a tags array — its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from the test. Filter with the tag query param.", - "Filter with status / verdict / date range; page with cursor + limit.", - "To find a run by date, pass sinceIso/untilIso to narrow server-side — do not list the full history and scan it yourself. To compare two dated runs, make one narrowed call per date rather than one broad listing.", - "If a date resolves to more than one run, ask the user which; do not assume the first or the latest.", - "One call returns this test's run history — reuse it and page with cursor when needed; do not re-list the same window." - ], - "returns": [ - "runs", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListLoadTestRunsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "paginated": true, + "max_page_size": 100 + }, + { + "name": "listLoadTests", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/loadTests", + "mode": "read", + "entity": "loadTest", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "testType", + "type": "string", + "values": [ + "plu", + "blu", + "hybrid" + ], + "description": "Filter by test type." + }, + { + "name": "framework", + "type": "string", + "values": [ + "k6", + "jmeter", + "gatling", + "locust" + ], + "description": "Filter by framework." + }, + { + "name": "tag", + "type": "string", + "description": "Filter by tag." + }, + { + "name": "search", + "type": "string", + "description": "Filter by test name." + }, + { + "name": "updatedSince", + "type": "string", + "description": "ISO-8601 timestamp; only tests updated after it. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + } + ], + "intent": "List the load tests in a project \u2014 use this to find a testId or browse tests, optionally filtered by type, framework, tag or recent activity.", + "guidance": [ + "Resolve projectId via listLoadTestProjects first.", + "To find a test by name, pass search= so the server matches it in one call \u2014 do not page the whole project and match names yourself. In a large project this is the difference between one call and many.", + "If several tests match the name, disambiguate by lastRun / updatedSince \u2014 do not guess. A descriptive or auto-generated name (e.g. Automation_lts_*) is a real test, not noise; never silently drop it in favour of one that merely sounds more relevant.", + "The numeric testId returned here is what getLoadTest and startLoadTestRun need \u2014 not a runId.", + "lastRun summarises the most recent execution when present." + ], + "returns": [ + "tests", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "getLoadTestHistoricalTrends", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/trends", - "mode": "read", - "entity": "trend", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "windowRuns", - "type": "integer", - "description": "How many recent runs to aggregate (1-100, default 10)." - }, - { - "name": "aggregation", - "type": "string", - "values": [ - "perRun" - ], - "description": "Aggregation granularity. Only perRun is supported today." - }, - { - "name": "verdictFilter", - "type": "string", - "values": [ - "passed", - "failed", - "all" - ], - "description": "Restrict to runs of this verdict." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Show how one test's metrics trend across its recent runs — use this for 'is this test getting slower over time?'", - "guidance": [ - "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", - "windowRuns bounds how many recent runs are aggregated.", - "Returns the whole windowed series across metrics in one call — request all needed metrics together; do not call once per metric or re-fetch the same window." - ], - "returns": [ - "metrics" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestHistoricalTrendsData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestMetricsManifest", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/metricsManifest", - "mode": "read", - "entity": "metrics", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "intent": "Discover which metrics, @ aliases and groupBy options a test supports — call this before requesting a report, trend or comparison so metric names are valid.", - "guidance": [ - "Depends only on the test's type; cache the result client-side.", - "Use the returned names/aliases in the metrics parameter of report, trends and compare." - ], - "returns": [ - "testType", - "metrics", - "aliases" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestMetricsManifestData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "updateLoadTest", - "method": "PUT", - "path": "/api/v1/agent/loadTests/{testId}", - "mode": "write", - "entity": "loadTest", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "description": "New test name." - }, - { - "name": "config", - "type": "object", - "description": "Partial config update (tags, envVarKeys, slaThresholds, scriptRef, integrations)." - }, - { - "name": "ifVersion", - "type": "string", - "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).", - "format": "date-time" - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - }, - { - "name": "pendingScriptUpload", - "type": "object", - "description": "Two-phase script replacement, same flow as createLoadTest.", - "fields": [ - { - "name": "framework", - "type": "string" - }, - { - "name": "expectedContentType", - "type": "string" - } - ] - }, - { - "name": "runId", - "type": "string", - "description": "Tag a specific RUN instead of the test definition. When set, config.tags is applied to that run's own tags (the saved test is NOT changed); only config.tags may accompany runId. Omit it to edit the test." - } - ], - "intent": "Update an existing load test's name or configuration — use this to change tags, SLA thresholds, script or settings; also tags a specific run when runId is set.", - "guidance": [ - "Partial update: send only the fields to change.", - "Pass ifVersion for optimistic concurrency; a stale value returns 409 VERSION_CONFLICT.", - "Script replacement uses the same two-phase pendingScriptUpload flow as create.", - "config is a partial update, but each field it carries REPLACES that field wholesale — it does not merge. tags overwrites the entire tag set; it does not append. To add a tag to a test (or the same tag across several tests), getLoadTest each one first and send the union under config.tags.", - "To change the load profile, send config.loadProfile plus that profile's fields (see createLoadTest's load-profile guidance for the four shapes). Switching profiles clears the previous profile's fields — e.g. ramping→throughput empties the stages and drops iterations. iterations and throughput are PLU-only.", - "To tag a specific RUN rather than the test, pass runId (a run UUID from listLoadTestRuns) together with config.tags — the tags apply to that one run and the saved test is untouched. A run inherits the test's tags until you set its own; setting run tags overrides (they also replace wholesale, so send the union to add). Omit runId to tag the test itself.", - "Tags live on the load test, not on its runs — there is no per-run tagging, so do not touch runs when asked to tag a test. To tag every test in a project, list them with listLoadTests and page through with cursor until hasMore is false, then updateLoadTest each one — do not stop after the first page or a subset.", - "Optimistic concurrency is opt-in: pass ifVersion (the version token from a prior get/create/update) so the edit fails with 409 CONFLICT if the test changed meanwhile; OMITTING ifVersion is a last-write-wins update that overwrites any concurrent change." - ], - "returns": [ - "testId", - "version" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "UpdateLoadTestData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "409": { - "$response": "Conflict" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTest", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}", - "mode": "read", - "entity": "loadTest", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "include", - "type": "string", - "description": "Comma-separated sub-resources to expand — any combination of: config, thresholds, tags, children (e.g. \"config,thresholds\"). Ask for everything you need in ONE call (include=config,thresholds,tags) rather than one request per sub-resource." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - } - ], - "intent": "Get the full configuration of a single load test — use this to inspect a test's settings, script reference, SLA thresholds and children.", - "guidance": [ - "testId is numeric (from listLoadTests).", - "Use include to expand config / thresholds / tags / children.", - "A single call returns the complete configuration — VU/ramp profile, duration, load-generator regions and SLA thresholds (expand them with include=config,thresholds). Do not call again for the same testId within a task; reuse the result." - ], - "returns": [ - "testId", - "name", - "testType", - "framework", - "version", - "projectId", - "ownerUserId", - "config" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestData" - } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" + { + "properties": { + "data": { + "$schema": "ListLoadTestsData" } + } } + ] } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "paginated": true, + "max_page_size": 100 + }, + { + "name": "searchLoadTests", + "method": "GET", + "path": "/api/v1/agent/loadTests/search", + "mode": "read", + "entity": "loadTest", + "query": [ + { + "name": "name", + "type": "string", + "description": "Test name to search for (substring match, case-insensitive); required.", + "example": "checkout" + }, + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + } ], - "schemas": { - "SuccessEnvelope": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "Operation payload; its fields are listed in each capability's `returns`." - } - }, - "required": [ - "success", - "data" - ] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "errorCode": { - "type": "string", - "example": "NOT_FOUND" - }, - "error": { - "type": "string", - "example": "The requested resource was not found." - } + "intent": "Find a load test by name across ALL of the caller's projects \u2014 use this to resolve a named test to a testId without knowing which project it is in.", + "guidance": [ + "Group-scoped: unlike listLoadTests it needs no projectId. Prefer it to resolve a named test in one call instead of paging every project.", + "Each row carries testId, projectId, name, testType and framework \u2014 disambiguate same-named tests in different projects by projectId.", + "Returns one row per test (its latest version); the numeric testId is what getLoadTest and startLoadTestRun need.", + "hasMore:true means more matches exist beyond the returned limit \u2014 there is NO cursor and no page 2; narrow the name query or raise limit to surface them (results are the top matches, not a paginated list)." + ], + "returns": [ + "tests", + "hasMore" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" }, - "required": [ - "success", - "error" - ] - }, - "GetLoadTestPlatformStatusData": { - "type": "object", - "properties": { - "platformState": { - "type": "string" - }, - "components": { - "type": "array" - }, - "checkedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "ListLoadTestProjectsData": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "testCount": { - "type": "integer", - "nullable": true - }, - "lastActivityAt": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } - } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" - } - } - }, - "ListLoadTestsData": { - "type": "object", - "properties": { - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true - }, - "testType": { - "type": "string", - "description": "plu (API), blu (Browser) or hybrid." - }, - "framework": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "owner": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "lastRun": { - "type": "object", - "description": "Most recent execution, when present.", - "properties": { - "runId": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "verdict": { - "type": "string", - "nullable": true - } - } - } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" + { + "properties": { + "data": { + "$schema": "SearchLoadTestsData" } + } } - }, - "SearchLoadTestsData": { - "type": "object", - "properties": { - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "testType": { - "type": "string" - }, - "framework": { - "type": "string", - "nullable": true - } - } - } - }, - "hasMore": { - "type": "boolean" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestProjectTrends", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/trends", + "mode": "read", + "entity": "trend", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "windowRuns", + "type": "integer", + "description": "How many recent runs to aggregate (1-100, default 10)." + }, + { + "name": "aggregation", + "type": "string", + "values": [ + "perRun" + ], + "description": "Aggregation granularity. Only perRun is supported today." + }, + { + "name": "verdictFilter", + "type": "string", + "values": [ + "passed", + "failed", + "all" + ], + "description": "Restrict to runs of this verdict." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Show how a project's load-test metrics trend across recent runs \u2014 use this for 'is performance getting better or worse across this project?'", + "guidance": [ + "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", + "windowRuns bounds how many recent runs are aggregated.", + "Returns the whole windowed series across metrics in one call \u2014 request all needed metrics together; do not call once per metric or re-fetch the same window." + ], + "returns": [ + "metrics" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestProjectTrendsData" } + } } - }, - "GetLoadTestProjectTrendsData": { - "type": "object", - "properties": { - "metrics": { - "description": "Trend series payload; shape per getLoadTestMetricsManifest." + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "listProjectLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/runs", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "status", + "type": "string", + "values": [ + "active", + "terminal", + "all" + ], + "description": "Filter by lifecycle state." + }, + { + "name": "verdict", + "type": "string", + "values": [ + "passed", + "failed", + "error" + ], + "description": "Filter by run verdict." + }, + { + "name": "startedBy", + "type": "string", + "description": "Filter by the user id that started the run." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "hadSlaBreach", + "type": "boolean", + "description": "Filter to runs that did / did not breach SLA." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." + } + ], + "intent": "List the execution history across every load test in a project \u2014 newest first, paginated. Use for 'what ran last week in this project?'.", + "guidance": [ + "Returns run metadata only, not metrics \u2014 use getLoadTestRunReport for a run's KPIs.", + "Each run carries a tags array \u2014 its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from its test. Filter with the tag query param.", + "Page with cursor + limit; nextCursor is opaque.", + "For a single test's history use /loadTests/{testId}/runs; for currently-live runs use /loadTests/runs/active.", + "One call returns the page of run history \u2014 reuse it for follow-ups and page with cursor only when more rows are needed; do not re-list the same window." + ], + "returns": [ + "runs", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListProjectLoadTestRunsData" } + } } - }, - "ListProjectLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "status": { - "type": "string", - "nullable": true - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "durationSec": { - "type": "integer", - "nullable": true, - "description": "Wall-clock seconds; null while running or when timestamps are missing." - }, - "vus": { - "type": "integer", - "nullable": true - }, - "verdict": { - "type": "string", - "nullable": true - }, - "vuHours": { - "type": "number", - "nullable": true, - "description": "Billed VU-hours; null until billed." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." - } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "paginated": true, + "max_page_size": 100 + }, + { + "name": "listActiveLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/active", + "mode": "read", + "entity": "run", + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-200, default 50).", + "minimum": 1, + "maximum": 200 + }, + { + "name": "minElapsedSec", + "type": "integer", + "description": "Only runs elapsed at least this many seconds." + }, + { + "name": "projectId", + "type": "integer", + "description": "Restrict to a project." + }, + { + "name": "userId", + "type": "integer", + "description": "Restrict to runs started by a user." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." + } + ], + "intent": "List load-test runs currently executing \u2014 use this for 'what's running right now?' or to find a runId to stop or monitor.", + "guidance": [ + "Returns non-terminal runs across the account.", + "runId here is the UUID needed by getLoadTestRunStatus and stopLoadTestRun.", + "Each run carries a tags array \u2014 its own run-level tags if set, otherwise the tags inherited from its test. Filter with the tag query param.", + "vuHoursBurnedSoFar reflects consumption at the moment of the call." + ], + "returns": [ + "runs" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListActiveLoadTestRunsData" } + } } - }, - "ListActiveLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "testId": { - "type": "integer", - "nullable": true - }, - "testName": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "startedBy": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "elapsedSec": { - "type": "integer", - "nullable": true - }, - "vus": { - "type": "integer", - "nullable": true - }, - "vuHoursBurnedSoFar": { - "type": "number" - }, - "status": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." - } - } - } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestRunStatus", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/status", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "example": "b1e2c3d4-...", + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + } + ], + "intent": "Poll the live status and progress of a run \u2014 use this to wait for a run to finish or check elapsed/remaining time.", + "guidance": [ + "jobId is the run UUID from startLoadTestRun or listLoadTestRuns.", + "Respect pollAfterSeconds between polls instead of tight-looping.", + "slaBreachFlags surfaces threshold breaches as they trip.", + "For 'is it done?' / 'how much time is left?', this status call is sufficient \u2014 do not fetch the run report to answer a status question." + ], + "returns": [ + "status", + "elapsedSec", + "remainingSec", + "pollAfterSeconds", + "slaBreachFlags" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestRunStatusData" } + } } - }, - "GetLoadTestRunStatusData": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "elapsedSec": { - "type": "number" - }, - "remainingSec": { - "type": "number" - }, - "pollAfterSeconds": { - "type": "number" - }, - "slaBreachFlags": { - "type": "array" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestInsightSummary", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/insights", + "mode": "read", + "entity": "insight", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "sections", + "type": "string", + "values": [ + "summary", + "rootCause", + "recommendations", + "evidence" + ], + "description": "Comma-separated subset of insight sections (default summary)." + } + ], + "intent": "Get the AI insight (summary, root cause, recommendations) for a completed run \u2014 use this for 'why did this run regress or fail?'", + "guidance": [ + "This is the canonical answer to a 'summarize this run' / 'give me a summary' request: return ONLY these AI insight summary points and stop. Do NOT also call getLoadTestRunReport or append KPIs / web vitals / transactions unless the user explicitly asks for them.", + "Only meaningful after the run is terminal; a running job returns an in_progress state, not a report.", + "May return 503 if the insight backend is unavailable \u2014 retry later. If no insight exists for the run, fall back to a brief status + headline KPIs from getLoadTestRunReport, not the full report." + ], + "returns": [ + "state", + "status", + "report" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestInsightSummaryData" } + } } - }, - "GetLoadTestInsightSummaryData": { - "type": "object", - "properties": { - "state": { - "type": "string" - }, - "status": { - "type": "string" - }, - "report": { - "type": "object" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + }, + "503": { + "$response": "ServiceUnavailable" + } + } + }, + { + "name": "getLoadTestRunReport", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/report", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "detail", + "type": "string", + "values": [ + "aggregate", + "per-txn", + "full" + ], + "description": "Level of detail (default aggregate)." + }, + { + "name": "view", + "type": "string", + "values": [ + "summary", + "detail", + "full" + ], + "description": "Response view." + }, + { + "name": "topN", + "type": "integer", + "description": "Top-N transactions/errors to include (1-100, default 10)." + }, + { + "name": "byteCap", + "type": "integer", + "description": "Hard response size cap in KB (1-256, default 128). Sets meta.truncated when hit." + }, + { + "name": "groupBy", + "type": "string", + "description": "Rollup dimension (url/browser/location/transaction/step)." + }, + { + "name": "slaOnly", + "type": "boolean", + "description": "Return only SLA-related data." + }, + { + "name": "errorCategory", + "type": "string", + "description": "Comma-separated error categories \u2014 any combination of: 5xx, 4xx, timeout, connection, assertion (e.g. \"5xx,4xx\")." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound; slices the report to data in this time window. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound for the time-window slice. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Get the full metrics report for a completed run \u2014 KPIs, SLA verdicts, per-transaction and error breakdowns. Use this to answer 'how did this run perform?'", + "guidance": [ + "Only valid for a terminal run.", + "Start with detail=aggregate; escalate to per-txn or full only when you need transaction- or network-level detail \u2014 full is large.", + "groupBy and errorCategory narrow the payload; byteCap hard-caps the response (max 256 KB).", + "metrics accepts dotted names or @ aliases; see getLoadTestMetricsManifest for what applies to this test type.", + "Answer a scoped question with the narrowest slice instead of the whole report: one metric \u2192 metrics=; error breakdown \u2192 errorCategory= (or slaOnly=true); a time window \u2192 sinceIso/untilIso; slowest transaction \u2192 detail=per-txn with groupBy=transaction and topN.", + "One call with the right params returns everything for that question \u2014 do not re-fetch the same run with the same params. Fetch the full report (view=full / detail=full) only when the user explicitly asks for the raw or complete report.", + "A plain 'summarize this run' / 'give me a summary' request is answered by getLoadTestInsightSummary, NOT this endpoint. Only call this when the user asks for specific KPIs, SLA verdicts, web vitals, transactions, or the raw/complete report.", + "durationSec is the run's wall-clock length in WHOLE SECONDS. When presenting it to the user, render it as minutes and seconds (e.g. 330 -> '5m 30s', 90 -> '1m 30s', 45 -> '45s'), not as a raw seconds count; keep the raw seconds only for calculations." + ], + "returns": [ + "runId", + "testType", + "status", + "testName", + "durationSec", + "kpis", + "slaVerdicts", + "transactions", + "errorsByCategory", + "meta" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestRunReportData" } + } } - }, - "GetLoadTestRunReportData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testType": { - "type": "string" - }, - "status": { - "type": "string" - }, - "testName": { - "type": "string" - }, - "durationSec": { - "type": "number", - "description": "Run wall-clock duration in whole seconds. Present to the user as minutes + seconds (e.g. 5m 30s)." - }, - "kpis": { - "type": "object" - }, - "slaVerdicts": { - "type": "array" - }, - "transactions": { - "type": "array" - }, - "errorsByCategory": { - "type": "object" - }, - "meta": { - "type": "object" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "stopLoadTestRun", + "method": "POST", + "path": "/api/v1/agent/loadTests/runs/{jobId}/stop", + "mode": "write", + "entity": "run", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId) to stop." + } + ], + "intent": "Stop a running load test \u2014 use this to abort an execution that is running or queued.", + "guidance": [ + "Idempotent: stopping an already-terminal run returns alreadyTerminal:true, not an error.", + "Returns status:'stopping'; poll getLoadTestRunStatus to confirm it reaches a terminal state." + ], + "returns": [ + "runId", + "status", + "stoppedAt" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "StopLoadTestRunData" } + } } - }, - "StopLoadTestRunData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - }, - "stoppedAt": { - "type": "string", - "format": "date-time" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestQuota", + "method": "GET", + "path": "/api/v1/agent/loadTests/quota", + "mode": "read", + "entity": "quota", + "intent": "Check the account's remaining VU-hours and concurrency headroom \u2014 use this before starting runs to confirm capacity.", + "guidance": [ + "Account-scoped from the caller's credentials; takes no parameters.", + "Pair with estimateLoadTestRunCost to check a specific run fits before starting it." + ], + "returns": [ + "plan", + "vuHours", + "concurrency" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestQuotaData" } + } } - }, - "GetLoadTestQuotaData": { - "type": "object", - "properties": { - "plan": { - "type": "object" - }, - "vuHours": { - "type": "object" - }, - "concurrency": { - "type": "object" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "estimateLoadTestRunCost", + "method": "POST", + "path": "/api/v1/agent/loadTests/estimate", + "mode": "read", + "entity": "quota", + "body": [ + { + "name": "testId", + "type": "integer", + "required": true, + "description": "The test to price.", + "example": 1234 + }, + { + "name": "overrides", + "type": "object", + "description": "Hypothetical run configuration to price instead of the saved one.", + "fields": [ + { + "name": "vus", + "type": "integer" + }, + { + "name": "durationSec", + "type": "integer" + } + ] + } + ], + "intent": "Estimate the VU-hour cost of running a test before starting it \u2014 use this to answer 'how much will this run cost / will it fit in my quota?'", + "guidance": [ + "Read-only despite being a POST \u2014 nothing is started.", + "fitsInQuota / remainingAfterEstimate compare the estimate against current quota.", + "For the same check at start time, call startLoadTestRun with dryRun:true.", + "Always use this for a cost or VU-hour estimate of a hypothetical run \u2014 it models ramp-up. Do not compute VU-hours by hand from VUs \u00d7 duration.", + "An API (plu) run is billed at a full load-generator pod's capacity (e.g. 1000 VUs for k6), not the VUs it actually uses, and carries a minimum billing floor (5 minutes) \u2014 so a small or short plu run can estimate far higher than VUs \u00d7 duration would suggest. That is expected; report the returned estimatedVuHours as-is rather than second-guessing it as an error.", + "The estimate is for the test's OWN type: a browser (blu) run costs about 10x an API (plu) run at the same VUs and duration (browser VUs carry a 10x weight). So the number depends heavily on whether the test is plu, blu or hybrid \u2014 always state which type the estimate is for. When the user asks about a hypothetical run without fixing the type, do not silently inherit the type of whatever test you priced against: say the type explicitly, and if it is genuinely open, give both the plu and blu figures (they differ ~10x) or ask which they mean." + ], + "returns": [ + "estimatedVuHours", + "estimationBasis", + "rangeBasis", + "rangeExplanation", + "fitsInQuota", + "remainingAfterEstimate" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "EstimateLoadTestRunCostData" } + } } - }, - "EstimateLoadTestRunCostData": { - "type": "object", - "properties": { - "estimatedVuHours": { - "type": "number" - }, - "estimationBasis": { - "type": "string" - }, - "rangeBasis": { - "type": "string" - }, - "rangeExplanation": { - "type": "string" - }, - "fitsInQuota": { - "type": "boolean" - }, - "remainingAfterEstimate": { - "type": "number" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "compareLoadTestRuns", + "method": "POST", + "path": "/api/v1/agent/loadTests/compare", + "mode": "read", + "entity": "report", + "body": [ + { + "name": "baselineRunId", + "type": "string", + "required": true, + "description": "Run UUID to compare against.", + "format": "uuid", + "example": "6f1d2c9a-7b3e-4a10-9c22-0e5f8a1b2c3d" + }, + { + "name": "candidateRunId", + "type": "string", + "required": true, + "description": "Run UUID being evaluated; must differ from baselineRunId.", + "format": "uuid", + "example": "b2e4f6a8-1c3d-4e5f-8a90-2b4c6d8e0f11" + }, + { + "name": "dimensions", + "type": "array", + "description": "Which delta dimensions to compute (any of kpi, transaction, sla).", + "values": [ + "kpi", + "transaction", + "sla" + ] + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. latency.p95, @vitals). Filters kpiDeltas; the first entry with a per-transaction value also picks the transaction metric. Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "groupBy", + "type": "string", + "description": "Axis for transaction deltas (default transaction = request name).", + "values": [ + "transaction", + "label", + "threadGroup", + "scenario" + ] + }, + { + "name": "topN", + "type": "integer", + "description": "Top-N deltas to include (1-100)." + }, + { + "name": "regressedOnly", + "type": "boolean", + "description": "Return only regressed transaction rows." + }, + { + "name": "pctChangeMin", + "type": "number", + "description": "Minimum absolute percent change for a KPI or transaction row to be included." + } + ], + "intent": "Compare two executions (runs) of the SAME load test and surface the deltas \u2014 the regression check ('did the latest run regress vs the previous one?'). Not for comparing different tests: runs of different tests are rejected (DIFFERENT_TESTS). For a trend across more than two runs use getLoadTestHistoricalTrends.", + "guidance": [ + "Pick both runs from listLoadTestRuns for ONE testId, filtered to finished runs (status=terminal). The older run is the baseline, the newer the candidate.", + "'Compare the latest run' means the latest finished run (candidate) vs the finished run before it (baseline).", + "Both runs must be finished and must share the same test types \u2014 otherwise the call fails with VALIDATION_ERROR. Runs of different tests fail with DIFFERENT_TESTS; do not retry with other tests' runs.", + "Use this to compare two runs \u2014 it returns per-KPI, per-transaction and SLA-verdict deltas from the same data as the Compare Runs page. Do not fetch both run reports and diff them yourself.", + "pctChange is signed (candidate vs baseline); direction/regressed already account for whether higher or lower is better \u2014 report those, do not re-derive them. direction \"unknown\" (regressed null) means one or both runs have no value for that metric: report it as missing data, never as no change.", + "Transaction deltas are computed on one metric: the first metrics entry that has a per-transaction value (latency.*, errors.rate, throughput.*), else latency.p95. groupBy picks the transaction axis.", + "regressedOnly + pctChangeMin filter to material regressions; regressedOnly applies to transactionDeltas, pctChangeMin to kpiDeltas and transactionDeltas.", + "Check warnings before reporting: they name metrics with no data on a run, runs whose SLA thresholds were not evaluated, SLA definitions that changed between the runs, and requested metrics compare cannot serve (e.g. engine.*). Surface any warning to the user." + ], + "returns": [ + "baseline", + "candidate", + "kpiDeltas", + "transactionDeltas", + "slaVerdictChanges", + "warnings" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "CompareLoadTestRunsData" } + } } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "createLoadTest", + "method": "POST", + "path": "/api/v1/agent/loadTests", + "mode": "write", + "entity": "loadTest", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "maxLength": 255, + "description": "Test name. Max 255 characters \u2014 truncate before sending; a longer name is rejected with a 400.", + "example": "checkout-baseline" + }, + { + "name": "testType", + "type": "string", + "required": true, + "description": "plu = API load (server-side, non-browser); blu = real-browser load; hybrid = BOTH in one test (one PLU leg + one BLU leg, supplied via children). These tokens are INTERNAL wire values \u2014 send them here but never show them to the user; the user-facing labels are 'API' (plu), 'Browser' (blu) and 'Hybrid' (hybrid). All three are valid, user-selectable options \u2014 hybrid is not a rare/advanced variant, offer it alongside the other two.", + "values": [ + "plu", + "blu", + "hybrid" + ] + }, + { + "name": "framework", + "type": "string", + "required": true, + "description": "The framework MUST match testType (the backend rejects a mismatched pair). Offer ONLY the frameworks for the already-chosen testType \u2014 read them from valuesByTestType[testType] below \u2014 and NEVER present the full `values` list across types. API (plu): k6, jmeter, gatling, locust. Browser (blu): playwright, selenium, webdriverio, nightwatch, lcncnightwatch. For a hybrid test this top-level framework is the Browser leg; the API leg's framework goes in children[].", + "scopedBy": "testType", + "valuesByTestType": { + "plu": [ + "k6", + "jmeter", + "gatling", + "locust" + ], + "blu": [ + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ], + "hybrid": [ + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ] }, - "CompareLoadTestRunsData": { - "type": "object", - "properties": { - "baseline": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testId": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "candidate": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testId": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "kpiDeltas": { - "type": "array", - "description": "Per-KPI baseline→candidate deltas.", - "items": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "baseline": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "candidate": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "absChange": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "pctChange": { - "type": "number", - "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", - "nullable": true - }, - "direction": { - "type": "string", - "enum": [ - "improved", - "regressed", - "unchanged", - "unknown" - ], - "description": "\"unknown\" when either run has no value for the metric." - } - } - } - }, - "transactionDeltas": { - "type": "array", - "description": "Per-transaction deltas on one metric, sorted by |pctChange| desc.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "metric": { - "type": "string" - }, - "baseline": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "candidate": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "absChange": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "pctChange": { - "type": "number", - "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", - "nullable": true - }, - "regressed": { - "type": "boolean", - "description": "Null when either run lacks the transaction (unknown, not unregressed).", - "nullable": true - } - } - } - }, - "slaVerdictChanges": { - "type": "array", - "description": "SLA thresholds whose verdict changed between the runs.", - "items": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "type": { - "type": "string" - }, - "condition": { - "type": "string" - }, - "threshold": {}, - "unit": { - "type": "string" - }, - "request": {}, - "from": { - "type": "string", - "description": "Null when the threshold is absent on that run.", - "nullable": true - }, - "to": { - "type": "string", - "description": "Null when the threshold is absent on that run.", - "nullable": true - }, - "diffStatus": { - "type": "string", - "enum": [ - "now_failing", - "now_passing", - "added", - "dropped" - ] - } - } - } - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } + "values": [ + "k6", + "jmeter", + "gatling", + "locust", + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ] + }, + { + "name": "projectId", + "type": "integer", + "description": "Target project. Provide this OR projectName, not both." + }, + { + "name": "projectName", + "type": "string", + "description": "Target project by name; created if it does not exist. Provide this OR projectId." + }, + { + "name": "config", + "type": "object", + "description": "Test configuration incl. scriptRef {source: s3|s3_upload|existing_zip_id|sample, value|identifier}, loadProfile (constant|ramping|iterations|throughput), vus, vuRamp, durationSec, iterations, targetRps, maxVus, loadGeneratorLocations, slaThresholds, envVarKeys, tags. See the load-profile guidance for which fields each loadProfile needs (iterations + throughput are PLU-only). To supply a script from a local file or URL, use the pendingScriptUpload flow, then source='s3'. To create a test from the platform's built-in sample (no upload), use scriptRef:{source:'sample'} \u2014 for Selenium also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber)." + }, + { + "name": "children", + "type": "array", + "description": "Child scenarios for a hybrid test. A hybrid is exactly one PLU (API) leg + one BLU (browser) leg \u2014 each leg carries its own framework + scriptRef (a sample or an uploaded script). Send the BLU leg as the top-level testType='blu'/framework/config and put the PLU leg in children[] (one entry, testType='plu' with its framework + config.scriptRef). The backend requires exactly one PLU and one BLU leg and rejects any other composition." + }, + { + "name": "idempotencyKey", + "type": "string", + "description": "Dedupe key so a retried create does not duplicate the test." + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + }, + { + "name": "pendingScriptUpload", + "type": "object", + "description": "Request a presigned upload URL instead of creating; returns uploadUrl + s3Key to PUT the script to, then call create again with config.scriptRef.source='s3_upload'.", + "fields": [ + { + "name": "framework", + "type": "string" + }, + { + "name": "expectedContentType", + "type": "string" + } + ] + } + ], + "intent": "Create a new load test in a project \u2014 use this to define a test before running it. JSON body only (no multipart).", + "guidance": [ + "PRECONDITION \u2014 do NOT call this endpoint until THREE things are known: (1) testType (plu/blu/hybrid), (2) framework, (3) the script (a built-in sample or the user's own upload). These are MANDATORY and NOT defaultable \u2014 never infer or default them. If the user's request does not state one of the three, ASK the user for the missing ones and WAIT for their answer before creating. 'create a test' with no type/framework/script named means all three are missing \u2192 ask all three; do not silently pick k6/plu/sample. Only vus, duration, region and load profile are defaultable (see the DEFAULTS line) \u2014 the three above are not.", + "'plu' / 'blu' / 'hybrid' are INTERNAL wire values \u2014 send them in the request, but NEVER show them to the user. Do not print them in questions, option labels, confirmations, summaries, or prose (no 'Browser (BLU)', no '(plu+blu)'). User-facing labels are: plu \u2192 'API', blu \u2192 'Browser', hybrid \u2192 'Hybrid (both API and Browser)'. Frameworks (k6, jmeter, selenium, playwright, \u2026) are real product names and ARE shown to the user.", + "When you ASK the user to pick testType or framework, present the FULL, exact option set \u2014 never drop, merge, or cross-contaminate options. testType: offer all THREE, using the user-facing labels above \u2014 'API', 'Browser', and 'Hybrid'; do not omit Hybrid. framework (ask AFTER testType is chosen, and show only that type's frameworks \u2014 the authoritative per-type list is the `framework.valuesByTestType[testType]` array; present exactly those, each as its OWN separate option, and NEVER the full cross-type `values` list): for an API test offer exactly k6, jmeter, gatling AND locust (all four \u2014 do not drop locust); for a Browser test offer exactly playwright, selenium, webdriverio AND nightwatch (all four \u2014 never list the API frameworks under a Browser test, and never merge selenium and playwright into one option). For a Hybrid test, collect an API leg (its framework + script) AND a Browser leg (its framework + script) and assemble them per the children field \u2014 Hybrid needs both legs, so it takes more than the three questions a single-type test does.", + "Provide exactly one of projectId or projectName; projectName creates the project if absent.", + "Script upload is two-phase: send pendingScriptUpload to get a presigned uploadUrl + s3Key, PUT the file, then call create again with the scriptRef source and s3Key returned in the response's nextStep.", + "To spin up a test from a built-in sample with NO file handling (the dashboard's 'use sample script'), pass scriptRef:{source:'sample'} \u2014 the platform's bundled sample for the given framework is used. For Selenium, also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber) to pick the binding. testType and framework are still needed; the load profile defaults to constant 1 VU / 30s (see below).", + "Pass idempotencyKey so a retried create does not duplicate the test.", + "Only THREE things must be known before creating, and each is taken from the user's request when they've stated it \u2014 ask ONLY for the ones still missing, and ask about nothing else: (1) testType \u2014 plu (API load), blu (real-browser load) or hybrid (both); (2) framework \u2014 k6 / jmeter / gatling / locust for plu, playwright / selenium / webdriverio / nightwatch for blu; (3) the script \u2014 a built-in sample (scriptRef:{source:'sample'}; for Selenium also pass scriptRef.subType) OR the user's own script via the pendingScriptUpload flow. Do not guess a framework/testType, but do not re-ask for one the user already gave.", + "There is no clone capability. To duplicate a test, getLoadTest the source and copy its full config into this create \u2014 vuRamp/vus, durationSec, loadGeneratorLocations and slaThresholds included; nothing is inherited from the source, so anything you omit is dropped.", + "DEFAULTS apply ONLY after the three mandatory fields (testType, framework, script) are known \u2014 they never substitute for a missing one of those. Everything BEYOND those three \u2014 do not ask about it; apply the default unless the user explicitly specifies otherwise: loadProfile='constant', config.vus=1, config.durationSec=30, region us-east-1 (omit loadGeneratorLocations so the backend defaults it), and NO advanced features (do not enable log capture, response capture, artifacts, thresholds, etc.). Send config.vus=1 + config.durationSec=30 with no vuRamp for the default constant profile.", + "Only when the user explicitly asks for a different load shape, set config.loadProfile: 'ramping' (config.vuRamp: array of {vus, durationSec} stages; ramp up/down is inferred from adjacent VU deltas), 'iterations' (PLU only \u2014 config.iterations = fixed run count, plus config.vus), or 'throughput' (PLU and only JMeter/Gatling/locust/k6 \u2014 config.targetRps + config.maxVus + config.durationSec). A stage's own 'type' is NOT honored \u2014 every stage persists as a hold.", + "testType='hybrid' REQUIRES children[] with the PLU leg (top-level is the BLU leg). NEVER call create with testType='hybrid' and no children[] \u2014 it is rejected 'hybrid testType requires children[]'. If you don't yet have both legs' framework+script, ASK for them first; do not submit a hybrid without children to 'see what happens'.", + "On a 400, FIX the payload per the error message and only then resend \u2014 NEVER resubmit an unchanged payload (add children[] for hybrid, shorten name to <=255, supply testType+framework). Do not call getLoadTest after a FAILED create; there is nothing to fetch. Repeatedly retrying the same rejected body is the main source of wasted create calls.", + "Always send idempotencyKey, and reuse the SAME key across retries of one logical create, so a late success can't create a duplicate test." + ], + "returns": [ + "testId", + "name", + "projectId", + "version", + "dashboardLink" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "CreateLoadTestData" } + } } - }, - "CreateLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." - }, - "dashboardLink": { - "type": "string" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "startLoadTestRun", + "method": "POST", + "path": "/api/v1/agent/loadTests/{testId}/runs", + "mode": "write", + "entity": "run", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "overrides", + "type": "object", + "description": "Per-run overrides.", + "fields": [ + { + "name": "vus", + "type": "integer" + }, + { + "name": "durationSec", + "type": "integer" + }, + { + "name": "loadGeneratorLocations", + "type": "array" + }, + { + "name": "envOverrides", + "type": "object" + } + ] + }, + { + "name": "dryRun", + "type": "boolean", + "description": "Return the VU-hour estimate and quota fit without starting a run." + }, + { + "name": "idempotencyKey", + "type": "string", + "description": "Dedupe key so a retried start does not launch a duplicate run." + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + } + ], + "intent": "Start a run of an existing load test \u2014 use this to execute a test, optionally overriding VUs, duration or locations.", + "guidance": [ + "testId is the numeric test id, not a run id.", + "dryRun:true returns the VU-hour estimate and quota fit without starting anything.", + "On success returns runId (UUID) + dashboardLink; poll getLoadTestRunStatus with the runId.", + "A run without dryRun generates real load and consumes VU-hours. Unless the user has already asked to run it now, dryRun:true first, show the estimate/quota fit and the parameters that will be used (VUs, duration, target), and start the real run only after the user confirms \u2014 do not treat your own confirmation as the user's." + ], + "returns": [ + "runId", + "status", + "startedAt", + "dashboardLink", + "estimatedVuHoursRange" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "StartLoadTestRunData" } + } } - }, - "StartLoadTestRunData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "dashboardLink": { - "type": "string" - }, - "estimatedVuHoursRange": { - "description": "Low/high VU-hour estimate for the started run." + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "listLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/runs", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "status", + "type": "string", + "values": [ + "active", + "terminal", + "all" + ], + "description": "Lifecycle filter (default all)." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "verdict", + "type": "string", + "description": "Filter by run verdict." + }, + { + "name": "startedBy", + "type": "integer", + "description": "Filter by the user who started the run." + }, + { + "name": "hadSlaBreach", + "type": "boolean", + "description": "Only runs that breached an SLA." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from the test when it has none of its own)." + } + ], + "intent": "List the execution history of a test \u2014 use this to find past runs of a specific test or to get a runId.", + "guidance": [ + "testId is numeric; runId values returned are UUIDs.", + "Each run carries a tags array \u2014 its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from the test. Filter with the tag query param.", + "Filter with status / verdict / date range; page with cursor + limit.", + "To find a run by date, pass sinceIso/untilIso to narrow server-side \u2014 do not list the full history and scan it yourself. To compare two dated runs, make one narrowed call per date rather than one broad listing.", + "If a date resolves to more than one run, ask the user which; do not assume the first or the latest.", + "One call returns this test's run history \u2014 reuse it and page with cursor when needed; do not re-list the same window." + ], + "returns": [ + "runs", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListLoadTestRunsData" } + } } - }, - "ListLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "status": { - "type": "string", - "nullable": true - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "durationSec": { - "type": "integer", - "nullable": true, - "description": "Wall-clock seconds; null while running or when timestamps are missing." - }, - "vus": { - "type": "integer", - "nullable": true - }, - "verdict": { - "type": "string", - "nullable": true - }, - "vuHours": { - "type": "number", - "nullable": true, - "description": "Billed VU-hours; null until billed." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." - } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "paginated": true, + "max_page_size": 100 + }, + { + "name": "getLoadTestHistoricalTrends", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/trends", + "mode": "read", + "entity": "trend", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "windowRuns", + "type": "integer", + "description": "How many recent runs to aggregate (1-100, default 10)." + }, + { + "name": "aggregation", + "type": "string", + "values": [ + "perRun" + ], + "description": "Aggregation granularity. Only perRun is supported today." + }, + { + "name": "verdictFilter", + "type": "string", + "values": [ + "passed", + "failed", + "all" + ], + "description": "Restrict to runs of this verdict." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Show how one test's metrics trend across its recent runs \u2014 use this for 'is this test getting slower over time?'", + "guidance": [ + "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", + "windowRuns bounds how many recent runs are aggregated.", + "Returns the whole windowed series across metrics in one call \u2014 request all needed metrics together; do not call once per metric or re-fetch the same window." + ], + "returns": [ + "metrics" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestHistoricalTrendsData" } + } } - }, - "GetLoadTestHistoricalTrendsData": { - "type": "object", - "properties": { - "metrics": { - "description": "Trend series payload; shape per getLoadTestMetricsManifest." + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestMetricsManifest", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/metricsManifest", + "mode": "read", + "entity": "metrics", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "intent": "Discover which metrics, @ aliases and groupBy options a test supports \u2014 call this before requesting a report, trend or comparison so metric names are valid.", + "guidance": [ + "Depends only on the test's type; cache the result client-side.", + "Use the returned names/aliases in the metrics parameter of report, trends and compare." + ], + "returns": [ + "testType", + "metrics", + "aliases" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestMetricsManifestData" } + } } - }, - "GetLoadTestMetricsManifestData": { - "type": "object", - "properties": { - "testType": { - "type": "string" - }, - "metrics": { - "type": "array" - }, - "aliases": { - "type": "object" + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "updateLoadTest", + "method": "PUT", + "path": "/api/v1/agent/loadTests/{testId}", + "mode": "write", + "entity": "loadTest", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "description": "New test name." + }, + { + "name": "config", + "type": "object", + "description": "Partial config update (tags, envVarKeys, slaThresholds, scriptRef, integrations)." + }, + { + "name": "ifVersion", + "type": "string", + "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).", + "format": "date-time" + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + }, + { + "name": "pendingScriptUpload", + "type": "object", + "description": "Two-phase script replacement, same flow as createLoadTest.", + "fields": [ + { + "name": "framework", + "type": "string" + }, + { + "name": "expectedContentType", + "type": "string" + } + ] + }, + { + "name": "runId", + "type": "string", + "description": "Tag a specific RUN instead of the test definition. When set, config.tags is applied to that run's own tags (the saved test is NOT changed); only config.tags may accompany runId. Omit it to edit the test." + } + ], + "intent": "Update an existing load test's name or configuration \u2014 use this to change tags, SLA thresholds, script or settings; also tags a specific run when runId is set.", + "guidance": [ + "Partial update: send only the fields to change.", + "Pass ifVersion for optimistic concurrency; a stale value returns 409 VERSION_CONFLICT.", + "Script replacement uses the same two-phase pendingScriptUpload flow as create.", + "config is a partial update, but each field it carries REPLACES that field wholesale \u2014 it does not merge. tags overwrites the entire tag set; it does not append. To add a tag to a test (or the same tag across several tests), getLoadTest each one first and send the union under config.tags.", + "To change the load profile, send config.loadProfile plus that profile's fields (see createLoadTest's load-profile guidance for the four shapes). Switching profiles clears the previous profile's fields \u2014 e.g. ramping\u2192throughput empties the stages and drops iterations. iterations and throughput are PLU-only.", + "To tag a specific RUN rather than the test, pass runId (a run UUID from listLoadTestRuns) together with config.tags \u2014 the tags apply to that one run and the saved test is untouched. A run inherits the test's tags until you set its own; setting run tags overrides (they also replace wholesale, so send the union to add). Omit runId to tag the test itself.", + "Tags live on the load test, not on its runs \u2014 there is no per-run tagging, so do not touch runs when asked to tag a test. To tag every test in a project, list them with listLoadTests and page through with cursor until hasMore is false, then updateLoadTest each one \u2014 do not stop after the first page or a subset.", + "Optimistic concurrency is opt-in: pass ifVersion (the version token from a prior get/create/update) so the edit fails with 409 CONFLICT if the test changed meanwhile; OMITTING ifVersion is a last-write-wins update that overwrites any concurrent change." + ], + "returns": [ + "testId", + "version" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "UpdateLoadTestData" } + } } - }, - "UpdateLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "409": { + "$response": "Conflict" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTest", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}", + "mode": "read", + "entity": "loadTest", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "include", + "type": "string", + "description": "Comma-separated sub-resources to expand \u2014 any combination of: config, thresholds, tags, children (e.g. \"config,thresholds\"). Ask for everything you need in ONE call (include=config,thresholds,tags) rather than one request per sub-resource." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + } + ], + "intent": "Get the full configuration of a single load test \u2014 use this to inspect a test's settings, script reference, SLA thresholds and children.", + "guidance": [ + "testId is numeric (from listLoadTests).", + "Use include to expand config / thresholds / tags / children.", + "A single call returns the complete configuration \u2014 VU/ramp profile, duration, load-generator regions and SLA thresholds (expand them with include=config,thresholds). Do not call again for the same testId within a task; reuse the result." + ], + "returns": [ + "testId", + "name", + "testType", + "framework", + "version", + "projectId", + "ownerUserId", + "config" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestData" } + } } - }, - "GetLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "testType": { - "type": "string" - }, - "framework": { - "type": "string" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + } + ], + "schemas": { + "SuccessEnvelope": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "description": "Operation payload; its fields are listed in each capability's `returns`." + } + }, + "required": [ + "success", + "data" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "errorCode": { + "type": "string", + "example": "NOT_FOUND" + }, + "error": { + "type": "string", + "example": "The requested resource was not found." + } + }, + "required": [ + "success", + "error" + ] + }, + "GetLoadTestPlatformStatusData": { + "type": "object", + "properties": { + "platformState": { + "type": "string" + }, + "components": { + "type": "array" + }, + "checkedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ListLoadTestProjectsData": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "testCount": { + "type": "integer", + "nullable": true + }, + "lastActivityAt": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "ListLoadTestsData": { + "type": "object", + "properties": { + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true + }, + "testType": { + "type": "string", + "description": "plu (API), blu (Browser) or hybrid." + }, + "framework": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "owner": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "lastRun": { + "type": "object", + "description": "Most recent execution, when present.", + "properties": { + "runId": { + "type": "string", + "nullable": true }, - "projectId": { - "type": "integer" + "status": { + "type": "string", + "nullable": true }, - "ownerUserId": { - "type": "integer" + "startedAt": { + "type": "string", + "nullable": true }, - "config": { - "type": "object" + "verdict": { + "type": "string", + "nullable": true } + } } + } } - }, - "responses": { - "BadRequest": { - "description": "Invalid parameters or body.", - "schema": { - "$schema": "ErrorResponse" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "SearchLoadTestsData": { + "type": "object", + "properties": { + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "testType": { + "type": "string" + }, + "framework": { + "type": "string", + "nullable": true } - }, - "Unauthorized": { - "description": "Missing or invalid credentials.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "hasMore": { + "type": "boolean" + } + } + }, + "GetLoadTestProjectTrendsData": { + "type": "object", + "properties": { + "metrics": { + "description": "Trend series payload; shape per getLoadTestMetricsManifest." + } + } + }, + "ListProjectLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "durationSec": { + "type": "integer", + "nullable": true, + "description": "Wall-clock seconds; null while running or when timestamps are missing." + }, + "vus": { + "type": "integer", + "nullable": true + }, + "verdict": { + "type": "string", + "nullable": true + }, + "vuHours": { + "type": "number", + "nullable": true, + "description": "Billed VU-hours; null until billed." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." } - }, - "NotFound": { - "description": "The referenced project, test or run was not found or not visible to the caller.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "ListActiveLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "testId": { + "type": "integer", + "nullable": true + }, + "testName": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "startedBy": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "elapsedSec": { + "type": "integer", + "nullable": true + }, + "vus": { + "type": "integer", + "nullable": true + }, + "vuHoursBurnedSoFar": { + "type": "number" + }, + "status": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." } - }, - "Conflict": { - "description": "Optimistic-concurrency version conflict (ifVersion mismatch).", - "schema": { - "$schema": "ErrorResponse" + } + } + } + } + }, + "GetLoadTestRunStatusData": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "elapsedSec": { + "type": "number" + }, + "remainingSec": { + "type": "number" + }, + "pollAfterSeconds": { + "type": "number" + }, + "slaBreachFlags": { + "type": "array" + } + } + }, + "GetLoadTestInsightSummaryData": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "report": { + "type": "object" + } + } + }, + "GetLoadTestRunReportData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "testName": { + "type": "string" + }, + "durationSec": { + "type": "number", + "description": "Run wall-clock duration in whole seconds. Present to the user as minutes + seconds (e.g. 5m 30s)." + }, + "kpis": { + "type": "object" + }, + "slaVerdicts": { + "type": "array" + }, + "transactions": { + "type": "array" + }, + "errorsByCategory": { + "type": "object" + }, + "meta": { + "type": "object" + } + } + }, + "StopLoadTestRunData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "stoppedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GetLoadTestQuotaData": { + "type": "object", + "properties": { + "plan": { + "type": "object" + }, + "vuHours": { + "type": "object" + }, + "concurrency": { + "type": "object" + } + } + }, + "EstimateLoadTestRunCostData": { + "type": "object", + "properties": { + "estimatedVuHours": { + "type": "number" + }, + "estimationBasis": { + "type": "string" + }, + "rangeBasis": { + "type": "string" + }, + "rangeExplanation": { + "type": "string" + }, + "fitsInQuota": { + "type": "boolean" + }, + "remainingAfterEstimate": { + "type": "number" + } + } + }, + "CompareLoadTestRunsData": { + "type": "object", + "properties": { + "baseline": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testId": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "candidate": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testId": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "kpiDeltas": { + "type": "array", + "description": "Per-KPI baseline\u2192candidate deltas.", + "items": { + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "baseline": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "candidate": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "absChange": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "pctChange": { + "type": "number", + "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", + "nullable": true + }, + "direction": { + "type": "string", + "enum": [ + "improved", + "regressed", + "unchanged", + "unknown" + ], + "description": "\"unknown\" when either run has no value for the metric." } - }, - "ServiceUnavailable": { - "description": "A dependency (e.g. the AI insight backend) is temporarily unavailable.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "transactionDeltas": { + "type": "array", + "description": "Per-transaction deltas on one metric, sorted by |pctChange| desc.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "metric": { + "type": "string" + }, + "baseline": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "candidate": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "absChange": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "pctChange": { + "type": "number", + "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", + "nullable": true + }, + "regressed": { + "type": "boolean", + "description": "Null when either run lacks the transaction (unknown, not unregressed).", + "nullable": true } - }, - "InternalServerError": { - "description": "Unexpected server error.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "slaVerdictChanges": { + "type": "array", + "description": "SLA thresholds whose verdict changed between the runs.", + "items": { + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "type": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "threshold": {}, + "unit": { + "type": "string" + }, + "request": {}, + "from": { + "type": "string", + "description": "Null when the threshold is absent on that run.", + "nullable": true + }, + "to": { + "type": "string", + "description": "Null when the threshold is absent on that run.", + "nullable": true + }, + "diffStatus": { + "type": "string", + "enum": [ + "now_failing", + "now_passing", + "added", + "dropped" + ] } + } } - }, - "paging": { - "GET /api/v1/agent/projects": { - "page": "cursor", - "size": "limit" - }, - "GET /api/v1/agent/projects/{projectId}/loadTests": { - "page": "cursor", - "size": "limit" - }, - "GET /api/v1/agent/loadTests/{testId}/runs": { - "page": "cursor", - "size": "limit" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" } - }, - "entities": { - "loadTest": { - "entity": "loadTest", - "title": "Load Test", - "description": "A saved load-test configuration (framework, script, load profile and thresholds); addressed by a numeric testId. Runs are executions of it.", - "aliases": [ - "test", - "load test", - "test configuration", - "test config", - "scenario", - "k6 test", - "jmeter test", - "gatling test" - ], - "id_convention": "testId is a numeric JobConfiguration id (integer). It is NOT the run id. Always discover it via listLoadTests for a project — never assume a value. A test also carries a version — an opaque ISO-8601 token that advances on each edit; pass it back verbatim as ifVersion for optimistic concurrency.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "belongs to" - }, - { - "entity": "run", - "via": "is executed as" - }, - { - "entity": "metrics", - "via": "declares" - } - ], - "capabilities": [ - "listLoadTests", - "searchLoadTests", - "getLoadTest", - "createLoadTest", - "updateLoadTest" - ] - }, - "run": { - "entity": "run", - "title": "Run", - "description": "A single execution of a load test; addressed by runId (a UUID, also called jobId). Carries status, metrics and a report.", - "aliases": [ - "execution", - "job", - "test run", - "load test run", - "run", - "job execution" - ], - "id_convention": "A run is addressed by runId (also called jobId), a UUID hashedId string — NOT the numeric testId. Discover run ids via listLoadTestRuns or listActiveLoadTestRuns; never assume or fabricate one.", - "parents": [ - "loadTest" - ], - "relations": [ - { - "entity": "loadTest", - "via": "executes" - }, - { - "entity": "report", - "via": "produces" - }, - { - "entity": "insight", - "via": "produces" - } - ], - "capabilities": [ - "startLoadTestRun", - "stopLoadTestRun", - "getLoadTestRunStatus", - "listLoadTestRuns", - "listActiveLoadTestRuns", - "listProjectLoadTestRuns" - ] - }, - "project": { - "entity": "project", - "title": "Project", - "description": "A container that groups related load tests under a group/account; addressed by a numeric projectId.", - "aliases": [ - "project", - "workspace", - "folder", - "test group" - ], - "id_convention": "projectId is a numeric Project id (integer). Discover it via listLoadTestProjects; a project can also be created implicitly by createLoadTest via projectName.", - "parents": [], - "relations": [ - { - "entity": "loadTest", - "via": "contains" - }, - { - "entity": "trend", - "via": "aggregates" - } - ], - "capabilities": [ - "listLoadTestProjects" - ] - }, - "report": { - "entity": "report", - "title": "Run Report", - "description": "The results of a completed run (KPIs, SLA verdicts, per-transaction rows, error breakdown); addressed by the run's runId.", - "aliases": [ - "report", - "result", - "results", - "metrics report", - "run report", - "summary", - "kpis" - ], - "id_convention": "A report is not independently identified — it is addressed by the run's runId (jobId). Fetch it only after a run reaches a terminal state.", - "parents": [ - "run" - ], - "relations": [ - { - "entity": "run", - "via": "belongs to" - } - ], - "capabilities": [ - "getLoadTestRunReport", - "compareLoadTestRuns" - ] - }, - "insight": { - "entity": "insight", - "title": "AI Insight", - "description": "AI-generated analysis of a completed run (root cause and recommendations); addressed by the run's runId, and may be pending until generated.", - "aliases": [ - "insight", - "ai insight", - "root cause", - "rca", - "analysis", - "recommendation", - "summary" - ], - "id_convention": "Addressed by the run's runId (jobId). Insights are AI-generated after a run completes; a non-terminal run returns an in_progress state rather than a report.", - "parents": [ - "run" - ], - "relations": [ - { - "entity": "run", - "via": "belongs to" - } - ], - "capabilities": [ - "getLoadTestInsightSummary" - ] - }, - "trend": { - "entity": "trend", - "title": "Historical Trend", - "description": "Historical time-series of a metric across a test's or project's runs; scoped by testId or projectId, not independently identified.", - "aliases": [ - "trend", - "trends", - "history", - "time series", - "over time", - "historical trends", - "regression trend" - ], - "id_convention": "Not independently identified — scoped by either a testId (per-test trend) or a projectId (project rollup). Resolve those ids first.", - "parents": [ - "loadTest", - "project" - ], - "relations": [ - { - "entity": "loadTest", - "via": "aggregates" - }, - { - "entity": "project", - "via": "aggregates" - } - ], - "capabilities": [ - "getLoadTestHistoricalTrends", - "getLoadTestProjectTrends" - ] - }, - "quota": { - "entity": "quota", - "title": "Quota & Cost", - "description": "The caller's VU-hour entitlement, usage and concurrency limits; account/group-scoped, with no id.", - "aliases": [ - "quota", - "vu hours", - "vuh", - "vu-hours", - "credits", - "entitlement", - "plan limits", - "concurrency", - "cost", - "estimate", - "budget" - ], - "id_convention": "Account/group-scoped — there is no id. Resolved from the authenticated caller's credentials; never pass a tenant or group id as a parameter.", - "parents": [], - "relations": [ - { - "entity": "run", - "via": "is consumed by" - } - ], - "capabilities": [ - "getLoadTestQuota", - "estimateLoadTestRunCost" - ] - }, - "metrics": { - "entity": "metrics", - "title": "Metrics Manifest", - "description": "The per-testType catalogue of available metric names, @-aliases and groupBy dimensions; a manifest, not an instance.", - "aliases": [ - "metric", - "metrics", - "kpi", - "measurement", - "manifest", - "available metrics", - "metric names", - "aliases" - ], - "id_convention": "Not an instance — a per-testType catalogue of the metric names, @ aliases and groupBy options a test supports. Scoped by testId; fetch once and cache client-side.", - "parents": [ - "loadTest" - ], - "relations": [ - { - "entity": "loadTest", - "via": "describes" - } - ], - "capabilities": [ - "getLoadTestMetricsManifest" - ] - }, - "platform": { - "entity": "platform", - "title": "Platform Status", - "description": "Health status of the Load Testing service and its dependencies; a singleton probe with no id.", - "aliases": [ - "platform", - "status", - "health", - "service status", - "availability", - "uptime" - ], - "id_convention": "Singleton — no id. A shallow health probe of the Load Testing service dependencies.", - "parents": [], - "relations": [], - "capabilities": [ - "getLoadTestPlatformStatus" - ] + } + } + }, + "CreateLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + }, + "dashboardLink": { + "type": "string" + } + } + }, + "StartLoadTestRunData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "dashboardLink": { + "type": "string" + }, + "estimatedVuHoursRange": { + "description": "Low/high VU-hour estimate for the started run." + } + } + }, + "ListLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "durationSec": { + "type": "integer", + "nullable": true, + "description": "Wall-clock seconds; null while running or when timestamps are missing." + }, + "vus": { + "type": "integer", + "nullable": true + }, + "verdict": { + "type": "string", + "nullable": true + }, + "vuHours": { + "type": "number", + "nullable": true, + "description": "Billed VU-hours; null until billed." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." + } + } } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "GetLoadTestHistoricalTrendsData": { + "type": "object", + "properties": { + "metrics": { + "description": "Trend series payload; shape per getLoadTestMetricsManifest." + } + } + }, + "GetLoadTestMetricsManifestData": { + "type": "object", + "properties": { + "testType": { + "type": "string" + }, + "metrics": { + "type": "array" + }, + "aliases": { + "type": "object" + } + } + }, + "UpdateLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + } + } + }, + "GetLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "testType": { + "type": "string" + }, + "framework": { + "type": "string" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + }, + "projectId": { + "type": "integer" + }, + "ownerUserId": { + "type": "integer" + }, + "config": { + "type": "object" + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid parameters or body.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Unauthorized": { + "description": "Missing or invalid credentials.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "NotFound": { + "description": "The referenced project, test or run was not found or not visible to the caller.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Conflict": { + "description": "Optimistic-concurrency version conflict (ifVersion mismatch).", + "schema": { + "$schema": "ErrorResponse" + } + }, + "ServiceUnavailable": { + "description": "A dependency (e.g. the AI insight backend) is temporarily unavailable.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "InternalServerError": { + "description": "Unexpected server error.", + "schema": { + "$schema": "ErrorResponse" } + } + }, + "paging": { + "GET /api/v1/agent/projects": { + "page": "cursor", + "size": "limit" + }, + "GET /api/v1/agent/projects/{projectId}/loadTests": { + "page": "cursor", + "size": "limit" + }, + "GET /api/v1/agent/loadTests/{testId}/runs": { + "page": "cursor", + "size": "limit" + } + }, + "entities": { + "loadTest": { + "entity": "loadTest", + "title": "Load Test", + "description": "A saved load-test configuration (framework, script, load profile and thresholds); addressed by a numeric testId. Runs are executions of it.", + "aliases": [ + "test", + "load test", + "test configuration", + "test config", + "scenario", + "k6 test", + "jmeter test", + "gatling test" + ], + "id_convention": "testId is a numeric JobConfiguration id (integer). It is NOT the run id. Always discover it via listLoadTests for a project \u2014 never assume a value. A test also carries a version \u2014 an opaque ISO-8601 token that advances on each edit; pass it back verbatim as ifVersion for optimistic concurrency.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "belongs to" + }, + { + "entity": "run", + "via": "is executed as" + }, + { + "entity": "metrics", + "via": "declares" + } + ], + "capabilities": [ + "listLoadTests", + "searchLoadTests", + "getLoadTest", + "createLoadTest", + "updateLoadTest" + ] + }, + "run": { + "entity": "run", + "title": "Run", + "description": "A single execution of a load test; addressed by runId (a UUID, also called jobId). Carries status, metrics and a report.", + "aliases": [ + "execution", + "job", + "test run", + "load test run", + "run", + "job execution" + ], + "id_convention": "A run is addressed by runId (also called jobId), a UUID hashedId string \u2014 NOT the numeric testId. Discover run ids via listLoadTestRuns or listActiveLoadTestRuns; never assume or fabricate one.", + "parents": [ + "loadTest" + ], + "relations": [ + { + "entity": "loadTest", + "via": "executes" + }, + { + "entity": "report", + "via": "produces" + }, + { + "entity": "insight", + "via": "produces" + } + ], + "capabilities": [ + "startLoadTestRun", + "stopLoadTestRun", + "getLoadTestRunStatus", + "listLoadTestRuns", + "listActiveLoadTestRuns", + "listProjectLoadTestRuns" + ] + }, + "project": { + "entity": "project", + "title": "Project", + "description": "A container that groups related load tests under a group/account; addressed by a numeric projectId.", + "aliases": [ + "project", + "workspace", + "folder", + "test group" + ], + "id_convention": "projectId is a numeric Project id (integer). Discover it via listLoadTestProjects; a project can also be created implicitly by createLoadTest via projectName.", + "parents": [], + "relations": [ + { + "entity": "loadTest", + "via": "contains" + }, + { + "entity": "trend", + "via": "aggregates" + } + ], + "capabilities": [ + "listLoadTestProjects" + ] + }, + "report": { + "entity": "report", + "title": "Run Report", + "description": "The results of a completed run (KPIs, SLA verdicts, per-transaction rows, error breakdown); addressed by the run's runId.", + "aliases": [ + "report", + "result", + "results", + "metrics report", + "run report", + "summary", + "kpis" + ], + "id_convention": "A report is not independently identified \u2014 it is addressed by the run's runId (jobId). Fetch it only after a run reaches a terminal state.", + "parents": [ + "run" + ], + "relations": [ + { + "entity": "run", + "via": "belongs to" + } + ], + "capabilities": [ + "getLoadTestRunReport", + "compareLoadTestRuns" + ] + }, + "insight": { + "entity": "insight", + "title": "AI Insight", + "description": "AI-generated analysis of a completed run (root cause and recommendations); addressed by the run's runId, and may be pending until generated.", + "aliases": [ + "insight", + "ai insight", + "root cause", + "rca", + "analysis", + "recommendation", + "summary" + ], + "id_convention": "Addressed by the run's runId (jobId). Insights are AI-generated after a run completes; a non-terminal run returns an in_progress state rather than a report.", + "parents": [ + "run" + ], + "relations": [ + { + "entity": "run", + "via": "belongs to" + } + ], + "capabilities": [ + "getLoadTestInsightSummary" + ] + }, + "trend": { + "entity": "trend", + "title": "Historical Trend", + "description": "Historical time-series of a metric across a test's or project's runs; scoped by testId or projectId, not independently identified.", + "aliases": [ + "trend", + "trends", + "history", + "time series", + "over time", + "historical trends", + "regression trend" + ], + "id_convention": "Not independently identified \u2014 scoped by either a testId (per-test trend) or a projectId (project rollup). Resolve those ids first.", + "parents": [ + "loadTest", + "project" + ], + "relations": [ + { + "entity": "loadTest", + "via": "aggregates" + }, + { + "entity": "project", + "via": "aggregates" + } + ], + "capabilities": [ + "getLoadTestHistoricalTrends", + "getLoadTestProjectTrends" + ] + }, + "quota": { + "entity": "quota", + "title": "Quota & Cost", + "description": "The caller's VU-hour entitlement, usage and concurrency limits; account/group-scoped, with no id.", + "aliases": [ + "quota", + "vu hours", + "vuh", + "vu-hours", + "credits", + "entitlement", + "plan limits", + "concurrency", + "cost", + "estimate", + "budget" + ], + "id_convention": "Account/group-scoped \u2014 there is no id. Resolved from the authenticated caller's credentials; never pass a tenant or group id as a parameter.", + "parents": [], + "relations": [ + { + "entity": "run", + "via": "is consumed by" + } + ], + "capabilities": [ + "getLoadTestQuota", + "estimateLoadTestRunCost" + ] + }, + "metrics": { + "entity": "metrics", + "title": "Metrics Manifest", + "description": "The per-testType catalogue of available metric names, @-aliases and groupBy dimensions; a manifest, not an instance.", + "aliases": [ + "metric", + "metrics", + "kpi", + "measurement", + "manifest", + "available metrics", + "metric names", + "aliases" + ], + "id_convention": "Not an instance \u2014 a per-testType catalogue of the metric names, @ aliases and groupBy options a test supports. Scoped by testId; fetch once and cache client-side.", + "parents": [ + "loadTest" + ], + "relations": [ + { + "entity": "loadTest", + "via": "describes" + } + ], + "capabilities": [ + "getLoadTestMetricsManifest" + ] + }, + "platform": { + "entity": "platform", + "title": "Platform Status", + "description": "Health status of the Load Testing service and its dependencies; a singleton probe with no id.", + "aliases": [ + "platform", + "status", + "health", + "service status", + "availability", + "uptime" + ], + "id_convention": "Singleton \u2014 no id. A shallow health probe of the Load Testing service dependencies.", + "parents": [], + "relations": [], + "capabilities": [ + "getLoadTestPlatformStatus" + ] + } } + } } diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index adc97827..f88bc332 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -3,6 +3,7 @@ "version": "1.0", "build_id": "6782f7e_2026-10-16T13:54:22Z", "mobiledevicecontrol": { + "title": "Mobile Device Control (App Automate)", "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; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", "routing_terms": [ "app automate", diff --git a/capability/tm.capability-index.json b/capability/tm.capability-index.json index 4f731103..9250f2a3 100644 --- a/capability/tm.capability-index.json +++ b/capability/tm.capability-index.json @@ -1,50420 +1,50426 @@ { - "schema_version": 1, - "version": "1.46", - "build_id": "cb63a4a5_2026-09-16T18:02:57Z", - "tm": { - "routing_terms": ["team", "setting", "workspace", "suite"], - "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. A run is executed against a named configuration and environment, and records the app version and build its results came from. Destructive capabilities exist and always need explicit human approval.", - "capabilities": [ - { - "name": "assign_test_run_owner", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "owner", - "type": "integer", - "description": "The ID of the user to assign as the owner of the test run.", - "json_path": "/test_run/owner" - } - ], - "intent": "Assign a owner to the test run", - "returns": [ - "id", - "identifier", - "name", - "active_state", - "assignee_imported", - "created_at", - "description", - "environment", - "is_automation", - "is_dynamic", - "observability_url", - "owner" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__AssignTestRunOwner" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `owner` flat. The `test_run` wrapper is added for you from the field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "The reply's `tags` can be [] even though the run's stored tags are untouched — the owner assign does not clear them. Re-read the run before reporting its tags." - ] - }, - { - "name": "assign_test_run_test_cases", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "assign_to", - "type": "array", - "required": true, - "description": "One entry per case (or execution) to reassign. Top level — NOT wrapped in a test_run object, unlike the update calls on this run.", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "assignee", - "type": "string" - }, - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "execution_id", - "type": "integer" - } - ] - } - ], - "intent": "Hand specific test cases inside a run to the people who should execute them (or clear their assignee)", - "guidance": [ - "Queued, not applied: the 200 {success: true} only means the job was accepted. Re-read the run's test cases to confirm the assignees actually changed.", - "`assignee` is an email (or TM user id) resolved inside the run's group. An address that does not resolve is skipped silently — that case keeps its old assignee and nothing is reported.", - "Send `assignee: null` on an entry to unassign that case.", - "Each entry must carry `test_case_id` (TC-NNN) or `execution_id`; an entry with neither is dropped. Add `configuration_id` to target one configuration row when the case is mapped to several — omit it and every row for that case is reassigned.", - "The body is not validated before the 200: a missing or empty `assign_to` still answers 200 {success: true} and changes nothing.", - "Assignment is reversible — assign again (or to null) to undo." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "description": "Assignment job queued. Body is {success: true}; the assignments are applied asynchronously.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "description": "The job could not be enqueued; nothing was assigned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - } - } - }, - { - "name": "bulk_archive_test_cases_by_project", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-archive", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all` — the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder to scope the selection to, at top level. Merged into the search scope and it OVERWRITES `q.folder_id` when both are present. Do not confuse it with the DESTINATION, which for move lives at `test_case.destination_folder_id` and for copy at top-level `destination_folder_id`." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of `folder_id`. Compared as the string `true`. The UI sets it for consolidated (non-folder) views." - }, - { - "name": "ids", - "type": "array", - "required": true, - "description": "Integer ids of the cases to archive / retrieve.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "required": true, - "description": "Ids to exclude when `select_all` is true.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "required": true, - "description": "Apply to every case in scope, minus `de_selected_ids`.", - "json_path": "/test_case/select_all" - } - ], - "intent": "Bulk Archive Test Cases", - "guidance": [ - "Archiving hides cases from the working set without deleting them; they can be retrieved again. Prefer it to deletion when the user says 'remove' or 'clean up' without being explicit." - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkArchiveResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "bulk_archive_test_plans", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/bulk-archive", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_plan_ids", - "type": "array", - "required": true, - "minItems": 1, - "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids — the first one present wins, so do not send two. Every entry is run through to_i and anything that yields 0 is silently dropped, so a TP-NNN identifier is discarded without an error." - } - ], - "intent": "Archive test plans in bulk — the reversible way to get plans out of the active listing; use this instead of delete whenever the user might want them back", - "returns": [ - "success", - "async", - "unique_id", - "plans", - "count" - ], - "responses": { - "200": { - "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` — the plans are not archived yet.\n", - "schema": { - "$schema": "V1TestPlanBulkResponse__BulkArchiveTestPlans" - } - }, - "400": { - "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Archiving is gated on a group feature flag. When the flag is off the call returns 404 with {success: false, errors: 'Feature not available'} — a 404 here means the feature is unavailable, NOT that the plans are missing. The same gate covers bulk-retrieve and both archived reads.", - "Permission is the test-plan DELETE right, not write. A user who can create and edit plans can still be 403'd here, and on restore and on the archived listing/count.", - "Ids are coerced with to_i: 'TP-12' and any non-numeric entry become 0 and are dropped silently. If every id drops the call is 400 {success: false, errors: 'Please provide test plan ids'} — the same error you get for an empty list.", - "More than 30 ids is handed to a background job and returns {success: true, async: true, unique_id} — NOTHING is archived yet at that moment. Re-read the plans (or the archived listing) to confirm; there is no job-status endpoint for this in the profile.", - "The 30 counts the ids YOU send. Sub-plan expansion happens upstream, so archiving a parent can touch far more plans than the count you get back.", - "Failures use the plural key `errors`, not `error` — {success: false, errors: '...'}. Branch on `success`, not on the key name.", - "Unlike the public/v2 archive route, this path is not JSON-schema validated, so the 1000-id cap that route enforces is not applied here. Batch large sets yourself.", - "To undo, call the bulk-retrieve capability with the same ids." - ] - }, - { - "name": "bulk_set_test_case_assignee_in_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/bulk_assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "When a folder_id is given, also include cases in its sub-folders when resolving a select_all." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the test-case listing returned AFTER the assignment. It does not affect which rows are assigned." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Page size of the listing returned after the assignment. Anything outside 1..100, or non-numeric, silently falls back to 30 rather than erroring." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort of the listing returned after the assignment." - } - ], - "body": [ - { - "name": "assignee_id", - "type": "integer", - "example": 2, - "description": "TM user id (not a BrowserStack user id, not an email) of the person to assign. The id must belong to the caller's group or the request 404s. Omit it entirely to clear the assignee on the selected rows — there is no separate 'unassign' call." - }, - { - "name": "mapping_ids", - "type": "array", - "example": [ - 999, - 991, - 1024, - 1019 - ], - "description": "TRTC MAPPING ids — the `mapping_id` field on the rows a test-run test-case listing returns — NOT test case ids. A case that runs against N configurations has N mapping ids, and passing the case's `id` here assigns nothing and still returns 200." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Assign every case the current filter resolves to, instead of an explicit mapping_ids list. The filter is `q` (plus folder_id / include_subfolders_tc); with no `q` this means the whole run." - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Mapping ids to exclude. Subtracted from mapping_ids, and from the resolved set when select_all is true." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Scope the selection — and the listing returned afterwards — to one folder of the run. Its presence also changes the response: the post-assign listing comes back from the folder view and carries an extra `folder` key." - }, - { - "name": "q", - "type": "object", - "description": "The same filter object the run's test-case listing takes. Only read when select_all is true; it is what select_all resolves against." - }, - { - "name": "automation", - "type": "boolean", - "default": false, - "description": "Automation-run path. When true the selection resolver is bypassed entirely: `test_run_ids` is taken as the id list and the operation is ALWAYS queued, whatever its size. Leave it off for manual runs." - }, - { - "name": "test_run_ids", - "type": "array", - "description": "Only read when automation is true; ignored otherwise." - } - ], - "intent": "Set the assignee on many test cases inside a test run in one call — pick the rows explicitly, or select every row matching the current filter and deselect a few", - "returns": [ - "success", - "info", - "test_cases", - "folder", - "async", - "unique_id", - "id", - "identifier", - "name", - "mapping_id", - "test_case_id", - "latest_status", - "result_status", - "test_assignee", - "last_executed_by", - "last_executed_at", - "defects", - "configuration", - "result_custom_fields", - "execution_eligible", - "owner", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "BulkAssignTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The ids here are mapping ids, not test case ids. Read the run's test cases first and take `mapping_id` off each row — `id` is the test case and assigning by it silently does nothing.", - "Above 300 selected rows the call switches to a queued write and returns {success:true, async:true, unique_id} with NO test_cases — the assignment happens later. Below that it applies inline and returns the re-listed page. Branch on `async` rather than assuming the list is there, and never retry on the async acknowledgement.", - "The selected mapping ids are locked for the duration. A concurrent bulk operation on any of the same rows comes back 400 {success:false, message:'TestCases in progress with another operation'} — that is contention, not bad input; wait and retry.", - "An empty resolution is refused, not silently accepted: 400 'No test cases to update.' when nothing is selected, and 422 'Could not resolve the selected test cases. Please retry.' when select_all counted rows it could not turn into ids.", - "assignee_id must be a TM user id inside the caller's group; an id from another group raises a 404 rather than being ignored. Omitting it clears the assignee.", - "The 200 body is the run's test-case listing re-read after the write, not a report of what changed — it is paged, so it can show fewer rows than you assigned. Re-read and check `test_assignee` if you need confirmation.", - "Needs the test-run write permission; on an automation run the automation variant of that permission is demanded instead and a role that only holds the manual one gets 403." - ], - "paginated": true, - "max_page_size": 100 - }, - { - "name": "bulk_copy_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-copy", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "description": "Copy every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default." - }, - { - "name": "folder_id", - "type": "string", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "destination_folder_id", - "type": "integer", - "required": true, - "description": "Target folder for the copies, at TOP level (bulk-move puts its destination inside `test_case` instead). Required in practice: the request is pre-checked by looking the folder up inside `destination_project_id`, and a folder that does not exist there is a bare 400 with no message. The only way to omit it is the `copy_folder` flow below, which mints the destination first." - }, - { - "name": "destination_project_id", - "type": "string", - "description": "Target project id. Only needed for a copy into ANOTHER project — it defaults to the source project when omitted, so a same-project copy can leave it out. Integer and string are both accepted. Ignored when `clone_in_place` is true, which always stays in the source project." - }, - { - "name": "clone_in_place", - "type": "boolean", - "description": "Duplicate each selected case into its OWN source folder (the product's Clone action) instead of into one shared destination. Never crosses projects. `destination_folder_id` is still pre-validated even though the copies do not land there, so it must still name a real folder." - }, - { - "name": "copy_folder", - "type": "boolean", - "description": "Compared as the string `true`. Creates the destination folder from `test_case_folder` before copying, and that new folder then wins over `destination_folder_id`. Leave it unset and pass an existing `destination_folder_id` for the normal case." - }, - { - "name": "test_case_folder", - "type": "object", - "description": "Only read when `copy_folder` is true: the destination folder to create. `test_case_folder_id` is the PARENT folder's integer id (0 with `root: true` creates a root folder); `prev`/`next` position it among its siblings.", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "notes", - "type": "string" - }, - { - "name": "test_case_folder_id", - "type": "integer" - }, - { - "name": "root", - "type": "boolean" - }, - { - "name": "prev", - "type": "integer" - }, - { - "name": "next", - "type": "integer" - } - ] - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Copy test cases into another folder or another project — by listing their ids, or by copying everything a filter matches — and clone cases in place.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "destination_path_folders", - "source_path_folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkCopyTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Selection is one of two modes and they do not mix: either `test_case.ids` (explicit integer ids), or `test_case.select_all: true`, which resolves the set from top-level `q` + `folder_id` and ignores `ids` entirely.", - "The destination is TOP-LEVEL `destination_folder_id` here. Bulk-move puts its destination inside `test_case` — copying the move body onto this route silently sends no destination and 400s.", - "The destination folder is looked up before anything is copied, inside `destination_project_id` (which defaults to the source project). A folder id that does not exist there comes back as a bare 400 with no message — that is 'no such folder', not 'bad request'.", - "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 30 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed SOURCE view (or the search results) plus folder counts — it does NOT contain the new copies or their ids. To get them, list the destination folder afterwards; `cases_count[]` only tells you the new total.", - "Copies are new test cases with new integer ids and new TC-NNN identifiers; the originals are untouched. There is no undo — the way back is to delete the copies." - ] - }, - { - "name": "bulk_delete_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-delete", - "mode": "destructive", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Delete every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Delete many test cases at once from the project-wide search/filter view — by id, or everything a filter matches.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "path_folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkDeleteTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Selection is either `test_case.ids` or `test_case.select_all: true` scoped by top-level `q` + `folder_id`. With select_all the `ids` you send are ignored, so a mis-typed filter key (which is dropped, not rejected) deletes the whole scope.", - "Whether this is recoverable depends on the workspace: with the move-to-bin feature enabled the cases go to the Bin and can be restored, otherwise the delete is permanent. Assume permanent unless you have confirmed the Bin is on for this group.", - "Deleting also drops the cases' results and unlinks them from schedules — those do not come back even when the case is restored from the Bin.", - "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed listing after the delete (`test_cases` + `info` + `cases_count`), not a report of what was deleted. The one exception: with `q.is_archived` set, the response is just `{success:true}`." - ], - "disabled": true - }, - { - "name": "bulk_delete_test_results", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-results/bulk-delete", - "mode": "destructive", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "result_ids", - "type": "array", - "required": true, - "description": "Result INTEGER ids. Non-empty, max 300.", - "minItems": 1, - "maxItems": 300 - } - ], - "intent": "Delete test results in bulk — PARTIAL SUCCESS is normal, always read `skipped`", - "returns": [ - "success", - "result", - "deleted_ids", - "skipped", - "id", - "reason" - ], - "responses": { - "200": { - "description": "Partial success is normal — `deleted_ids` is what actually went, `skipped` is what\ndid not (usually authorisation).\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "result": { - "type": "object", - "description": "Both lists are nested HERE, not at the top level. Reading\nresponse[\"deleted_ids\"] directly finds nothing and makes a successful\ndelete look like a no-op.\n", - "properties": { - "deleted_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "skipped": { - "type": "array", - "description": "Entries are OBJECTS, not bare ids — {\"id\": 13, \"reason\": \"not_found\"}.\nCommon reasons: not_found, or the caller is neither author nor project admin.\n", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "reason": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "400": { - "description": "`result_ids` missing or empty." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "description": "More than 300 ids in one request." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Partial success is the normal outcome and it still answers 200 with success:true. Both lists sit under a top-level `result` key: read result.deleted_ids and result.skipped. Never infer 'all deleted' from the status code — diff deleted_ids against the ids you sent and report the rest.", - "`skipped` entries are OBJECTS, not bare ids: {id, reason}. The only two reasons the handler produces are `not_found` and `not_authorised`.", - "Deletion is authorised per id: a project admin's whole batch is authorised wholesale, everyone else may only delete results they personally logged. A non-admin batch is resolved id by id against the recorded author.", - "The authorship lookup fails CLOSED. If that upstream call errors, you still get 200 / success:true with deleted_ids empty and EVERY id back in skipped with reason `not_found`. An all-not_found batch is as likely an upstream failure as genuinely missing ids — re-read the results before telling the user they do not exist.", - "`result_ids` is read from the top level of the body — it is not wrapped in an object. Ids are coerced with to_i and anything coercing to 0 (a non-numeric string, a null, 0 itself) is dropped silently; if that empties the list you get 400 'result_ids must be a non-empty array' even though you sent a non-empty array.", - "More than 300 ids is refused with 422 before anything is deleted — nothing partial happens, so just re-batch. Sending exactly the cap is fine.", - "There is no recycle bin for results. Deleting a case's latest result rolls that case's outcome in the run back to its previous attempt (or back to unexecuted if it was the only one); the deleted result itself is unrecoverable." - ], - "disabled": true - }, - { - "name": "bulk_replace_test_case_fields", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Edit every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "automation_status", - "type": "string", - "json_path": "/test_case/automation_status", - "description": "Automation state as an internal_name string (e.g. `not_automated`). Forwarded verbatim on this path — no name-to-id resolution happens. Prefer `automation_state` with its numeric option id." - }, - { - "name": "case_type", - "type": "integer", - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id — NOT the display name. Unlike single-case edit, this path always sends it as an id, so a name here is stored as a broken id. Resolve the id from the project's system field values first." - }, - { - "name": "custom_fields", - "type": "object", - "required": true, - "description": "Map of custom-field id to value. ALWAYS SEND THIS KEY — pass `{}` when changing no custom fields. Omitting it returns 500 (the server dereferences a nil hash), the same defect as on PATCH .../test-cases.", - "json_path": "/test_case/custom_fields" - }, - { - "name": "issues", - "type": "array", - "json_path": "/test_case/issues", - "description": "Linked issues, as `[{issue_id, issue_type}]` (bare strings are coerced to ``issue_id``). Read via a separate permit than the other fields, so it survives even though it is not in the edit allow-list. It is sent on EVERY bulk edit — as `[]` when you omit it — so treat it as a full replacement of the issue list, not an append." - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id (not a BrowserStack user id, not an email). An id that does not resolve to a user is not a validation error — it dereferences nil and returns 500.", - "json_path": "/test_case/owner" - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "json_path": "/test_case/priority", - "description": "Numeric priority option id — NOT the display name. Resolve it from the project's system field values first." - }, - { - "name": "status", - "type": "integer", - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "tags", - "type": "array", - "description": "REPLACES the entire tag list on every selected case. Read the existing tags and send the union if you mean to add.", - "json_path": "/test_case/tags" - }, - { - "name": "review_status", - "type": "string", - "json_path": "/test_case/review_status", - "description": "Review state applied to every selected case (e.g. `pending`, `in_review`, `approved`). Only meaningful on a Team-Pro workspace with review-approve enabled on the project." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_case/reviewers", - "description": "Reviewer TM user ids. Honoured only on a Team-Pro workspace with review-approve enabled on the project; otherwise dropped without any error." - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state", - "description": "Numeric automation-state option id. Takes precedence over `automation_status`." - }, - { - "name": "template", - "type": "string", - "json_path": "/test_case/template", - "description": "Step-shape template applied to every selected case. Validated against a closed set (`test_case_text`, `test_case_steps`, `bdd`, `test_case_bdd`) and DROPPED WITHOUT ERROR if the value is not one of them — a template id or a custom template name here is a silent no-op.", - "values": [ - "test_case_text", - "test_case_steps", - "bdd", - "test_case_bdd" - ] - }, - { - "name": "estimate", - "type": "string", - "json_path": "/test_case/estimate", - "description": "Forwarded to the backend on this path (unlike single-case edit, which drops it), but every read serialises `estimate` as null, so you cannot confirm it and should not report it as saved. There is no bulk equivalent of `estimated_duration_seconds`." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Free-text note carried with the bulk edit." - }, - { - "name": "folder_id", - "type": "integer", - "json_path": "/test_case/folder_id", - "description": "DANGER — inside `test_case` this MOVES every selected case into that folder. The top-level `folder_id` of the same name only scopes the selection. Omit this unless you mean to move them." - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Set the same field values — owner, priority, status, case type, tags, custom fields, review state — on many test cases at once from the project-wide search/filter view.", - "returns": [ - "success", - "test_cases", - "info", - "filter_details" - ], - "paginated": true, - "responses": { - "200": { - "description": "Applied synchronously: the refreshed search listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no test_cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkReplaceTestCaseFields" - } - }, - "filter_details": { - "type": "object", - "description": "Echo of the resolved filter; {} when no q was sent." - }, - "folders": { - "type": "object" - }, - "workflow": { - "type": "object" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "ALWAYS send `test_case.custom_fields`, `{}` when you are changing none. Omitting the key dereferences a nil hash and returns 500 — this is the single most common failure on this route.", - "priority, status, case_type and automation_state must be NUMERIC option ids here. The single-case edit accepts a display name for some of them; this path does not, and a name is written through as a broken id rather than rejected.", - "`tags` REPLACES the whole tag list on every selected case. Read the existing tags and send the union if you mean to add. The same is true of `issues`, which is sent as `[]` when you omit it.", - "`test_case.folder_id` moves the cases; the top-level `folder_id` only scopes the selection. Two different meanings for one name in one body.", - "The editable set is narrow and everything outside it is dropped without error: owner, priority, status, case_type, automation_state/status, preconditions, template, review_status, reviewers, comment, custom_fields, tags, issues. `name`, `description`, `expected_result` and `test_case_steps` are NOT bulk-editable on this route.", - "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "Every successful edit writes a new version of each case, so an unwanted bulk edit can be undone case by case by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "bulk_edit_test_cases_in_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false - }, - { - "name": "status_id", - "type": "integer" - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "example": [], - "description": "Attachment blob ids to carry with the result. Declared as a scalar array — an array of objects is dropped by the permit filter and only reaches the re-architected mark path, so keep it to plain ids." - }, - { - "name": "de_selected_ids", - "type": "array", - "example": [], - "description": "Mapping ids to exclude when `select_all` is true. Optional." - }, - { - "name": "description", - "type": "string", - "example": "

something

", - "description": "HTML comment/note recorded against each result. @-mentions in it are resolved and notify those users." - }, - { - "name": "folder_id", - "type": "integer", - "example": 21, - "description": "Restrict the edit to the run's cases in this folder, and make the response the folder view instead of the whole run listing." - }, - { - "name": "issues", - "type": "array", - "example": [], - "description": "Defect ids to link to each result. Declared as a scalar array, so an array of objects is dropped by the permit filter — send plain ids. Issue display metadata is enriched asynchronously after the response." - }, - { - "name": "mapping_ids", - "type": "array", - "example": [ - 999, - 991, - 1024, - 1019 - ], - "description": "The run's per-case mapping ids (NOT test case ids and NOT TC-NNN) — the ids the run's test-case listing returns for each row. Ignored when `select_all` is true." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Apply to every case in the run (or in `folder_id`) that `q` matches, minus `de_selected_ids`. Compared as the string `true`." - }, - { - "name": "status_id", - "type": "integer", - "example": 21, - "description": "Numeric result-status id to mark each selected case with. Take it from the project's result statuses; a status NAME goes in `status` instead." - }, - { - "name": "test_run_ids", - "type": "array", - "example": [ - 1001, - 1002, - 1003 - ], - "description": "Automation BTCER ids the bulk edit applies to. Only read when `automation` is `true`." - }, - { - "name": "test_case_counts", - "type": "array", - "example": [ - 1, - 3, - 2 - ], - "description": "Per-entry case counts aligned positionally with `test_run_ids`. Only read when `automation` is `true`." - }, - { - "name": "status", - "type": "string", - "description": "Result status by DISPLAY NAME. Resolved to a status id server-side when `status_id` is absent; if it cannot be resolved the mark silently keeps the existing status. Prefer `status_id`." - }, - { - "name": "assignee_id", - "type": "integer", - "description": "TM user id to assign every selected execution to. Sending the key with `null` UNASSIGNS — omitting the key leaves the assignee alone, so null and absent are not the same thing. An id outside the group is rejected." - }, - { - "name": "planned_execution_date", - "type": "string", - "description": "Planned execution date for each selected execution." - }, - { - "name": "custom_fields", - "type": "object", - "description": "Result-level custom field values, keyed by custom field id." - }, - { - "name": "automation", - "type": "boolean", - "description": "Compared as the string `true`. Routes the edit down the automation path, which reads `test_run_ids`/`test_case_counts` instead of the manual selection." - }, - { - "name": "q", - "type": "object", - "description": "Filter that scopes `select_all` within the run (status, assignee, search, …). Read from the request body on this route; array values are flattened to comma-separated strings before the search." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a `folder_id`-scoped selection into its subfolders. Compared as the string `true`." - }, - { - "name": "shared", - "type": "boolean", - "description": "Scope the selection to cases shared in from other projects. Compared as the string `true`." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the listing returned after the edit. Read from the body as well as the query string." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing returned after the edit. Read from the body as well as the query string." - } - ], - "intent": "Mark many of a test run's cases at once — set their result status, assign them, add a comment, link defects or set result custom fields.", - "returns": [ - "success", - "info", - "test_cases", - "folder", - "ancestors" - ], - "responses": { - "200": { - "$response": "TestRunCasesBulkEditResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "The selection is `mapping_ids` — the run's per-case mapping ids from the run's test-case listing. These are NOT test case ids and NOT TC-NNN identifiers; passing case ids here silently matches nothing.", - "The whole body is FLAT — there is no `test_case` wrapper on this route, unlike the project-wide bulk test-case capabilities, which all nest under a `test_case` wrapper.", - "Two ways to name a status: `status_id` (numeric, authoritative) or `status` (display name, resolved server-side). When the name cannot be resolved the mark proceeds and keeps the existing status, so prefer the id.", - "`assignee_id` distinguishes absent from null: send the key with `null` to unassign, omit it to leave the assignee unchanged.", - "`issues` and `attachments` are permitted as arrays of SCALARS. Arrays of objects are dropped before the write on the main path, so send plain ids.", - "Above the async threshold you get 200 `{success:true, async:true, unique_id}` and no listing; below it the 200 is the refreshed run listing (`test_cases` + `info`, plus `folder` and `ancestors` when `folder_id` was sent).", - "This writes execution results, not the test cases themselves — the cases' own fields are untouched. A result can be overwritten by marking again, but the previous result is not restorable.", - "The run id accepts both `TR-14` and the bare integer on this route, unlike the v2 run endpoints which take TR-NNN only." - ] - }, - { - "name": "bulk_edit_test_cases", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all` — the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Optional scope hint when editing within one folder." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "With `folder_id`, also include cases in its subfolders." - }, - { - "name": "ids", - "type": "array", - "required": true, - "description": "Integer ids of the cases to edit. One id is fine — this endpoint is also the cleanest way to add/remove a tag on a single case.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Ids to exclude when `select_all` is true.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Apply to every case in scope, minus `de_selected_ids`.", - "json_path": "/test_case/select_all" - }, - { - "name": "tags", - "type": "string", - "description": "Tag names. Supports `add` / `remove` — use those rather than `replace` unless the intent really is to discard the existing tags.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/tags" - }, - { - "name": "issues", - "type": "string", - "description": "Linked issues; each value item is `{issue_id, issue_type}`. Supports `add` / `remove`.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "custom_fields", - "type": "object", - "required": true, - "description": "Keyed by custom-field id (numeric string), each entry `{\"value\": …, \"operation\": …}`. Collection-valued custom fields support `add` / `remove`; scalar ones take `replace` / `empty`.\nALWAYS SEND THIS KEY, even when changing no custom fields — pass `{}`. Omitting it makes the server dereference a nil hash and return 500 (`undefined method 'each' for nil`). The server's own request validator wrongly ", - "json_path": "/test_case/custom_fields" - }, - { - "name": "priority", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/priority" - }, - { - "name": "status", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/status" - }, - { - "name": "case_type", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/case_type" - }, - { - "name": "automation_state", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy internal_name string alias for `automation_state`.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/automation_status" - }, - { - "name": "owner", - "type": "string", - "description": "TM user id (`users[].id` from GET .../users-v2), not browserstack_user_id.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/owner" - }, - { - "name": "preconditions", - "type": "string", - "description": "Text value.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/preconditions" - }, - { - "name": "review_status", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "string", - "description": "Reviewer TM user ids. Only honoured on a Team-Pro workspace with review-approve enabled.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/reviewers" - } - ], - "intent": "Bulk edit test cases with per-field add / remove / replace operations (PREFERRED bulk write).", - "guidance": [ - "Applies one change to many cases at once. Confirm the selection resolves to what the user meant before sending — there is no per-case confirmation and no undo." - ], - "returns": [ - "async", - "unique_id" - ], - "paginated": true, - "responses": { - "200": { - "description": "Cases updated, or accepted asynchronously above the threshold.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean", - "description": "Present and true when the selection exceeded the sync threshold." - }, - "unique_id": { - "type": "string", - "description": "Async job id — poll/re-read to confirm (see `async-jobs`)." - }, - "info": { - "$schema": "PageInfo" - }, - "filter_details": { - "type": "object" - }, - "folders": { - "type": "object" - }, - "test_cases": { - "type": "object" - }, - "workflow": { - "type": "object" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "bulk_move_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-move", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Move every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "destination_folder_id", - "type": "integer", - "description": "Target folder's integer id, INSIDE `test_case` (bulk-copy puts its destination at top level instead). Falls back to `test_case.folder_id` when omitted; if neither is sent the move is issued with no destination.", - "json_path": "/test_case/destination_folder_id" - }, - { - "name": "folder_id", - "type": "integer", - "description": "Legacy alias for `test_case.destination_folder_id`, used only when that is absent. Note this is the folder the cases move TO — the identically named TOP-LEVEL `folder_id` is the source scope.", - "json_path": "/test_case/folder_id" - }, - { - "name": "destination_project_id", - "type": "integer", - "description": "Set only for a cross-project move. Must be a JSON INTEGER — a numeric string is rejected up front with a missing-parameter 400. Sending it also requires a non-empty `ids` or `select_all: true`. Cases shared in from other projects cannot be moved across projects and come back as 422.", - "json_path": "/test_case/destination_project_id" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Move test cases into another folder, or into another project, by id or by filter.", - "paginated": true, - "responses": { - "200": { - "$response": "BulkMoveTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The destination lives INSIDE `test_case` on this route (`test_case.destination_folder_id`) — bulk-copy puts it at top level. Swapping the two bodies is the usual cause of a move that reports success and changes nothing.", - "The top-level `folder_id` scopes the SOURCE selection; `test_case.folder_id` is a legacy alias for the DESTINATION. Same name, opposite meaning, decided by nesting.", - "`destination_project_id` must be a real JSON integer. A string like \"42\" is rejected before anything runs, as a missing-parameter 400.", - "A cross-project move of shared cases is blocked by the backend: the response is 422 with `type: \"shared_tc_error\"` and a `shared_count`, and nothing moved.", - "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed listing plus `cases_count`, `destination_path_folders` and `source_path_folders`; `moved_cases_count` only appears on a cross-project move. The cases keep their ids and TC-NNN identifiers, so a move can be undone by moving them back." - ], - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "destination_path_folders", - "source_path_folders", - "moved_cases_count" - ] - }, - { - "name": "bulk_restore_archived_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-retrieve", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "Accepted but IGNORED on this route: the archive scope is fixed to `is_archived: true` before the selection is resolved, so a filter here cannot narrow what is un-archived." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Accepted but ignored on this route — the selection scope is replaced with the archived scope before it is used." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer ids of the ARCHIVED cases to restore. Not required in itself — send either `ids` or `select_all: true` — but a selection that resolves to zero cases is a bare 400.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Restore EVERY archived case in the project, minus `de_selected_ids`. The scope is the whole archive — `q` and `folder_id` do not narrow it here. Compared as the string `true`.", - "json_path": "/test_case/select_all" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - } - ], - "intent": "Restore archived test cases back into their folders — the undo for a bulk archive.", - "returns": [ - "success", - "folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkRetrieveTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the un-archive, not a read. It takes ARCHIVED case ids: list them with the archived test cases call first, because a live case id here simply resolves to nothing.", - "The scope is forced to the archive before your selection is resolved, so `q` and `folder_id` are accepted and then ignored. `select_all: true` therefore means the WHOLE archive, not the filtered subset you may think you asked for.", - "Plan-gated: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and does nothing. Check `success`, not the status code.", - "The 200 body is `{success:true, folders:{...}}` — folder case counts, NOT the restored test cases. Nothing in the response names what was restored; re-read the folders to confirm.", - "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input." - ], - "disabled": true - }, - { - "name": "bulk_restore_archived_test_plans", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/bulk-retrieve", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_plan_ids", - "type": "array", - "required": true, - "minItems": 1, - "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids — the first one present wins. Entries are coerced with to_i and anything yielding 0 is silently dropped, so a TP-NNN identifier is discarded without an error." - } - ], - "intent": "Restore archived test plans in bulk — puts them back into the normal plan listing; this is the undo for a bulk archive", - "returns": [ - "success", - "async", - "unique_id", - "plans", - "count" - ], - "responses": { - "200": { - "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` — the plans are not restored yet.\n", - "schema": { - "$schema": "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans" - } - }, - "400": { - "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Find the ids to restore with the archived-plans listing — archived plans do not appear in the normal plan list at all.", - "Same group feature flag as archive: when it is off this returns 404 with {success: false, errors: 'Feature not available'}, which is a feature gap, not a missing plan.", - "Permission is the test-plan DELETE right, not write.", - "More than 30 ids goes async and returns {success: true, async: true, unique_id} — nothing is restored yet. Re-read the archived listing to confirm.", - "Ids are coerced with to_i and zeroes dropped; if every id drops you get 400 {success: false, errors: 'Please provide test plan ids'}.", - "Failures use the plural key `errors`.", - "Restoring does not undo a delete. Deleted plans are gone; only archived ones can come back this way." - ] - }, - { - "name": "bulk_set_test_result_status", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - } - ], - "body": [ - { - "name": "status", - "type": "string", - "required": true, - "minLength": 1, - "example": "Untested", - "description": "The single status every targeted case is moved to, by NAME as configured for the project. Matched case-insensitively against the configured internal name or display value. \"Reset\" is not a special mode — it is just naming an untested-style status." - }, - { - "name": "test_case_ids", - "type": "array", - "description": "Test case identifiers to target, e.g. [\"TC-101\",\"TC-102\"]. OMITTING this (or sending an empty array) targets EVERY test case in the run. Identifiers not in the run are skipped silently." - } - ], - "intent": "Move many test cases in a test run to one status in a single call — the bulk 'mark these passed' or 'reset the run to untested' action, which replaces each case's current outcome instead of adding a new one", - "guidance": [ - "NO UNDO. Each targeted case's current outcome is superseded in place by the status you name; the status that was there is not kept as a separate history entry and cannot be restored. Recovering means knowing the old status per case and setting it back by hand.", - "Omitting `test_case_ids` (or sending []) applies the status to EVERY case in the run. There is no cap, no dry run and no confirmation step on the server — send an explicit list unless you really mean the whole run.", - "`status` must be a name already configured for the project; an unconfigured name is rejected 400 before anything is queued. It is matched case-insensitively against either the configured internal name or the display value, so 'passed' and 'Passed' both resolve. Resolve the project's configured result statuses first.", - "Targets are resolved to test cases, not to cells. A case that appears in the run more than once — several configurations, or a duplicated mapping — is targeted once, and only one of its cells is moved; there is no configuration or mapping parameter to pick the other. On a multi-configuration run, read the run's cases back and expect to fix the stragglers individually.", - "Fire-and-forget: HTTP 200 {success: true} only means the job was queued. Identifiers that are not in the run are skipped with no error, and any failure inside the worker never reaches the caller — read results back to confirm.", - "No unrecognised keys are allowed in the body — `status` and `test_case_ids` only.", - "This only moves status. It records no description, defects, step results or attachments, and it deletes nothing — to log a real execution with detail, use create_test_results_for_test_run.", - "Requires the execute permission on the run; a caller without it gets 403 before anything is queued." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "V2BulkResultStatusAcceptedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "description": "The job could not be queued; nothing was applied.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - } - } - } - } - } - }, - { - "name": "bulk_update_test_run_test_cases", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "add_test_cases", - "type": "array", - "json_path": "/test_run/add_test_cases", - "fields": [ - { - "name": "test_case_ids", - "type": "array", - "description": "Non-empty list of TC-NNN strings. Must be strings — integers are rejected." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." - }, - { - "name": "source_project", - "type": "string", - "description": "Origin project identifier (PR-NNN) for shared cases — the readable alternative to source_project_id." - }, - { - "name": "configuration_ids", - "type": "array", - "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." - } - ], - "minItems": 1, - "description": "Case+configuration entries to add to the run. Send this OR remove_test_cases, never both." - }, - { - "name": "remove_test_cases", - "type": "array", - "json_path": "/test_run/remove_test_cases", - "fields": [ - { - "name": "test_case_ids", - "type": "array", - "description": "Non-empty list of TC-NNN strings. Must be strings — integers are rejected." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." - }, - { - "name": "source_project", - "type": "string", - "description": "Origin project identifier (PR-NNN) for shared cases — the readable alternative to source_project_id." - }, - { - "name": "configuration_ids", - "type": "array", - "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." - } - ], - "minItems": 1, - "description": "Case+configuration entries to take out of the run. Send this OR add_test_cases, never both. Removal deletes the recorded results for those mappings unless preserve_existing_results is true." - }, - { - "name": "preserve_existing_results", - "type": "boolean", - "default": false, - "json_path": "/test_run/preserve_existing_results", - "description": "Keep the results already recorded against the cases being removed. Defaults to FALSE, i.e. the results are deleted with the mapping. Must be a real boolean (or \"true\"/\"false\"); anything else is a 400." - } - ], - "intent": "Change which test cases a run covers — bulk-add cases to it, or bulk-remove them, optionally per configuration", - "guidance": [ - "Everything goes inside a `test_run` object, and exactly one of add_test_cases / remove_test_cases may be present and non-empty — both, or neither, is a 400.", - "Always asynchronous. A 202 {success, async, unique_id} means only that the job was queued; nothing has changed yet. There is no status endpoint for unique_id over HTTP (completion is announced on a websocket channel), so confirm by re-reading the run's test cases.", - "Removing a case DISCARDS its recorded results unless you send preserve_existing_results: true. Runs keep no version history, so those results cannot be restored — ask before removing cases from a run that has been executed.", - "At most 300 distinct test_case_ids per request, counted across all entries; a duplicate id within or across entries is a 400. Split larger changes into batches.", - "Only the shape is validated before the 202 — an id that does not exist, or is not shared with this project, fails inside the worker and is not reported back on this response.", - "Cross-project (shared) cases need source_project or source_project_id on the entry; supplying both is a 400.", - "This is the only call that changes a run's cases INCREMENTALLY, and the one to use to add cases: add_test_cases adds only the listed cases and leaves every existing case, its run row id and its assignee untouched. The run-update calls (edit_test_run, update_test_run, replace_test_run) do NOT union — the case list they are sent replaces the run's membership, silently dropping any case left out along with its results." - ], - "returns": [ - "success", - "async", - "unique_id" - ], - "responses": { - "202": { - "description": "Bulk change accepted and queued. Body is {success: true, async: true, unique_id} — the run is unchanged at this point.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean" - }, - "unique_id": { - "type": "string" - } - } - } - }, - "400": { - "description": "Synchronous validation failure. Body is {success: false, error, message}, where error is one of operation_required, only_one_operation_allowed, invalid_preserve_flag, invalid_entry_shape, invalid_test_case_id, invalid_configuration_ids, duplicate_test_case_id, max_test_cases_per_request_exceeded, validation_failed.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "clone_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/clone", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true - } - ], - "intent": "Copy an existing exploratory session — its description, timebox, configurations and custom fields, plus its log entries, and optionally its tags, linked issues and owner — to re-run the same exploration", - "responses": { - "201": { - "$response": "ExploratorySessionCloneSyncResponse" - }, - "202": { - "$response": "ExploratorySessionCloneAsyncResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The clone body is FLAT — it is not wrapped in an `exploratory_session` object, unlike create and update on this entity.", - "There are two completely different success shapes and you must branch on the source session's size. Fewer than 50 logs: 201 with {success, async:false, unique_id:null, exploratory_session:{...}} and the copy fully built. 50 logs or more: 202 with {success, async:true, unique_id} and NO session object — the copy exists but you are not told its id.", - "Read session_log_count on the source session first to know which branch you will get. If it is 50 or more, plan to find the new session by listing sessions afterwards (its default title is ' - Copy').", - "unique_id is a realtime broadcast channel name, not a job id — there is no endpoint that reports the clone's progress or result. The only way to confirm an async clone is to re-read the sessions list.", - "The charter and the folder are NOT copied. The clone lands at the project root with an empty charter even though description, timebox, configurations and custom field values are copied. Set them with an update afterwards if they matter.", - "copy_tags, copy_issues and copy_session_owner all default to FALSE, so a plain clone arrives with no tags, no linked issues and nobody assigned. copy_issues also controls whether each copied log keeps its own linked defects.", - "The clone is always created in the active state, even when the source session is closed.", - "The three copy_* flags must be real JSON booleans — the request schema rejects the strings \"true\"/\"false\" with 400.", - "If the asynchronous log copy fails, the newly created session is deleted again, so a failed clone leaves nothing behind — and nothing over this API tells you it happened.", - "The source session is read before anything is created, so an unknown session_id fails as a bare {success:false} 404 with no session created." - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Checkout flow exploration - rerun", - "description": "Title for the new session. Sent at the top level of the body, not inside an exploratory_session wrapper. Defaults to ' - Copy' when omitted — which is also how you find the copy after an async clone.", - "json_path": "/name" - }, - { - "name": "copy_tags", - "type": "boolean", - "default": false, - "description": "Copy the source session's tags. Must be a real JSON boolean; the string \"true\" is rejected with 400. Defaults to false, so the clone is untagged unless you ask.", - "json_path": "/copy_tags" - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false, - "description": "Copy the linked issues — both the session's own issue links AND the defects on every copied log entry. Must be a real JSON boolean. Defaults to false.", - "json_path": "/copy_issues" - }, - { - "name": "copy_session_owner", - "type": "boolean", - "default": false, - "description": "Keep the source session's assignee. Must be a real JSON boolean. Defaults to false, which leaves the clone unassigned.", - "json_path": "/copy_session_owner" - } - ], - "returns": [ - "success", - "async", - "unique_id", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "session_state", - "timebox_duration", - "session_log_count", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "created_at", - "updated_at" - ] - }, - { - "name": "clone_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/clone", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/test_plan/name", - "description": "Name of the NEW plan. Required — there is no server-generated copy name. Omit it (or send blank) and the call is 400 {success: false, message: 'Name field cannot be blank'}." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Description of the new plan. Sanitised against the rich-text allowlist server-side, so the stored value can differ from what you sent. Not copied from the source plan — omit it and the clone has an empty description." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "json_path": "/test_plan/start_date", - "description": "ISO-8601 date for the new plan. Not inherited from the source." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "json_path": "/test_plan/end_date", - "description": "ISO-8601 date for the new plan. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and clones nothing." - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_plan/owner", - "description": "Owner as the internal TM user id (the `id` from the project users read, NOT a BrowserStack user id or an email). Omitted means the caller. An id that does not resolve inside the caller's group is swallowed rather than rejected and the clone ends up with no real owner." - }, - { - "name": "tags", - "type": "array", - "json_path": "/test_plan/tags", - "description": "Plain tag strings for the new plan. If ANY entry is not a string the whole list is silently replaced with [] — no error." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_plan/reviewers", - "description": "Reviewer user ids for the new plan. OMIT the key to copy the source plan's reviewers; send [] to clone with no reviewers. Validated against the project's allowed-reviewer list." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_plan/attachments", - "description": "Attachment ids already uploaded via the attachments surface." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_plan/custom_fields", - "description": "Custom-field values for the new plan, keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: } before any cloning starts." - }, - { - "name": "parent_plan_id", - "type": "integer", - "json_path": "/test_plan/parent_plan_id", - "description": "Set it to make the clone a SUB-plan of that parent (integer id). Omitted/null gives a top-level plan. This is fixed at clone time — a plan cannot be promoted or demoted later." - }, - { - "name": "copy_all_runs", - "type": "boolean", - "default": false, - "json_path": "/test_plan/copy_all_runs", - "description": "Copy every test run linked to the source plan. When true, test_runs is not consulted." - }, - { - "name": "test_runs", - "type": "array", - "json_path": "/test_plan/test_runs", - "description": "Clone only these runs of the source plan, as run ids. Ignored when copy_all_runs is true; send neither and the clone is created with no runs." - }, - { - "name": "copy_all_sub_plans", - "type": "boolean", - "default": false, - "json_path": "/test_plan/copy_all_sub_plans", - "description": "Clone every sub-plan of the source plan under the new plan." - }, - { - "name": "sub_plan_ids", - "type": "array", - "json_path": "/test_plan/sub_plan_ids", - "description": "Clone only these sub-plans (integer ids). Ignored when copy_all_sub_plans is true." - }, - { - "name": "status_ids", - "type": "array", - "json_path": "/test_plan/status_ids", - "description": "Run-level filter: keep only source runs that contain at least one test case in one of these result-status ids. Entries must be integers — anything else is 400 {success: false, error: 'status_ids must be a list of integers'}. It filters RUNS, not cases: every case inside a kept run is copied." - } - ], - "intent": "Copy a test plan into a brand-new plan, bringing its test runs (and optionally its sub-plans) with it — the copy is built in the background, so this hands back a job id rather than the new plan", - "responses": { - "200": { - "$response": "TestPlanCloneAcceptedResponse" - }, - "400": { - "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`), non-integer status_ids, or a rejected custom field (`{success: false, error: ...}`). Also used for a body with no `test_plan` wrapper.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "error": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is ASYNC. A 200 means the clone job was queued, not that the plan exists — the body is {success: true, unique_id} and carries no plan id, no identifier and no name. The source plan is untouched either way.", - "unique_id is only a correlation id for logs; there is no job-status capability for it in this profile. To find the new plan, poll the project's plan listing for the name you sent.", - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own `test_plan` object is rejected before the call as an unknown body key.", - "name is REQUIRED even though this is a clone. There is no automatic 'Copy of ...' name.", - "Nothing except the runs/sub-plans is inherited: description, dates, tags, owner and custom fields all come from THIS request, so a clone sent with only a name produces an otherwise empty plan.", - "Choose the run scope explicitly — copy_all_runs: true, or an explicit test_runs list. With neither, the job creates the plan with no runs at all and still returns success.", - "Copied runs come over whole: the job forces copy_all_cases, and keeps each case's assignee, issues and tags. status_ids narrows which RUNS are copied, never which cases inside them.", - "Date validation is a silent trap: end_date before start_date returns HTTP 200 with {success: false, error: 'Please check date params'}. Check the `success` field, not the status code.", - "Needs the test-plan write permission." - ], - "returns": [ - "success", - "unique_id" - ] - }, - { - "name": "clone_test_run_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/clone", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Cloned Test Run - 2025", - "description": "Name for the new run. Leave it out and the run is auto-named, which is how projects end up with several identically named clones." - }, - { - "name": "copy_all_cases", - "type": "boolean", - "default": false, - "description": "Copy every case from the source run. WITHOUT this (and without status_id) the clone is created EMPTY — the default is a bare run, not a copy of the case list." - }, - { - "name": "status_id", - "type": "array", - "example": [ - 1, - 2 - ], - "description": "Copy only the cases whose latest result is one of these numeric status ids (resolve them via the project's result-status field values). Numeric ids only — this is the filter that is honoured on every backend flow." - }, - { - "name": "status", - "type": "array", - "example": [ - "passed", - "failed" - ], - "description": "Status NAMES to filter the copied cases by. Honoured only on the legacy backend flow; on the current (build-backed) flow it is dropped and a clone whose only case selector was `status` copies nothing and still returns 200. Send status_id instead." - }, - { - "name": "copy_tc_assignee", - "type": "boolean", - "default": false, - "description": "Carry each copied case's assignee across. Only meaningful together with copy_all_cases / status_id — it applies to the copied rows, not to the run." - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false, - "description": "Carry the linked defects across onto the copied case rows." - }, - { - "name": "copy_tags", - "type": "boolean", - "default": false, - "description": "Copy the source run's tags onto the new run." - }, - { - "name": "source_build_id", - "type": "integer", - "description": "Optional explicit source build to copy cases from, for callers that already resolved it. Normally omitted — it is looked up from the source run." - } - ], - "intent": "Copy an existing test run into a new one — optionally carrying over its cases, their assignees, tags and linked defects — to re-execute the same scope without rebuilding the selection", - "returns": [ - "data", - "success", - "test_run", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "attachments", - "metadata", - "project_id", - "created_at", - "updated_at", - "created_by", - "observability_url", - "links" - ], - "responses": { - "200": { - "$response": "TestRunResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A clone with no case selector produces an EMPTY run. Send copy_all_cases:true, or status_id, or you get a 200 and a run with zero cases — this is the single most common surprise on this endpoint.", - "Filter the copied cases by status_id (numeric), not by `status` (names). The name form is only read on the legacy backend flow; on the current one it is ignored and the clone silently comes back empty.", - "The clone always starts in run_state `new_run` regardless of the source run's state, and its description is always copied. Tags, defects and per-case assignees are copied only when you ask. Configurations, the owner and the dynamic-filter rule are NOT carried over — set them afterwards with the edit call.", - "Cloning a run created before the current ingest flow, while asking for its cases, is refused with 422 'The source run has no test cases available to clone. Retry without case selection.' Retrying without copy_all_cases/status_id gives you an empty clone of the header.", - "Population is not synchronous on every path: the run exists as soon as the call returns, but re-read its cases rather than trusting an immediate empty list, and never retry the clone — that makes a second run.", - "Take `uuid` (the integer) from the response, not `id` — `id` is the TR-NNN string here as everywhere else on runs.", - "The new run comes back under `data.test_run` (with an underscore). The write endpoints on this entity — create, edit, assign, close, delete — use `data.testrun` instead. Do not reuse one reader for both.", - "Needs the test-run write permission; cloning an automation run demands the automation variant of it and 403s for a role that only holds the manual one." - ] - }, - { - "name": "clone_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/clone", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "test_case_selection", - "type": "string", - "values": [ - "all", - "by_results" - ], - "default": "all", - "description": "Which cases of the source run the copy gets: every case, or only those whose latest result matches `statuses`. Any other value is a 400." - }, - { - "name": "statuses", - "type": "array", - "fields": [ - { - "name": "status_name", - "type": "string" - } - ], - "example": [ - "failed", - "blocked" - ], - "description": "Result status NAMES (case-insensitive) — not ids. Required and non-empty when test_case_selection is \"by_results\"; an unknown name is a 400 naming it. Defaults plus any custom status configured for the group are accepted." - }, - { - "name": "copy_tc_assignee", - "type": "boolean", - "default": true, - "description": "Carry per-case assignees over. Defaults to TRUE here (the v1 clone defaults it to false)." - }, - { - "name": "copy_tags", - "type": "boolean", - "default": true, - "description": "Carry the source run's tags over. Defaults to TRUE here (the v1 clone defaults it to false)." - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false - }, - { - "name": "name", - "type": "string", - "example": "Sprint 24 - Regression Re-Run", - "description": "Name for the new run. Omitted or blank gives \" - Copy\"." - } - ], - "intent": "Copy an existing run into a brand-new run — the usual way to re-execute just the failed or blocked cases from the last cycle", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Body is flat — these fields sit at the top level, not inside a `test_run` object like the update calls.", - "Identity is synchronous, data is not: the response carries the new run as soon as it exists, but its case mappings are filled in by a background worker. Reading the new run's test cases immediately can legitimately return zero rows — retry before concluding the clone was empty.", - "A 202 (instead of 200) means the run was cloned and one bookkeeping leg is still settling: do NOT retry, or you get a second clone.", - "Test plan / sub-plan links are NOT carried over; link the copy afterwards if you need it under a plan.", - "A dynamic source run passes its is_dynamic flag on, but the filter is not re-evaluated, so the copy starts from the source's case list.", - "Cloning an automation run is blocked by the write-permission gate (403) — this path is for manual runs.", - "The copy is a new run, so nothing about the source changes; discard it by deleting the copy.", - "The clone comes back at the top level as `test_run`, not under `data`. A 202 means the copy is still populating.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunV2Response" - }, - "202": { - "description": "Run cloned, background bookkeeping still in progress. Body is {success: true, build_pending: true, message, test_run}. Treat as success; retrying would create a duplicate run.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "build_pending": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "test_run": { - "$schema": "TestRun" - } - } - } - }, - "400": { - "description": "Clone options failed validation — unknown test_case_selection, missing statuses for \"by_results\", or an unknown status name. Body is {success: false, message}.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "close_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/close", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Mark an exploratory testing session finished so it leaves the active listing and is archived as completed work", - "responses": { - "200": { - "description": "Exploratory session closed successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This call takes no body at all. The handler's actual_duration pass-through is commented out, so posting a duration here returns 200 and changes nothing. Write actual_duration with the session update call BEFORE closing.", - "Closing is one-way through the API: nothing accepts session_state, and there is no reopen endpoint. Treat it as irreversible even though it is not classed destructive.", - "After closing, the session disappears from the sessions listing unless you pass q[session_state]=closed — the listing defaults to active.", - "Closing also changes who may delete the session afterwards: deletion of a closed session is gated by the closed-test-run delete permission rather than the ordinary one, so a caller who could have deleted it a moment earlier may now get 403.", - "The response is only {success:true}. Re-read the session to see session_state, closed_at and closed_by.", - "Closing an already-closed session is not rejected here; whatever the store does with it surfaces, if at all, as the generic 400 {success:false, message:'The API request is invalid or improperly formed'}.", - "A session id that does not exist returns a bare {success:false} with 404 and no message." - ], - "returns": [ - "success" - ] - }, - { - "name": "close_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/close", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "intent": "Close a test run — freeze its results, mark the cycle finished and drop it out of the default run listing", - "returns": [ - "data", - "success", - "testrun", - "project", - "skipped_test_case_ids", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__CloseTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No body at all. This is a fixed transition to run_state `closed`; anything you send is ignored.", - "Closing is final. A snapshot of every result is taken and the run's linked build in Test Observability is closed too, and nothing sets the run back to an active state afterwards — confirm with the user before closing a run that may need more testing.", - "A closed run disappears from the default project run listing — it is only visible through the closed-runs listing or a listing that explicitly asks for it. Deleting it afterwards also demands a different permission from deleting an open run.", - "A closed run cannot be reopened. The partial run update answers 404 'Test Run ID is invalid' for a closed run, and the full edit returns success while leaving it closed — do not report either as a reopen. To keep testing, clone the run (clone_test_run with copy_all_cases) and add cases to the copy; re-link it to its plan afterwards, since the clone does not carry the plan over.", - "The run comes back under `data.testrun` — one word, no underscore — alongside `data.project`. The read endpoints use `data.test_run`. The 200 also carries a TOP-LEVEL `skipped_test_case_ids` array (a sibling of `data`, not inside it).", - "Client errors from the backing service come back as `{data: {success: false, error: }}` with the 4xx status, not as the flat error envelope other endpoints use — read `data.success` before trusting `data.testrun`.", - "Needs the test-run write permission; an automation run demands the automation variant and 403s otherwise." - ] - }, - { - "name": "copy_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folders/copy", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "source_folder_id", - "type": "integer", - "required": true, - "example": 2, - "description": "Integer id of the folder to copy. Must exist in the project named in the path. Blank/absent returns 400 {error:'source_folder_id is required'}." - }, - { - "name": "destination_folder_id", - "type": "integer", - "example": 3, - "description": "Parent folder the copy is placed under, in destination_project_id. NOT required: omitting it (or sending null) is what switches the call into copy-to-root mode, exactly as copy_to_root:true does." - }, - { - "name": "destination_project_id", - "type": "integer", - "example": 10000, - "description": "Destination project. NOT required — defaults to the project in the path. Cross-project copies are allowed here (unlike cross-project moves, which are blocked when the folder has shared test cases)." - }, - { - "name": "copy_to_root", - "type": "boolean", - "default": false, - "description": "Place the copy at the destination project's root instead of under a parent folder. Accepts the boolean true or the literal string \"true\". It is also forced on whenever destination_folder_id is absent." - } - ], - "intent": "Duplicate a folder — its whole subtree of subfolders and every test case in them — into another folder, or to the root of any project you can reach; always runs as a background job", - "returns": [ - "success", - "async", - "unique_id", - "message" - ], - "responses": { - "202": { - "$response": "FolderCopyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - } - }, - "guidance": [ - "This endpoint only queues work. It answers 202 Accepted (not 200) with {success:true, async:true, unique_id, message} the moment validation passes — nothing has been copied yet when you get the response.", - "There is no way to poll the job over HTTP. `unique_id` is just the X-Request-ID header echoed back (a fresh UUID if you sent none), and progress is pushed over a websocket channel the API does not expose. Confirm the copy by re-reading the destination folder's contents after a delay.", - "`async` and `copy_test_cases` are not read anywhere in the handler or the worker. The copy is always asynchronous, and it always copies the full subtree — every nested subfolder and every test case. You cannot ask for a folders-only copy.", - "Name clashes are resolved silently, not rejected: an existing folder of the same name in the destination makes the copy land as `copy-of-`, then `copy-of-2-`, `copy-of-3-` and so on. Match on the returned tree, not on the name you sent.", - "Copying a folder into itself or into one of its own descendants is refused with 400 {error:'Cannot copy folder into itself or its subfolder'} — but only for same-project copies that name a destination folder. Copy-to-root and cross-project copies skip that check entirely.", - "Missing source or destination is 404 with a bare {error:'...'} and no `success` key — this endpoint does not use the usual {success:false} envelope for that case.", - "If the worker fails partway it rolls the whole copy back, so a failed run should leave no half-copied tree — but you only learn that by re-reading, not from this response.", - "Requires the folder-create permission on the project in the path." - ] - }, - { - "name": "count_run_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/selection-count", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "select_all", - "type": "boolean", - "required": true, - "example": false, - "description": "Select every case in the project. Marked required because the request body must contain a `selection` object at all — sending only `configurations` is rejected 400 before anything is counted, and this is the cheapest field that materialises `selection`. Pass false alongside `folders` for a folder-scoped count.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 2, - "description": "The caller's own count of distinct cases in the selection. Echoed through; it does not constrain the answer.", - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. Case ids must be integers — strings are rejected by the schema.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Map of project ids to their shared test-case selection, same inner shape as `selection`. Top level, NOT inside `selection`." - }, - { - "name": "configurations", - "type": "array", - "required": true, - "example": [], - "description": "Configuration ids (integers). Required by the schema — send an empty array when there are none; omitting the key is a 400." - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "description": "Per-configuration case mappings. An ARRAY of objects — an object here is rejected. Unknown keys inside an entry are rejected too.", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Ask how many test cases a folder/case selection will actually resolve to — including the multiplication by configurations — before committing it to a run create or edit", - "shape": "discovered", - "responses": { - "200": { - "description": "The number of test-case x configuration rows the selection resolves to.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "count": { - "type": "integer", - "example": 166 - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is schema-validated with unknown top-level keys REJECTED. Only `selection`, `shared_selection`, `configurations` and `trtc_configuration_mappings` are accepted; anything else — including run fields like `name` or `filters` — returns 400 {error: 'Invalid JSON data', details: [...]} before any counting happens.", - "`selection` and `configurations` are both mandatory. A body with a selection but no `configurations` key is refused; pass `configurations: []` when the run has none.", - "The answer is the number of case-times-configuration rows the run would get, not the number of distinct test cases — with two configurations selected the count is roughly double `unique_test_case_count`. That is the point of the call.", - "Read-only and side-effect free: it resolves the selection but creates nothing. Use it before a create or edit when the user needs to see the size of what they are about to commit.", - "The body must be sent as application/json; a form-encoded body skips the schema check entirely and then fails further in with a less useful error.", - "The response is flat — {success, count} with no `data` envelope." - ], - "returns": [ - "success", - "count" - ] - }, - { - "name": "create_bulk_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_cases", - "type": "array", - "required": true, - "description": "One UNWRAPPED object per case (no per-item `test_case` key), max 10 on the synchronous path. Several fields are read from the FIRST item only and applied to all — see guidance. Send `template` and `test_case_steps` on every item.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body as well as in the path; the path folder is not substituted in." - }, - { - "name": "attachments", - "type": "array", - "description": "Blob ids. Taken from the FIRST item only and copied onto EVERY case in the batch. A blob belonging to another group fails the whole request with 400." - }, - { - "name": "automation_state", - "type": "integer", - "description": "NOT accepted on the bulk path — dropped by the allow-list. Use `automation_status` here, or edit the cases afterwards." - }, - { - "name": "automation_status", - "type": "string" - }, - { - "name": "case_type", - "type": "integer" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Taken from the FIRST item only and applied to EVERY case in the batch. Per-item custom field values are silently discarded — create cases with differing custom fields one at a time, or edit them afterwards." - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted by the allow-list and then never written; every read serialises `estimate` as null. Do not report an estimate as saved." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "NOT accepted on the bulk path — dropped by the allow-list. Set it with an edit after creation." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean" - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer" - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer" - }, - { - "name": "review_status", - "type": "string", - "description": "NOT accepted on the bulk path — dropped by the allow-list. The created cases take the project's default review state." - }, - { - "name": "reviewers", - "type": "array", - "description": "NOT accepted on the bulk path — dropped by the allow-list. Set reviewers with an edit after creation." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "NOT accepted on the bulk path — dropped by the allow-list." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "NOT accepted on the bulk path — dropped by the allow-list." - }, - { - "name": "status", - "type": "integer" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Also decides, from the FIRST item only, which system template id every case in the batch falls back to (`test_case_steps` when the first item omits it) — so send it on every item, and keep the batch homogeneous." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string", - "description": "NOT accepted on the bulk path — dropped by the allow-list. Use `template`." - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is dropped and the case is created with no steps, still 200." - }, - { - "name": "metadata", - "type": "string", - "description": "JSON-encoded string of opaque metadata (this is where an AI prompt reference rides). Unparseable JSON degrades to `{}` rather than erroring." - } - ] - }, - { - "name": "unique_id", - "type": "string", - "description": "Client-supplied idempotency key. When present AND the group has the async bulk-create flag on, the request is queued instead: 202 with the echoed `unique_id`, the ≤10 item limit is replaced by a 500-item cap, and completion arrives over the WebSocket channel. Without the flag it is inert." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Create (or reuse) a subfolder named `scenario_name` under the path folder and put every case in the batch there instead. Requires `scenario_name`. If the batch then fails, a subfolder created for it is deleted again." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is set." - } - ], - "intent": "Create several test cases in one folder in a single call (up to 10) — the call to use whenever you have more than one case to add.", - "returns": [ - "success", - "data", - "test_cases", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "description": "Test cases created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__CreateBulkTestCases" - } - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "PathFolder__CreateBulkTestCases" - } - }, - "request_trace_id": { - "type": "string" - } - } - } - } - } - }, - "202": { - "description": "Accepted for asynchronous processing (feature-flag gated; requires unique_id). Completion is broadcast on common_channel_ with operation bulk_create and the echoed unique_id.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean" - }, - "unique_id": { - "type": "string" - }, - "scenario_name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "message": { - "type": "string" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Items are UNWRAPPED — `test_cases` is a plain array of case objects with no per-item `test_case` key. A non-array, or more than 10 items, is a 400 naming the problem.", - "Not everything single-create accepts survives here: `automation_state`, `review_status`, `reviewers`, `send_for_review`, `shared_precondition_id`, `template_step_type` and `estimated_duration_seconds` are all dropped by this route's allow-list, without error. Create then edit if you need them.", - "`custom_fields` and `attachments` are read from the FIRST item and applied to all of them. A batch where cases differ in custom field values will silently come out wrong — create those one at a time.", - "`test_case_folder_id` is required inside each item even though the folder is already in the path; omitting it is the usual cause of a 422 wrapping an upstream 404.", - "Steps only come from `test_case_steps`. A `steps` key is dropped, and the cases are still created — with no steps and a 200 — so verify against the response.", - "The created cases are at `data.test_cases`, with `data.path_folders` and a `data.request_trace_id` for the AI enrichment that runs afterwards. Take each new case's integer `id` and TC-NNN `identifier` from there.", - "With `unique_id` and the async flag enabled, you instead get 202 `{success, async, unique_id, count}` and NO cases — the work finishes on a WebSocket channel you cannot read through this API, so poll the folder listing instead.", - "Any unexpected failure inside this route is flattened to 500 `{success:false, message:\"Failed to create test cases.\"}` — that message covers backend rejections too, so do not read it as an outage." - ] - }, - { - "name": "create_configuration", - "method": "POST", - "path": "/api/v1/projects/{project_id}/configurations", - "mode": "write", - "entity": "configuration", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Create a named configuration — an OS / browser / device combination that test runs track their per-case results against", - "returns": [ - "success", - "configuration", - "id", - "name", - "group_id", - "record_status", - "is_suggested", - "created_at" - ], - "responses": { - "200": { - "$response": "ConfigurationCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Display name of the configuration, e.g. \"Chrome 120 on Windows 11\". This is the ONLY field the handler reads — os, os_version, browser, device and every other attribute the read returns cannot be set here and are silently ignored if sent." - } - ], - "guidance": [ - "`name` is the only input. The structured attributes (os, browser, device, versions) that come back on the listing cannot be set through this call; anything else you put in the body is dropped without an error.", - "Group-scoped, not project-scoped: `project_id` matches the route but is never sent onward, so the configuration is created at workspace level and appears in EVERY project in the group. There is no such thing as a project-private configuration here.", - "No request-body schema is registered for this action, so an omitted or empty `name` is not caught locally — whatever the backing service does with it is what you get, and the failure arrives without a field name.", - "The created record comes back under `configuration` (not `data`) beside `success:true`. Carry `configuration.id` — that integer is what a test run's configuration list takes; the run APIs want configuration ids, not configuration objects or names.", - "Check the configuration listing first. Configurations accumulate at workspace level and duplicates by name are not prevented here, so search before creating.", - "There is no delete-configuration or rename-configuration capability in this profile — a configuration created by mistake has to be cleaned up in the Test Management UI.", - "This action is missing from the RBAC permission map, so it is not permission-gated even though the rest of the configurations controller is (the listing needs test_run_view and the group/option writes need test_run_write)." - ] - }, - { - "name": "create_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces from storage." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id the option is added to. Not validated by this handler." - } - ], - "body": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label. Must be present — a null or missing value is a 400 'Invalid Params'. Must be unique within the dataset; a duplicate is rejected with the upstream message relayed." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "REQUIRED — a missing or null value is a 400. Use false for every option of a multi_dropdown, and true for at most one option of a dropdown; this layer does not enforce either rule." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "nested_dropdown children only — the option id this one hangs under, from the dataset options listing. Forwarded only when non-empty; meaningless on a flat dropdown." - } - ], - "intent": "Add one more choice to a dropdown-style custom field — a new value in an existing dataset, for every project that dataset covers", - "responses": { - "200": { - "$response": "CustomFieldAdminOptionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`is_default` is required and must be present, not null: omitting it (or option_value) is a 400 {success:false, error:'Invalid Params'}, whatever else is wrong.", - "The new option appears in every project the dataset is linked to, not just one. Check the dataset's project list before adding.", - "Only set is_default true for a single-select `dropdown`, and only for one option; for a multi_dropdown every option must be is_default false. Nothing in this layer enforces it — a second default is accepted here and the behaviour downstream is undefined.", - "`parent_option_id` is forwarded only when non-empty, and only means anything for a nested_dropdown. Get the parent id from the dataset options listing.", - "The created option comes back under `data` with its `id` — capture it, that is what the update and delete calls take.", - "A duplicate option label is rejected upstream and relayed verbatim ('Value with same name already exists. Use a unique name.'); other upstream failures are masked as 400 'The API request is invalid or improperly formed'." - ], - "returns": [ - "success", - "data", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id" - ] - }, - { - "name": "create_custom_field_dataset", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id to hang the dataset off. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "body": [ - { - "name": "linked_projects", - "type": "array", - "description": "Integer project ids this dataset applies to — this is what makes the field visible in a project. Must be an array (or omitted) or the call is a 400. Note the spelling differs from the project-mapping call, which uses link_projects / unlink_projects." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "default": false, - "description": "Auto-link projects created after this dataset. Must be a literal true/false; any other value is a 400." - }, - { - "name": "options", - "type": "array", - "description": "The allowed values, for dropdown-style fields. Each entry needs option_value and a non-null is_default or the call is a 400.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "Must be present (not null). False for every option of a multi_dropdown; at most one true for a dropdown." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "For nested_dropdown children only — the option this one hangs under." - } - ] - } - ], - "intent": "Attach another option set and project scope to an existing custom field — the way to give one field different dropdown values in different projects", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A dataset is the pairing of an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, is invisible in every project even though the definition exists.", - "`linked_projects` takes INTEGER project ids, not PR-NNN identifiers. A non-array value is a 400 'Invalid Params'; a valid array of ids that are not real projects fails upstream and comes back masked.", - "`linked_to_all_projects` is NOT read by this endpoint — it is silently ignored. It works only inside the `datasets` entries of the create-a-field call. To cover every project from here, enumerate the ids, or use the dataset project-mapping call with select_all.", - "A project can belong to only one dataset per field. Linking a project that is already on another dataset of the same field fails upstream, and this endpoint does NOT relay the reason — you get 400 {success:false, message:'The API request is invalid or improperly formed'}. Read the field's existing datasets first.", - "Options are only meaningful for dropdown / multi_dropdown / nested_dropdown fields, but nothing rejects them on other types. Each option needs option_value and a non-null is_default; for a multi_dropdown every is_default must be false, and for a dropdown at most one may be true.", - "The response is the created dataset under `data`; take `data.id` — it is the dataset_id every option and project-mapping call needs.", - "This is additive and reversible by deleting the dataset, but deleting it later destroys the values stored against its options." - ], - "returns": [ - "success", - "data", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects" - ] - }, - { - "name": "create_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields", - "mode": "write", - "entity": "custom_field", - "body": [ - { - "name": "field_name", - "type": "string", - "required": true, - "maxLength": 256, - "example": "Test Environment", - "description": "Display label. Stripped of surrounding whitespace before the reserved-name check and before storage. Blank or longer than 256 characters → 400 'Invalid Params'." - }, - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Data type. Use THIS vocabulary — the legacy `field_*` spellings that the project form-fields read returns (field_dropdown, field_string, …) are rejected here. `url` and `nested_dropdown` are not valid when entity_type is TestCaseStep." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Which entity the field hangs off; a field belongs to exactly one and cannot be moved later. Any other value → 400 'Invalid Params'. TestCaseStep additionally requires the group's step-custom-fields flag." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "description": "Whether the field must be filled. Must be a literal JSON boolean — omitting it, or sending \"true\" as a string, is a 400." - }, - { - "name": "datasets", - "type": "array", - "required": true, - "description": "REQUIRED for every field type — omitting it (or sending a non-array) is a 400. Carries the option values and the project scope. An empty array passes validation but leaves the field linked to nothing.", - "fields": [ - { - "name": "linked_projects", - "type": "array", - "description": "Integer project ids to link. Every entry must be a positive integer; PR-NNN identifiers are rejected." - }, - { - "name": "unlinked_projects", - "type": "array", - "description": "Integer project ids to exclude; only read when linked_to_all_projects is true." - }, - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "true makes the handler resolve every project in the workspace (max 3000) into linked_projects before sending upstream." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Auto-link projects created later." - }, - { - "name": "options", - "type": "array", - "description": "Option values for dropdown-style types. Each needs option_value (non-blank) and a non-null is_default; nested_dropdown entries may carry a `children` array of the same shape." - } - ] - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "Hint text shown in the form. Stripped before storage; longer than 256 characters → 400." - }, - { - "name": "default_value", - "type": "boolean", - "description": "Honoured ONLY when field_type is `boolean`, and then it must be a literal true/false. For every other field type the handler overwrites it with null, so a default cannot be set at create time." - }, - { - "name": "levels", - "type": "array", - "description": "REQUIRED when field_type is `nested_dropdown`: at least 2 entries, each with a non-blank `name` and `level_type`. Ignored for every other type.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true - }, - { - "name": "level_type", - "type": "string", - "required": true - } - ] - }, - { - "name": "parent_custom_field_id", - "type": "integer", - "description": "Adds a child level under an existing nested_dropdown field. When present the handler validates ONLY field_name and forwards the rest untouched — field_type, is_required and datasets are not checked at all." - } - ], - "intent": "Define a new custom field for the workspace — pick its data type, whether it is mandatory, and (via datasets) which projects get it and what option values it offers", - "responses": { - "200": { - "$response": "CustomFieldAdminWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`datasets` is required for EVERY field type, dropdown or not. The validator only checks it is an array, so `datasets: []` passes — and creates a field linked to no project, invisible everywhere. Send one entry carrying `linked_projects` if you want the field to appear.", - "`is_required` must be a literal JSON boolean. The string \"true\" fails the check and the whole call comes back 400 {success:false, error:'Invalid Params'} with no indication of which field was wrong — that one error covers field_type, field_name, is_required, place_holder_text, default_value, levels and datasets alike.", - "`default_value` is read ONLY when field_type is `boolean`; for every other type the handler forces it to null before sending upstream. Setting a default on a string/int/date field through this call is impossible.", - "field_name is stripped before both the duplicate check and storage, so \" Priority \" is stored as \"Priority\" and collides with the reserved name. Reserved names (Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status) return 400 'Field with same name already exists. Use a unique name.'; TestPlan and ExploratorySession fields may reuse 'Automation Status' and 'Type of Test Case'.", - "A duplicate-name rejection from storage is relayed verbatim (400 with the upstream message). Every OTHER upstream failure on this call is masked to {success:false, message:'The API request is invalid or improperly formed'} — do not try to parse a reason out of it.", - "Inside a `datasets` entry, `linked_to_all_projects: true` makes the handler expand the link list to every project in the workspace (capped at 3000) before sending. That flag works HERE but is ignored by the standalone add-a-dataset call.", - "entity_type `TestCaseStep` is accepted only when the group has step custom fields enabled; otherwise 400 'Step custom fields are not enabled for this group.'. Step fields also cannot be `url` or `nested_dropdown`.", - "The response echoes only the stored field attributes — no options, no project links. Read the field back, or read the project's form fields, to confirm what the datasets actually produced.", - "Nothing here is project-scoped: the definition is created for the whole workspace and the datasets decide where it shows up. Name the projects you are about to affect before creating." - ], - "returns": [ - "success", - "data", - "id", - "field_name", - "field_type", - "entity_type", - "is_required", - "default_value", - "place_holder_text" - ] - }, - { - "name": "create_dataset", - "method": "POST", - "path": "/api/v1/projects/{project_id}/datasets", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "json_path": "/dataset/name", - "description": "Dataset name. Must be unique within the project: a collision comes back as 400 {success:false, message:'Dataset name already exists.'}, which is the one 400 here that is NOT a schema rejection." - }, - { - "name": "variables", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 40, - "json_path": "/dataset/variables", - "description": "The columns, in order — row_data positions map to this array by index. Duplicate names (compared case-insensitively) fail the call. A column with a blank name, or one empty in every row, is SILENTLY DROPPED with its slice of every row, so the stored column list can be shorter than what you sent. 40 is the default cap; groups on the raised-limit flag get 100.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", - "description": "Must start with a letter and contain only letters, digits and underscores — no spaces, hyphens or leading digits." - }, - { - "name": "uuid", - "type": "string", - "description": "Omit on create; the server mints it. Present only so the value read back can be sent to update unchanged." - } - ] - }, - { - "name": "rows", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 100, - "json_path": "/dataset/rows", - "description": "The data rows. Heavily normalised before the write: all-blank rows are dropped, cell values are whitespace-trimmed, rows whose trimmed values are identical are de-duplicated to the first occurrence, and row_number is then REASSIGNED 1..N in the order you sent them — the numbers you supply are ignored. 100 rows is the default cap; groups with the raised-limit flag get 200.", - "fields": [ - { - "name": "row_number", - "type": "integer", - "required": true, - "description": "Required by the schema but overwritten server-side with the row's final 1-based position. Send anything valid; do not rely on it round-tripping." - }, - { - "name": "row_data", - "type": "array", - "required": true, - "minItems": 1, - "description": "Cell values, positional against `variables`. Every element must be a STRING — a number or null is a schema rejection. After empty columns are pruned the remaining length must equal the remaining column count, else 422 'Row data size mismatch with column count.'" - }, - { - "name": "uuid", - "type": "string", - "description": "Omit on create; the server mints it." - } - ] - }, - { - "name": "tags", - "type": "array", - "maxItems": 20, - "json_path": "/dataset/tags", - "description": "Free-text labels on the dataset. Plain strings, each at most 50 characters, at most 20. Accepted but undocumented in the spec.", - "fields": [ - { - "name": "tag", - "type": "string", - "maxLength": 50 - } - ] - }, - { - "name": "metadata", - "type": "object", - "json_path": "/dataset/metadata", - "description": "Free-form key/value bag stored with the dataset, at most 20 top-level keys. Only settable here — the update endpoint accepts it through validation and then drops it, so metadata cannot be changed after creation through this API." - } - ], - "intent": "Create a dataset — a named table of columns (variables) and rows that drives data-driven execution, so one test case runs once per row with the row's values substituted in", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "tags", - "metadata", - "created_at", - "created_by" - ], - "responses": { - "201": { - "description": "Dataset created. The handler renders 201 Created, not 200.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__CreateDataset" - } - } - } - }, - "400": { - "description": "Either a request-schema rejection ({error:'Invalid JSON data', details:[...]}) or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:...}. 'Feature not available' = the group lacks Team Pro (entitlement, not payload). Any other message is a dataset-shape validation failure." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key.", - "Nothing outside the declared keys is allowed: the request schema sets additionalProperties:false at the top level, inside `dataset`, and inside each variable and row object. A misspelled or extra key fails the whole call with 400 {error:'Invalid JSON data', details:[...]} rather than being ignored.", - "Send Content-Type: application/json. The schema check is skipped for any other content type, and the handler then crashes on a missing `variables` instead of returning a clean 400.", - "What you get back is not what you sent: blank rows and all-blank columns are stripped, duplicate rows collapsed, cells trimmed, and row_number renumbered 1..N. Read `dataset.variables` and `dataset.rows` from the response rather than assuming your payload was stored verbatim.", - "422 is overloaded on every dataset endpoint. {message:'Feature not available'} means the group lacks the Team Pro plan — stop, do not retry with a different body. Any other 422 message ('Maximum 40 columns are allowed', 'Duplicate variable names found: ...', 'Row data size mismatch with column count.', 'Please add non null/ valid data.') is a payload problem you can fix.", - "Success is 201, not 200, and the dataset is at the top level as `dataset` — there is no `data` envelope.", - "Carry `dataset.uuid` forward: it is the id every other dataset call takes, and the integer id used internally is never exposed.", - "Needs the test-case write permission (tc_write), not a dataset-specific one." - ] - }, - { - "name": "create_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "example": "Checkout flow exploration", - "description": "Title of the exploratory session. The only field the store requires. Send it as `title` — `name` is accepted by the request schema and then silently dropped.", - "json_path": "/exploratory_session/title" - }, - { - "name": "description", - "type": "string", - "example": "Explore edge cases in the checkout flow.", - "description": "Optional free-text description.", - "json_path": "/exploratory_session/description" - }, - { - "name": "charter", - "type": "string", - "example": "Focus on payment error handling", - "description": "Testing charter describing the scope and goals. Note that cloning a session does NOT copy the charter.", - "json_path": "/exploratory_session/charter" - }, - { - "name": "timebox_duration", - "type": "integer", - "example": 60, - "description": "Planned duration of the session. TM's own exported reports render this value as minutes, while the upstream store documents the field as seconds — it is passed through unconverted, so agree the unit with the product before relying on it.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/timebox_duration" - }, - { - "name": "owner", - "type": "integer", - "example": 42, - "description": "TCM user id of the session owner. This is the field to use: it is translated to the upstream user id here. An id matching no user in this group is accepted and stored as no assignee.", - "json_path": "/exploratory_session/owner" - }, - { - "name": "assignee", - "type": "integer", - "example": 42, - "description": "NOT a safe alias on create — unlike `owner` it is forwarded without translation to an upstream user id, so it sets the wrong user. Use `owner` instead. Ignored entirely when `owner` is also present.", - "json_path": "/exploratory_session/assignee" - }, - { - "name": "folder_id", - "type": "integer", - "example": 456, - "description": "Folder to place this session in; a numeric string is also accepted. Omit to create it at the project root.", - "json_path": "/exploratory_session/folder_id" - }, - { - "name": "test_plan_id", - "type": "integer", - "example": 77, - "description": "Integer id of the test plan to link this session to (not a TP-NNN identifier). Only reachable through the wrapped body — the unwrapped fallback does not permit it.", - "json_path": "/exploratory_session/test_plan_id" - }, - { - "name": "duration", - "type": "integer", - "example": 45, - "description": "Tracked duration. Accepted on create but normally left to be set later by the update call.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/duration" - }, - { - "name": "actual_duration", - "type": "integer", - "example": 50, - "description": "Final recorded duration. Accepted on create but normally set by the update call before closing — the close call does not accept it.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/actual_duration" - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "mobile" - ], - "description": "Plain tag strings. The request schema rejects any non-string entry.", - "json_path": "/exploratory_session/tags" - }, - { - "name": "configurations", - "type": "array", - "example": [ - 1, - 3 - ], - "description": "Configuration ids (integers, or numeric strings) — not the configuration objects returned on read. An id that does not exist in this project surfaces only as the generic upstream 400.", - "json_path": "/exploratory_session/configurations" - }, - { - "name": "attachments", - "type": "array", - "example": [ - 101, - 102 - ], - "description": "Integer blob ids from a prior generic attachment upload — never file content. A blob already used by another entity is refused with 422. Unknown ids error only when none of them resolve; otherwise they are silently skipped.", - "json_path": "/exploratory_session/attachments" - }, - { - "name": "issues", - "type": "array", - "description": "Issues to link. Each entry is {issue_id, issue_type} and may also carry metadata {url, ticket_id, title, display_category, issue_category}. A list of bare id strings is coerced to jira issues. A list of anything else is silently reduced to no issues. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. Linking is applied asynchronously after the 201.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/exploratory_session/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "101": "Regression", - "102": 42 - }, - "description": "Custom field values as a map of {custom_field_id: value}, converted server-side into the upstream write shape. This is NOT the array shape the read endpoints return. Resolve the ids for entity type ExploratorySession first; a misspelled key is dropped rather than rejected.", - "json_path": "/exploratory_session/custom_fields" - } - ], - "intent": "Start a new exploratory testing session in a project — give it a charter, a timebox, an owner, tags, configurations and a folder — so that observations can be logged against it", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "created_at", - "updated_at" - ], - "responses": { - "201": { - "description": "Exploratory session created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 naming a missing `exploratory_session` means the body reached the server empty, not that you must nest it.", - "`title` is the only field the store requires. Everything else is optional.", - "Set the assignee with `owner` (a TCM user id) — NOT with `assignee`. On this create path only `owner` is translated to the upstream user id; `assignee` is forwarded raw and lands as a wrong or invalid user. On update both are translated, so the two endpoints disagree.", - "An `owner` that resolves to no user in this group does not fail: the session is created 201 with no assignee at all. Re-read the session if the assignment matters.", - "`issues` is forgiving in dangerous ways. A list of bare strings is accepted and coerced to {issue_id, issue_type:'jira'}. Jira ids are validated against ^[A-Z][A-Z0-9]*-[0-9]+$ and a bad one fails the whole call. But a list whose entries are neither strings nor objects carrying both issue_id and issue_type is silently replaced with an empty list — the session is created with no issues and no error. Each entry may also carry a `metadata` object ({url, ticket_id, title, display_category, issue_category}) even though only issue_id/issue_type are declared.", - "Issue linking happens asynchronously after the 201, so the session in the response does not yet show the links. Re-read it, or read the session's defects, to confirm.", - "`attachments` are integer ActiveStorage blob ids from a prior generic attachment upload — not file content, and not URLs. A blob already attached to another entity is refused with 422 'the attachment has been referenced elsewhere'. Unknown ids only error when NONE of them exist; a mixed list quietly attaches the ids that resolve and drops the rest.", - "`session_state` and `name` pass the request schema and are then dropped by the parameter filter — a session cannot be created closed, and the title must be sent as `title`.", - "`custom_fields` goes in as a map of {custom_field_id: value} and comes back as the normalised array shape; the two are not symmetric.", - "Take `exploratory_session.id` (integer) from the response and carry it as session_id into every log, defect, close, clone, update and delete call for this session.", - "Any upstream validation failure — an unknown configuration id, a folder in another project, a malformed value — arrives as 400 {success:false, message:'The API request is invalid or improperly formed'}. The message is the same for every cause, so it never tells you which field was wrong." - ] - }, - { - "name": "create_exploratory_session_log", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "body": [ - { - "name": "content", - "type": "string", - "example": "

Tapped checkout — spinner appeared but no order was created.

", - "description": "Rich-text HTML for the entry. Sanitised on write: anchors and images pointing outside this project/group are stripped. Reference already-uploaded images by blob id in tags here — this is the only way to attach media to a log.", - "json_path": "/session_log/content" - }, - { - "name": "status", - "type": "string", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "example": "fail", - "description": "Outcome recorded for this step. `skipped` is valid. Any other value is rejected by the request schema with 400 before the handler runs.", - "json_path": "/session_log/status" - }, - { - "name": "elapsed_time", - "type": "integer", - "example": 120, - "description": "Time spent on this step, in seconds. Must be an integer — a numeric string is rejected by the request schema.", - "json_path": "/session_log/elapsed_time" - }, - { - "name": "defects", - "type": "array", - "description": "Issues raised by this observation. Each entry is {issue_id, issue_type} and may carry a metadata object. Bare id strings are coerced to jira issues; a list whose entries lack issue_id or issue_type is silently reduced to no defects. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. Linked asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/session_log/defects" - } - ], - "intent": "Record one observation during an exploratory session — a note about what was tried and what happened, its pass/fail/bug outcome, the time it took and any defects it raised", - "returns": [ - "success", - "session_log", - "id", - "session_id", - "hashed_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "attachment_relations", - "created_at", - "updated_at" - ], - "responses": { - "201": { - "description": "Session log created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_log": { - "$schema": "ExploratorySessionLog" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 saying 'session_log is required' means the body was empty, not that you must nest it.", - "This is a child of a session: the session_id in the path is the parent, and the `id` you get back is the log_id you must carry — together with the same session_id — into the update and delete calls. A log id from one session is not addressable under another.", - "`status` accepts pass, fail, bug, retest, block, note and skipped. `skipped` is real and is missing from older declarations of this field; anything outside the seven is rejected by the request schema with 400.", - "Every field is optional — a log with no content, status or elapsed_time is created successfully. Nothing here rejects an empty observation.", - "`content` is rich-text HTML and is sanitised on write: anchors and images pointing outside this project or group are stripped, so the stored content can differ from what you sent.", - "Screenshots are not uploaded through this call. Upload the image first through the generic attachment upload, then reference the returned blob id in an tag inside `content`; the handler derives the attachment links from the blob ids it finds in the HTML. If ANY referenced blob id is not visible to your group or project, EVERY attachment link for the call is dropped and the log is still created 201 with no images attached.", - "`defects` behaves like the session's issues: bare id strings are coerced to jira issues, entries missing issue_id or issue_type silently reduce the list to empty, and a jira id must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. A per-entry metadata object is accepted even though only issue_id and issue_type are declared.", - "Defect linking is applied asynchronously after the 201, so the log echoed back may not show the links yet — re-read the logs, or the session's defects, to confirm.", - "`elapsed_time` is seconds.", - "There is no bulk log-create on this path; one call per observation." - ] - }, - { - "name": "create_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/filter", - "mode": "write", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "description": "Display name of the saved view. Must be unique for this project and entity — a duplicate is rejected by the backing service as a 400 'Filter name already exists'. There is also a server-side maximum length, also reported as a 400." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Which list this view filters. Validated by a request-body schema before the handler runs, so an unlisted value returns 400 'Invalid JSON data' with the accepted set echoed back. Cannot be inferred — it is required even though the conditions live in `filters`." - }, - { - "name": "is_project_level", - "type": "boolean", - "default": false, - "description": "true publishes the view to everyone on the project; false/omitted keeps it to you. Must be a real JSON boolean — the string \"true\" fails schema validation with 400." - }, - { - "name": "filters", - "type": "object", - "required": true, - "description": "The condition set the view applies, in the product UI's filter-state shape — NOT the flat id lists the entity search takes. The UI only applies a view whose conditions are in this shape: {systemFields: {priority: [{labelInnerText: 'High', label: 'High', value: 9262}]}, extraSystemFields: {issue_type: {label: 'Jira', value: 'jira'}}, matchAllMatchAny: {global: {value: 'all'}, priority: {value: 'any'}}}. Each selected option carries the project's numeric option id as `value` (resolve it with get_project_form_fields or get_system_field_values — the ids differ per project) and its display name as `label` and `labelInnerText`. `extraSystemFields.issue_type` is REQUIRED even when the view does not filter on linked issues: without it the view saves but the UI does not apply it. matchAllMatchAny carries `global` ('all' = match every filter, 'any' = match any one) plus one entry per filtered field ('any' = contains any one value, 'all' = contains all values). Nothing inside is validated, so a wrong shape still saves with a success response. Sent only when non-empty: `{}` is treated as absent and the backing service then rejects the create with 400 'Filters cannot be empty'." - } - ], - "intent": "Save a reusable named filter view on a project — the condition set behind a test case, test run, test plan or exploratory-session list — so the same selection can be reapplied later or shared with everyone on the project", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is flat — there is no wrapper object. It is also closed: any key other than name, entity, is_project_level and filters is rejected with 400 'Invalid JSON data' by the request-body schema before the handler sees it.", - "name AND entity are both required on create even though the conditions live in `filters`. entity is what binds the view to a list type; it is not derived from the contents of `filters`.", - "`filters: {}` is dropped as blank before the request is built, so an empty object behaves exactly like omitting the key and the create then fails downstream. Send at least one real condition.", - "Conditions are not checked against the project's custom fields at write time. A condition on a custom field that does not exist is accepted here and is silently pruned — permanently — the first time anyone reads the filter back.", - "Carry `data.id` from the response: that integer is the filter_id every later read, update and delete takes.", - "`extraSystemFields.issue_type` names the issue tracker the view's linked-issues condition searches, and must always be present. Send {label: 'Jira', value: 'jira'} unless the view filters on linked issues from another tracker. The allowed values are the product's own tracker names — jira, azure, asana, linear, clickup, devrev, github, gitlab, zoho, trello — not the integration route names (`jira-app` and the rest), which are a different vocabulary. Nothing checks that the project has that tracker connected; get_project_settings shows which trackers a project uses. To filter on another tracker's linked issues, take the value from the `issue_type` those issues carry on the test case.", - "Only the backing service's 400 is translated into `{success:false, message:...}`; any other failure from it is re-raised and surfaces as a 500, so a 500 here is not necessarily a server bug.", - "Not permission-gated — the filter controller has no entry in the RBAC map, so anyone with product access to the project can create a project-level view." - ] - }, - { - "name": "create_project", - "method": "POST", - "path": "/api/v1/projects", - "mode": "write", - "entity": "project", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Checkout Regression", - "json_path": "/project/name", - "description": "Project name. Leading/trailing whitespace is stripped. Not checked for uniqueness — a duplicate name is accepted." - }, - { - "name": "description", - "type": "string", - "json_path": "/project/description", - "description": "Free text. HTML is escaped server-side, so what comes back can differ from what you sent." - }, - { - "name": "access_type", - "type": "string", - "values": [ - "private", - "restricted", - "org_wide" - ], - "json_path": "/project/access_type", - "description": "Visibility of the new project. Only honoured when project-level RBAC is enabled for the group — otherwise accepted and ignored. When it IS enabled, a value outside this set fails the whole request with 400 'Invalid access type'." - }, - { - "name": "users", - "type": "array", - "fields": [ - { - "name": "user", - "type": "string" - }, - { - "name": "role", - "type": "string" - } - ], - "json_path": "/project/users", - "description": "Per-user grants applied at creation, as [{user, role}]. Only read when access_type is also sent AND project-level RBAC is on; ignored entirely otherwise." - } - ], - "intent": "Create a new project — the top-level container every folder, test case, run, plan and report lives under; returns both the integer id the v1 routes take and the PR-NNN identifier the v2 routes take", - "returns": [ - "data", - "success", - "project", - "id", - "identifier", - "name", - "description", - "created_at", - "thProjectId", - "normalisedName", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "starred", - "jira_mapped", - "user_role", - "permissions", - "links", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project created. The project is at data.project — same envelope and same serializer as the v1 project-detail read.", - "schema": { - "$schema": "V1ProjectDetail" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `project` wrapper is added for you from each field's json_path, and sending your own `project` object is rejected before the call as an unknown body key.", - "`name` is the only field that must be present. It is stripped of surrounding whitespace, and name uniqueness is not enforced here — creating a second project with an existing name succeeds and gives you two projects that are indistinguishable by name.", - "`description` is HTML-escaped server-side, so the stored value can differ from what you sent.", - "`access_type` and `users` are read only when project-level RBAC is enabled for the group; with that off both keys are accepted and silently ignored, and the project is created with the group default. With it on, an access_type outside private/restricted/org_wide fails the whole call with 400 'Invalid access type'.", - "The new project is at `data.project` — this v1 route wraps its payload in a `data` envelope. Carry `data.project.id` (integer) for every other the v1 surface project route and `data.project.identifier` (PR-NNN) for anything under the v2 surface; the two id forms are not interchangeable.", - "Requires the project_create permission under RBAC; without it the call is 403 'This action is not permitted for the user'.", - "There is no delete-project capability in this index, so a project created by mistake cannot be removed through the registry — only through the UI." - ] - }, - { - "name": "create_report_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "projectId", - "type": "integer", - "example": 10000, - "description": "Integer project id. Alias for `project_id`; either satisfies the project requirement on create, and the project in the path is used when both are absent." - }, - { - "name": "project_id", - "type": "integer", - "example": 10000, - "description": "Integer project id the report is primarily filed under. Must be one of `project_ids` when that is also sent." - }, - { - "name": "project_ids", - "type": "array", - "example": [ - 10000, - 10001 - ], - "description": "Integer project ids for a cross-project report. Max 60 (max 5 for User Workload Report), and only accepted for the seven multi-project types — any other type with more than one id is 400. The first entry becomes the primary project when `project_id` is omitted." - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary", - "description": "Report type as its DISPLAY NAME, not a slug. `Defects Summary` and `Defects Detailed Report` are accepted but have no server-side data branch, so such a report always reads back `report_data: null` — for defect figures use `Test Run Summary` and its `issues_by_priority` / `issues_by_status` sections. `Test Plan Detailed Report` is feature-flagged: an unflagged group gets 403." - }, - { - "name": "title", - "type": "string", - "example": "Weekly Test Case Activity" - }, - { - "name": "description", - "type": "string", - "example": "Testing" - }, - { - "name": "report_timeframe", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "The window the report covers, and the value of `reportTimeRange` on the summary-by-type read. The four relative windows and `custom` compute dates; `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500, not a 422). The remaining five carry no dates — each selects entities through one matching `report_filters` key." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "example": "custom_report", - "description": "`system_generated` is the one-click report the product creates for you; `custom_report` is user-configured. For a Requirement Traceability Report, `system_generated` makes the server auto-populate `report_filters.requirements` with every requirement in the project — do not also send that filter." - }, - { - "name": "grain", - "type": "string", - "values": [ - "test_case", - "test_plan" - ], - "default": "test_case", - "description": "Requirement Traceability Report only: `test_plan` renders Requirement to Test Plan to Sub-Test Plan rows. Any other report type is 400, and it is single-project only. Immutable — a later edit that changes it is refused 400 (resending the same value is fine)." - }, - { - "name": "is_dynamic", - "type": "boolean", - "description": "Whether the report re-resolves its selection each run. Omit and the server infers it from whether any `dynamic_filters` value is non-blank." - }, - { - "name": "test_run", - "type": "object", - "fields": [ - { - "name": "created_at", - "type": "string", - "required": true - }, - { - "name": "status", - "type": "array", - "required": true - }, - { - "name": "owner", - "type": "array", - "required": true - }, - { - "name": "automation_status", - "type": "array", - "required": true - }, - { - "name": "type", - "type": "array", - "required": true - } - ], - "json_path": "/dynamic_filters/test_run", - "description": "Dynamic (re-evaluated each run) run selection. Accepted under either `dynamic_filters` or `filters`; presence of any non-blank value here is what flips the report to dynamic when `is_dynamic` is omitted." - }, - { - "name": "status", - "type": "array", - "json_path": "/report_filters/status" - }, - { - "name": "priority", - "type": "array", - "json_path": "/report_filters/priority" - }, - { - "name": "case_type", - "type": "array", - "json_path": "/report_filters/case_type" - }, - { - "name": "assignee", - "type": "array", - "json_path": "/report_filters/assignee" - }, - { - "name": "automation_status", - "type": "array", - "json_path": "/report_filters/automation_status" - }, - { - "name": "automation_state", - "type": "array", - "json_path": "/report_filters/automation_state" - }, - { - "name": "test_runs", - "type": "array", - "description": "Run ids, and they must be INTEGERS — a quoted id or a TR-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_runs.", - "json_path": "/report_filters/test_runs" - }, - { - "name": "test_plans", - "type": "array", - "description": "Plan ids, and they must be INTEGERS — a quoted id or a TP-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_plans.", - "json_path": "/report_filters/test_plans" - }, - { - "name": "test_cases", - "type": "object", - "description": "Case selection — pairs with report_timeframe=specific_test_cases. Must be an OBJECT (an array is rejected 400 'Invalid report filters'), folder-keyed like SelectionData. Folder ids are STRING keys; case ids are INTEGERS (the numeric `id`, not TC-NNN). A wrong-but-object shape saves an UNSCOPED project-wide report at 200.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "required": true - }, - { - "name": "folders", - "type": "object", - "required": true - }, - { - "name": "unique_test_case_count", - "type": "integer", - "required": true - } - ], - "json_path": "/report_filters/test_cases" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cross-project case selection: { \"\": } for the report's non-primary projects. A project id outside `project_ids` is dropped silently.", - "json_path": "/report_filters/shared_selection" - }, - { - "name": "session_ids", - "type": "array", - "description": "Exploratory session ids — pairs with report_timeframe=specific_sessions.", - "json_path": "/report_filters/session_ids" - }, - { - "name": "requirements", - "type": "object", - "description": "{ issue_type: [issue_id, ...] } — pairs with report_timeframe=requirements. Must be an object; an array is rejected 400. Auto-populated by the server when report_creation_mode is system_generated on a traceability report.", - "json_path": "/report_filters/requirements" - }, - { - "name": "test_plan_selection", - "type": "object", - "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. `select_all` is required on each entry and must be a real boolean; a malformed entry is silently discarded rather than rejected.", - "json_path": "/report_filters/test_plan_selection" - }, - { - "name": "test_runs_by_project", - "type": "object", - "description": "Cross-project run selection: { \"\": [run_id, ...] }. Keys must be numeric strings and values arrays of numeric ids or the request is 400. When present it REPLACES the flat `test_runs` key, which is then ignored.", - "json_path": "/report_filters/test_runs_by_project" - }, - { - "name": "test_plans_by_project", - "type": "object", - "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Same validation as test_runs_by_project, and it replaces the flat `test_plans` and `test_plan_selection` keys.", - "json_path": "/report_filters/test_plans_by_project" - }, - { - "name": "builds", - "type": "array", - "json_path": "/report_filters/builds", - "description": "Internal build ids paired 1:1 with `test_runs`. Sent back by the read path for round-tripping an edit; do not invent values." - }, - { - "name": "projects", - "type": "array", - "json_path": "/report_filters/projects" - }, - { - "name": "folder", - "type": "array", - "json_path": "/report_filters/folder" - }, - { - "name": "test_case_tags", - "type": "array", - "json_path": "/report_filters/test_case_tags" - }, - { - "name": "tc_custom_fields", - "type": "object", - "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` — on the other types the server never reads it and drops it silently. Persisted, and read back, under the name `custom_fields`.", - "json_path": "/report_filters/tc_custom_fields" - }, - { - "name": "custom_date_range", - "type": "string", - "example": "2025-04-16 2025-04-24", - "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED whenever report_timeframe is `custom` — omitting it is a 500, not a validation error. Ignored (and nulled on edit) for every other timeframe." - }, - { - "name": "users", - "type": "array", - "description": "TM user ids to mail the report to. Every id must belong to your group or the whole request is 400 'Invalid mail to users'. Only persisted when the report is scheduled.", - "json_path": "/mail_to/users" - }, - { - "name": "external_mails", - "type": "array", - "description": "Email addresses outside the group. Only persisted when the report is scheduled.", - "json_path": "/mail_to/external_mails" - }, - { - "name": "frequency", - "type": "string", - "values": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly", - "description": "Scheduling cadence. Send `frequency` and `frequency_details` together or omit BOTH — omitting both creates a valid unscheduled, on-demand report (and `mail_to` is then dropped silently). The enum is daily/weekly/monthly only: `once` passes body validation and then raises 'Unsupported frequency type' as a 500." - }, - { - "name": "time", - "type": "string", - "example": "08:00", - "description": "24-hour HH:MM, UTC. Required for every cadence; missing or malformed is 400 'Invalid report frequency'.", - "json_path": "/frequency_details/time" - }, - { - "name": "day", - "type": "string", - "example": "monday", - "description": "Required for weekly — a lowercase English day name, nothing else — and for monthly, where it is an integer 1-28 (29-31 are rejected). Omit for daily.", - "json_path": "/frequency_details/day" - } - ], - "intent": "Save a report definition over a project's runs, plans, cases, sessions or requirements — scheduled to email on a cadence, or unscheduled and read on demand", - "returns": [], - "responses": { - "200": { - "$response": "CreateReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Four body fields are required by the validator, not just the three the spec marks: `report_type`, `report_timeframe`, `report_creation_mode`, and a project (either `project_id`/`projectId` in the body or the project in the path). Any one missing comes back 400 `Validation error. Invalid report type|timeframe|creation mode.` — the message names which.", - "This returns HTTP 200, not 201, despite creating a record. The new report is at `data`; carry `data.id` (an integer) forward as the `report_id` for every other report call. `data.identifier` is the SC-NNN display string and is NOT accepted anywhere as an id.", - "Creating the report does NOT compute it. The response `data` is the definition only; there is no `report_data` here. Read the report afterwards to get figures, and name the sections you want.", - "`report_timeframe` and `report_filters` must agree. The five entity timeframes each read exactly one filter key — specific_test_runs reads `test_runs`, specific_test_plans reads `test_plans`, specific_test_cases reads `test_cases`, specific_sessions reads `session_ids`, requirements reads `requirements`. Naming the timeframe and leaving its filter empty saves a report that resolves to nothing, at 200.", - "`report_filters.test_runs` and `.test_plans` must be arrays of INTEGERS. A string id (\"123\") or a TR-NNN/TP-NNN identifier fails the whole request with 400 `Validation error. Invalid report filters.`, and `test_cases` / `requirements` must be objects, not arrays.", - "Scheduling is inferred, never declared: the report is scheduled iff BOTH `frequency` and `frequency_details` are present. `mail_to` is only persisted for a scheduled report — recipients sent on an unscheduled one are dropped silently and `next_run_at` stays null. Use the send-email-now capability to mail an unscheduled report.", - "`report_timeframe: custom` without `custom_date_range` is an unhandled 500, not a 422 — the server splits the value unconditionally. Send \"YYYY-MM-DD YYYY-MM-DD\", space separated.", - "`frequency: once` is accepted by body validation and then raises 'Unsupported frequency type' as a 500. Omit both scheduling fields for a one-off report instead.", - "Cross-project: send `project_ids` (integers). At most 60, and only for Test Run Summary, Test Plan Summary, Test Run Detailed Report, Test Plan Detailed Report, Test Case Activity, Requirement Traceability Report and User Workload Report — anything else is 400. User Workload Report caps at 5 projects. When both are sent, `project_id` must be one of them; when only `project_ids` is sent the first entry becomes the primary project.", - "`report_creation_mode: system_generated` on a Requirement Traceability Report makes the server fetch every linked requirement in the project and populate `report_filters.requirements` for you — do not also send that filter.", - "`grain: test_plan` is Requirement Traceability Report only, single-project only, and immutable after creation (a later edit that changes it is refused 400). Omit it for the normal test-case grain.", - "Requires the report_create permission on the project; 403 otherwise. `Test Plan Detailed Report` additionally needs a per-group feature flag and returns 403 `Operation not permitted` without it.", - "The write also syncs the report to Test Observability. If that sync fails the created row is deleted again and the call errors — a 5xx here means nothing was kept." - ] - }, - { - "name": "create_root_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folders", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Root Folder A", - "json_path": "/folder/name", - "description": "Folder name. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." - }, - { - "name": "notes", - "type": "string", - "example": "This folder contains all regression test cases", - "json_path": "/folder/notes", - "description": "Free-text description, HTML allowed. It is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. Note the key is `notes`: `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Send the literal string \"true\" to have a clashing name auto-suffixed instead of rejected. Any other value, and any boolean, is treated as false — the comparison is against the string \"true\"." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Integer ids of already-uploaded attachment blobs to bind to the folder. Each must belong to this group/project and must not already be attached to anything else — a reused blob fails the whole call with 422 and the bare string \"the attachment has been referenced elsewhere\"." - } - ], - "intent": "Create a top-level folder in a project — the first thing you need before test cases can be filed anywhere", - "returns": [ - "data", - "success", - "folder", - "project", - "id", - "name", - "notes", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__CreateRootFolder" - }, - "400": { - "$response": "BadRequest" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `name` and `notes` flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", - "Uniqueness is always enforced against sibling root folders. A duplicate name comes back with the entity store's own message and status relayed verbatim — this is one of the few folder endpoints that does NOT mask the upstream text. Send auto_rename:\"true\" if you want it renamed instead.", - "The created folder is at `data.folder`, not at the top level. `data.project` carries the project it landed in. The id you carry forward is `data.folder.id`, a plain integer.", - "Passing the literal path segment `new` as project_id CREATES a default project and puts the folder in it. Only do that deliberately.", - "Requires the folder-create permission; 403 otherwise.", - "Folders keep no version history, so a folder created here can only be undone by deleting it — and folder deletion is itself not reversible through this API." - ] - }, - { - "name": "create_shared_step", - "method": "POST", - "path": "/api/v1/projects/{project_id}/shared-steps", - "mode": "write", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the shared step block. Only presence of the key is checked here — an empty string passes this layer and is rejected by the backing service, so a blank title fails late with a different error than a missing one." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Shared-field folder to file the block under. The key is forwarded only when present; omit it (or send null) to leave the block at the root (Unassigned)." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "description": "Plain tag name strings on the shared step itself (separate from the tags on the test cases that embed it). Accepted here but previously undeclared." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The ordered step rows. Required — omitting the key entirely fails local validation with a flat 400 'Validation Error' that names no field. An empty array IS accepted and creates a titled block with no steps.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "minimum": 1, - "description": "1-based position. Every row needs order >= 1; a missing or 0 order fails the whole request, not just that row." - }, - { - "name": "step", - "type": "string", - "description": "Step body, HTML. Each row must carry at least one of step or result or the whole request is rejected." - }, - { - "name": "result", - "type": "string", - "description": "Expected result, HTML. Each row must carry at least one of step or result." - }, - { - "name": "test_data", - "type": "string" - } - ] - } - ], - "intent": "Create a reusable shared step block in a project — a titled set of step/result rows that many test cases can then embed by reference instead of copying", - "returns": [ - "success", - "data", - "id", - "title", - "step_count", - "test_case_count", - "tags", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedStepsPostResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "render_image_urls", - "type": "boolean", - "default": false, - "description": "When true, inline image attachments referenced in the step/result HTML are returned as resolved URLs rather than raw references." - } - ], - "guidance": [ - "The body is flat — title, folder_id, tags and shared_step_details sit at the top level with no wrapper.", - "shared_step_details is required even when empty. Every row must have order >= 1 and at least one of step/result; one bad row fails the entire call with a bare 400 `{data:null, success:false, message:\"Validation Error\"}` that does not say which row or which field.", - "Success is 200 with `{data, success:true}` — not 201, and the body is not empty.", - "Carry `data.id`. That integer is the shared_step_id you put on a test case's `test_case_steps[].shared_step_id`, and the id for every later read, update and delete of this block.", - "Creating a shared step attaches it to nothing. Embedding is done from the test case side; this call only makes the block available.", - "step and result are HTML. Inline attachment references are validated server-side and an unresolvable one fails the call with 400 'attachment in text is invalid'.", - "Requires tc_write — the same permission as editing a test case." - ] - }, - { - "name": "create_step_result", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/step/{step_id}", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (this route only matches digits; a PR-NNN identifier never reaches the API)." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run id. This v1 route accepts both the bare integer and the TR-NNN identifier." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1 - }, - { - "name": "step_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric id of the step on the test case (from the case detail), not its position in the step list. The route only matches digits." - } - ], - "body": [ - { - "name": "status", - "type": "string", - "values": [ - "untested", - "passed", - "failed", - "retest", - "blocked", - "skipped", - "in_progress" - ], - "example": "passed", - "json_path": "/test_run_step_result/status", - "description": "Step outcome as a NAME. This is the value that is actually written. A same-named key at the top level of the body is validated but never written." - }, - { - "name": "status_id", - "type": "integer", - "json_path": "/test_run_step_result/status_id", - "description": "Numeric status id. Accepted and forwarded, but the step surface is name-based — prefer `status` unless you know the backing service expects an id here." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run_step_result/description", - "description": "Note for this step. Rich text; links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent. Also copied onto the case-level result." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_run_step_result/attachments", - "example": [ - 10288829 - ], - "description": "Integer blob ids from a prior generic attachment upload (or {id: } objects) — NOT filenames and NOT file bytes. An unresolvable or already-used blob fails the whole call with 422." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_run_step_result/custom_fields", - "description": "Custom field values keyed by custom field id. Copied onto the case-level result as well as the step." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run_step_result/issues", - "description": "Linked defects. Each entry must be an OBJECT; a bare string is dropped by the parameter filter with no error, so the link is silently never created." - } - ], - "intent": "Log the outcome of ONE step inside a test case's execution in a run — the per-step pass/fail you record while working through a manual test case, as opposed to the case's overall result", - "responses": { - "200": { - "description": "The full test result for the case, at the top level of the body (no envelope). The step's own outcome is nested under `test_run_step_result`.", - "schema": { - "$schema": "TestResult__CreateStepResult" - } - }, - "400": { - "description": "Either a duplicate request inside the one-second lock ({success:false, message:'Request is already being processed'}) or a top-level `status` outside the supported step statuses ({error:'Validation error'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Caller lacks execute permission on this test run ({success:false, message:\"You don't have permission to execute test cases in this test run\"})." - }, - "422": { - "description": "An attachment blob id could not be resolved or is already referenced elsewhere ({base: [reason]})." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `test_run_step_result` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. That object is forwarded to the backing service essentially verbatim, so only the keys below are acted on locally and unrecognised keys are passed through rather than rejected.", - "The step's status travels as a NAME inside the wrapper (`status`), not as the numeric status id the case-level result endpoints take. The supported names are untested, passed, failed, retest, blocked, skipped, in_progress.", - "There is a decoy: a TOP-LEVEL `status` key is validated against that same list and 400s {error:'Validation error'} when it is not in it — but it is not the value that gets written. Sending only a top-level status passes validation and logs nothing.", - "Repeat calls for the same project/run/case/step within one second are refused with 400 {success:false, message:'Request is already being processed'} by a one-second lock. That is a de-duplication guard, not a validation failure — pause and retry.", - "Logging a step result also rewrites the CASE's result in that run: if the wrapper carries any of description, attachments, custom_fields or issues, those are copied onto the case-level result and its status is taken from the step write. A status-only step write leaves the case result's fields alone.", - "`attachments` here are integer blob ids from a prior generic attachment upload — not filenames and not file content. An id that cannot be resolved, or a blob already attached elsewhere, fails the whole call with 422.", - "Requires execute permission on the run (403 otherwise). `step_id` is the numeric id of the step on the test case — read it from the case detail; it is not the step's 1-based position.", - "The 200 body is the full CASE result object at the top level — no envelope, no `success` key, and no separate step-result record. The step's own outcome is nested under `test_run_step_result` on it." - ], - "query": [ - { - "name": "configuration_id", - "type": "integer", - "description": "Target the execution for one configuration when the case runs against a configuration matrix. Forwarded as configId." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target one specific run/case row. Also selects the newer write path, on which the response is an optimistic payload built from the submitted body rather than a re-read of the stored result." - } - ], - "returns": [ - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "attachments", - "issues", - "test_run_step_result", - "links" - ] - }, - { - "name": "create_sub_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mkdir", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/folder/name", - "description": "Name of the new subfolder. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." - }, - { - "name": "notes", - "type": "string", - "json_path": "/folder/notes", - "description": "Free-text description, HTML allowed; sanitised server-side so the stored value can differ from what you sent. The key is `notes` — `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Integer ids of already-uploaded attachment blobs. Each must belong to this group/project and must be unattached; a reused blob fails the call with 422 and the bare string \"the attachment has been referenced elsewhere\"." - } - ], - "intent": "Create a folder inside an existing folder — use this to build out a nested test-case tree one level at a time", - "returns": [ - "data", - "success", - "folder", - "id", - "name", - "notes", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__CreateSubFolder" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", - "`folder_id` in the path is the PARENT. It is fetched first, so a folder id from another project comes back as a bare 404 {success:false} with no message telling you which id was wrong.", - "folder_id must be a positive integer. Literal 0 raises an unhandled ArgumentError and surfaces as a 500, not a 400.", - "Sibling-name uniqueness is always checked and the upstream message is relayed verbatim on failure (unusually — most folder endpoints mask it). auto_rename:\"true\" turns the rejection into a silent rename.", - "The new folder is at `data.folder`; there is no `folder` at the top level and no `project` key on this action (unlike root creation). Carry `data.folder.id` forward.", - "Requires the folder-create permission." - ] - }, - { - "name": "create_template", - "method": "POST", - "path": "/api/v2/templates", - "mode": "write", - "entity": "template", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Template name. Must be unique within the workspace for the entity type; a duplicate is rejected upstream with a 400 carrying the reason.", - "minLength": 1, - "example": "Regression Template" - }, - { - "name": "entity", - "type": "string", - "description": "What the template applies to. Only test-case templates are supported today; leave this out and it defaults to TestCase.", - "values": [ - "TestCase" - ], - "default": "TestCase" - }, - { - "name": "description_fields", - "type": "array", - "description": "Ordered fields placed in the template's description section. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean", - "description": "True for a built-in field, false or absent for a custom field." - }, - { - "name": "is_mandatory", - "type": "boolean", - "description": "Whether a test case using this template must fill the field." - } - ] - }, - { - "name": "properties_fields", - "type": "array", - "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields — the template gets exactly the ids you send. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - }, - { - "name": "linked_projects", - "type": "object", - "description": "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", - "fields": [ - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Automatically link projects created later.", - "default": false - }, - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "Link every existing project.", - "default": false - }, - { - "name": "link_projects", - "type": "array", - "description": "Integer project ids to link. These are the numeric project ids, not the PR- identifiers." - }, - { - "name": "unlink_projects", - "type": "array", - "description": "Integer project ids to unlink. Meaningless on a create." - } - ] - } - ], - "intent": "Create a new test-case template — define which description and property fields a test case gets and, optionally, which projects it applies to.", - "guidance": [ - "NON-REMOVABLE SYSTEM FIELDS 17 AND 18 MUST BE INCLUDED or the create fails with 400 `templateFieldValidationError: Non-removable System fields not present in template.` These are the Review & Approve pair. You cannot discover them by reading an existing template: get_template DROPS them from its response (`field_count` 16 vs 14 entries returned, on every template including one just created with all 16), so a read-then-clone round trip always fails. Add {id:17,...} and {id:18,...} to properties_fields explicitly.", - "Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", - "Paid plans only. On a free plan the gate answers with HTTP 200 and a body of success:false / 'This Feature is only for paid customers'. Check the success flag, not the status code, before reporting the template as created.", - "Nothing is validated locally — name, field ids and project ids all go straight upstream, so a malformed body surfaces as a 400 with a single message rather than a per-field error list.", - "A create does not auto-inject the Review & Approve system fields, but a later update of properties_fields will. The two calls therefore do not behave the same way on the same field list.", - "The 201 body carries the full template including its fields — read it rather than re-fetching, and keep the returned id.", - "Success is 201 with the full template echoed, linked to the named project. A 403 'This action is not permitted for the user' is a permission gap on the calling credential, not the paid-plan gate." - ], - "returns": [ - "success", - "template", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at", - "description_fields", - "properties_fields", - "step_fields" - ], - "responses": { - "201": { - "$response": "TemplateDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_case_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Create one test case at the project level — the call to use to seed the FIRST case in a project that has no folders yet, via create_at_root.", - "returns": [ - "success", - "data", - "test_case", - "folder", - "project", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "$schema": "V1TestCaseCreateResponse__CreateTestCaseByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This route has no folder in its path, so you must supply one: either `test_case.test_case_folder_id` (an existing folder) plus a top-level `folder_id` naming the same folder, or `create_at_root: true` to have the folder minted.", - "Without a resolvable folder the create still reaches the backend and then fails while building the response, surfacing as a 422 rather than a clear validation error. Resolve the folder id first.", - "`create_at_root: true` only works on a project with ZERO folders — it creates a folder called 'New Folder' and puts the case there. On a project that already has folders it is 400 'Root Folder Already Exists'.", - "For any project that already has folders, prefer the folder-scoped create instead; this route exists for the empty-project case.", - "Steps only come from `test_case_steps`, and only when `template`/`template_step_type` selects a step-shaped template — the default is `test_case_text`, which stores no steps and still returns 200.", - "The new case comes back at `data.test_case`; take its integer `id` and its TC-NNN `identifier` from there. `data.folder`, `data.project` and `data.path_folders` describe where it landed.", - "If the project has e-signature enabled and you set `review_status: in_review` (or `send_for_review`), the call needs a `test_case.reauth_token`; without it the response is 401 `{success:false, error_code:\"reauth_required\"}`.", - "A test case keeps version history, so an edit is recoverable — but nothing undoes a create except deleting the case." - ], - "body": [ - { - "name": "test_case", - "type": "object", - "required": true, - "description": "The case to create. Everything about the case goes inside this wrapper — the handler requires it, so a flat body is a 400 before any field is read.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body — the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." - }, - { - "name": "automation_state", - "type": "integer" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." - }, - { - "name": "case_type", - "type": "integer", - "description": "Numeric case-type option id, or the project's display name for it." - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Ignored on create — dataset mappings are always regenerated from `test_case_dataset`." - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer", - "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Dropped on create — the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." - }, - { - "name": "status", - "type": "integer", - "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here — it is written through as the id and breaks the field." - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string" - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." - } - ] - }, - { - "name": "create_at_root", - "type": "boolean", - "description": "Create the project's first folder ('New Folder') and put this case in it. Only valid when the project has NO folders — otherwise 400 'Root Folder Already Exists'." - }, - { - "name": "folder_id", - "type": "integer", - "description": "TOP-LEVEL folder id, outside `test_case`. This is what the ownership check reads and what the success response's folder block is built from; without it (and without `create_at_root`) the response build fails and you get a 422." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there. The subfolder is deleted again if the create then fails." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." - } - ] - }, - { - "name": "create_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_case", - "type": "object", - "required": true, - "description": "The case to create. Everything about the case goes inside this wrapper — the handler requires it, so a flat body is a 400 before any field is read.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body — the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." - }, - { - "name": "automation_state", - "type": "integer" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." - }, - { - "name": "case_type", - "type": "integer", - "description": "Numeric case-type option id, or the project's display name for it." - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Ignored on create — dataset mappings are always regenerated from `test_case_dataset`." - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer", - "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Dropped on create — the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." - }, - { - "name": "status", - "type": "integer", - "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here — it is written through as the id and breaks the field." - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string" - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." - } - ] - }, - { - "name": "create_at_root", - "type": "boolean", - "description": "Not useful on this route — the folder is already in the path. It only has an effect on a project with zero folders, where it mints 'New Folder'; otherwise it turns the call into a 400 'Root Folder Already Exists'." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there instead of in the path folder." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." - } - ], - "intent": "Create one test case in a folder — the normal single-case create. For more than one case, use the bulk create instead.", - "returns": [ - "success", - "data", - "test_case", - "folder", - "project", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "$schema": "V1TestCaseCreateResponse__CreateTestCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the plural name this creates exactly ONE case: the body is a single `test_case` object, not a list. The bulk route is the one that takes an array.", - "`test_case.test_case_folder_id` is required in the BODY as well as in the path — the path folder is not substituted in, and omitting it is the most common create failure (a 422 wrapping an upstream 404).", - "Steps only come from `test_case_steps`, and only when the template is step-shaped. Neither `template` nor `template_step_type` sent means `test_case_text`, which stores no steps and still returns 200 — verify `test_case_steps` in the response.", - "priority and case_type accept either the numeric option id or the project's display name; `status` does NOT — a status name is written through as an id. Resolve the ids from the project's system field values first.", - "`review_status` is never left alone on create: omitted, it is set from the project's review-approve setting, falling back to `pending`. `reviewers` is only honoured on a Team-Pro workspace with review-approve enabled, and including the owner is a 422.", - "The new case is at `data.test_case` — its integer `id` and TC-NNN `identifier` are both there. `data.request_trace_id` refers to the AI enrichment that continues after the response.", - "Linked `issues` are processed asynchronously after the response, so a 200 does not mean the links exist yet." - ] - }, - { - "name": "create_test_plan_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/test_plan/name", - "description": "Plan name. REQUIRED — a missing or blank name is 400 {success: false, message: 'Name field cannot be blank'}, and nothing else in the body is looked at." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text. Sanitised against the RTE allowlist and rewritten server-side, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "description": "ISO-8601 date. Stored as midnight UTC. An unparseable value is silently dropped rather than rejected.", - "format": "date", - "json_path": "/test_plan/start_date" - }, - { - "name": "end_date", - "type": "string", - "description": "ISO-8601 date. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and creates nothing.", - "format": "date", - "json_path": "/test_plan/end_date" - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "ACCEPTED BUT NEVER APPLIED ON CREATE — it is not forwarded upstream, so every new plan starts as `new`. Move it on with a follow-up plan update.", - "json_path": "/test_plan/plan_status" - }, - { - "name": "owner", - "type": "integer", - "description": "Owner as the internal TM user id (the `id` from the project users read — NOT a BrowserStack user id and not an email). Omit the key to own it yourself. An id that does not resolve inside the caller's group is swallowed, not rejected, and the plan is created with no real owner.", - "json_path": "/test_plan/owner" - }, - { - "name": "parent_plan_id", - "type": "integer", - "description": "Parent plan's INTEGER id. Set it to create a SUB-plan — the result carries an STP-NNN identifier. Null/omitted means a top-level plan. This choice is effectively permanent: a plan can never later be demoted into a sub-plan or promoted out of one.", - "json_path": "/test_plan/parent_plan_id" - }, - { - "name": "tags", - "type": "array", - "description": "Plain tag strings. If ANY entry is not a string the whole list is silently replaced with [] — the plan is created with no tags and no error.", - "json_path": "/test_plan/tags" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer user ids. Attach-only — adding reviewers does NOT send the plan for review, and reviewers are only emailed once the plan is actually in review. Rejected if the project restricts reviewers and one is not on the allowed list.", - "json_path": "/test_plan/reviewers" - }, - { - "name": "attachments", - "type": "array", - "description": "Ids of attachments already uploaded via the attachments surface. An id that does not verify against this group returns 422 with the verifier's own body, not the usual {success, error} envelope.", - "json_path": "/test_plan/attachments" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom-field values keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: }.", - "json_path": "/test_plan/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked tracker issues. Structured — NOT a flat array of keys. A bare string entry is coerced to {issue_id: }, losing the tracker type. Only url and ticket_id are kept inside metadata; other metadata keys are dropped.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_plan/issues" - }, - { - "name": "test_runs", - "type": "array", - "description": "Test runs to link, as run INTEGER ids (a run's `uuid`, not its TR-NNN `id`). Linking MOVES a run: it leaves whatever plan held it, with nothing in the 200 to say so.", - "json_path": "/test_plan/test_runs" - } - ], - "intent": "Create a test plan to group test runs for a release or milestone — or create a SUB-plan under an existing plan by setting parent_plan_id", - "responses": { - "200": { - "description": "The created plan under a top-level `test_plan` key (no `data` wrapper). `identifier` is the `TP-NNN` (or `STP-NNN` for a sub-plan) and `id` is the integer the other v1 routes take.\n", - "schema": { - "$schema": "V1TestPlanDetail__CreateTestPlanByIntegerId" - } - }, - "400": { - "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`) or a rejected custom field (`{success: false, error: ...}`). A body with no `test_plan` wrapper lands here too.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "error": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Also used for an upstream payload rejection: `{success: false, error: \"Invalid Params\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Every field goes inside a `test_plan` object. A flat body is rejected before any field is read.", - "name is the only required field, and it is checked first — a 400 {success: false, message: 'Name field cannot be blank'} means exactly that and nothing about the rest of the body.", - "plan_status is accepted and then ignored. A new plan is always `new`; drive it to started/completed with the plan update capability, which needs the matching date in the same request.", - "Bad dates are a 200 trap: end_date < start_date returns HTTP 200 carrying {success: false, error: 'Please check date params'}. Branch on `success`, never on the status code.", - "An upstream rejection of the payload is reported as HTTP 404 with {success: false, error: 'Invalid Params'} — a 404 from this endpoint usually means a bad body, not a missing project.", - "Linked issues are processed on a background job, so they are usually absent from the plan you get back in this response even though the call succeeded. Re-read the plan to see them.", - "Carry `test_plan.id` (the integer) forward for every other v1 plan route; `test_plan.identifier` is the TP-NNN (STP-NNN for a sub-plan) used for display and by the newer API surface.", - "The reply is the re-read plan under a top-level `test_plan` key — there is no `data` wrapper." - ], - "returns": [ - "success", - "test_plan", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "start_date", - "end_date", - "owner", - "assignee", - "created_by", - "tags", - "issues", - "attachments", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "total_test_runs_count", - "test_plan_progress_v2", - "record_status", - "project_id", - "created_at", - "updated_at", - "links" - ] - }, - { - "name": "create_test_result_for_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "status_id", - "type": "integer", - "description": "Result status id — resolve the name (\"Passed\", \"Failed\") to an id first; this field takes the id. Effectively required, though a missing value is accepted and leaves the result unstatused.", - "json_path": "/test_result/status_id" - }, - { - "name": "description", - "type": "string", - "json_path": "/test_result/description" - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_result/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects. Each entry is an OBJECT — a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_result/issues" - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_result/attachments" - }, - { - "name": "elapsed_time", - "type": "integer", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration this result is for. TOP level — the server does not read it from inside `test_result`, so nesting it silently logs the result against the default configuration." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target a specific run/case mapping. Top level." - }, - { - "name": "test_case_count", - "type": "integer", - "description": "Total number of test cases linked to the automation execution" - } - ], - "intent": "Create a new test result for a test case in a test run", - "guidance": [ - "This is how a pass or fail is recorded against a case in a run. Editing the run or the case does not record an outcome." - ], - "returns": [ - "id", - "status", - "backtrace", - "configuration_id", - "created_at", - "created_by_imported", - "custom_fields", - "description", - "error_description", - "expanded", - "failure_type", - "finished_at" - ], - "responses": { - "201": { - "$response": "TestResultCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_results_for_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - } - ], - "body": [ - { - "name": "results", - "type": "array", - "minItems": 1, - "description": "Bulk form: one entry per test case. Mutually exclusive with the single form below — send `results` OR `test_result`, never both. Each entry needs `test_result` plus `test_case_id` or `execution_id`. No other keys are allowed on an entry.", - "fields": [ - { - "name": "test_case_id", - "type": "string", - "description": "Test case identifier, e.g. TC-101. Required unless execution_id is given." - }, - { - "name": "execution_id", - "type": "string", - "description": "Automation execution id, as an alternative target to test_case_id." - }, - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "test_result", - "type": "object", - "description": "The outcome itself: status (name), description, issues, step_results. Same shape as the single-result `test_result` below." - }, - { - "name": "attachments", - "type": "array", - "maxItems": 10, - "description": "Multipart file part filenames for this entry. Ignored on a plain JSON request." - } - ] - }, - { - "name": "status", - "type": "string", - "description": "Result status by NAME as configured for the project (\"Passed\", \"Failed\", \"Untested\"). v1's numeric status_id is NOT accepted here. Required unless step_results is sent instead.", - "json_path": "/test_result/status" - }, - { - "name": "description", - "type": "string", - "description": "Notes on the outcome. Rich text is accepted. Silently truncated at 16 KB rather than rejected.", - "json_path": "/test_result/description" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects as plain KEY STRINGS (e.g. [\"JIRA-12\"]) — unlike v1, which takes objects. Pair with issue_tracker to type them.", - "json_path": "/test_result/issues" - }, - { - "name": "issue_tracker", - "type": "object", - "description": "Types the linked issues, e.g. {\"name\": \"jira\"}. Only read from inside test_result.", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "json_path": "/test_result/issue_tracker" - }, - { - "name": "elapsed_time", - "type": "integer", - "description": "Execution time in seconds. Stored as time_elapsed.", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Result custom fields keyed by field name; resolved against the project's TestResult custom fields.", - "json_path": "/test_result/custom_fields" - }, - { - "name": "step_results", - "type": "array", - "minItems": 1, - "description": "Per-step outcomes. Send these INSTEAD of a top-level status; each entry needs `status` plus `step_id` or `step_index`. The parent case status is rolled up from the steps.", - "fields": [ - { - "name": "step_id", - "type": "number" - }, - { - "name": "step_index", - "type": "number" - }, - { - "name": "status", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "created_at", - "type": "string" - }, - { - "name": "updated_at", - "type": "string" - } - ], - "json_path": "/test_result/step_results" - }, - { - "name": "test_case_id", - "type": "string", - "example": "TC-101", - "description": "Single form: which case in the run this result is for. TOP level — not inside test_result. One of test_case_id, execution_id or shared_test_case_ids is required." - }, - { - "name": "execution_id", - "type": "string", - "description": "Single form: target an automation execution instead of a case identifier. Top level." - }, - { - "name": "shared_test_case_ids", - "type": "object", - "description": "Single form: target a case shared from another project, as {project identifier: [test case identifiers]}. Must resolve to EXACTLY ONE case — zero or several gives 400 \"Test Case ID is invalid\"." - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration this result is for. TOP level. Omitting it logs against the default configuration." - }, - { - "name": "attachments", - "type": "array", - "maxItems": 10, - "description": "Filenames of multipart file parts to attach to this result. Only binds on a multipart/form-data request whose JSON body rides in a `payload` part; on a plain JSON request it is accepted and silently does nothing. Each entry is a bare filename string of 1-255 characters." - }, - { - "name": "validate_tc", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Bulk only. \"true\" pre-checks every test_case_id exists in the project and 400s listing the invalid ones. Rejected outright when the request carries 300 or more unique test case ids." - } - ], - "intent": "Record the outcome of running test cases in a test run — pass/fail/blocked with notes, per-step results, linked defects and attachments — for one case or for many in a single call", - "guidance": [ - "Two shapes on one endpoint: `results: [...]` for many cases, or `test_result` plus one target (`test_case_id`, `execution_id` or `shared_test_case_ids`) for one. Sending both, neither, or any unrecognised top-level key is rejected 400 as invalid params.", - "The single form answers with `test-result` for an ordinary submission, but a submission carrying `step_results` answers with `test-run-step-results` and `overall-result-status` INSTEAD — there is no `test-result` key on that response at all. Branch on which key is present rather than assuming.", - "Status is a NAME configured for the project, not an id. An unconfigured name fails that entry with INVALID_STATUS_VALUE.", - "Each submission ADDS a result to the case's history in this run; it never replaces the previous one. To move many cases to one status instead, use bulk_set_test_result_status.", - "The bulk form is queued, not applied inline: 200 means accepted. Read `results[]` — every entry is ACCEPTED or FAILED. A mix of good and bad entries still returns HTTP 200 but with `success: false` and `status: \"PARTIAL\"`; only an all-bad request returns 400. Treating 200 as 'all logged' will misreport.", - "`test-result.issues` comes back as an array of plain issue KEY STRINGS, matching the way issues are sent in, not as objects.", - "`test-result.urls.self` points at the case's ROW IN THE RUN, so its last path segment is the run row's id, not the id of the result just created. Use `test-result.id` for anything that addresses the result itself.", - "A project can restrict which review statuses may be executed. Blocked cases come back with a REVIEW_STATUS_GATE error and are never logged, whatever the status you sent.", - "Attachments require a multipart/form-data request: JSON body in a `payload` part, each file in its own part, and every name in `attachments` must match a part's filename or the request is rejected. Limits are 10 files per result, 100 per request, 50 MB per file, and filenames must be unique across the request.", - "The `attachments` array in the response is the bind result, not the stored attachment records — each entry carries the attachment id, the filename, a time-limited url and the expiry of that url. `attachment_errors` appears only when a bind failed, and `dropped_attachments` only on a bulk request where a rejected entry or an unresolvable inline reference left files behind.", - "Attachments cannot be combined with step_results on the same bulk entry — that request is rejected 400.", - "Success is HTTP 200 here, not the 201 the v1 create returns.", - "Requires the execute permission on the run; a caller without it gets 403 before anything is written." - ], - "returns": [ - "success", - "test-result", - "test-run-step-results", - "overall-result-status", - "results", - "status", - "message", - "attachments", - "attachment_errors", - "dropped_attachments" - ], - "responses": { - "200": { - "$response": "V2TestRunResultSubmitResponse" - }, - "400": { - "description": "Invalid params, unconfigured status for every entry, every case blocked by the review-status gate, or a multipart/attachment rejection. Bulk bodies carry per-case `results` entries with an `error.code` such as INVALID_STATUS_VALUE or REVIEW_STATUS_GATE.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "results": { - "type": "array", - "items": { - "$schema": "V2BulkResultOutcome" - } - }, - "dropped_attachments": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Rich-text description referenced an attachment that could not be resolved.", - "schema": { - "type": "string", - "example": "attachment in rich text editor is invalid" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "filters", - "type": "object", - "example": { - "priority": [ - "High" - ], - "folder_ids": [ - 105 - ] - }, - "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body — not inside `test_run`. This does NOT select cases: it never back-fills existing ones, so a create whose only case-bearing key is `filters` returns 200 with an EMPTY run. Use `test_case_ids` or `selection` to put cases in the run, and send `filters` only in addition, when the user asked the run to stay in sync.\nSending a non-empty " - }, - { - "name": "dynamic_filter", - "type": "object", - "example": { - "owner": [], - "status": [], - "priority": [] - }, - "description": "Filter criteria (backward compatible format - use `filters` instead). Top level, same as `filters`, including that it selects no cases and flips the run dynamic." - }, - { - "name": "test_case_ids", - "type": "array", - "example": [ - 2336 - ] - }, - { - "name": "auto_assign", - "type": "boolean" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Selection of cases shared in from other projects. Top level, same as `selection`." - }, - { - "name": "run_state", - "type": "string", - "required": true, - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "new_run", - "description": "MANDATORY. The v1 endpoint validates this against the enum and hard-rejects anything else (including a missing key) with `400 \"invalid data\"`. Use `new_run` for a freshly created run. Note: v2 defaulted this to `new_run` server-side; v1 does NOT.", - "json_path": "/test_run/run_state" - }, - { - "name": "name", - "type": "string", - "example": "Regression – Login", - "json_path": "/test_run/name" - }, - { - "name": "description", - "type": "string", - "example": "", - "json_path": "/test_run/description" - }, - { - "name": "owner", - "type": "integer", - "example": 2, - "description": "TM user id of the run owner. Unresolvable ids are silently treated as unassigned rather than erroring.", - "json_path": "/test_run/owner" - }, - { - "name": "is_dynamic", - "type": "boolean", - "example": false, - "description": "Keep the run's membership live against `filters`. Must be sent INSIDE `test_run` — v1 does not read a top-level `is_dynamic` and will silently create a static run if you put it there.", - "json_path": "/test_run/is_dynamic" - }, - { - "name": "configurations", - "type": "array", - "example": [], - "description": "Configuration ids to run against — ids, not the configuration objects returned on read.", - "json_path": "/test_run/configurations" - }, - { - "name": "test_plans", - "type": "array", - "example": [], - "description": "Test plan ids. Only the first entry is linked; a run belongs to at most one plan.", - "json_path": "/test_run/test_plans" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login" - ], - "json_path": "/test_run/tags" - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues" - }, - { - "name": "environment", - "type": "string", - "json_path": "/test_run/environment" - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_run/attachments" - }, - { - "name": "metadata", - "type": "object", - "json_path": "/test_run/metadata" - }, - { - "name": "build_group_id", - "type": "integer", - "json_path": "/test_run/build_group_id" - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 83, - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "json_path": "/selection/folders" - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Create a test run for a project", - "guidance": [ - "Discovering cases does not put them in a run. Carry the case ids you found into this call — a create whose only case-bearing field is a filter rule succeeds and creates an EMPTY run.", - "run_state is required and is validated before the selection, so a bare 'invalid data' error means a bad run field, not a bad selection.", - "A filter rule also makes the run keep syncing as cases change, even if you did not ask for that. Create runs static unless the user explicitly wants a run that keeps updating.", - "Dynamic runs match on priority, status, category, owner, automation status, automation state, folder, tags and issues only. Creation and update dates are echoed back but never matched, so a 'last 7 days' dynamic run silently drops the date window and keeps collecting.", - "Preview the selection first if the count matters — it reports what the selection resolves to before anything is created." - ], - "returns": [ - "id", - "identifier", - "name", - "active_state", - "assignee_imported", - "created_at", - "description", - "environment", - "is_automation", - "is_dynamic", - "observability_url", - "owner" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__CreateTestRunByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "delete_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id holding the option. Not validated by this handler." - }, - { - "name": "option_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric option id to delete, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." - } - ], - "intent": "Permanently remove one choice from a dropdown-style custom field, dropping that value from every case that had it selected", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible, and it takes data with it: every case, result or plan holding this option loses that value, across every project the dataset covers. There is no bin.", - "If the value is simply wrong, rename it through the option update instead — a rename keeps the option id, so nothing stored is lost.", - "On a nested_dropdown, deleting a parent option orphans the children that pointed at it; list the options first and delete the subtree deliberately.", - "None of the three path ids is validated in this layer, so a wrong or mismatched triple reaches storage and returns a bare 404 {success:false} with no message — indistinguishable from a permissions failure.", - "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change after the call returns.", - "Success is only `{success: true}`; capture the option's label and id beforehand if you need to report what went." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_custom_field_dataset", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler — a wrong value surfaces as a bodyless 404 from storage." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Not validated by this handler; a dataset that belongs to a different field returns the same bodyless 404." - } - ], - "intent": "Remove one option set and its project scope from a custom field — the field survives, but every value its projects stored through this dataset is gone", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. The dataset, all of its options, and the values recorded against those options in every project the dataset linked are removed together. There is no bin.", - "Count and name the affected projects first (read the dataset, or the field with its datasets). Callers routinely think a dataset belongs to one project.", - "Neither path id is validated before the upstream call, so a wrong or mismatched pair reaches storage and returns a bare 404 {success:false} with no message. Confirm the pair with a dataset read first.", - "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape after the call returns.", - "If you only want to stop one project seeing the field, unlink that project through the dataset project-mapping call instead — narrower, though it also drops that project's stored values.", - "Success is only `{success: true}`; nothing about what was removed is echoed back." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id, as returned by the workspace field list. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Permanently remove a custom field from the workspace, together with every value stored against it in every project that used it", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible and workspace-wide. The field, its datasets, its options and every value stored on every test case / result / plan in every linked project go at once. There is no bin and no restore.", - "Read the field first and report how many projects its datasets link to before asking to proceed — the caller usually thinks of a field as belonging to one project.", - "A side effect reaches outside the field: saved report filters that referenced this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape minutes after the call returns.", - "The path id is the numeric custom field id. The route only matches digits, and an id of 0 returns 400 'Invalid Params'. A well-formed id that does not exist comes back as a bare 404 {success:false} with no message — the same body a permissions failure would give.", - "Success is only `{success: true}`; nothing is echoed back, so capture the field's name and id before deleting if you need them for the report." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_dataset", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "destructive", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string. There is no integer-id form of this route." - } - ], - "intent": "Permanently delete a dataset and all of its rows", - "responses": { - "200": { - "description": "Dataset deleted.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "DatasetV2 deleted successfully" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project; nothing was deleted." - }, - "422": { - "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible and unguarded: there is no soft delete, no recycle bin and no restore endpoint for datasets, and the handler performs no check for test cases still bound to this dataset — it deletes and reports success either way.", - "Check what depends on it first with the dataset's linked-test-cases listing, and read the dataset itself if the contents matter, because neither is recoverable afterwards.", - "An unknown uuid is 404 {success:false, message:'Dataset not found.'} — the lookup happens before the delete, so a 404 means nothing was removed.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad request.", - "The success body is {success:true, message:'DatasetV2 deleted successfully'} — the internal 'DatasetV2' name is not a typo you can rely on staying.", - "Needs the test-case write permission (tc_write)." - ], - "returns": [ - "success", - "message" - ], - "disabled": true - }, - { - "name": "delete_exploratory_session", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "destructive", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Permanently remove an exploratory session and everything recorded inside it — every log entry and every defect link goes with it", - "responses": { - "200": { - "description": "Exploratory session deleted successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Forbidden. Insufficient permissions to delete this session.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "message": { - "type": "string", - "example": "You don't have permission to delete this exploratory session" - } - } - } - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "There is no recycle bin for exploratory sessions. Unlike test cases, a deleted session has no restore path: it and all of its log entries are gone.", - "Permission depends on the session's state, and the handler reads the session before deciding: a closed session needs the closed-test-run delete permission, an active one the ordinary test-run delete permission. The refusal is 403 {success:false, message:\"You don't have permission to delete this exploratory session\"} and nothing is deleted.", - "Because the session is fetched first, an unknown session_id fails as a bare {success:false} 404 with no message.", - "Delete the logs individually first only if you want them removed selectively — deleting the session needs no cleanup of its children.", - "The response is only {success:true}; there is no echo of what was removed, so capture anything you need from the session before calling this." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_exploratory_session_log", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", - "mode": "destructive", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - }, - { - "name": "log_id", - "type": "integer", - "required": true, - "example": 789 - } - ], - "intent": "Permanently remove one observation from an exploratory session, discarding its note, outcome, elapsed time and defect links", - "responses": { - "200": { - "description": "Session log deleted successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Both ids are integers and both must match: log_id is only addressable under the session_id that owns it.", - "There is no bin or restore for session logs — the entry, its embedded screenshots' links and its defect links are gone.", - "This removes one log only. It does not touch the issues linked at session level, so a defect raised here can still appear in the session's defects listing through the session-level link.", - "The session's session_log_count drops, which can move a session back below the 50-log threshold that pushes a clone onto the asynchronous path.", - "The response is only {success:true}. An unknown log id surfaces as a bare {success:false} 404 with no message.", - "Deleting a log needs the test-run delete permission — the same permission as deleting a whole session, with no separate closed-session variant at log level." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_filter", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "destructive", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete a saved filter view from a project permanently", - "responses": { - "200": { - "$response": "FilterDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Removes the saved view only. The test cases, runs or plans it selected are untouched — this deletes a query, not data.", - "There is no undo and no bin for filter views, and no version history to restore from. Recreating one means re-authoring the whole condition set, so read the filter first if you might want it back.", - "A project-level view is deleted for everyone on the project, not just for you, and the filter controller is not RBAC-gated — any user with product access to the project can delete a view someone else created.", - "The success body is `{success:true}` and nothing else: no id echo, no copy of what was deleted, nothing to carry forward.", - "Deleting an id that does not exist comes back as 400 `{success:false, message:...}` rather than 404 — only the backing service's 400 is translated here, and its other failures surface as a 500." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_folder_and_contents", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm", - "mode": "destructive", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Permanently delete a folder together with everything beneath it — every nested subfolder and every test case in the whole subtree", - "returns": [ - "data", - "success", - "folder", - "path_folders", - "id", - "name", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "description": "Folder and its contents deleted successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder" - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "success": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is recursive and total. It removes the folder, all of its descendant folders, and every test case in that entire subtree — not just the cases filed directly in it. There is no option to keep the contents or to delete the folder only.", - "Run the folder remove-summary dry run first. It is the only way to see, before committing, how many active test cases, archived test cases, subfolders and low-code test recordings this call will destroy.", - "Recoverability depends on a group feature flag you cannot read from this API. When Move to Bin is enabled for the group, the folder's TEST CASES land in the project recycle bin and can be listed and restored from there until they auto-purge. When it is not enabled, the test cases are deleted outright.", - "The FOLDERS themselves are never recoverable, under either setting. Nothing in this API restores a deleted folder — the bin holds test cases only, and restored cases do not bring their folder tree back with them. Treat the folder structure as gone and be ready to recreate it.", - "Do not plan on the undo-remove endpoint as your way back. It is a dead alias that re-issues this same delete; it does not restore anything (see the flag on delete_folder_and_subtree).", - "Linked low-code test recordings belong to a different product and are destroyed too. The summary's has_other_product_entity flag is true exactly when the subtree contains such recordings — check it before deleting if anyone else depends on them.", - "Requires the folder-delete permission (403 otherwise), which is a higher bar than the edit permission most other folder writes need.", - "On success the deleted folder is echoed at `data.folder` with cases_count and total_cases_count forced to 0 — those zeros are cosmetic, not a report of what was removed. `data.path_folders` is the surviving breadcrumb of the deleted folder's parent, useful for re-pointing a UI.", - "Failure does not use the usual envelope: an upstream rejection is relayed as the raw upstream body with the upstream status code, so do not expect {success:false, message} to be there to parse.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." - ], - "disabled": true - }, - { - "name": "delete_report_schedule", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/reports/schedules/{schedule_id}", - "mode": "destructive", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "schedule_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[query]", - "type": "string", - "description": "Only filters the listing returned AFTER the delete (matches report type, title, or owner name) — it does not scope what is deleted." - }, - { - "name": "q[scheduled]", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Only the exact string `true` does anything (restricts the returned listing to scheduled reports). `false` or any other value is ignored and returns everything." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the listing returned after the delete. Fixed 30 per page; past the last page returns 400." - } - ], - "intent": "Permanently delete a report — the whole saved report and its filters, not just its email schedule", - "returns": [ - "success", - "data", - "scheduled_report_count", - "total_report_count", - "info" - ], - "responses": { - "200": { - "$response": "DeleteScheduleResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This deletes the report record itself. Despite the path, there is no way to remove only the schedule: to stop the emails but keep the report, edit it and omit `frequency` / `frequency_details` instead.", - "The delete is unconditional and silent. A `schedule_id` that does not exist, or belongs to another group, still returns 200 with `success: true` — there is no 404 and no count of what was removed. Confirm by checking that the returned `data` list no longer contains the id.", - "The report is matched on group + id only; the project in the path does not scope it, so an id belonging to a sibling project in the same group IS deleted.", - "The response body is not the deleted report — it is the CURRENT page of the caller's remaining reports, plus `scheduled_report_count` and `total_report_count`. Treat it as a fresh listing.", - "That embedded listing is paged at a fixed 30 per page, honours `p`, and `p` past the last page returns 400 `page_out_of_bounds`. Do not page it by re-issuing the delete — call list_schedules instead.", - "Deleting also removes the report's filter rows and mail recipients and propagates a delete to Test Observability; there is no undo and no restore endpoint. Requires the report_delete permission (403 otherwise)." - ], - "disabled": true - }, - { - "name": "delete_shared_step", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "destructive", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete a shared step from a project permanently — every test case that embeds the block loses it, in one operation", - "responses": { - "200": { - "$response": "SharedStepDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the widest single delete in the shared-step surface. A shared step is embedded by reference by many test cases at once, so removing it takes that block out of all of them together. The blast radius is not visible from the id — read the shared step, or find its row in the shared-step listing, and look at `test_case_count`. That number is exactly how many test cases this affects; anything above zero means you are editing that many cases, not deleting an unused fragment.", - "The delete is not blocked by references. A block used by hundreds of cases deletes as readily as an unused one, and nothing warns you.", - "There is no undo: shared steps have no bin, no archive and no version history. Recreating a block with the same title and steps does NOT re-link the test cases that referenced the old one — their references are gone and each case has to be re-edited by hand. Capture the block's steps (and, if it matters, which cases used it) before deleting.", - "Deleting the block is not the way to stop one test case using it — edit that test case's steps instead. This call is for retiring a fragment project-wide.", - "Success is 200 with `{success:true}`, not 204 with an empty body. Do not treat a body-less 204 as the success signal.", - "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}` before any call is made — and the route segment only matches digits, so a non-numeric id never reaches the handler at all.", - "Requires tc_write, the same permission as editing a test case; there is no separate shared-step delete permission, so anyone who can edit cases can do this." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_template", - "method": "DELETE", - "path": "/api/v2/templates/{id}", - "mode": "destructive", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id — a plain integer with no prefix, discovered from the template list.", - "pattern": "^[0-9]+$" - } - ], - "intent": "Permanently delete a template together with its field and project links — there is no bin and no undo; disable it instead if you might want it back.", - "guidance": [ - "This removes the template record itself, along with its field mappings and its project mappings. Test Management exposes no restore for a deleted template.", - "If the goal is only to stop people picking the template for new test cases, DISABLE it instead — disabling is reversible and keeps the fields and project links intact.", - "System templates and the workspace default template cannot be deleted; the attempt comes back as a 400 with the reason.", - "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is deleted — check the flag, not the status code.", - "Success returns only success:true, with no record of what was removed. Read the template first if you need its name or field list for the record." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TemplateWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "delete_test_case_attachment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments/{attachment_id}/delete", - "mode": "destructive", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "attachment_id", - "type": "string", - "required": true - } - ], - "intent": "Remove one file from a test case and purge the stored blob — takes a screenshot, log or document off the case for good", - "returns": [ - "data", - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "$response": "TestCaseAttachmentDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "LIKELY BROKEN, and dangerous to retry. It matches the attachment by comparing a stored integer id against the id from the path, which arrives as text, so the match fails and the next line dereferences nothing — a bare 500. Because the purge is queued before the response, treat a 500 here as an UNKNOWN outcome rather than a failure, and re-read the case before assuming nothing was removed.", - "The response is wrapped in `data`: read data.attachments for the removed record and data.success. This action wraps; the attachment list and upload on the same path do not. Reading a top-level `attachments` finds nothing.", - "Irreversible. The link is removed AND the underlying stored file is scheduled for purge, so the file itself goes, not just the association. There is no undo and no recycle bin for attachments.", - "`attachment_id` must be an id from this case's own attachment listing. The handler looks it up inside the case's in-memory attachment list and does not return a clean 404 when it is absent, so verify the id with a list read first rather than probing.", - "`folder_id` is in the route but is NOT used to find the case — the lookup is by project and test case id, so a stale folder id after a move still resolves.", - "Note the shape: a POST to .../delete, not a DELETE.", - "The v2 attachment surface is the maintained one for listing and reading attachments; use this only when you specifically need to remove a v1 test-case attachment." - ], - "disabled": true - }, - { - "name": "delete_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", - "mode": "destructive", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Permanently delete a test plan or sub-plan — there is no recycle bin and no undo; archive it instead if there is any chance it is wanted back", - "responses": { - "200": { - "description": "Plan deleted: `{success: true}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No such plan: `{success: false, error: \"Test Plan not found\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. Plans have no bin and no version history, so a deleted plan cannot be restored by any capability here — the reversible option is bulk-archive, which keeps the plan and can be undone with bulk-retrieve.", - "The path segment is the plan's INTEGER id. A TP-NNN / STP-NNN identifier does not match the route and never reaches the handler.", - "The only miss signal is 404 {success: false, error: 'Test Plan not found'}; a success is a bare {success: true} with no echo of what was removed. Read the plan first if you need to tell the user what is about to go.", - "Needs the test-plan DELETE permission, which is separate from the write permission used by create/update.", - "Deleting emits an activity-feed event and a personal alert to the plan's owner, so the side effect is visible outside this API.", - "What happens to the plan's sub-plans and to the test runs it held is decided upstream and is not echoed in the response — read the sub-plans and linked runs first if that matters." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_test_result", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-results/{test_result_id}/delete", - "mode": "destructive", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_result_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete one logged test result by its id — removes that single execution record while leaving the case's other attempts in the run intact", - "responses": { - "200": { - "description": "Result deleted.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Caller is neither the result's author nor a project admin." - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Authorship-gated: only the user who logged the result may delete it, unless the caller is a project admin. Anyone else gets 403 {success:false, message:\"You can delete only results logged by you, unless you're a project admin\"} and nothing is removed.", - "Irreversible — there is no recycle bin for results. After the delete the case's outcome in that run rolls back to its previous attempt, or to unexecuted if this was the only result.", - "Note the shape: a POST to .../delete, not a DELETE, and `test_result_id` must be numeric — the route only matches digits, so a TR-/TC-style identifier 404s at routing.", - "To remove several results use the bulk delete instead: it authorises per id and reports what it skipped, whereas this one refuses the entire call on the first authorisation failure.", - "Success is only {success: true} — the deleted record is not echoed back, so capture anything you need from it first." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/delete", - "mode": "destructive", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "intent": "Permanently delete a test run and every result recorded in it — there is no bin and no restore", - "returns": [ - "data", - "success", - "testrun", - "project", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "project_id", - "created_at", - "updated_at", - "links" - ], - "responses": { - "200": { - "$response": "TestRunWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. A test run has no version history and no recycle bin: the run, its case mappings and every result recorded against them go, and nothing in this API brings them back. Confirm with the user before calling.", - "Deleting a CLOSED run needs a different permission (closed-run delete) from deleting an open one — a role that can delete working runs can be 403'd on a finished one. Automation runs demand the automation variant again.", - "The run's build in Test Observability is archived FIRST, and a rejection there aborts the delete, so a failure can leave the run intact. Re-read before assuming it is gone.", - "The run is also stripped out of every saved report filter that referenced it; those reports silently narrow rather than erroring.", - "The deleted run is echoed back under `data.testrun` — one word, no underscore — with `data.project`. Do NOT read `data.test_run` here; that key does not exist on this response.", - "The path accepts either the TR-NNN identifier or the integer id, but the body key and every downstream reference wants the integer from `uuid`." - ], - "disabled": true - }, - { - "name": "discard_duplicate", - "method": "PUT", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", - "mode": "write", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id, from the `id` of a listing row. Here it is a path segment — unlike the resolve call, which takes it in the body." - } - ], - "body": [ - { - "name": "discard_reason", - "type": "string", - "description": "Short reason code or label for why this is not a duplicate. Flat in the body (no wrapper object). Optional and unvalidated — no closed set is enforced, and omitting it stores null." - }, - { - "name": "user_feedback", - "type": "string", - "description": "Free-text feedback. Flat in the body. Stored under a different name (`discard_feedback`) than the one you send, so do not expect it echoed back under this key." - } - ], - "intent": "Dismiss a duplicate suggestion as wrong — removes it from the review queue and feeds the reason back to the model, without touching either test case", - "responses": { - "200": { - "description": "Duplicate discarded. Body is {success:true} only.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "The caller lacks the test-case write permission (tc_write)." - }, - "404": { - "description": "{success:false, error:'Duplicate not found.'} — no such pair for this project, or it is no longer in `suggested` status." - }, - "409": { - "description": "{success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} — a dedupe data migration is in progress. Nothing was discarded; retry later." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the 'not a duplicate' verdict. Neither test case is touched — nothing is archived, merged, commented on or tagged. Use the resolve call when a case should actually go away.", - "The body is flat: `discard_reason` and `user_feedback` sit at the top level, with no wrapper object and no request-schema check, so a misspelled key is silently dropped and you get 200 with the feedback lost.", - "One-way. The pair moves to `discarded` and leaves every listing; there is no un-discard endpoint and the pair can no longer be read, so confirm the verdict before calling.", - "Suggested-only: a pair already merged, archived or discarded is 404 {success:false, error:'Duplicate not found.'} — usually 'already handled' rather than 'never existed'.", - "Undeclared until now, and retryable: while a dedupe data migration is in progress this is refused with 409 {success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. Nothing was discarded; retry later.", - "Note the envelope differs from the resolve call, which uses `errors` for its failures where this one uses `error`.", - "Needs the test-case write permission (tc_write) — lighter than the delete permission the resolve call demands." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "download_report", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/download", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv", - "pdf", - "xlsx" - ], - "example": "csv", - "description": "Export format, as a single STRING. Anything outside the three values is 422 `Invalid file_type.`. Note the same-named field on send-email-now is an ARRAY." - }, - { - "name": "required", - "type": "object", - "description": "Optional column selection, honoured only for `Test Run Detailed Report` and `Test Plan Detailed Report`; ignored elsewhere. Shape `{ \"\": { \"fields\": [\"id\",\"title\",...] } }`; `fields` may also be a comma-separated string, and `[\"all\"]` expands to every supported column. Invalid names are NOT rejected here — the export silently reverts to defaults." - }, - { - "name": "columns", - "type": "object", - "description": "Alias for `required`, and it WINS when both are sent. Same shape and same silent-fallback behaviour." - } - ], - "intent": "Kick off an async export of a report to CSV, PDF or XLSX — returns a job channel, not the file", - "returns": [ - "channel", - "success" - ], - "responses": { - "200": { - "$response": "DownloadReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This returns immediately with `{ channel: , success: true }` and nothing else. The export runs in a background worker and the finished file's URL is pushed over a realtime channel named from that uuid — there is NO endpoint to poll it and no way to fetch the artefact through the API. If you need the file itself, use the send-email-now capability and have it mailed, or hand the channel to a UI client.", - "No bytes come back from this call and no download URL appears anywhere in the response — a `channel` that you cannot resolve is the whole result, so do not report the export as delivered.", - "`file_type` is a single STRING here (`csv` / `pdf` / `xlsx`). The same-named field on send-email-now is an ARRAY — they are not interchangeable. An unsupported value is 422 `Invalid file_type.`, not 400.", - "Column selection (`required`, or its alias `columns`, which wins when both are sent) is only honoured for `Test Run Detailed Report` and `Test Plan Detailed Report`. On every other report type it is ignored. Shape is `{ \"\": { \"fields\": [...] } }` with entities test_run_test_case / test_run / test_result (and test_plan for the plan report).", - "Unlike the on-screen read, this path does NOT validate your column list: a misspelled field name is not an error here — the worker silently falls back to the report's default columns, so the export arrives missing what you asked for. Verify field names against the report read first.", - "`fields: [\"all\"]` for an entity expands to every supported column for that entity.", - "The report is matched on group + id, so the project in the path does not scope the lookup; an unknown id is 404 `Report not found.`. A `Test Plan Detailed Report` on a group without the feature flag is 403 `Operation not permitted`. Requires the report_view permission." - ], - "disabled": true - }, - { - "name": "edit_test_case_partial", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit-v2", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append — any currently-attached id missing from the array is detached. Read the case's existing attachments first and send them back alongside the new ids.", - "json_path": "/test_case/attachments" - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, given as an internal_name string (e.g. `not_automated`, `automated`). Applied only when `automation_state` is omitted.", - "json_path": "/test_case/automation_status" - }, - { - "name": "case_type", - "type": "integer", - "example": 490, - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "123": "option_value", - "456": [ - "multi", - "select" - ] - }, - "json_path": "/test_case/custom_fields", - "description": "Map of custom-field id to value, applied only when the key is present AND non-empty — `{}` is a no-op here (it is the BULK edit that 500s when the key is missing). Values replace the field's current value." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description" - }, - { - "name": "estimate", - "type": "string", - "description": "ACCEPTED BUT NOT PERSISTED — passes validation and is then dropped before the write, on this path as well as create. Use `estimated_duration_seconds`; never report an estimate as saved.", - "json_path": "/test_case/estimate" - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS; `null` clears it. This is the field that actually stores an estimate.", - "json_path": "/test_case/estimated_duration_seconds" - }, - { - "name": "expected_result", - "type": "string", - "description": "Overall expected outcome, distinct from a step's own `result`.", - "json_path": "/test_case/expected_result" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Must be `true` for a `test_case_dataset` change to be applied; with any other value the dataset payload is ignored.", - "json_path": "/test_case/is_dataset_modified" - }, - { - "name": "issues", - "type": "array", - "example": [ - { - "issue_id": "TM-1234", - "issue_type": "jira" - } - ], - "description": "Linked issues/defects. Processed ASYNCHRONOUSLY after the response returns, so a 200 does not mean the links exist yet — re-read the case to confirm.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "name", - "type": "string", - "example": "Verify login functionality", - "description": "The name/title of the test case.", - "json_path": "/test_case/name" - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_case/owner", - "description": "TM user id. Unlike the POST edit path, an id that does not resolve is silently skipped rather than rejected, so the owner is left unchanged with a 200." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "example": 483, - "json_path": "/test_case/priority", - "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state, e.g. `pending` / `in_review` / `approved`. Unlike POST .../edit, omitting it here leaves the stored value untouched.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled on the project — otherwise silently ignored. Including the owner is rejected with 422 'Owner cannot be a reviewer'. This path does NOT run the send-for-review checks or send review emails.", - "json_path": "/test_case/reviewers" - }, - { - "name": "status", - "type": "integer", - "example": 496, - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "steps", - "type": "string", - "description": "LEGACY — use `test_case_steps` instead. This param skips the request allowlist and is read with JSON.parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200.", - "json_path": "/test_case/steps" - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "smoke" - ], - "description": "Tag names. `[]` removes all tags; omit the field to keep the existing ones.", - "json_path": "/test_case/tags" - }, - { - "name": "template", - "type": "string", - "description": "Template NAME (the same values the create paths accept) — not an id. Sending an integer is rejected with 422 \"Invalid template value \"; use `template_id` for the numeric custom-template reference.", - "json_path": "/test_case/template" - }, - { - "name": "template_id", - "type": "integer", - "description": "Custom-template id.", - "json_path": "/test_case/template_id" - }, - { - "name": "template_step_type", - "type": "string", - "description": "Takes precedence over `template` when both are sent.", - "json_path": "/test_case/template_step_type" - }, - { - "name": "test_case_dataset", - "type": "object", - "description": "Dataset binding for a data-driven case, as an OBJECT — the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, read it and send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as returned. It is a full replace of the case's binding, and on this path it is applied only when `is_dataset_modified` is true. The edit answers only {success:true}, so re-read the case (or the dataset's linked-test-cases listing) to confirm the binding landed.", - "fields": [ - { - "name": "variables", - "type": "array" - }, - { - "name": "rows", - "type": "array" - } - ], - "json_path": "/test_case/test_case_dataset" - }, - { - "name": "test_case_folder_id", - "type": "string", - "description": "Move the case to a different folder (integer id). Dropped server-side when it matches the current folder, so passing the existing folder is a safe no-op.", - "json_path": "/test_case/test_case_folder_id" - }, - { - "name": "test_case_steps", - "type": "array", - "example": [ - { - "order": 1, - "step": "Navigate to the login page", - "result": "The login page is displayed" - }, - { - "order": 2, - "step": "Submit valid credentials", - "result": "The user lands on the dashboard" - } - ], - "description": "The structured step list — same shape as the create body. `order` is filled in by position when omitted. `[]` removes all steps; omit the field to keep them.", - "fields": [ - { - "name": "order", - "type": "integer" - }, - { - "name": "step", - "type": "string" - }, - { - "name": "result", - "type": "string" - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps" - }, - { - "name": "third_party_identifier", - "type": "string", - "json_path": "/test_case/third_party_identifier", - "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." - }, - { - "name": "isMergedDuplicate", - "type": "boolean", - "json_path": "/test_case/isMergedDuplicate", - "description": "Compared to the literal boolean `true`. Tells the backend to keep the case's existing attachments rather than reconciling them against `attachments`. Used by the duplicate-merge flow." - } - ], - "intent": "Change just the fields you name on one test case, leaving everything you omit untouched — the safe path for an inline single-field edit.", - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TestCaseWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "The response is `{success: true}` and NOTHING ELSE. It does not return the updated case, so re-read the case if you need to confirm or show the new values. (The POST .../edit sibling does return `data.test_case`.)", - "Only the keys present in `test_case` are applied — this is the partial-update path. The POST .../edit on the same case rebuilds more of the record, including resetting `review_status`.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "`steps` is legacy and is read with a JSON parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200. Use `test_case_steps`.", - "`attachments` is the complete set the case should end up with, not an append — anything currently attached and missing from the array is detached. `test_case_steps: []` and `tags: []` likewise clear; omit the key to keep what is there.", - "A workflow that forbids the status transition comes back as 422 with `code: \"transition_not_allowed\"` and a `violations` list — that is a rule rejection, not bad input.", - "This path does not accept `send_for_review` or `shared_precondition_id`, and does not run the review-payload checks; use the POST .../edit path for a send-for-review flow.", - "Every successful edit writes a new version, so an edit is recoverable by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "edit_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "json_path": "/test_case/attachments", - "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append — anything currently attached and missing from the array is detached. Read the case's existing attachments first." - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, taken as an internal_name string (e.g. `not_automated`, `automated`). Only applied when `automation_state` is omitted. Prefer `automation_state`.", - "json_path": "/test_case/automation_status" - }, - { - "name": "case_type", - "type": "integer", - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_case/custom_fields", - "description": "Map of custom-field id to value. Values replace the field's current value; a field id the project does not define is rejected by the backend with a field-named 400 message." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description" - }, - { - "name": "estimate", - "type": "string", - "description": "ACCEPTED BUT NOT PERSISTED — the field passes request validation and is then dropped before the write on both the create and the edit paths. Use `estimated_duration_seconds` instead; do not report an estimate as saved.", - "json_path": "/test_case/estimate" - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. `null` clears it. This is the field that actually persists an estimate.", - "json_path": "/test_case/estimated_duration_seconds" - }, - { - "name": "expected_result", - "type": "string", - "description": "Overall expected outcome (distinct from a step's own `result`). Rich-text field — send plain text/HTML, not a JSON document.", - "json_path": "/test_case/expected_result" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Set with `test_case_dataset` on an edit to signal the dataset changed; on create the mappings are always regenerated and this is ignored.", - "json_path": "/test_case/is_dataset_modified" - }, - { - "name": "issues", - "type": "array", - "json_path": "/test_case/issues", - "description": "Linked issues as `[{issue_id, issue_type, metadata?}]` (bare strings are coerced to ``issue_id``). Processed ASYNCHRONOUSLY after the response, so a 200 does not mean the links exist yet." - }, - { - "name": "name", - "type": "string", - "json_path": "/test_case/name" - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_case/owner", - "description": "TM user id. An id that is not a member of the group is silently replaced with the caller rather than rejected." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "json_path": "/test_case/priority", - "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state, e.g. `pending` / `in_review` / `approved`. When omitted it is set from the project's review-approve setting, falling back to `pending` — it is never left untouched on create or on POST .../edit.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Only honoured when the workspace is on a Team-Pro plan AND the project has review-approve enabled; otherwise silently ignored. Including the `owner` is rejected with 422 \"Owner cannot be a reviewer\".", - "json_path": "/test_case/reviewers" - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "EDIT PATHS ONLY — drives the per-reviewer review-request email. Dropped without error on create (the create flow already notifies from `reviewers` + `review_status`) and not accepted by PATCH .../edit-v2.", - "json_path": "/test_case/send_for_review" - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body.", - "json_path": "/test_case/shared_precondition_id" - }, - { - "name": "status", - "type": "integer", - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "tags", - "type": "array", - "json_path": "/test_case/tags" - }, - { - "name": "template", - "type": "string", - "json_path": "/test_case/template", - "description": "Step-shape template NAME, not an id. When neither this nor `template_step_type` is sent it DEFAULTS to `test_case_text` on this path — which silently discards `test_case_steps`. Always send it on an edit that touches steps." - }, - { - "name": "template_id", - "type": "integer", - "json_path": "/test_case/template_id" - }, - { - "name": "template_step_type", - "type": "string", - "description": "Step shape — `test_case_steps` | `test_case_text` | `test_case_bdd`. OPTIONAL; when sending `template_id`, use that template's own `step_type` from the templates listing rather than assuming.", - "json_path": "/test_case/template_step_type" - }, - { - "name": "test_case_dataset", - "type": "object", - "description": "Dataset binding for a data-driven case, as an OBJECT — the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as the dataset read returns them. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here; on an edit, send `is_dataset_modified: true` alongside it.", - "fields": [ - { - "name": "variables", - "type": "array" - }, - { - "name": "rows", - "type": "array" - } - ], - "json_path": "/test_case/test_case_dataset" - }, - { - "name": "test_case_folder_id", - "type": "string", - "description": "Target folder's integer id. On create this is REQUIRED IN THE BODY as well as in the path — omitting it is the most common create failure (422 wrapping an upstream 404). Integer or string are equivalent; the server coerces with .to_i, so the distinction does not matter.", - "json_path": "/test_case/test_case_folder_id" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The ONLY key the server reads for steps — on single create, bulk create and the update paths alike. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is silently ignored and the create still returns 200 for a case with NO steps, so verify against `test_case_steps` in the response.", - "json_path": "/test_case/test_case_steps" - }, - { - "name": "steps", - "type": "string", - "json_path": "/test_case/steps", - "description": "LEGACY — use `test_case_steps`. Read with a JSON parse, so it must be a JSON-encoded STRING. A real JSON array parses to `[]` and WIPES the case's steps while still returning 200." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "description": "One-shot re-authentication token, required only when the project has e-signature on send-for-review enabled AND this edit sets `send_for_review` or `review_status: in_review`. Missing or expired is 401 with `error_code: \"reauth_required\"`." - }, - { - "name": "third_party_identifier", - "type": "string", - "json_path": "/test_case/third_party_identifier", - "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." - }, - { - "name": "isMergedDuplicate", - "type": "boolean", - "json_path": "/test_case/isMergedDuplicate", - "description": "Compared to the literal boolean `true`. Keeps the case's existing attachments instead of reconciling them against `attachments`. Used by the duplicate-merge flow." - } - ], - "intent": "Edit one test case through the full edit form — rename it, rewrite its steps, preconditions and expected result, retag it, or send it for review.", - "returns": [ - "success", - "data", - "test_case" - ], - "responses": { - "200": { - "$response": "V1TestCaseWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "This is the FULL edit path, not a patch: `review_status` is recomputed on every call (omitted, it falls back to the project's review-approve default), and `template` defaults to `test_case_text` when you omit it, which discards any `test_case_steps` you sent. Send the case's current template unless you mean to change it.", - "For a targeted single-field change prefer the PATCH .../edit-v2 sibling, which applies only the keys you send.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "Array fields REPLACE rather than append: `attachments`, `tags` and `test_case_steps` are each the complete intended set, and `[]` clears. Read the case first and send the union.", - "Reviewers and the owner cannot overlap: this path rejects it up front with 400 `code: \"OWNER_AS_REVIEWER\"`, and sending `send_for_review` with no reviewers is 400 `code: \"NO_REVIEWERS\"`. (Create, which skips that check, surfaces the same conflict as a 422.)", - "If the project has e-signature enabled, an edit that sets `send_for_review` or `review_status: in_review` needs `test_case.reauth_token`; without it the answer is 401 `{success:false, error_code:\"reauth_required\"}`.", - "The updated case comes back at `data.test_case` — note the `data` envelope, and note that `estimate` always serialises as null regardless of what you sent.", - "Every successful edit writes a new version, so this is recoverable by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "edit_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "run_state", - "type": "string", - "required": true, - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "description": "MANDATORY on every edit, even one that is not changing the state — resend the run's current value. It is validated against the enum before anything else, so a missing or misspelled value returns 400 {success:false, message:'invalid data'} with no hint about which field was wrong. `closed` is one-way: a closed run cannot be reopened, and this call does not do it. The full edit: on a closed run it returns success and leaves the run closed.", - "json_path": "/test_run/run_state" - }, - { - "name": "name", - "type": "string", - "example": "Regression - Login", - "description": "Only applied when non-empty. Sending \"\" leaves the existing name rather than clearing it.", - "json_path": "/test_run/name" - }, - { - "name": "description", - "type": "string", - "example": "", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent. Unlike name, an empty string here DOES clear the description.", - "json_path": "/test_run/description" - }, - { - "name": "owner", - "type": "integer", - "example": 2, - "description": "Run owner as a TM user id (a BrowserStack user id is also accepted and resolved). An id that matches nobody in the group is silently treated as unassigned — the run's owner is cleared rather than the call failing.", - "json_path": "/test_run/owner" - }, - { - "name": "is_dynamic", - "type": "boolean", - "example": false, - "description": "Keep the run's membership live against `filters`. On EDIT a TOP-LEVEL `is_dynamic` is also read and OVERRIDES this one; on create only the nested form exists. Send it in one place only. Omit the key entirely to leave the run's current dynamic flag alone — sending false on a full resend flips a dynamic run back to static.", - "json_path": "/test_run/is_dynamic" - }, - { - "name": "configurations", - "type": "array", - "example": [], - "description": "Configuration ids (integers) the run executes against — not the configuration objects returned on read.", - "json_path": "/test_run/configurations" - }, - { - "name": "test_plans", - "type": "array", - "example": [], - "description": "Test plan ids. Only the FIRST entry is used; a run belongs to at most one plan. Send the plan's integer id.", - "json_path": "/test_run/test_plans" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login" - ], - "description": "Plain tag strings. The list you send REPLACES the run's tags — omit the key to leave them alone, since there is no history to restore a cleared list from.", - "json_path": "/test_run/tags" - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "description": "Linked defects. Each entry is an object; a bare string is coerced to {issue_id: ...} and loses the tracker type. Linking kicks off an asynchronous fetch of the ticket metadata, so titles appear on a later read.", - "json_path": "/test_run/issues" - }, - { - "name": "environment", - "type": "string", - "json_path": "/test_run/environment" - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the run. Sending [] to remove every attachment only works where the clear-all behaviour is enabled for the group; elsewhere an empty array is a no-op. An id that does not verify fails the whole edit with 422 'attachment is invalid'.", - "json_path": "/test_run/attachments" - }, - { - "name": "metadata", - "type": "object", - "description": "Free-form metadata. Only forwarded when non-empty; sending {} does not clear it.", - "json_path": "/test_run/metadata" - }, - { - "name": "build_group_id", - "type": "integer", - "description": "Only forwarded when present; there is no way to unset it through this call.", - "json_path": "/test_run/build_group_id" - }, - { - "name": "filters", - "type": "object", - "example": { - "priority": [ - "High" - ], - "folder_ids": [ - 105 - ] - }, - "fields": [ - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "automation_status", - "type": "array" - }, - { - "name": "automation_state", - "type": "array" - }, - { - "name": "folder_ids", - "type": "array" - }, - { - "name": "issue_ids", - "type": "array" - }, - { - "name": "reviewers", - "type": "array" - }, - { - "name": "review_status", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body — NOT inside `test_run`. It selects nothing and never back-fills, so an edit whose only case-bearing key is `filters` returns 200 having added none. To add cases use bulk_update_test_run_test_cases with add_test_cases — test_case_ids and selection here replace the run's membership rather than adding to it. Every `owner`/`reviewers` entry must resolve to a user in this group or the edit 400s. Labels are resolved to ids for you." - }, - { - "name": "dynamic_filter", - "type": "object", - "example": { - "owner": [], - "status": [], - "priority": [] - }, - "description": "Backward-compatible alias of `filters`, same top-level position and same semantics. `filters` wins when both are sent." - }, - { - "name": "test_case_ids", - "type": "array", - "example": [ - 2336 - ], - "description": "Test case ids — but NOT added to the run: together with `selection` this becomes the run's COMPLETE case list, so any case already in the run and missing from what you send is REMOVED, along with its results and assignee, and the call still succeeds. To add cases, use bulk_update_test_run_test_cases with add_test_cases, which leaves existing cases untouched. Top level, not inside `test_run`. Must be an array — a scalar is rejected." - }, - { - "name": "auto_assign", - "type": "boolean", - "description": "Top level. Forwarded to the backing service to auto-assign the newly added cases." - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cases shared in from other projects, as a map of project id to a selection object with the same inner shape as `selection`. Top level." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Make every case in the project the run's case list. Like the rest of the selection this is not additive — it replaces the run's membership, so it only adds cases in the sense that the whole project ends up in the run. To add specific cases, use bulk_update_test_run_test_cases with add_test_cases.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 83, - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "description": "Per-configuration case mappings, at the TOP level of the body. An ARRAY of objects.", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Rewrite a test run through the full edit form — rename it, move its state on, change owner, tags, configurations or linked plan. NOT the way to add cases: the case selection you send becomes the run's entire membership, so add cases with bulk_update_test_run_test_cases (add_test_cases) instead", - "returns": [ - "data", - "success", - "testrun", - "project", - "skipped_test_case_ids", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "attachments", - "metadata", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "project_id", - "created_at", - "updated_at", - "created_by", - "updated_by", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__EditTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send every field flat, including the case-selection keys. Each field's json_path decides where it lands — the run's own fields go inside a `test_run` object and the selection keys (test_case_ids, selection, shared_selection, filters, dynamic_filter, trtc_configuration_mappings, auto_assign, is_dynamic) stay beside it at the top level. Building either level yourself is rejected before the call as an unknown body key.", - "run_state is mandatory on every call, including edits that only rename the run. It is validated first, so a bare 400 'invalid data' almost always means a missing or misspelled run_state — not a bad selection.", - "NOT ADDITIVE: the case selection you send (test_case_ids + selection) REPLACES the run's membership, and run fields you omit — the owner among them — are cleared. The call still returns success. ALWAYS call get_test_run_edit_selection first and resend what it returns — `selection`, `shared_selection`, `trtc_configuration_mappings` and every run field — changing only what you mean to change, after checking the stored selection still matches the run's current cases (it is not updated by the add-only and remove calls, so a stale one re-adds removed cases); that read-then-resend is how the product UI edits a run without losing anything. To only add cases, use bulk_update_test_run_test_cases with add_test_cases instead, which adds without disturbing existing cases, their row ids or assignees. Results on removed cases cannot be restored.", - "There is no undo. A test run keeps no version history, so an overwritten name, a replaced tag list or a cleared description cannot be restored — confirm destructive-looking edits with the user first.", - "Changing which cases the run covers is queued: the call returns before the new cases are attached, and an immediate re-read of the run's cases can be short. Re-read rather than retrying, which can double-add.", - "`filters` is a sync rule, not a selection. An edit carrying only `filters` returns 200 and adds nothing, while quietly marking the run dynamic if you also sent is_dynamic.", - "is_dynamic is read from BOTH the top level and inside `test_run`, and the top-level one wins on this endpoint. Pick one place. Omitting it entirely is the only way to leave the run's current mode untouched.", - "An owner id that matches nobody in the group unassigns the run instead of erroring — but an unresolvable user inside `filters.owner` or `filters.reviewers` hard-fails the whole edit with 400 'invalid data'. The two behave oppositely.", - "The updated run comes back under `data.testrun` — one word, no underscore — with `data.project`, plus a TOP-LEVEL `skipped_test_case_ids` array listing cases the review gate refused to add. Check it: those cases are silently missing from the run.", - "Backing-service client errors are returned as `{data: {success: false, error: }}` with the 4xx status, so read `data.success` before reading `data.testrun`.", - "Needs the test-run write permission, with the automation variant demanded on automation runs." - ] - }, - { - "name": "get_test_run_edit_selection", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit/selection", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Either the TR-NNN identifier or the bare integer — the v1 run routes accept both forms in the path." - } - ], - "intent": "Read a test run's stored case selection and editable fields — the exact state to resend when editing the run through the full edit form, so the edit does not drop cases, configurations or the owner", - "returns": [ - "data", - "success", - "selection", - "shared_selection", - "test_run", - "dynamic_filter", - "filters", - "trtc_configuration_mappings" - ], - "responses": { - "200": { - "$response": "TestRunEditSelectionResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read this before every edit_test_run call. The full edit replaces whatever it is not sent, so resend `data.selection` as `selection`, `data.shared_selection` as `shared_selection` and `data.trtc_configuration_mappings` as `trtc_configuration_mappings` unchanged, together with every field of `data.test_run` — name, description, run_state, owner, is_dynamic, configurations, tags, issues and test_plans.", - "`configurations` and `test_plans` come back on `data.test_run` as objects; the edit takes plain integer ids, so map each to its `id` before resending. Tags are sent as their plain strings.", - "`selection` carries a top-level `select_all`, a `unique_test_case_count` and `folders`. `selection.folders` is keyed by folder id AS A STRING. In each entry `test_case_select_all: true` means every case in that folder — including cases added to it later — with `deselected_test_cases` as the exceptions; `false` means only the ids in `selected_test_cases`. Do not rebuild this from the run's case listing: whether a folder was stored as all-or-some exists only here, and getting it wrong adds or drops cases.", - "To add cases through the edit, merge the new case ids into `selection.folders[\"0\"].selected_test_cases` (create that entry with test_case_select_all: false and empty lists if it is absent) — the product resolves each case's real folder. When the goal is only to add cases, bulk_update_test_run_test_cases with add_test_cases is simpler and cannot remove anything.", - "`selection` can be `{}`, and a run with no configurations still returns one default entry in `trtc_configuration_mappings` — {configuration_id: null, select_all: true, linked_test_cases: [], unlinked_test_cases: []}. Resend both as returned rather than omitting them.", - "`dynamic_filter` and `filters` carry the same rule twice. For a dynamic run, resend it under both keys plus a top-level `is_dynamic`; for a static run they are null and should be left out.", - "The stored selection is NOT kept in step with bulk_update_test_run_test_cases or update_test_run: cases removed or added that way can still be listed here, or be missing, and a folder can still be stored as select-all. Before resending it, compare `selection.unique_test_case_count` with `test_run.test_cases_count` and the run's case listing; when they disagree, resending as-is re-adds removed cases and pulls in whole folders — rebuild the folders entries from the run's current cases, or avoid the full edit.", - "Everything is under `data`, including `success` — there is no top-level `success` on this response. Closed runs can be read.", - "A read only: it changes nothing and needs no permission beyond access to the project." - ] - }, - { - "name": "export_dashboard_analytics", - "method": "POST", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/download", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "csv" - ], - "example": "csv", - "json_path": "/q/type", - "description": "Export format. Only the literal 'csv' is accepted — any other value is dropped and the export record is created with no file type, while the worker still writes CSV. Must be nested under `q`; a top-level `type` is never read." - }, - { - "name": "created_at", - "type": "string", - "example": "3_month", - "json_path": "/q/created_at", - "description": "Relative window on creation date, _[_gte]. 'all_time' is honoured here and applies no bound. Send this explicitly whenever you send any other filter." - }, - { - "name": "closed_at", - "type": "string", - "example": "3_month", - "json_path": "/q/closed_at", - "description": "Relative window on close date, same token format. Only read when created_at is absent or 'all_time'. 'all_time' is NOT honoured on this key — it becomes 1_year." - }, - { - "name": "owner", - "type": "string", - "example": "3700,3701", - "json_path": "/q/owner", - "description": "Comma-separated TM user ids. Unresolvable ids are dropped silently; if none resolve the filter is omitted rather than erroring." - } - ], - "intent": "Queue a CSV export of the whole project dashboard (active + closed runs, issue counts, case-type split, automation stats) and get back an export id — the file itself is delivered out of band", - "returns": [ - "success", - "export_id" - ], - "responses": { - "200": { - "$response": "DashboardExportInitiatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The inputs are the same q[...] filter object the dashboard reads take, NOT flat body fields. The handler reads params[:q]; a top-level {\"type\":\"csv\"} is ignored and the export runs unfiltered. On this POST the object may be sent in the JSON body as {\"q\": {...}} or as a query string — both land in the same place.", - "CSV is the only format. Anything other than the literal 'csv' is discarded by the handler, and the worker writes a CSV regardless — there is no PDF or Excel export behind this route despite what the format name suggests.", - "You cannot collect the file through this API. The worker uploads the CSV to S3 and pushes the URL over a websocket channel addressed to the requesting user; it never attaches the file to the export record and never advances the export's status past 'pending'. The generic export status/download endpoints therefore report pending forever and 404 on download for this export type, and neither is exposed as a capability here. Treat the returned export_id as a correlation token, not as something you can redeem.", - "Date filtering follows the dashboard rules: q[created_at] wins over q[closed_at], and sending any q key without an explicit q[created_at] defaults both to 'all_time', which on the closed_at path becomes the last 1 year — so an owner-only filter silently produces a 12-month export.", - "The response is immediate and says nothing about whether the export succeeded; a failure happens later in the background and is only signalled on the websocket.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API." - ] - }, - { - "name": "get_active_test_runs_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/active-test-runs-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart the result-status breakdown across the project's currently active test runs — how many results are passed / failed / blocked / untested right now", - "shape": "discovered", - "responses": { - "200": { - "$response": "ActiveTestRunsInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The payload is an OBJECT with a `data` array, not a bare array: read `data`, not the response root.", - "When more than six result statuses exist, the response is collapsed: only the top six by count survive and everything else is merged into a single row named 'Other'. That 'Other' row has NO `result_status` object — it carries a `links.filter` URL instead — so code that reads row.result_status.id must tolerate its absence.", - "Counts are results across active runs, not runs and not test cases. There is no drill-down here; to see the runs themselves use the test-run listings.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ], - "returns": [ - "data", - "name", - "y", - "result_status", - "links" - ] - }, - { - "name": "list_archived_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/archived_test_cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List the test cases that have been archived in a project — the source of the ids the bulk-retrieve (un-archive) call needs.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count" - ], - "responses": { - "200": { - "$response": "ArchivedTestCasesListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-gated, and it fails OPEN-LOOKING: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and no test cases. Check `success`, not the status code, before concluding the archive is empty.", - "Project-wide only — there is no folder scoping on this route. Each row carries its original folder path so you can tell where a case will be restored to.", - "Take the integer `id` of each row: that is what bulk-retrieve takes to un-archive them.", - "Paged with `p`; there is no per_page on this route, so walk pages until `info.next` is null.", - "`cases_count` in the response is keyed by folder id, and on this route that key is null because no folder was scoped — read `info.count` for the total instead." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no per_page here; the page size is the server default." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token (`-ASC|DESC`). `case_type` is translated to the backend's `category` for you." - } - ], - "paginated": true - }, - { - "name": "get_archived_test_plan_count", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/archive_count", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "How many test plans in this project are archived — the badge number for the archived view; use the archived-plans listing when you need the plans themselves", - "shape": "discovered", - "responses": { - "200": { - "description": "Archived plan count: `{success, count}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "count": { - "type": "integer" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a fixed literal route on the project's plan collection, not a plan-scoped read — it takes no plan id and no filters. Counting archived plans is all it does.", - "Counts top-level archived plans only, matching the archived listing; archived sub-plans are not included, so the number can look low against what a user expects.", - "Gated on the same group archive flag as archive/restore: when it is off the call is 404 {success: false, errors: 'Feature not available'} — a feature gap, not an empty project.", - "Needs the test-plan DELETE permission, not view. Someone who can read plans normally may still be 403'd here.", - "The read is forced onto the primary store, so the number is correct immediately after an archive or restore rather than lagging behind it.", - "A count the upstream page info does not carry comes back as 0 rather than an error, so 0 can mean 'none archived' or 'upstream returned no paging info'." - ], - "returns": [ - "success", - "count" - ] - }, - { - "name": "get_automation_stats", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/automation-stats", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Get the project's automation coverage — what percentage of its test cases are automated, with the automated / manual / total counts behind it", - "returns": [ - "success", - "automated_coverage", - "automated_test_cases", - "manual_test_cases", - "total_test_cases", - "empty_data" - ], - "responses": { - "200": { - "$response": "AutomationStats" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the one dashboard chart assembled in this service rather than passed through, so the field names below are exact: `automated_coverage` is a percentage (0–100, a float), and the three counts are test cases.", - "`manual_test_cases` is derived as total minus automated, so every automation state that is not 'automated' — including 'in progress' or unset — is counted as manual.", - "When the project has no test cases, `empty_data` is true and `automated_coverage` is computed from a zero denominator; treat empty_data:true as 'no data' and ignore the coverage number entirely rather than rendering it.", - "Returns counts only — there is no list of which cases are automated. Use the test case search with an automation_state filter for that.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_closed_test_runs", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/closed", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List a project's finished (closed) test runs — the ones the default run listing leaves out — to report on completed cycles", - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "build_meta", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListClosedTestRuns" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no page-size parameter here: the page is fixed at 30 rows, so walk `info` rather than asking for more." - } - ], - "guidance": [ - "This is the counterpart to the main run listing, which hides closed runs. Neither one alone is 'all our runs' — answering that question means reading both.", - "Page size is fixed at 30 and cannot be raised; only `p` moves. Stop when `info.next` is null.", - "Each run's `id` is the TR-NNN STRING and the integer lives in `uuid`. Carry `uuid` anywhere a numeric run id is needed — passing the string through reaches the backing service uncoerced and fails there.", - "The response is flat: {success, test_runs, info, count} with no `data` envelope, unlike the single-run write endpoints on this entity.", - "`count` is an object of per-state totals for the whole project, not the size of this page — the page size is in `info`.", - "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent. Do not read a zeroed `overall_progress` as 'nothing was executed'." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_closed_test_runs_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how many test runs were closed per month over the selected window — the project's throughput trend", - "returns": [ - "data", - "empty_data" - ], - "responses": { - "200": { - "$response": "ClosedTestRunsInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Reads the CLOSE date, so q[closed_at] is the filter that matters here, not q[created_at]. Sending q[created_at] shadows q[closed_at] entirely (created_at wins) and you get a creation-window chart instead.", - "Each entry in `data` is a positional pair — period label first, count second — not a {month, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", - "`empty_data` is the reliable 'nothing to plot' signal — the series is padded, so a non-empty `data` array does not mean there were any closed runs.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_closed_test_runs_split", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-split", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart closed test runs per month split by result status — a stacked view of how each month's closed runs finished", - "returns": [ - "data", - "date_list", - "empty_data", - "name", - "result_status" - ], - "responses": { - "200": { - "$response": "ClosedTestRunsSplit" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each entry in `data` is one status series: `name` plus a `data` array of per-period counts aligned positionally with the top-level `date_list`. The Nth number in a series belongs to the Nth label in date_list; there is no month key on the numbers themselves.", - "Filters on close date — q[closed_at] is the meaningful key, and q[created_at] shadows it when both are sent.", - "This route caches for six hours for one specific large project, so a freshly closed run may not appear immediately there.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_configurations", - "method": "GET", - "path": "/api/v1/projects/{project_id}/configurations", - "mode": "read", - "entity": "configuration", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List the configurations a project's test runs can execute against — search by name or fetch specific ids, to resolve the configuration_id a run needs", - "returns": [ - "success", - "configurations", - "info", - "id", - "name", - "record_status", - "is_suggested", - "group_id", - "created_at" - ], - "responses": { - "200": { - "description": "Successfully retrieved configurations", - "schema": { - "$schema": "ConfigurationListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search. `name` is an accepted alias and `q` WINS when both are sent." - }, - { - "name": "name", - "type": "string", - "description": "Alias for `q`. Only used when `q` is absent." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `page` is an accepted alias and `p` WINS. Only `p` is validated — a non-positive or non-numeric `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`, while a bad `page` is forwarded as-is." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page. `count` is an accepted alias and `count` WINS when both are sent. Forwarded unchanged to the backing service, which owns the real ceiling — nothing bounds it here." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "description": "Alias for `per_page`, and the one that wins when both are sent." - }, - { - "name": "ids", - "type": "string", - "example": "101,102,103", - "description": "Comma-separated configuration ids to fetch, as a STRING — not a JSON array. Split on commas before being forwarded." - }, - { - "name": "type", - "type": "string", - "values": [ - "system", - "custom", - "all" - ], - "description": "Configuration source filter, validated locally: anything else returns 400 `{success:false, error:\"Invalid type parameter. Must be one of: system, custom, all\"}`." - }, - { - "name": "custom", - "type": "boolean", - "default": false, - "description": "CHANGES THE RESPONSE KEY. When 'true' the rows come back under `custom_configurations` instead of `configurations`; everything else about the response is identical. Code that always reads `configurations` gets nothing back." - }, - { - "name": "is_suggested", - "type": "boolean", - "description": "Forwarded unchanged to the backing service to filter on whether the configuration is a suggested/default one. Ignored on the `custom=true` branch." - } - ], - "guidance": [ - "The `custom=true` branch renames the array: rows arrive under `custom_configurations`, not `configurations`. Read the key that matches the request you made — this is the easiest way to get an apparently empty result from a call that worked.", - "Group-scoped, not project-scoped: the list is the workspace's configurations and `project_id` only matches the route. The same set comes back for every project in the group.", - "`ids` is a comma-separated string, not an array; sending `ids[]=101&ids[]=102` does not filter.", - "Alias pairs and which one wins: q beats name, p beats page, count beats per_page. Only `p` is validated, so a typo'd `page` is forwarded and ignored rather than rejected.", - "Rows carry os, os_version, device, browser, browser_version and is_valid_on_bstack in addition to id/name/group_id/record_status/is_suggested — more than the Configuration schema in this index describes. `is_valid_on_bstack` is what tells you the combination is still runnable on BrowserStack.", - "Carry `id` from the row: the run APIs take configuration ids, not names or objects.", - "Paged via `info` {page, page_size, count, prev, next}; stop when `next` is null.", - "Requires test_run_view — configurations are gated on the test-run permissions, not the test-case ones." - ], - "paginated": true - }, - { - "name": "get_custom_field_dataset", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id, from the field read or from the create-dataset response. 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Read one dataset of a custom field — which projects it covers and, on request, the option values it holds", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Options are NOT returned by default. Pass fetch_options=true or you get project links only and will wrongly conclude the dataset has no values.", - "There is no `info` in this response even though the linked-projects list is paged: `p` is honoured, the page size is fixed at 30, and you get no total and no next-page marker. Use `linked_project_count` on the dataset to tell whether more pages exist.", - "`linked_to_all_projects` is computed here by comparing linked_project_count against the workspace project count — it is derived per request, not stored.", - "Both path ids must be positive integers; 0 in either position is a 400 'Invalid Params'. A well-formed id that does not exist is a bare 404 {success:false} with no message.", - "Nothing checks that the dataset actually belongs to the field in the path before the upstream call — a mismatched pair surfaces as that same bodyless 404." - ], - "query": [ - { - "name": "fetch_options", - "type": "boolean", - "default": false, - "description": "Include the dataset's option values in the response. Omit it and `options` is absent entirely — which reads identically to a dataset that has none." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the dataset's linked projects. Page size is fixed at 30 and `per_page` is ignored; no page metadata is returned." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects", - "options" - ] - }, - { - "name": "get_custom_field_definition", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Read one custom field's stored definition together with its datasets and the projects each dataset is linked to — the call to make before any edit, since edits here are full replaces", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this to build an update body: the edit call requires field_name, field_type and is_required together and clears place_holder_text and default_value when they are omitted.", - "`entity_type` is NOT in this response. If you need to know whether the field belongs to TestCase, TestResult, TestPlan or ExploratorySession, take it from the workspace field list instead.", - "The response carries datasets with their project links but NOT their option values. Read a dataset's options through the dataset options list.", - "Paging is per dataset row and is controlled only by `p`; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata.", - "`linked_to_all_projects` on a dataset is computed by comparing its linked project count against the workspace project count — it is derived, not a stored flag.", - "A field id that does not exist comes back as a bare 404 {success:false} with no message; id 0 is a 400 'Invalid Params'." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the field's datasets. The only pagination control — `per_page` is read but discarded, the page size is always 30." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "info", - "id", - "field_name", - "field_type", - "system_name", - "is_required", - "place_holder_text", - "is_bulk_editable", - "is_filterable", - "default_value", - "datasets", - "levels", - "type" - ] - }, - { - "name": "get_custom_field_values", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route also matches the literal 'new', which returns an empty 200 rather than an error. PR-NNN identifiers do not match the route." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-case", - "test-result", - "test-plan", - "exploratory-session" - ], - "description": "Hyphenated, lower-case entity segment. Anything outside this set fails the route constraint and 404s. It must match the field's own entity type or the lookup returns nothing." - }, - { - "name": "field_id", - "type": "string", - "required": true, - "example": "4821", - "description": "Numeric custom field id, from the workspace field list or the project's form fields. Whatever you send here becomes the top-level key of the response, verbatim." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Substring search over the option labels (or user names for a `user` field). Forwarded upstream as the value filter." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, forwarded unchanged. There is no per-page control on this endpoint — the backing service owns the page size; read it back from `info`." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "nested_dropdown only: return the child options of this parent instead of the level's own values. Get the parent id from a previous call on the parent level." - } - ], - "intent": "List the values one custom field offers inside a given project — the dropdown choices, or the users for a user field — so you can pick a valid value before writing it onto a test case or result", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "CustomFieldValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - } - }, - "guidance": [ - "This is the project-scoped value lookup, not a definition read. Use it to turn a value a human named into something you can send; the field's definition, type and project scope live in the workspace field list.", - "The response has no fixed top-level key: the payload sits under the field id exactly as you spelled it in the path (`{\"4821\": {field_type, values}, info, success}`). Read it back by that same string, not by a constant name.", - "`field_type` comes back in the LEGACY spelling — field_dropdown, field_multi_dropdown, field_string, field_user, field_boolean … — not the canonical vocabulary the admin write calls take. Translate before feeding it anywhere else.", - "A nested_dropdown field has no legacy spelling, so its `field_type` comes back null. Do not treat that as 'field not found'.", - "For a nested_dropdown, pass `parent_option_id` to get the children of one parent; without it you get the level's own values.", - "A field that does not exist, or any upstream failure, returns 400 {success:false, error:'Something went wrong'} — there is no 404 here and no way to tell a bad field id from a backend outage.", - "A project_id the route accepts but which is not numeric (the literal 'new') returns 200 with field_type null, values null and info null — an empty success, not an error.", - "The entity_type segment is hyphenated and lower-case (test-case / test-result / test-plan / exploratory-session); anything else does not match the route and 404s before reaching the API.", - "Boolean fields have their values converted to real true/false before rendering; every other type returns them as stored." - ], - "returns": [ - "success", - "info", - "field_type", - "values" - ] - }, - { - "name": "get_dataset", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits, so a PR-NNN identifier never reaches the API." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string, as returned by the list or create calls. There is no integer-id form of this route." - } - ], - "intent": "Read one dataset in full — its columns, every row of data, tags and metadata — by dataset uuid", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "created_at", - "created_by" - ], - "responses": { - "200": { - "description": "The dataset with its full column and row contents.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__GetDataset" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" - }, - "422": { - "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan. Entitlement, not payload." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the only dataset call that returns the actual data: the list endpoint returns counts and metadata but no `variables` or `rows`.", - "Read this before any update — update is a full replace with no history, so the response here is the only copy of the prior contents you will get.", - "`rows_count` is computed from the rows in this payload, so it always agrees with `rows.length`.", - "`created_by` is resolved against users in the caller's group; a creator who has left resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", - "An unknown uuid is 404 {success:false, message:'Dataset not found.'} — note the doubled space in the message if you are matching on it.", - "422 {message:'Feature not available'} means the group lacks the Team Pro plan. That is an entitlement answer, not a bad request; do not retry.", - "The dataset is at the top level as `dataset`; there is no `data` envelope." - ] - }, - { - "name": "list_dataset_linked_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}/test-cases", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string, from the dataset list or a create response." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, forwarded as-is to the backing store. A non-numeric or non-positive value is rejected globally with 400 {success:false, error:'Invalid Params'}. No page-size parameter is exposed." - } - ], - "intent": "List the test cases bound to a dataset — check what a dataset drives before you edit or delete it", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "A page of linked test cases. Note there is no `success` key in this envelope.", - "schema": { - "type": "object", - "required": [ - "test_cases", - "info" - ], - "properties": { - "test_cases": { - "type": "array", - "description": "Test cases linked to the dataset, forwarded from the backing store; the per-row field set is not shaped by this service.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Either 'Dataset not found.' (no such dataset) or 'No test cases found for dataset .' (the backing store returned nothing)." - }, - "422": { - "description": "`Feature not available` — the group lacks the Team Pro plan. Not a payload error; do not retry with a different body." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Run this before deleting or rewriting a dataset — nothing else warns you that test cases depend on it, and the delete gives no preview.", - "This response has NO `success` key, unlike every other dataset endpoint: it is exactly {test_cases, info}. Do not branch on `success` here.", - "Two different 404s: an unknown dataset uuid fails in the lookup with 'Dataset not found.', while a dataset the backing store returns nothing at all for fails with 'No test cases found for dataset .'. Neither distinguishes 'exists but has no linked cases' reliably — treat a 404 as 'nothing to read', not as proof the dataset is gone.", - "The test-case rows are passed through from the backing store unchanged; their field set is not shaped by this service and is not declared here.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint." - ], - "returns": [ - "test_cases", - "info" - ] - }, - { - "name": "get_dataset_variables", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/variables", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[name]", - "type": "string", - "description": "Partial, case-insensitive match on the column name. This is the search this endpoint exists for." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}. There is no page-size parameter; read `info.page_size` for what you got." - } - ], - "intent": "List every dataset column defined anywhere in the project, each with the dataset it belongs to — use this to find which dataset holds a variable name you saw in a test case", - "returns": [ - "success", - "variables", - "info", - "uuid", - "name", - "dataset" - ], - "paginated": true, - "responses": { - "200": { - "description": "A page of dataset columns, each with its owning dataset, plus paging metadata under `info`.", - "schema": { - "$schema": "DatasetVariablesResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is project-wide, not per dataset. `variables` on this path is a fixed route segment, not a dataset uuid — it lists columns across every dataset in the project. To see one dataset's columns, read that dataset instead.", - "Each row carries its owning dataset inline as `dataset` {uuid, name, count_rows}, so this call resolves a bare variable name to the dataset uuid you need for every other dataset operation.", - "Column names are not unique across datasets — the same name in two datasets is two rows here. Disambiguate on `dataset.uuid`.", - "The page size is set by the backing store and cannot be requested from here; page with `p` and read `info` for page/page_size/next.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint.", - "`success`, `variables` and `info` are all top level — `variables` is the array itself, not a wrapper object." - ] - }, - { - "name": "get_dedupe_status", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/status", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - } - ], - "intent": "Check whether duplicate detection has ever run for a project and where the latest scan got to — the only way to tell 'dedupe is off or has never run' from 'it ran and found nothing'", - "returns": [ - "status", - "job_id", - "scheduled_run_time", - "actual_end_time", - "updated_at", - "message" - ], - "responses": { - "200": { - "description": "The latest dedupe job for the project. When no job has ever run, only {status:'NOT_ENABLED', message:'No dedupe job found for this project'} is returned and the job fields are absent.", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "Latest job status, or the synthesised 'NOT_ENABLED' when no job exists.", - "values": [ - "NOT_ENABLED", - "QUEUED", - "PENDING", - "IN_PROGRESS", - "CLUSTER", - "CLUSTERING", - "COMPLETED", - "FAILED", - "RETRY", - "RETRY_FAILED", - "STATISTICAL_FAILED", - "STATISTICAL_RETRY", - "CLUSTERING_FAILED", - "CLUSTERING_RETRY", - "CANCELLED", - "BACKFILLING", - "LLM_ANALYSIS_SKIPPED", - "CLUSTERING_LIMIT" - ] - }, - "job_id": { - "type": "integer", - "description": "Pipeline job id. Absent when status is NOT_ENABLED." - }, - "scheduled_run_time": { - "type": "string", - "format": "date-time", - "description": "When the scan was scheduled to run. Nullable; absent when status is NOT_ENABLED." - }, - "actual_end_time": { - "type": "string", - "format": "date-time", - "description": "When the scan finished. Null while the job is still in flight; absent when status is NOT_ENABLED." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Last status change. Absent when status is NOT_ENABLED." - }, - "message": { - "type": "string", - "description": "Present only with NOT_ENABLED: 'No dedupe job found for this project'." - } - } - } - }, - "400": { - "description": "{error:'Missing project_id'} — unreachable through this route, where project_id is a required path segment." - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Call this first whenever a duplicates listing comes back empty. An empty listing is returned both when the feature is off and when a completed scan found nothing; only this endpoint separates them.", - "`NOT_ENABLED` is synthesised, not stored: it means no scan job row exists for this project at all, and it arrives with an explanatory `message` and none of the job fields. Every other value is a real job status and comes with job_id and timestamps.", - "Only the MOST RECENT job is reported, by last update. There is no history and no way to page back to earlier runs.", - "Statuses are not just running/done. COMPLETED, FAILED, CANCELLED, RETRY_FAILED, STATISTICAL_FAILED, CLUSTERING_FAILED, CLUSTERING_LIMIT and LLM_ANALYSIS_SKIPPED are terminal; QUEUED, PENDING, IN_PROGRESS, CLUSTER, CLUSTERING, BACKFILLING, RETRY, STATISTICAL_RETRY and CLUSTERING_RETRY mean a scan is still in flight and the duplicates listing may be incomplete.", - "This is a plain read of the last job record — it does not start a scan, and nothing in this API can. Scans are scheduled by the dedupe pipeline.", - "Unlike the rest of this family, no role permission is checked here; project access is enough.", - "The documented 400 for a missing project id is unreachable through this route, since the id is a required path segment." - ] - }, - { - "name": "get_duplicate_source_test_case", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}/source_tc", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id of an ALREADY-MERGED pair. The route only matches digits." - } - ], - "intent": "Read the pre-merge snapshot of the test case that was folded away when a duplicate was merged — the only record of what the archived case contained at merge time", - "shape": "discovered", - "responses": { - "200": { - "description": "The pre-merge snapshot, wrapped in a `data` envelope: {data:{success:true, test_case:{...}}}. `test_case` is the frozen full test-case serialisation stored at merge time (steps, attachments with signed URLs, template); its field set is whatever was captured then and is not shaped by this endpoint.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_case": { - "type": "object", - "description": "Frozen pre-merge serialisation of the source test case." - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "description": "Two distinct bodies: {data:{success:false, error:'Source test case snapshot not available'}} when the pair merged but retained no snapshot, and {success:false, error:'Duplicate not found.'} (no `data` key) when the pair is unknown or was never merged." - }, - "422": { - "description": "{success:false, message:'Invalid source_tc data'} — the stored snapshot could not be parsed." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This works only for a pair whose status is `merged` — the exact opposite of the read/resolve/discard calls, which work only while a pair is `suggested`. Calling it on a live suggestion is a 404.", - "The snapshot is written only by the merge path. A pair resolved by archiving instead of merging is also marked merged but stores no snapshot, so it answers 404 with the message 'Source test case snapshot not available'. Treat that as 'not retained', not as a retryable error.", - "The two 404s have DIFFERENT envelopes. Missing snapshot: {data:{success:false, error:'Source test case snapshot not available'}}. Unknown or unmerged pair: {success:false, error:'Duplicate not found.'} with no `data`. Check for `data` before reading `error`.", - "Success is wrapped: the test case is at `data.test_case`, not at the top level. This is the only duplicate endpoint with a `data` envelope.", - "The snapshot is a frozen serialisation taken at merge time, including steps, attachments (with freshly signed, short-lived URLs) and the template — it is not re-read from the live test case, so it will not reflect anything that happened afterwards.", - "A stored snapshot that cannot be parsed comes back 422 {success:false, message:'Invalid source_tc data'} — a data fault, not something a different request fixes.", - "To find the merged pair id in the first place: it is no longer in the duplicates listing, so you need the id you saw before resolving, or the id embedded in the merge comment left on the surviving test case." - ], - "returns": [ - "data", - "success", - "test_case" - ], - "disabled": true - }, - { - "name": "get_duplicate", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id, taken from the `id` of a row in the duplicates listing or search. The route only matches digits." - } - ], - "intent": "Read one suggested duplicate pair — the two test cases side by side with their titles, descriptions, folders and owners, plus the confidence score and the model's reason — so a human can decide how to resolve it", - "shape": "discovered", - "responses": { - "200": { - "description": "The duplicate pair with both test cases resolved, at the top level as `duplicate`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "duplicate": { - "$schema": "V1DuplicatePair" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "{errors:'Duplicate not found.'} — no such pair in this project, or its status is no longer `suggested`. Or {errors:'Test case not found.'} — the pair exists but a member test case could not be resolved." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Suggested-only. A pair that has already been merged, archived or discarded answers 404 {errors:'Duplicate not found.'} — here a 404 usually means 'already handled', not 'never existed'. Check the listing before concluding the id is wrong.", - "The error key is `errors` (plural), not `error` or `message`, on both 404 paths from this endpoint.", - "A second 404, {errors:'Test case not found.'}, means the pair exists but one of its two test cases could not be resolved (deleted or moved out of reach). The pair is unusable — it is also silently dropped from the listings for the same reason.", - "Unlike the listings, this has NO feature-flag gate: a single pair stays readable by id even for a group whose dedupe listing returns empty.", - "The pair is at the top level as `duplicate`; there is no `data` envelope. Inside it, `test_cases` always holds exactly two entries and each entry's `id` is the integer test-case id you pass to the resolve call as source or target.", - "`owner` is a resolved display name and is an empty string when the owner cannot be resolved; `owner_id` is the raw BrowserStack user id. `description` is '' rather than null when absent.", - "`ai_score` is the model's confidence and `duplicate_type` is exact vs semantic — read both before merging, since an exact pair and a low-confidence semantic pair warrant different treatment." - ], - "returns": [ - "success", - "duplicate", - "id", - "ai_score", - "reason", - "duplicate_type", - "test_cases", - "identifier", - "folder_id", - "folder_path", - "title", - "description", - "owner_id", - "owner" - ] - }, - { - "name": "list_entity_filter_details", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity}/filter-details", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "trtc", - "test-runs" - ], - "example": "test-cases", - "description": "Which entity's filter vocabulary to resolve against — this also decides which user fields are resolved. 'test-plans' is allowed by the route but rejected by the handler with 400." - } - ], - "query": [ - { - "name": "q[status]", - "type": "string", - "example": "9319,9321", - "description": "Comma-separated numeric status IDs to resolve. Returned under filter_details.status as objects with id, field_value, internal_name and colour." - }, - { - "name": "q[priority]", - "type": "string", - "example": "550376,9261", - "description": "Comma-separated numeric priority IDs to resolve; returned under filter_details.priority." - }, - { - "name": "q[case_type]", - "type": "string", - "example": "9379,9375", - "description": "Comma-separated numeric case-type IDs to resolve; returned under filter_details.case_type." - }, - { - "name": "q[automation_state]", - "type": "string", - "example": "automated", - "description": "Numeric ids, or the literal word 'automated' / 'manual' which is expanded to this project's ids first; returned under filter_details.automation_state." - }, - { - "name": "q[automation_status]", - "type": "string", - "example": "passed,failed", - "description": "Comma-separated values; split on commas and echoed back as an array, not resolved against anything." - }, - { - "name": "q[folder_ids]", - "type": "string", - "example": "3306878,3669741", - "description": "Comma-separated folder ids; returned under filter_details.folders as a folder tree with paths and ancestors. At most 600 folders are resolved." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids. Resolved for entity=test-cases and entity=test-runs only; ignored for trtc." - }, - { - "name": "q[reviewers]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=test-cases only." - }, - { - "name": "q[assignee]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=trtc only." - }, - { - "name": "q[last_executed_by]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=trtc only." - }, - { - "name": "q[test_case_tags]", - "type": "string", - "example": "[\"regression\",\"smoke\"]", - "description": "Must be a JSON-encoded string containing an array of tag names — it is JSON.parse'd and echoed back. A bare comma-separated list raises rather than filtering. `q[tags]` is not a recognised key." - }, - { - "name": "q[custom_fields]", - "type": "string", - "example": "{\"45\":[\"abc\"]}", - "description": "Custom-field selections keyed by field id; returned resolved under filter_details.custom_fields. Date custom fields echo back the raw value you sent rather than the normalised one." - }, - { - "name": "q[result_custom_fields]", - "type": "string", - "example": "{\"88\":[\"a\",\"b\"]}", - "description": "Test-result custom-field selections; resolved separately and returned under filter_details.result_custom_fields." - }, - { - "name": "q[configuration_id]", - "type": "string", - "example": "101,102", - "description": "Comma-separated configuration ids; resolved to configuration objects under filter_details.configurations." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Echoed back verbatim under filter_details.created_at — not parsed, not validated." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "3_month", - "description": "Echoed back verbatim under filter_details.updated_at." - }, - { - "name": "q[latest_status]", - "type": "string", - "example": "passed,failed", - "description": "Comma-separated values, split and echoed back as an array." - } - ], - "intent": "Resolve a set of filter IDs you already hold into their display objects — turn priority/status/case-type/custom-field ids, folder ids and user ids into names, colours and folder paths so a saved filter can be rendered; it does NOT list the filter options available in a project", - "returns": [ - "success", - "filter_details", - "priority", - "status", - "case_type", - "automation_state", - "automation_status", - "custom_fields", - "result_custom_fields", - "folders", - "configurations", - "created_at", - "updated_at", - "test_case_tags", - "latest_status", - "owner", - "reviewers", - "assignee", - "last_executed_by" - ], - "responses": { - "200": { - "description": "Everything is nested under `filter_details`; only the keys you supplied under q are present, and an empty q yields filter_details:{}.", - "schema": { - "type": "object", - "required": [ - "success", - "filter_details" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "filter_details": { - "type": "object", - "description": "Resolved form of the filters that were sent. Keys absent from the request are absent here.", - "properties": { - "priority": { - "type": "array", - "description": "Resolved priority options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } - }, - "status": { - "type": "array", - "description": "Resolved status options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } - }, - "case_type": { - "type": "array", - "description": "Resolved case-type options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } - }, - "automation_state": { - "type": "array", - "description": "Resolved automation-state options.", - "items": { - "$schema": "ResultStatus" - } - }, - "automation_status": { - "type": "array", - "description": "The q[automation_status] value split on commas and echoed back unresolved.", - "items": { - "type": "string" - } - }, - "test_case_tags": { - "type": "array", - "description": "The JSON-decoded q[test_case_tags] value, echoed back.", - "items": { - "type": "string" - } - }, - "latest_status": { - "type": "array", - "description": "The q[latest_status] value split on commas and echoed back.", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string", - "nullable": true, - "description": "Echo of q[created_at], unparsed." - }, - "updated_at": { - "type": "string", - "nullable": true, - "description": "Echo of q[updated_at], unparsed." - }, - "custom_fields": { - "type": "object", - "description": "Resolved custom-field values keyed by field id. Date fields carry the raw value that was sent, not the normalised one." - }, - "result_custom_fields": { - "type": "object", - "description": "Resolved test-result custom-field values keyed by field id." - }, - "configurations": { - "type": "array", - "description": "Resolved configuration objects for q[configuration_id].", - "items": { - "$schema": "Configuration" - } - }, - "owner": { - "type": "array", - "description": "Resolved users for q[owner] — test-cases and test-runs only.", - "items": { - "$schema": "User__ListEntityFilterDetails" - } - }, - "reviewers": { - "type": "array", - "description": "Resolved users for q[reviewers] — test-cases only.", - "items": { - "$schema": "User" - } - }, - "assignee": { - "type": "array", - "description": "Resolved users for q[assignee] — trtc only.", - "items": { - "$schema": "User" - } - }, - "last_executed_by": { - "type": "array", - "description": "Resolved users for q[last_executed_by] — trtc only.", - "items": { - "$schema": "User" - } - }, - "folders": { - "$schema": "FilterDetailsFolderNode" - } - } - } - } - } - }, - "400": { - "description": "Unsupported entity (including test-plans, which the route allows but the handler rejects), or malformed filter values." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is an echo/hydration endpoint, not a discovery one. It returns only the keys you actually passed under `q`: send q[status]=9319 and you get back `status` resolved; send nothing and you get {success:true, filter_details:{}} with a 200. To enumerate the options a project offers, use the system/custom field value capabilities instead.", - "The result is nested one level down under `filter_details` — read filter_details.status, filter_details.priority, filter_details.folders. The keys are not at the top level.", - "Only three entity values work here: test-cases, trtc and test-runs. 'test-plans' passes the route constraint but is not in the handler's map and returns 400 — the route and the handler disagree.", - "Which user fields get resolved depends on the entity: owner and reviewers for test-cases, assignee and last_executed_by for trtc, owner for test-runs. A user id sent under the wrong key for that entity is ignored without comment.", - "Filter keys are renamed internally (q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id) but you send the un-suffixed names. q[automation_state] additionally accepts the literal words 'automated' / 'manual'.", - "q[test_case_tags] must be a JSON-ENCODED STRING of an array; it is JSON.parse'd and echoed back verbatim. `q[tags]` is not a key this endpoint knows and is dropped silently.", - "q[created_at] and q[updated_at] are echoed back exactly as sent — they are not parsed or normalised here.", - "Folder resolution caps out at 600 folders per call, so a very wide folder selection comes back truncated rather than paged.", - "q[query] has no effect on this endpoint — it is sanitised and then ignored." - ] - }, - { - "name": "get_exploratory_session", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Read one exploratory session in full — its charter, state, timings, assignee, tags, configurations, linked test plan, linked issues, custom fields and how many logs it holds — before reading its logs or changing it", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "closed_by", - "closed_at", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "timeline", - "created_at", - "updated_at", - "project_id", - "group_id", - "owner", - "record_status", - "attachment_relations" - ], - "responses": { - "200": { - "description": "Successfully retrieved the exploratory session.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "session_id is a plain integer. The route segment only matches digits, so the display identifier (ES-NNN) never reaches the API — it fails as a routing 404, not as an API error.", - "Read `session_log_count` first: it tells you how many log pages to walk, and it is the same number that decides whether a clone of this session runs synchronously or asynchronously (50 or more logs switches clone to the async path).", - "custom_fields is always present on this read — normalised into the flat TCM shape, and an empty array when the session has none. That normalisation is applied here, on create, update and clone, but NOT on the sessions listing.", - "assignee, created_by and closed_by are expanded into {id, email, full_name, group_id} objects by looking the user up locally. If the stored user is not a TM user in this group the field comes back null even though a user is set upstream.", - "A session id that does not exist comes back as a bare {success:false} with the 404 status and no message — the upstream error text is deliberately not relayed.", - "The session is at the top level under `exploratory_session`. There is no `data` envelope on any endpoint of this entity.", - "Requires the test-run view permission: exploratory sessions are gated by the test run permission set, not by a permission of their own.", - "The path takes the INTEGER session id, not the ES-NNN identifier — ES-NNN returns 404. The session's display field is `title`; there is no `name`. `summary` and `test_plan` come back from the LIST route only, not from this one." - ] - }, - { - "name": "get_exploratory_session_summary", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/exploratory-session-summary", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "mode", - "type": "string", - "values": [ - "time_period", - "session_selection" - ], - "default": "time_period", - "description": "`time_period` scopes by dates and ignores session_ids; `session_selection` scopes by session_ids and ignores the dates. Mismatched parameters are dropped silently, not rejected." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2025-05-01", - "description": "Inclusive start, YYYY-MM-DD. Only read in time_period mode; omitted entirely from the upstream call when absent." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2025-05-31", - "description": "Inclusive end, YYYY-MM-DD. Only read in time_period mode." - }, - { - "name": "session_ids", - "type": "string", - "example": "101,102,103", - "description": "Comma-separated session ids as a STRING, not an array. Only read in session_selection mode." - } - ], - "intent": "Get exploratory-session testing figures for a project directly — sessions, outcomes and top testers over a date range or a chosen set of sessions, with no saved report needed", - "shape": "discovered", - "responses": { - "200": { - "description": "Summary figures under `data`.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "Exploratory-session summary payload — session counts, outcome breakdown, `sessions` (with `assignee` expanded to a user object) and `top_testers` (each `{ user, bug_count }`, `user` null when the tester is no longer in the group)." - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this for an ad-hoc question about exploratory testing. It computes live from the project and needs no report to exist; the saved equivalent is an `Exploratory Session Summary` report read through the report capabilities.", - "The figures are under `data`, not at the top level. `data.top_testers[].user` and `data.sessions[].assignee` are expanded from ids into user objects server-side, and come back `null` for a tester who is no longer a TM user.", - "`mode` decides which of the other parameters matter: `time_period` (the default) uses `start_date` / `end_date` and ignores `session_ids`; `session_selection` uses `session_ids` and ignores the dates. Sending the wrong pair for the mode is not an error — it is silently ignored and you get an unintended scope.", - "`session_ids` is a comma-separated STRING here, not an array. Dates are plain YYYY-MM-DD.", - "Omitted parameters are dropped rather than defaulted locally, so the upstream service picks the window — always send explicit dates when the range matters.", - "This route only exists under a project; it reads the project from the path and requires the report_view permission (403 otherwise)." - ], - "returns": [ - "success", - "data" - ] - }, - { - "name": "get_filter", - "method": "GET", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "read", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one saved filter view — its name, scope and the exact condition set it applies — before reusing, editing or deleting it", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This read is not side-effect free. The handler validates every custom-field condition in the saved view against the project's current custom fields and, when any no longer exist, it rewrites the stored filter without them. Reading a filter can permanently change it.", - "So `data.filters.customFields` can legitimately be a subset of what was saved, and a second read will not put the pruned conditions back. Treat what you get as the new truth.", - "The entity_type to custom-field-entity map has no entry for `testrun`, so a run filter is validated against TEST CASE custom fields and conditions that were valid for runs can be pruned. Be cautious about reading run filters you do not want altered.", - "Known defect: if the custom-field lookup or the write-back fails with a 400, the action returns without rendering anything at all — you get an empty/500 response rather than the filter. Fall back to the filter listing, which does no validation.", - "The filter is under `data` with a sibling `success`; the conditions are at `data.filters` and the list type at `data.entity_type` (not `entity`, which is the request-side spelling).", - "Only a 400 from the backing service is translated to `{success:false, message:...}`; anything else from it surfaces as a 500, so an unknown filter_id does not reliably produce a 404." - ], - "disabled": true - }, - { - "name": "get_folder_remove_summary", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm-summary", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Dry-run a folder deletion — count the test cases, subfolders and cross-product recordings that would be destroyed — before committing to it", - "returns": [ - "success", - "has_other_product_entity", - "deleted_entity_count", - "active_test_case_count", - "archived_test_case_count", - "sub_folders", - "test_recordings_count" - ], - "responses": { - "200": { - "$response": "RemoveFolderSummaryResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read-only despite the delete machinery behind it: the handler calls the delete path with dry_run set, so nothing is removed. This is the safety check to run before deleting a folder.", - "It is nonetheless gated on the folder-DELETE permission, not the view permission. A caller who can read folders but not delete them gets 403 here.", - "The counts are nested under `deleted_entity_count` — active_test_case_count, archived_test_case_count, sub_folders and test_recordings_count all live inside that object, not at the top level.", - "`has_other_product_entity` is derived from one thing only: it is true exactly when test_recordings_count is greater than zero, meaning the subtree holds low-code test recordings owned by another product that the delete would take with it.", - "The folder is not fetched before the dry run, so a folder id that does not exist in this project surfaces as a bare 404 {success:false} with no message identifying the id.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." - ] - }, - { - "name": "get_folder_tree", - "method": "GET", - "path": "/api/v1/projects/{project_id}/repository/mapping", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Fetch a project's entire folder tree in one call — every root folder with its children nested under `contents` — to resolve a folder name to the integer id every other folder and test-case call needs", - "shape": "discovered", - "responses": { - "200": { - "description": "Folder tree.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "structure": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "401": { - "description": "Unauthorized." - } - }, - "query": [ - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Sibling ordering. Comma-separated combinations are accepted and each element is validated; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." - }, - { - "name": "shared", - "type": "boolean", - "description": "Any truthy presence switches the tree to the shared-folder view. Presence alone is what counts — sending shared=false still enables it, because only presence is tested." - } - ], - "guidance": [ - "This returns the WHOLE tree in one response and is not paginated — no page or per-page parameter is read. On a large project it is a big payload; use the per-folder listing instead if you only need one level.", - "The tree is at `structure`, a top-level array. There is no `data` envelope on this endpoint. Children hang off each node's `contents` array, recursively.", - "Folder ids in the tree are plain integers, and they are what every other folder capability expects in its path.", - "An unresolvable project (including the literal path segment `new`) returns 200 with {structure: [], success: true}, not a 404. An empty structure is therefore ambiguous between 'no folders' and 'no such project' — confirm the project separately.", - "When Review & Approve is switched on for the project, the per-folder test-case counts in the tree are silently narrowed to review-eligible statuses. If you also pass a review_status filter, it is intersected with the project's eligible set — and when the intersection is empty your filter is discarded and the project's set is used instead, so you can get counts you did not ask for rather than an error.", - "Counts here are aggregates over the tree; they are not a list of test cases. Read test cases through the test-case listings." - ], - "returns": [ - "success", - "structure" - ] - }, - { - "name": "list_group_users", - "method": "GET", - "path": "/api/v1/group/users-v2", - "mode": "read", - "entity": "user", - "query": [ - { - "name": "q", - "type": "string", - "description": "As a bare string: filters on FULL NAME only, as a SQL LIKE '%value%'. Email is not matched by this form and there is no partial-email search. Whitespace is not trimmed." - }, - { - "name": "q[email]", - "type": "string", - "example": "qa@acme.com", - "description": "Exact email lookup — no partial matching. Returns at most one user, and this branch OMITS the `info` pagination block entirely, so a reader that always dereferences info.next breaks on it." - }, - { - "name": "q[id]", - "type": "string", - "example": "14,27", - "description": "Comma-separated Test Management user ids. This does NOT filter — it only re-orders, putting the named ids first and returning the rest of the workspace behind them. It also raises the page size to the number of ids when that exceeds 30." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Previously undeclared. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Has no effect on the q[email] branch, which is unpaginated." - } - ], - "intent": "List or look up the people in this workspace — search by name, resolve an exact email, or read your own identity — to get the user id that owner and assignee fields take", - "returns": [ - "success", - "users", - "myself", - "info", - "id", - "browserstack_user_id", - "email", - "full_name", - "group_id", - "onboarded", - "reports_and_notification_enabled" - ], - "responses": { - "200": { - "$schema": "UsersV2Response__ListGroupUsers" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Scope is the whole WORKSPACE (group), not a project — every active user in the group is returned regardless of which projects they can see, and no project id can be supplied. This is not a way to answer 'who is on this project'.", - "`q[id]` is a sort, not a filter. Passing ids re-orders the full user list so those ids come first; you still get everyone. Filtering to specific people is not supported here — match on the ids yourself after reading.", - "The response shape varies by branch: the default, name-search and id branches return `{users, myself, success, info}`, but the `q[email]` branch returns `{users, myself, success}` with NO `info` at all.", - "`id` is the Test Management user id; `browserstack_user_id` is the separate BrowserStack account id for the same person, and the two are different numbers. Owner and assignee fields on cases and runs take the TM `id`; some listing filters take browserstack_user_id instead. Sending the wrong one usually resolves to nobody rather than erroring, so check which the target capability wants.", - "Only active users are returned. A deactivated person cannot be resolved here, which means an assignee who has left the workspace cannot be looked up to explain an existing assignment.", - "`myself` is the calling user as {id, full_name, email, reports_and_notification_enabled} — the cheapest way to get your own TM user id, with no search needed.", - "Page size is fixed at 30 (the q[id] branch excepted, where it becomes the number of ids); there is no per_page parameter.", - "No project is involved, so this works without a project_id and is not project-permission gated." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_issues_count_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/issues-count-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how many defects/issues were linked from this project per period — the defect-discovery trend", - "returns": [ - "data", - "empty_data" - ], - "responses": { - "200": { - "$response": "IssuesCountInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each entry in `data` is a positional pair — period label first, count second — not a {label, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", - "This counts issues per period; it does not list them. There is no way to reach the individual defects from this response.", - "`empty_data` is the 'nothing to plot' flag; the series is padded with zeroes otherwise.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_project_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Get a project by INTEGER id (v1) — full detail + its PR-NNN identifier", - "returns": [ - "data", - "success", - "project", - "id", - "identifier", - "name", - "description", - "created_at", - "thProjectId", - "normalisedName", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "starred", - "jira_mapped", - "links", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project detail; data.project.identifier is the PR-NNN form.", - "schema": { - "$schema": "V1ProjectDetail__GetProjectByIntegerId" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No project with that integer id, OR the project exists but is not visible to this user — both render as 404 {success:false}." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The path segment only matches digits (or the literal string 'new'), so a PR-NNN identifier never reaches this action — it is a routing 404, not an API error.", - "This is the integer-id -> PR-NNN translation step: read `data.project.identifier` and carry it into any the v2 surface route, which will not accept the integer.", - "The project sits under a `data` envelope: `data.project`. The paginated/minified project listings do NOT use `data` — they return `projects` at the top level. Do not reuse a path across the two.", - "A project that exists but is not visible to this caller comes back as 404 {success:false} — the underlying 403 is deliberately rewritten. You cannot distinguish 'no such project' from 'no access' on this route.", - "`test_cases_count`, `test_runs_count` and `test_plans_count` are fetched with the detail here (they are absent from the lean listings), which is the reason to use this call rather than filtering a listing.", - "`permissions` is the caller's own permission list for this project and `user_role` their role name — both reflect the requesting user, not the project.", - "Requires the project_view permission under RBAC." - ] - }, - { - "name": "get_project_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/form-fields-v2", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. PR-NNN identifiers do not match the route; the literal 'new' matches the route but is rejected with 400 'Invalid Params: project_id must be a valid integer'." - } - ], - "intent": "Get the test-case form for one project — the custom fields it shows, the built-in system fields, and the pick lists behind the standard priority / state / type / automation-status dropdowns", - "returns": [ - "success", - "custom_fields", - "default_fields", - "system_fields", - "id", - "field_name", - "field_type", - "entity_type", - "field_user_name", - "optional", - "is_required", - "is_bulk_editable", - "is_filterable", - "links", - "placeholder", - "default_value", - "option_values", - "field_values", - "sort_options" - ], - "responses": { - "200": { - "$response": "ProjectFormFieldsV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the read to make before creating or editing a test case: it tells you which fields that project actually shows, which are mandatory, and what the standard dropdowns accept.", - "The response is three lists at the top level — `custom_fields`, `default_fields`, `system_fields` — with NO `data` envelope and no pagination. Every custom field the project has comes back in one call.", - "`field_type` inside `custom_fields` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean …), not the canonical vocabulary the admin create/update calls take. Translate in both directions.", - "TestCase only. Result, plan, exploratory-session and step custom fields are served by different endpoints and never appear here.", - "`optional` and `is_required` are both present and are inverses of one another; prefer is_required.", - "Each row carries `links.self` — the ready-made path to that field's values in this project (or to the project user list for a `user` field). Follow it rather than building the path.", - "`option_values` / `field_values` appear only when the upstream row carried them, so an absent key does not mean the field has no options — follow links.self to be sure.", - "project_id must be numeric. The route also matches the literal 'new', but this handler rejects a non-numeric value with 400 'Invalid Params: project_id must be a valid integer'.", - "`automation_status` under default_fields is assembled in this layer, not read from storage, and the automation-status custom field is omitted for groups without that feature." - ] - }, - { - "name": "get_project_settings", - "method": "GET", - "path": "/api/v1/projects/{project_id}/settings", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "in_review_count", - "type": "string", - "values": [ - "true" - ], - "description": "Compute meta_data.in_review_count and meta_data.test_plan_in_review_count for the CALLING user. Without it both are returned as 0 rather than omitted, so an unrequested 0 is not evidence of an empty queue." - }, - { - "name": "fetch_dedupe_button", - "type": "string", - "values": [ - "true" - ], - "description": "Append a duplicate-review marker string to the project_settings array ('show_review_duplicates', 'disable_review_duplicates' or 'hide_review_duplicates'). Any failure computing it is swallowed and no marker is appended." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the underlying settings rows (30 per page) before they are flattened into project_settings[0]." - } - ], - "intent": "Read a project's configuration flags — review/approval gating, the custom test-case id prefix, time-tracking, Part 11 re-auth, Jira/Azure linking — and optionally the caller's own pending-review counts", - "returns": [ - "success", - "project_settings", - "info", - "meta_data", - "in_review_count", - "test_plan_in_review_count" - ], - "responses": { - "200": { - "description": "project_settings[0] is a flat attribute->value object (all values are strings); later elements, when present, are bare marker strings. info and meta_data are omitted entirely when the project has no stored settings.", - "schema": { - "type": "object", - "required": [ - "success", - "project_settings" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "project_settings": { - "type": "array", - "description": "Element 0 is a single object mapping each stored setting name to its string value. Any further elements are duplicate-review marker strings appended when fetch_dedupe_button=true. Empty array when nothing is stored.", - "items": { - "description": "Either the flattened settings object or a marker string." - } - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the underlying settings rows. Absent when the project has no stored settings." - }, - "meta_data": { - "type": "object", - "description": "Per-caller review counts. Present whenever settings exist; both fields are 0 unless in_review_count=true was passed.", - "properties": { - "in_review_count": { - "type": "integer", - "description": "Test cases in review where the calling user is a reviewer.", - "example": 4 - }, - "test_plan_in_review_count": { - "type": "integer", - "description": "Test plans in review for the calling user. Best-effort — a backend error yields 0.", - "example": 2 - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`project_settings` is an ARRAY, but not a list of settings. Element 0 is a single flat object holding every stored setting as attribute -> value; any further elements are bare marker STRINGS ('show_review_duplicates' / 'disable_review_duplicates' / 'hide_review_duplicates') appended only when fetch_dedupe_button=true. Read project_settings[0] for the settings and expect mixed types in the rest of the array.", - "A project with no stored settings returns {success:true, project_settings:[]} with NO `info` and NO `meta_data` at all — not an object of defaults. Absence of a key means 'never set', and the effective behaviour is the group default, which is not visible here.", - "Every value is stored as a string (the settings are an EAV store), so booleans come back as \"true\"/\"false\" and numbers as strings. Compare against strings, not native types.", - "`meta_data` is always present when any setting exists, but both counts are hard-zero unless you pass in_review_count=true — a 0 you did not ask for means 'not computed', not 'nothing in review'.", - "in_review_count counts test cases where the CALLER is a reviewer and the case is in review; it is per-user, not per-project. test_plan_in_review_count is computed on a separate path and is best-effort — a backend hiccup silently yields 0.", - "The underlying rows are paged at 30 per page (`p` selects the page) before being flattened, so a project with an unusually large settings set can produce an incomplete first-page object.", - "This read is not feature-gated, but the matching write is: update_project_settings returns 422 unless the new-project-settings flag is on for the group or user." - ] - }, - { - "name": "list_project_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users-v2", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "example": "jane", - "description": "Flat string: case-sensitive LIKE match on full_name (substring). Paginated, 30 per page." - }, - { - "name": "q[email]", - "type": "string", - "example": "jane@acme.com", - "description": "Exact email lookup, at most one row. This branch returns NO `info` block — do not read pagination from it. An unknown address returns an empty `users` array, not a 404." - }, - { - "name": "q[id]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids. This does NOT filter — it moves those users to the front of the full group list. Page size grows to the id count when more than 30 ids are given." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number (the param is `p`, not `page`). Overflow returns the last page rather than an empty one." - } - ], - "intent": "Find the users in this group — by name fragment, by exact email, or by id — and get the caller's own identity back alongside them; use this to resolve a person's name or email to the numeric user id that owner/assignee/reviewer filters and writes require", - "returns": [], - "responses": { - "200": { - "$schema": "UsersV2Response__ListProjectUsers" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the path, this is GROUP-scoped, not project-scoped: it lists every active user in the caller's group and never filters by the project in the URL. The project_id only has to be a real project the caller can see.", - "`q` is polymorphic and the three forms behave differently. `q=jane` (a flat string) is a name LIKE match and is paginated. `q[email]=jane@acme.com` is an exact lookup that returns at most one user AND OMITS the `info` block entirely. `q[id]=12,34` reorders those ids to the front of the full list rather than filtering to them — it is a sort, not a filter.", - "Page size is fixed at 30 and there is no size parameter. The one exception is `q[id]` with more than 30 ids, where the page grows to fit the id list.", - "Use `p` for the page number (not `page`). An out-of-range page returns the LAST page rather than an empty list, so never treat a non-empty response as proof the page number was valid.", - "`myself` is the caller's own record and is always present, but it is a reduced shape — id, full_name, email, reports_and_notification_enabled only, with no browserstack_user_id.", - "The `id` on each user is the TM user id. That is what the owner/assignee/reviewer search filters and test-run writes expect; `browserstack_user_id` is a different number and is only correct where a capability explicitly says BrowserStack id.", - "Only active users are returned — a deactivated user who still owns test cases will not appear here, so an unresolvable owner id is not necessarily invalid." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_projects_basic", - "method": "GET", - "path": "/api/v1/projects/basic", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Validated as an integer; a non-integer returns 400." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page. Validated as an integer here but NOT bounded in this service before being forwarded to the backing store; 300 is the documented maximum." - }, - { - "name": "q[query]", - "type": "string", - "example": "checkout", - "description": "Free-text match on project name." - }, - { - "name": "q[identifier]", - "type": "string", - "example": "PR-60863", - "description": "Exact PR-NNN identifier lookup. Note the matched rows do not echo `identifier` back." - } - ], - "intent": "Look up projects by name or by PR-NNN identifier, or page through them cheaply — the lean listing, and the only one with an identifier search; note the rows it returns do NOT include the identifier itself", - "returns": [], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectsPaginatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`q` must be a structured object here — q[query] for a name match, q[identifier] for an exact PR-NNN lookup. A flat `q=foo` string returns 400 'Invalid Params: q must be an object with identifier/query keys'. This is the exact opposite of list_projects, which demands a flat string.", - "Asymmetry worth planning around: you can FILTER by q[identifier], but the rows come back without `identifier` — the serializer here omits it. If you need the PR-NNN form back, use list_projects or the single-project read.", - "Only q[query] and q[identifier] are read; any other key under q is dropped silently and the search runs unfiltered, which reads as 'matched everything' rather than as an error.", - "Default page size is 30 (not 300). `count` is forwarded to the backing service and is not bounded in this codebase — 300 is the documented ceiling, so treat larger values as unsupported rather than assuming they are clamped.", - "Rows carry `permissions` (the caller's own rights on that project) but no counts — test_cases_count/test_runs_count are absent from this payload. Fetch the single project if you need them.", - "The response is `projects` + `info` at the top level; there is no `data` envelope." - ] - }, - { - "name": "get_projects_minify", - "method": "GET", - "path": "/api/v1/projects/minify", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 300, - "description": "Rows per page. Clamped to 300 — a larger value is silently reduced, not rejected. Omitting it gives 300, not 30." - } - ], - "intent": "Page through every project in the group as a flat dropdown list, 300 at a time — the fastest way to enumerate all projects, but it cannot search and it returns no pagination metadata", - "returns": [], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectMinifyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "There is no search on this route at all — no `q` in any form. To find a project by name or identifier use get_projects_basic.", - "The response has NO `info` block: `{success, projects}` only. You cannot tell from one page whether more exist — keep requesting pages until one comes back with fewer than `count` rows (or empty).", - "Default page size is 300, not 30, and `count` is hard-clamped to 300: a larger value is silently reduced rather than rejected. That makes this the cheapest full enumeration in the index.", - "Rows are the same lean dropdown shape as get_projects_basic — id, name, description, starred, the caller's `permissions` — with no identifier and no test case / run counts.", - "`p` is validated globally: a non-positive or non-numeric page returns 400 'Invalid Params'." - ] - }, - { - "name": "get_report_attachment_url", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/attachments/{attachment_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. Part of the lookup key — an attachment filed under a different project is a 404 even with the correct id." - }, - { - "name": "attachment_id", - "type": "integer", - "required": true, - "description": "Integer stored-file id for the attachment. Not a report id and not an export channel id; no report capability returns one." - } - ], - "intent": "Exchange a report attachment's id for a short-lived direct download URL", - "returns": [ - "url", - "success" - ], - "responses": { - "200": { - "description": "Successfully retrieved report attachment URL", - "schema": { - "type": "object", - "required": [ - "url", - "success" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com/download/report.pdf" - }, - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns a presigned URL you can fetch yourself — the bytes never pass through this API. It expires in 100 minutes, so fetch it promptly rather than storing it.", - "`attachment_id` is a stored-file id, not a report id and not a channel id. No capability in this index hands one out: the report reads do not return attachment ids, and the export path only broadcasts its result over a realtime channel. In practice you need the id from an attachment link the product rendered elsewhere.", - "A miss returns HTTP 404 with a normal-looking body `{ url: null, success: false }` rather than an error envelope — check `success`, not just the status, and never treat `url: null` as a valid URL.", - "The lookup is scoped by BOTH the attachment id and the `project_id` in the path (plus your group), so the right id under the wrong project is a 404. Requires the report_view permission." - ], - "disabled": true - }, - { - "name": "get_report_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "sections", - "type": "string", - "example": "test_case_created_priority,test_case_top_creators", - "description": "Comma-separated widget names to compute, from the report type's allow-list. Anything else fails the whole call 400 `Invalid sections passed`. Omit for the report's default (often nearly empty) payload." - }, - { - "name": "is_print", - "type": "boolean", - "description": "Send `true` to compute EVERY section for `Test Run Summary` and `Test Plan Summary` in one call, ignoring `sections`. Ignored by the other report types. Compared as the literal string 'true'." - }, - { - "name": "report_data_only", - "type": "boolean", - "description": "Return ONLY the `report_data` object, dropping the definition and filters. Any non-empty value is truthy — `report_data_only=false` also strips the envelope. Omit it entirely for the full body." - }, - { - "name": "widget_type", - "type": "string", - "description": "Required when `sections` names a `*_drilldown` widget; the allowed set differs per drilldown (see get_report_section). Missing or unknown is 400." - }, - { - "name": "segment", - "type": "string", - "description": "Drilldown sub-slice, sent as a JSON-ENCODED STRING (e.g. `{\"outcome\":\"bugs_found\"}`), not a nested object. Unparseable values are ignored rather than rejected." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for the row-bearing sections (drilldowns, trtc_details, traceability rows). Does not page the definition." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table. Ignored by the other report types, which use their own page size." - }, - { - "name": "today", - "type": "string", - "example": "2025-05-13", - "format": "date", - "description": "Overrides the server's notion of 'today' for Test Plan Summary overdue/near-completion maths. Unparseable values fall back to the real date silently." - }, - { - "name": "issue_categories", - "type": "string", - "example": "epic,story", - "description": "Requirement Traceability Report only: restrict to these issue types. Accepts a comma-separated string or a repeated array param. The catch-all value `other` is stripped before the upstream call." - } - ], - "intent": "Read one saved report — its definition, resolved filters, and computed figures — the general-purpose report read", - "returns": [ - "data", - "report_filters", - "report_data", - "dynamic_filters", - "project" - ], - "paginated": true, - "responses": { - "200": { - "$response": "GetReportDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the read to reach for when you have a report id and want everything: the definition is at `data`, the resolved filter selection at `report_filters`, and the numbers at `report_data`. Use get_report_section instead when you want one widget and nothing else.", - "`report_data` is computed per report type and is mostly EMPTY unless you name sections. A bare read fills a Test Case Activity report's headline counts but leaves Test Run Summary's issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null. A null section means NOT COMPUTED — never read it as 'no data'. Request the section, then judge.", - "Shortcut: `is_print=true` forces ALL sections for `Test Run Summary` and `Test Plan Summary` in a single call, which is usually what you want rather than guessing section names. User Workload Report already returns every section when `sections` is omitted.", - "`report_data_only` is a trap: any non-empty value is truthy, INCLUDING the string \"false\". Sending `report_data_only=false` collapses the response to the bare `report_data` object with no `data`, no `report_filters` and no envelope. Omit the parameter entirely when you want the full body.", - "A section name not in the report type's allow-list is 400 `Invalid sections passed` for the whole call. Do not answer that by retrying without `sections` — you get a stripped body, not the data. Fix the name (see get_report_section for the per-type lists).", - "A report saved with `report_timeframe: specific_test_plans` whose plan filters have since gone returns 422 `This report is invalid. Please create a new Report`. That report cannot be read at all; recreate it.", - "`Defects Summary` and `Defects Detailed Report` have no data branch on the server — they always read back `report_data: null` no matter what you ask for. For defect figures use a Test Run Summary and its issues_by_priority / issues_by_status sections.", - "The report is resolved by group + id; the project in the path does not scope the lookup. 404 `Report Not Found` means no such report in your group. Requires the report_view permission.", - "`*_drilldown` sections (tr_drilldown, tc_activity_drilldown, es_drilldown, tp_drilldown) return PAGINATED ROWS and additionally require `widget_type` — never tally those rows into a total, read the aggregate section instead." - ] - }, - { - "name": "get_report_section", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/{section_name}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - }, - { - "name": "section_name", - "type": "string", - "required": true, - "example": "test_case_created_priority", - "description": "One widget name from the report type's allow-list (see guidance for the per-type lists). A name outside the list is 400 `Invalid sections passed`; report types with no list accept anything and return a default payload." - } - ], - "query": [ - { - "name": "widget_type", - "type": "string", - "description": "Required for the `*_drilldown` sections and validated against a set that differs per drilldown (see guidance). Missing or unknown is 400 `invalid widget_type`." - }, - { - "name": "segment", - "type": "string", - "example": "{\"outcome\":\"bugs_found\"}", - "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object. For es_drilldown + session_outcomes the `outcome` value is validated (400 otherwise); elsewhere an unparseable value is ignored." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for row-bearing sections." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table; ignored elsewhere." - } - ], - "intent": "Fetch one report widget on its own — a single chart, table or drill-down slice from a saved report, without the report definition", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "The bare section payload (the report read's `report_data` object) — no success flag and no envelope. `null` for report types with no data branch.", - "schema": { - "type": "object", - "properties": { - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "issues_by_priority": { - "type": "string", - "nullable": true - }, - "issues_by_status": { - "type": "string", - "nullable": true - }, - "issues_data": { - "type": "string", - "nullable": true - }, - "test_cases_status": { - "type": "string", - "nullable": true - }, - "test_run_data": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 44 - }, - "closed": { - "type": "integer", - "example": 1 - }, - "test_cases": { - "type": "integer", - "example": 158 - }, - "test_results": { - "type": "integer", - "example": 9 - } - } - }, - "test_run_status": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "string", - "nullable": true - }, - "user_result_stats": { - "type": "string", - "nullable": true - } - } - } - }, - "400": { - "description": "Section name not in the report type's allow-list, or a missing/invalid widget_type or segment on a drilldown section." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "The report's saved plan selection no longer resolves — the report must be recreated." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is the BARE widget payload. This route runs the report read with `report_data_only` forced on, so there is no `success`, no `data` and no `report_filters` — just the section object keyed by the section name.", - "The section name must be in the report type's allow-list or the call is 400 `Invalid sections passed`. Test Run Summary: test_run_data, issues_data, test_run_status, test_cases_status, user_result_stats, issues_by_priority, issues_by_status, time_tracking, estimated_vs_actual, tr_drilldown. Test Run Detailed Report: trtc_details, trtc_filter_options, test_run_data, issues_data, trtc_performance, tr_drilldown.", - "Test Case Activity: test_case_created_count, test_case_updated_count, test_case_deleted_count, test_case_automation_coverage, test_case_top_creators, test_case_trend, test_case_created_priority, test_case_ai_coverage, test_case_list, tc_activity_drilldown. Test Plan Summary: test_run_data, test_run_status, test_plans_progress, test_plans_near_completion, overdue_test_plans, test_plans_execution_trend_list, test_plans_execution_trend_graph, test_result_user_stats, test_run_requirements, test_result_defects, defects_by_status, defects_by_priority, tp_drilldown. Exploratory Session Summary: es_drilldown. User Workload Report: cards, user_table, workload_distribution, execution_trend, status_distribution.", - "Requirement Traceability Report, Test Plan Detailed Report, Defects Summary and Defects Detailed Report have NO allow-list, so any section name is accepted and forwarded — a typo there returns an empty or default payload at 200 instead of a 400.", - "On `Defects Summary` / `Defects Detailed Report` the body is literally `null`: those types have no data branch at all. That is the report type, not a failure of your section name.", - "Drill-down sections need `widget_type` and reject anything outside their own set. tr_drilldown: active_runs, closed_runs, tr_performance, total_test_cases, linked_issues, requirements_linked, runs_by_state, defects_linked, assignee_breakdown, tcs_by_status. tc_activity_drilldown: created_test_cases, updated_test_cases, deleted_test_cases, automation_coverage, ai_coverage, trend, priority, top_creators. es_drilldown: total_sessions, linked_test_plans, session_outcomes. tp_drilldown has no allow-list and passes through.", - "For `es_drilldown` with widget_type `session_outcomes`, `segment` must carry an `outcome` from bugs_found, issues_found, needs_followup, skipped, no_issues, no_activity — anything else is 400.", - "Drill-downs return PAGINATED ROWS, not aggregates. Page them with `p`; never sum rows into a count — read the matching aggregate section instead.", - "Every precondition of the full report read still applies: 404 for an unknown report, 422 for a specific_test_plans report whose plan filters are gone, and the report_view permission.", - "The section name occupies a wildcard path segment. Two-segment paths with a reserved word (for example `detail`) are matched by other routes first, so only use real section names here." - ] - }, - { - "name": "get_report_summary_by_type", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "test_runs_summary", - "test_runs_details", - "test_plan_details", - "requirement_traceability_report" - ], - "description": "Which renderer to run. This is NOT checked against the report's stored report_type — a mismatch still computes and returns 200. Any other value is 400 `Report type not found`." - } - ], - "query": [ - { - "name": "reportTimeRange", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "Window to compute over. Required for test_runs_summary / test_runs_details — missing or unrecognised is an unhandled 500, not a 400. `custom` reads dates off the REPORT's stored custom_date_range. Ignored by requirement_traceability_report; optional for test_plan_details." - }, - { - "name": "sections", - "type": "string", - "example": "test_run_data,test_run_status", - "description": "Comma-separated widgets. test_runs_details defaults to `test_run_data,issues_data,trtc_performance` when omitted. Not allow-list-checked on this route." - }, - { - "name": "widget_type", - "type": "string", - "values": [ - "active_runs", - "closed_runs", - "tr_performance", - "total_test_cases", - "linked_issues", - "requirements_linked", - "runs_by_state", - "defects_linked", - "assignee_breakdown", - "tcs_by_status" - ], - "description": "Only read when `sections` includes `tr_drilldown`, and then required — an unknown value is 400 `invalid widget_type`." - }, - { - "name": "segment", - "type": "string", - "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for the row-bearing renderers (test_runs_details, test_plan_details, traceability)." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page for the test_runs_details table. The upstream ceiling is not enforced here." - }, - { - "name": "required", - "type": "object", - "description": "Column selection for test_runs_details / test_plan_details: `{ \"\": { \"fields\": [...] } }`, entities test_run_test_case / test_run / test_result (plus test_plan). `fields` may be a comma-separated string, and `[\"all\"]` expands to every supported column. Unknown names are 400 here." - }, - { - "name": "issue_categories", - "type": "string", - "example": "epic,story", - "description": "requirement_traceability_report only: restrict to these issue types. Comma-separated string or repeated array param; the catch-all `other` is stripped before the upstream call." - }, - { - "name": "today", - "type": "string", - "example": "2025-05-13", - "format": "date", - "description": "Overrides 'today' for plan overdue/near-completion maths. Unparseable values fall back silently." - } - ], - "intent": "Render a saved report through one of four fixed renderers — run summary, run detail table, test plan detail table, or requirement traceability", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "ReportSummaryResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The `report_type` path segment selects a RENDERER, not the report's own type, and the server never checks the two agree. Pointing `test_runs_summary` at a Test Case Activity report computes a run summary from that report's filters and returns 200 — so the figures may not be the report the user asked about. Prefer get_report_detail unless you specifically want one of these four renderings.", - "The path segment takes four values, one more than the spec lists: `test_runs_summary`, `test_runs_details`, `test_plan_details` and `requirement_traceability_report`. Anything else is 400 `Report type not found`, as is omitting it.", - "`reportTimeRange` is genuinely required for `test_runs_summary` and `test_runs_details`: omitting it, or sending a value outside the timeframe enum, is an unhandled 500 rather than a validation error. `test_plan_details` falls back to the report's saved timeframe, and `requirement_traceability_report` ignores it entirely.", - "`reportTimeRange=custom` reads the dates off the REPORT's stored `custom_date_range`, not from the request — on a report saved without one it is a 500. Use the report's own timeframe value unless you know it has a custom range.", - "The response is the renderer's payload merged onto `{ success: true }` — the keys differ per renderer, so read what came back rather than assuming a fixed shape.", - "`test_runs_details` defaults `sections` to `test_run_data,issues_data,trtc_performance`, pages with `p` / `per_page`, and accepts a column selection under `required` which IS validated here (an unknown field name is 400 `Invalid fields: ... for `).", - "`test_plan_details` is feature-flagged: a group without the flag gets 403 `Operation not permitted`.", - "`requirement_traceability_report` accepts `issue_categories` (comma-separated or repeated) to restrict issue types; the catch-all value `other` is stripped before the upstream call.", - "`widget_type` here is the tr_drilldown set, and only applies when `sections` includes `tr_drilldown`. 404 `Report Not Found` for an unknown id; requires the report_view permission." - ], - "returns": [ - "success" - ] - }, - { - "name": "list_root_folders", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folders", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "folder_name", - "type": "string", - "example": "Folder_123", - "description": "Name search across the project's folders, not just root ones. It is trimmed and ignored when blank, so an all-whitespace value quietly returns the unfiltered root listing instead of an empty result." - }, - { - "name": "include_folder_hierarchy", - "type": "boolean", - "default": false, - "description": "Adds a `folder_hierarchy` map of folder id to its ancestor chain — the only way to learn a matched folder's path from this endpoint. Enabled only by the literal string \"true\" (case-insensitively); any other value is false." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "description": "Page number, and the switch that turns paging on at all. With `p` absent NO page parameters are sent upstream, `per_page` and `case_count` are ignored entirely, and you get the upstream default page. Always send `p` if you intend to page." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "description": "Rows per page. Read only when `p` is also present, and forwarded upstream unchanged — this service enforces no ceiling of its own, so the real maximum is the upstream one (documented as 100; not enforceable from this codebase). Trust `info` for what you actually got." - }, - { - "name": "case_count", - "type": "boolean", - "default": true, - "description": "Send the literal string \"false\" to drop the expensive per-folder test-case counts and the total count, keeping only subfolder counts — a worthwhile speedup on wide projects. Read only when `p` is present; any value other than \"false\" leaves counts on." - }, - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Ordering. Comma-separated combinations are accepted; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." - } - ], - "intent": "List a project's root folders, or search folders anywhere in the project by name — the paginated way in when the full tree is too big to pull at once", - "returns": [ - "folders", - "info", - "folder_hierarchy", - "id", - "name", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "$response": "RootFolderDetailsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Without `folder_name` or a filter this returns ROOT folders only — it is not a flat list of every folder in the project. To reach nested folders either search by name, ask for the hierarchy, or walk the tree.", - "Paging is conditional on `p`. Sending only `per_page` does nothing at all: the handler never builds page parameters unless `p` is present, so you silently get the upstream default page size.", - "The payload is {folders: [...], info: {...}} with no `data` envelope, and `folder_hierarchy` appears only when you asked for it. Page forward using `info`, not by counting rows.", - "Folder ids are plain integers. `parent_id` is null on root folders.", - "Test-case filters passed under `q` narrow which folders come back (folders whose cases match). Using a 'contains only' match type without the feature flag returns 403 {success:false, message:'This feature is not available'} — that is a feature gap, not a permission problem.", - "Requires the folder-view permission." - ] - }, - { - "name": "get_selected_report_testcases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/selection/edit", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "intent": "Read a report's saved test-case selection in the folder-keyed shape the editor uses — the value to round-trip back when editing the report", - "returns": [ - "success", - "selection_data", - "selection", - "shared_selection" - ], - "responses": { - "200": { - "$response": "GetReportTestCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this before editing a report's case selection. The report edit treats `report_filters.test_cases` as authoritative and deletes what you omit, and a flat id list cannot express folder-level or project-level 'select all' — this returns the exact object to send back.", - "`selection` is the primary project's saved selection (`{}` when nothing is saved); `shared_selection` is `{ \"\": }` for the report's other projects on a cross-project report, and `{}` otherwise. `selection_data` is the project's full folder/case tree used to render the picker, not the saved selection — do not confuse the two.", - "Despite the name it is not traceability-specific: it works for any report type. It simply returns an unfiltered project selection tree when the report has no case filters saved.", - "Unlike the other report reads, the `project_id` in the path IS used — `selection_data` is built for that project, so pass the report's own project or the tree will not match the saved ids.", - "Case ids inside the selection are INTEGERS (the numeric id), not TC-NNN identifiers.", - "404 `Report Not Found` for an id that is not in your group. Requires the report_view permission." - ] - }, - { - "name": "get_shared_step", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "read", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the block's step ROWS (not over shared steps). A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 150, - "description": "Step rows per page. Forwarded unchanged to the backing service, which owns the ceiling — it is not bounded here. Previously undeclared, which is why long blocks silently truncated at 150." - }, - { - "name": "paginated", - "type": "boolean", - "default": false, - "description": "Does NOT control whether the rows are paginated — they always are. It only controls whether `page_info` is included in the response. Send 'true' or you get a page of rows with no way to tell that more exist." - } - ], - "intent": "Read one shared step with its full ordered step/result rows — the block that every referencing test case renders, and the row ids those references point at", - "returns": [ - "success", - "data", - "id", - "title", - "created_by", - "updated_by", - "shared_step_details", - "page_info" - ], - "paginated": true, - "responses": { - "200": { - "$schema": "SharedStepsIDResponse__GetSharedStep" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The rows are always fetched a page at a time (150 by default) regardless of `paginated`. What `paginated=true` buys you is the `data.page_info` block; without it a block with 400 steps returns its first 150 and looks complete. Always send paginated=true when you care about the whole list.", - "Each row in `data.shared_step_details` carries its own `id` — that integer is the `shared_step_detail_id` a referencing test-case step holds. Carry the row id, not its `order`, when you need to line up a test case's steps with this block.", - "Send these rows back verbatim (with their ids) when updating the block; the update replaces the whole array, so anything you did not read here is lost on the next write.", - "`test_case_count` and `step_count` appear only when the backing service returns them on this path — do not rely on them here. The shared-step listing always carries both.", - "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}`; a block that does not exist returns 404 `{data:null, success:false}`.", - "`created_by`/`updated_by` are null when the original author is no longer a user in this workspace.", - "Requires tc_view." - ] - }, - { - "name": "get_shared_steps", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-steps", - "mode": "read", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. There is no per_page here — the page size is whatever the backing service defaults to." - }, - { - "name": "q[query]", - "type": "string", - "description": "Free-text match on the shared step TITLE only — it does not search step or result text. A bare scalar ``q` foo` is ignored rather than rejected, so the nested form is required." - }, - { - "name": "q[tags]", - "type": "string", - "description": "Filter to shared steps carrying this tag. Match mode is fixed to 'any' server-side; a match_object you send is not read." - }, - { - "name": "q[empty][system_fields][]", - "type": "array", - "values": [ - "tags" - ], - "description": "Find blocks with the named field empty. Only `tags` is honoured — any other field name is filtered out silently and the condition simply disappears." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Restrict to one shared-field folder. Forwarded only when present." - }, - { - "name": "include_sub_collections", - "type": "boolean", - "default": false, - "description": "Include blocks in sub-folders of folder_id. Only the literal string 'true' turns it on." - }, - { - "name": "field_type", - "type": "string", - "description": "Shared-field type filter, shared with the shared-components listing. Leave unset for ordinary shared steps." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort column, forwarded unchanged to the backing service, which owns the accepted set and the default." - }, - { - "name": "pre_fetch", - "type": "boolean", - "default": false, - "description": "Inline the first row's step details under `pre_fetched_shared_step`. Only takes effect on page 1 and only for the first row — it is a UI optimisation, not a way to fetch details in bulk." - } - ], - "intent": "List a project's shared steps with how many steps each holds and how many test cases embed it — use this to find a shared_step_id and to size the blast radius before editing or deleting a block", - "returns": [ - "success", - "data", - "info", - "pre_fetched_shared_step", - "id", - "title", - "step_count", - "test_case_count", - "created_by", - "updated_by" - ], - "paginated": true, - "responses": { - "200": { - "$response": "SharedStepsGetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`test_case_count` on each row is the number of test cases embedding that block. It is the number to read before any edit or delete, and this listing is the cheapest place to get it.", - "`step_count` is how many step rows the block holds; the rows themselves are not in this response — read the block by id for those.", - "Search is title-only via `q[query]`. Sending `q` as a bare string is silently ignored, so a search that returns everything usually means the parameter was flat instead of nested.", - "Page with `p`; there is no page-size parameter on this listing at all, so you cannot pull it in fewer, larger pages. Walk until `info.next` is null.", - "`created_by` and `updated_by` are resolved from BrowserStack user ids to `{id, full_name}` where that person still exists in this workspace, and come back **null** when they do not. A null author means a missing or removed user, not a missing field.", - "Requires tc_view." - ] - }, - { - "name": "get_system_field_values", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/{field_name}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "field_name", - "type": "string", - "required": true, - "values": [ - "priority", - "status", - "case_type", - "automation_state", - "defects" - ] - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Filter the options by display value (substring match)." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - } - ], - "intent": "List the option ids and display names a project defines for one system field (priority, status, case type, automation state) — resolve these before writing any of them.", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "SystemFieldValuesResponse" - }, - "401": { - "$response": "Unauthorized" - } - }, - "guidance": [ - "This is the lookup that makes writes correct: priority, status, case type and automation state are per-project configurable, and the bulk-edit paths require the NUMERIC id, not the display name.", - "The list is keyed by the field name you asked for, so the options are at `.values`, not at a fixed key. Each entry is `{value: , name: }` — `value` is the id to send.", - "`field_name` is one of priority, status, case_type, automation_state, defects. Any other name is not rejected by the route; it resolves to nothing and you get an empty list back with `success: true`.", - "`q` filters the options by value text and `p` pages them — a project with many options needs paging, so check `info` rather than assuming one page is the whole vocabulary.", - "Custom fields are not here; this covers system fields only." - ], - "returns": [ - "success", - "values", - "value", - "name", - "info" - ] - }, - { - "name": "get_template", - "method": "GET", - "path": "/api/v2/templates/{id}", - "mode": "read", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id — a plain integer with no prefix. Discover it from the template list; a non-numeric value does not reach this endpoint at all.", - "pattern": "^[0-9]+$" - } - ], - "intent": "Get one template in full, including every description, property and step field it carries — read this before editing a template so you can resend the complete field set.", - "guidance": [ - "This is the only read that returns the field layout; the list returns metadata and counts only.", - "The field count is not calculated on this read and always comes back empty here, even though the same field is populated on the template list. Take the count from the list when you need it; do not read the empty value as a template with no fields.", - "The Review & Approve property fields (reviewers, review status) are always filtered out of this response, on every workspace, even when the template stores them. Do not conclude from a read that they are absent.", - "The properties list contains a synthetic extra row: whenever the automation-status field is present, a duplicate of it is emitted with its internal name rewritten to the automation-state spelling, sharing the original's mapping id, field id and position. It is one stored field presented as two rows.", - "A field update replaces the whole section, so treat this response as the baseline: drop what you want gone, keep everything else, then send the full array back. Two things make that unsafe to do literally - the duplicated automation row above, and the Review & Approve fields that were filtered out - so reconcile both before resending rather than echoing the arrays verbatim.", - "The step-field list is emptied for workspaces without step custom fields enabled, while the key itself is always present. An empty list therefore means either 'not enabled here' or 'none configured', and this response cannot tell you which.", - "For a custom field, the field definition reports its type twice under different names: one is the product-facing type name, the other is the stored type name. Both are text - neither is a number - and for a small number of workspaces the product-facing one can be replaced with a nested-dropdown type that does not appear in the ordinary set.", - "There are two independent required-ness signals and they can disagree: the mandatory flag on the placement row, and a required flag inside the field definition itself. A field can be required by its own definition while the placement row says it is not.", - "An unknown or out-of-workspace id comes back as a 404 saying the template was not found, not as an empty success." - ], - "returns": [ - "success", - "template", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at", - "description_fields", - "properties_fields", - "step_fields" - ], - "responses": { - "200": { - "$response": "TemplateDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "list_test_case_attachments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List the files attached to one test case — id, filename, MIME type and size per file, so you can see what evidence is on the case and pick an id to remove", - "returns": [ - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "description": "Attachments retrieved successfully", - "schema": { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentResponse" - } - }, - "success": { - "type": "boolean" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DOES NOT WORK. Returns a bare 500 on any request. The handler calls a collection method on a value that is a plain array on this path, so it raises before serialising. Read a case's attachments through the entity-attachments listing instead.", - "Metadata only: id, filename, content_type, byte_size. There is NO url and no download endpoint on this v1 surface — if you need to read the file, use the v2 attachment listing, which returns a readable URL per file.", - "`attachments` and `success` are at the top level; there is no `data` envelope here, even though the sibling delete on the same path wraps its payload in one.", - "Not paginated and not filterable — every attachment on the case comes back in one array.", - "`folder_id` is part of the route but is not used to locate the case; the lookup is by project and test case id, so a stale folder id after a move still resolves.", - "An empty array means the case has no attachments; it is not an error." - ], - "disabled": true - }, - { - "name": "get_test_case_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one test case by its INTEGER id inside a folder — the v1 lookup, and the way to translate an integer id into the TC-NNN identifier the v2 surface needs.", - "returns": [ - "success", - "data", - "test_case", - "id", - "identifier", - "name" - ], - "responses": { - "200": { - "description": "Test case detail; data.test_case.identifier is the TC-NNN form.", - "schema": { - "$schema": "V1TestCaseDetail" - } - }, - "401": { - "description": "Unauthorized — authentication missing or invalid." - }, - "404": { - "description": "No test case with that integer id in that folder (or not visible)." - } - }, - "guidance": [ - "All three path segments are plain integers here, and the route only matches digits — a TC-NNN or PR-NNN identifier never reaches the API and 404s at the router.", - "The folder id must be the case's CURRENT folder. A correct case id under the wrong folder is a 404, not a redirect.", - "The case is at `data.test_case` — there is a `data` envelope on this v1 read. `data.test_case.identifier` is the TC-NNN form (or the workspace's third-party identifier when the workspace issues project-level ids); the field is called `name`, not `title`.", - "`estimate` always comes back null on every read path — the stored estimate, if any, is in `estimated_duration_seconds`.", - "This returns the case record only. For its linked runs, results and issue metadata use the detail read instead." - ] - }, - { - "name": "get_test_case_count_trend", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-count-trend", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how the project's test case count grew month by month, broken down by case type — use this for 'how fast is the suite growing'", - "returns": [ - "data", - "empty_data", - "order" - ], - "responses": { - "200": { - "$response": "TestCaseCountTrend" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`data` is keyed BY CASE TYPE NAME, not by month — the keys are whatever case types this group has defined (including custom ones), so treat it as an open map and iterate it rather than looking up fixed names.", - "The monthly numbers sit one level deeper, under each entry's `graphData`, keyed by three-letter month abbreviation (Jan…Dec) for the trailing 12 months. `data.` itself is not the series.", - "Read `order` for the intended display sequence of those keys; object key order is not meaningful.", - "`Total` is a sibling key in the same map alongside the real case types (the CSV exporter special-cases it), so summing every key double-counts.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_test_case_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/detail", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "include", - "type": "string", - "values": [ - "folder_path" - ], - "example": "folder_path", - "description": "Comma-separated extras. `folder_path` is the only value read; anything else is ignored silently." - }, - { - "name": "exclude", - "type": "string", - "example": "results,defects", - "description": "Comma-separated relations to leave out (`results`, `defects`, `issue_metadata`). Passing it also switches off the heavier slide-over aggregation, so the response is smaller in more ways than the names suggest." - }, - { - "name": "hasDuplicates", - "type": "boolean", - "description": "When true, populates `duplicates_tc_count` from the dedupe store. Left null otherwise, and degraded to 0 rather than erroring if that store is unavailable." - } - ], - "intent": "Read one test case with its full slide-over detail — steps, custom fields, template, attachments and its linked runs, results and defects.", - "returns": [ - "data", - "success", - "test_case", - "is_archived", - "linked_issues", - "pagy" - ], - "responses": { - "200": { - "$response": "V1TestCaseDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The case is at `data.test_case`, and `success` is INSIDE `data` alongside it, not at the top level. `data.is_archived` tells you the case is archived rather than live.", - "By default this returns counts, not the run results themselves. Pass `exclude` to switch into the lighter mode, and read the paginated runs-results / runs-issues endpoints when you need the actual rows.", - "`include=folder_path` adds `data.test_case.folder_path` — the human-readable folder path. It is the only supported value.", - "All three path segments are plain integers and the route only matches digits; the folder id must be the case's current folder or it is a 404.", - "The template is resolved for you: `data.test_case.testcase_template` carries the field layout, and `using_fallback_template` is set when the case's own template could not be found and a system template was substituted.", - "`estimate` is always null here; the real value is `estimated_duration_seconds`." - ] - }, - { - "name": "list_test_case_histories", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List a test case's revision trail — who changed it, when, which fields moved, and the ids you need to view, diff or restore an earlier version", - "returns": [ - "success", - "histories", - "info", - "plan_restriction", - "id", - "entity_id", - "entity_type", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "user", - "user_id", - "comment", - "created_at", - "project_id", - "group_id", - "links" - ], - "responses": { - "200": { - "description": "Successfully retrieved test case histories", - "schema": { - "$schema": "HistoryListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only the FIRST record on each page carries the per-field `modified` map (old/new values). Every other record has `modified_fields` (the field NAMES only). To describe what actually changed in any other revision you must read that revision individually — one extra call per revision.", - "`plan_restriction: true` means the caller is not on Enterprise or Team Pro and the response was clamped to the last 30 days of revisions. Older revisions are simply absent with no other marker — never report 'no earlier changes' while that flag is true.", - "`version_name` (V1, V2, …) is computed from the record's POSITION within the visible total, so labels renumber as old revisions age out of a restricted window. Carry the numeric `id` forward to the single-revision read, the diff and the restore — never the V label.", - "Page size is fixed at 30; `p` is the only paging control. Counts and page metadata are under `info`.", - "`user` is resolved from the actor's id on the way out, and `links.self` points at the single-revision read for that record.", - "Revisions track edits to the test case itself — not executions of it. Result history lives on the test run.", - "A `source` of 'create' marks the original revision; it has no diff to fetch and is skipped by the enrichment above." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Page size is fixed at 30 server-side; there is no per_page on this endpoint." - } - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_test_case_history_diff", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/diff", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "versions[]", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two NUMERIC history record ids from the revision listing, old first. MUST BE SENT AS A REPEATED PARAMETER - `versions[]=141552670&versions[]=104806106`. A comma-joined single value (`versions[]=141552670,104806106`) is NOT parsed and the route answers a bare 404 {success:false}, which reads as 'case or revision not found' when the ids are perfectly valid.", - "style": "repeated" - } - ], - "intent": "Compare two revisions of a test case field by field — what the name, description, preconditions, steps, status, priority, owner, tags, defects, attachments, reviewers and custom fields looked like in each", - "returns": [ - "success", - "diff", - "links" - ], - "responses": { - "200": { - "$response": "HistoryDiffResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type does not support comparison ({success:false, message:'Not supported for build'})." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SEND `versions[]` AS TWO REPEATED PARAMETERS, not one comma-joined value. The joined form returns a bare 404 that looks like a missing record; the repeated form works.", - "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is compared — fall back to reading the two revisions individually, which is not plan-gated.", - "`versions[]` takes exactly two NUMERIC history record ids from the revision listing, in the order old then new: the first becomes `old` in every entry. The V1/V2 labels are not accepted here.", - "Omitting `versions[]` is not validated — both ids silently become 0 and the call fails downstream instead of telling you the parameter was missing. Always send two real ids.", - "Only fields that actually differ appear under `diff`, and only from a fixed comparable set: name, description, preconditions, automation_status, category, status, priority, owner, assignee, folder, tags, issues, custom_fields, test_case_steps, attachments, reviewers, review_status, review_sent_date. Anything outside that set is invisible here regardless of whether it changed.", - "Values differing only by type (1 versus \"1\") are treated as unchanged, so the diff will not show spurious id edits.", - "Steps come back as whole step arrays under old and new rather than a line-by-line diff — turning that into a readable 'what changed' summary is the caller's job.", - "`links` maps each version id to its single-revision read, which is where the rest of that snapshot lives." - ] - }, - { - "name": "get_test_case_history", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "history_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one revision of a test case in full — the per-field before/after for that single change and who made it; the follow-up read after finding a revision in the trail", - "returns": [ - "success", - "history", - "id", - "entity_id", - "entity_type", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "user", - "user_id", - "comment", - "created_at", - "project_id", - "group_id", - "links" - ], - "responses": { - "200": { - "description": "Successfully retrieved test case history", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "history": { - "$schema": "History__GetTestCaseHistory" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`history_id` is the numeric `id` from the revision listing, not the V1/V2 position label the listing also renders.", - "This read ALWAYS carries the full `modified` map. The listing only populates it for the first record on each page, so this is the call to make whenever you need actual old/new values for a specific revision.", - "The record is at the top level as `history` alongside `success` — no `data` envelope.", - "Unlike the diff and the restore, this read is not plan-gated. The listing that discovers ids is windowed to 30 days on non-Enterprise/Team Pro plans, though, so an older revision may simply be unreachable rather than forbidden.", - "Rows with no data on either side are dropped from `modified`, and the `modified_fields` banner is reconciled against what survived — so the two always agree, and a field named in neither genuinely did not change." - ] - }, - { - "name": "get_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/tags", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List the tag names currently on one test case — to see what it is labelled with before filtering, grouping or re-tagging", - "returns": [], - "responses": { - "200": { - "$response": "TestCaseTagListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a BARE JSON ARRAY of the case's tags. There is no `data`, no `success` and no test case object in it — anything reading `data.test_case` from this call gets nothing. Read the array itself.", - "Despite the path segments, this returns tags only. For the test case's own fields (name, description, owner, status, steps) use a test-case read capability instead.", - "All three path ids are required and folder_id must be the folder the case actually lives in — the case is looked up by the triple, so a stale folder_id resolves to no test case rather than to the case in its new folder. Re-read the case's folder after any move.", - "Tags are referenced by NAME everywhere in this product; there are no tag ids in this response and none are needed to filter by tag.", - "This is read-only. To change a case's tags, write the test case's `tags` field — there is no add/remove-tag capability in this profile.", - "The `tags_show` action is absent from the RBAC permission map, so unlike the rest of the test-case surface it is not permission-gated." - ] - }, - { - "name": "get_test_case_type_split", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-type-split", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how the project's test cases divide across case types (functional, regression, smoke, custom types…) — the current composition of the suite", - "returns": [ - "data", - "empty_data", - "name", - "y", - "value", - "field" - ], - "responses": { - "200": { - "$response": "TestCaseTypeSplit" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each row carries both `y` and `value`; they are the same count under two names (`y` is the chart's series value). `field` is the underlying field key and `name` its display label.", - "Case types are group-configurable, so the set of rows is whatever this group has defined — never assume a fixed list.", - "This is a snapshot of the current suite, not a trend; use get_test_case_count_trend for growth over time.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_test_run_test_cases_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Rows per page. Anything outside 1..100, or non-numeric, silently falls back to 30 instead of erroring — a request for 500 returns 30 rows and looks like a short run." - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority,tags,status", - "default": "owner,automation_status,automation_state,priority,tags,issues,reviewers", - "description": "COMMA-SEPARATED LIST OF COLUMNS to include on each row — not a mode. Omit it and the default column set above is used. `case_type` and `defects` are accepted as aliases of the stored column names." - }, - { - "name": "required[custom_fields]", - "type": "string", - "description": "Comma-separated test-case custom field ids to include on each row." - }, - { - "name": "required[result_custom_fields]", - "type": "string", - "description": "Comma-separated test-result custom field ids to include on each row." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-asc", - "description": "Column and direction, as `-`. TRAP: the column must also appear in required[fields] or the call fails with 'Sort by column is not present in required fields'. Custom fields (cf#) are exempt." - }, - { - "name": "shared", - "type": "boolean", - "default": false, - "description": "Return ONLY the cases shared into this run from other projects. Default (false) returns the run's full list including shared ones." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Restrict the list to the slice of the run contributed by one source project. Digits only; anything else is rejected." - }, - { - "name": "q", - "type": "object", - "description": "Filter object (same shape the run-detail search takes) applied to the rows. Sent as bracketed query params." - } - ], - "intent": "List the test cases inside a test run with their latest result, assignee and configuration — the execution grid for the run, and the source of the mapping ids the bulk operations take", - "shape": "discovered", - "responses": { - "200": { - "description": "The run's test cases for the requested page. Flat body: {success, info, test_cases}, plus a top-level skipped_test_case_ids array when the review gate excluded any case. Each row is one case x configuration mapping and carries both `id` (test case) and `mapping_id` (the run mapping).", - "schema": { - "$schema": "TestCaseListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are per case-times-configuration mapping, not per test case: a case running against three configurations appears three times, with the same `id` and three different `mapping_id` values. Count distinct `id` if you want a case count.", - "`mapping_id` is the id every bulk operation on this run takes — assigning, bulk-editing results, unlinking. `id` is the test case. Passing `id` where `mapping_id` is expected returns 200 and changes nothing.", - "sort_by silently depends on required[fields]: sorting by a column you did not ask for errors out. Add the column to required[fields] whenever you sort by it.", - "per_page tops out at 100 and out-of-range values fall back to 30 rather than erroring, so always read `info.page_size` for what you actually got and `info.count` for the total.", - "The 200 can carry a top-level `skipped_test_case_ids` array — cases the review gate excluded from the run. Their absence from the list is not a data error.", - "`latest_status` is 'untested' for a case that has never been executed; it is never blank.", - "The response is flat — {success, info, test_cases} with no `data` envelope.", - "Needs the test-run view permission, with the automation variant demanded on automation runs." - ], - "returns": [ - "success", - "info", - "test_cases", - "skipped_test_case_ids", - "id", - "identifier", - "name", - "mapping_id", - "test_case_id", - "test_run_id", - "latest_status", - "result_status", - "result_custom_fields", - "result_notes", - "defects", - "configuration", - "test_assignee", - "last_executed_by", - "last_executed_at", - "planned_execution_date", - "execution_eligible", - "owner", - "priority", - "tags", - "created_at", - "updated_at" - ], - "paginated": true, - "max_page_size": 100, - "disabled": true - }, - { - "name": "list_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Walk pages until `info.next` is null; `p` advances by the page size, not by how many rows came back." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", - "minimum": 1, - "maximum": 100, - "default": 30 - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." - }, - { - "name": "required[custom_fields]", - "type": "string", - "example": "121,119", - "description": "Comma-separated custom field ids to include on each row." - }, - { - "name": "all_folders", - "type": "string", - "values": [ - "true" - ], - "example": "true", - "description": "Send the string `true` to list the WHOLE project. Without it this route is scoped to no folder and comes back effectively empty — this is the single flag that makes the endpoint do what its name suggests. It also adds a `folders` block to the response." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-ASC", - "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." - } - ], - "intent": "List a project's test cases across every folder — the project-wide repository read. Requires all_folders=true.", - "returns": [ - "success", - "test_cases", - "info", - "folders" - ], - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "description": "Successfully retrieved test cases", - "schema": { - "$schema": "TestCaseConsolidatedListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`all_folders` is compared as the STRING `true` — a JSON boolean does not match. It is what makes this read project-wide; without it no folder scope is applied and the response is not the project's case list, while still reporting `success: true`.", - "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else — including a value above 100 — so `info.page_size` is what you actually got, not what you asked for.", - "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", - "Walk `p` until `info.next` is null. `p` advances by the page size rather than by the number of rows returned, so a partial read cannot be recovered by paging further.", - "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null on every row.", - "A backend 404 is swallowed into an empty list with `success: true` rather than surfacing — another reason a zero count is not proof the project is empty." - ] - }, - { - "name": "get_test_plan_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one test plan or sub-plan in full by its integer id — name, dates, status, owner, tags, custom fields, reviewers, progress rollup, and its TP-NNN / STP-NNN identifier", - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "assignee", - "created_by", - "test_runs_count", - "total_test_runs_count", - "test_results_trend_v2", - "links" - ], - "responses": { - "200": { - "description": "Test plan detail; `test_plan.identifier` (no `data` wrapper) is the TP-NNN / STP-NNN form.", - "schema": { - "$schema": "V1TestPlanDetail__GetTestPlanByIntegerId" - } - }, - "401": { - "description": "Unauthorized — authentication missing or invalid." - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No test plan with that integer id, not visible to this user, or archived and read without is_archived=true. Body is a bare `{success: false}` with no message.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "is_archived", - "type": "boolean", - "description": "Opt in to reading an ARCHIVED plan. Without it an archived plan is 404 — the read is active-only by default. Pass the literal string 'true'." - } - ], - "guidance": [ - "The path segment is the plan's INTEGER id; the route only matches digits, so a TP-NNN / STP-NNN identifier gives a routing 404 and never reaches the handler. Resolve the integer from the plan listing first.", - "This is the read for one plan. Two sibling routes on the same collection path are NOT plan reads and take no id: the archived listing and the archived count.", - "An archived plan reads as 404 unless you pass is_archived=true. If a plan a user named looks missing, retry with that flag before concluding it was deleted.", - "There is no `data` wrapper: the plan is at the top level under `test_plan`, and `test_plan.identifier` is the TP-NNN / STP-NNN form while `test_plan.id` is the integer every v1 route wants.", - "A 404 body is a bare {success: false} with no message — it carries nothing that distinguishes 'no such plan' from 'not visible to you'.", - "test_plan_progress is ALWAYS an empty object; the real rollup is test_plan_progress_v2 (counts keyed by result-status id). Same for test_results_trend vs test_results_trend_v2.", - "custom_fields comes back as [] (an empty array, not an object) when the plan has none.", - "parent_plan is present only on a sub-plan; on a top-level plan parent_plan_id is null and sub_plans_count tells you whether it has children.", - "review_status, review_sent_date, reviewers and review_settings appear only when the plan review workflow is configured for the project — do not treat their absence as 'not reviewed'.", - "Sub-plans and their runs are not embedded. Use the plan's test-runs listing (with include_sub_test_plan_runs) to see what is under it." - ] - }, - { - "name": "get_test_plan_execution_trend", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/execution-trend", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "test_plan_id", - "type": "integer", - "description": "Scope the trend to this plan (INTEGER id). Mutually exclusive with test_run_id — exactly one of the two must be present." - }, - { - "name": "test_run_id", - "type": "integer", - "description": "Scope the trend to this run instead of a plan. Mutually exclusive with test_plan_id." - }, - { - "name": "today", - "type": "string", - "format": "date", - "description": "Anchor date the trend window ends on. Defaults to the server's current date; an unparseable value silently falls back to it rather than erroring." - } - ], - "intent": "Execution-trend chart series (results over time) for exactly ONE test plan or ONE test run — the Insights trend graph; scope it with test_plan_id or test_run_id, never both", - "shape": "discovered", - "responses": { - "200": { - "description": "`{success, data}` — the requested section sits at `data.test_plans_execution_trend_graph`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "test_plans_execution_trend_graph": { - "type": "object", - "description": "The trend series for the scoped plan or run." - } - } - } - } - } - }, - "400": { - "description": "Neither or both of test_plan_id / test_run_id supplied. `{success: false, message: ...}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a project-level chart route, not a plan read: the plan or run is chosen by QUERY parameter, and the path carries only the project. It is a different endpoint from the plan detail read even though it sits on the same collection path.", - "Exactly one scope. Both ids → 400 {success: false, message: 'Provide either test_plan_id or test_run_id, not both'}; neither → 400 {success: false, message: 'test_plan_id or test_run_id is required'}. Note the error key is `message`, not `error`.", - "The series is nested: read data.test_plans_execution_trend_graph. `data` is the report envelope and carries only the section that was requested.", - "Ids are forwarded to the reporting backend uncoerced — send the INTEGER plan id / run id, not TP-NNN or TR-NNN.", - "There is nothing to create, update or delete here; this returns chart data only. For the burndown view there is a separate chart route that accepts a plan and rejects a run.", - "Needs the test-plan VIEW permission (unlike the archived reads, which need delete)." - ], - "returns": [ - "success", - "data", - "test_plans_execution_trend_graph" - ] - }, - { - "name": "get_test_plan_linked_sessions_chart_data", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/linked-sessions-chart-data", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart data for the exploratory sessions linked to one test plan — the plan's sessions insight tile", - "shape": "discovered", - "responses": { - "200": { - "description": "`{success, data}` — linked-session chart data for the plan.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes the plan's INTEGER id in the path and nothing else: no query parameters, no paging, no date window.", - "The payload is unwrapped for you — where the upstream reply nests everything under `result`, that inner object is what lands in `data`.", - "A plan with no linked exploratory sessions returns 200 with an empty/zeroed `data`, not a 404. Do not read an empty chart as a missing plan.", - "This route is served by the exploratory-sessions handler rather than the test-plan one, so it is not behind the test-plan view permission the other plan reads use.", - "Chart data only — there is no session list here. Use the exploratory-session listing capabilities to get the sessions themselves." - ], - "returns": [ - "success", - "data" - ] - }, - { - "name": "list_test_plan_test_runs_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page — the page size is fixed at 30." - }, - { - "name": "q", - "type": "string", - "description": "Free-text search over the linked runs. Supplying it also turns off the analytics rollup, so progress fields come back thinner than on an unsearched page." - }, - { - "name": "include_sub_test_plan_runs", - "type": "boolean", - "description": "Also return the runs linked to this plan's sub-plans. Off by default, so a run a user attributes to the plan can be missing simply because it hangs off a sub-plan." - }, - { - "name": "compact", - "type": "boolean", - "description": "Ask upstream for the slim linkage payload (identity fields only, no pagination) used to pre-seed an edit form. Do not combine it with paging expectations." - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Required to see the runs of an ARCHIVED plan — without it an archived plan's run list comes back empty rather than erroring." - } - ], - "intent": "List the test runs linked to a test plan, with each run's state and progress — optionally including the runs that belong to its sub-plans", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListTestPlanTestRunsByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The plan is addressed by INTEGER id; the route only matches digits, so a TP-NNN identifier 404s at routing.", - "Each row's `id` is the TR-NNN string and the integer lives in `uuid`. Take `uuid` for anything that wants a numeric run id — including the plan's own unlink call and the plan update's test_runs list.", - "Sub-plan runs are excluded by default. Pass include_sub_test_plan_runs=true before telling a user a run is not in the plan.", - "An archived plan returns an empty run list unless is_archived=true is passed — empty is not proof the plan has no runs.", - "Page size is fixed at 30 and there is no per_page parameter; page with `p` and stop using the `info` envelope. `count` is {active, closed} across the whole linkage, not the current page.", - "Rows are run summaries, not run detail: `overall_progress_by_status_id` is the useful rollup and `overall_progress` is the legacy one. To see a run's cases, read the run's test cases separately." - ], - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "run_state", - "active_state", - "owner", - "assignee", - "tags", - "is_automation", - "is_dynamic", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "test_plans", - "project_id", - "created_at", - "updated_at" - ], - "max_page_size": 30 - }, - { - "name": "get_test_results_custom_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-results/custom-fields", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. PR-NNN identifiers do not match the route." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control on this endpoint — the page size is fixed at 30." - } - ], - "intent": "List the custom fields a project shows on a test RESULT — the extra fields a tester fills when recording a pass or fail, with the ids and types needed to send values", - "returns": [], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "TestResultCustomFieldsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Result fields only. This endpoint hard-codes entity_type=TestResult, so nothing here overlaps the test-case form fields — a field defined against TestCase never appears, whatever the project.", - "`field_type` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean …), not the canonical vocabulary the admin create/update calls take. A nested_dropdown field has no legacy spelling and comes back as null.", - "Page size is fixed at 30 and cannot be raised: `per_page` is accepted on the wire and discarded, and only `p` is read. Page until `info.next` is null.", - "`optional` and `is_required` are inverses of each other; prefer is_required.", - "Each row's `links.self` is the ready-made path to that field's allowed values scoped to this project — follow it instead of building the path, and note it resolves to the project user list for a `user` field.", - "`option_values` / `field_values` are present only when the upstream row carried them, so an absent key is not proof the field has no options.", - "project_id is the integer id; PR-NNN identifiers do not match the route. An unknown project is rejected by the project lookup before the fields are read." - ] - }, - { - "name": "list_test_results_for_test_case_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "read", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Read the result history for one test case inside one test run — every attempt logged against that case with its status, author, note, attachments, linked defects and custom fields; the read for 'what happened when we executed this case?'", - "returns": [ - "success", - "test-results", - "info", - "unique_issues", - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "custom_fields_count", - "test_run_step_result", - "attachments", - "issues", - "links" - ], - "responses": { - "200": { - "$response": "TestResultListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The list key is `test-results` with a HYPHEN. Reading response['test_results'] finds nothing on a perfectly populated response — this endpoint is the odd one out.", - "Newest first: the first entry is the latest attempt, and it is exactly the one the PATCH-latest capability edits.", - "Page size is FIXED at 30 and is not caller-controllable — `p` is the only paging input, and there is no per_page. Paging metadata is under `info` (count / page / page_size / next / prev).", - "With a configuration matrix you get rows for every configuration mixed together. Narrow with configuration_id, or with mapping_id for one specific run/case row, before concluding anything about 'the' result.", - "`unique_issues` at the top level is the de-duplicated set of defects linked across all the results returned — use it rather than unioning each result's own issues.", - "Custom fields arrive already grouped and typed per result, with `custom_fields_count` beside them; per-step outcomes hang off each result as `test_run_step_result`.", - "An empty list means the case has never been executed in this run — it is not an error and not a permission problem." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Page size is fixed at 30 server-side and cannot be raised." - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Restrict to results logged against one configuration." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Restrict to one specific run/case mapping row." - } - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "list_test_results_for_test_case", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/results", - "mode": "read", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-101", - "description": "Test case IDENTIFIER (TC- form), resolved by lookup — not the numeric internal id the v1 route takes. An identifier that does not resolve in this project returns 404 \"Test case not found\"." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Note the name is `p`, not `page`; anything else is ignored and page 1 comes back." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Results per page. ONLY 30 or 300 are accepted — any other value is rejected 400 \"Invalid page_size\"." - }, - { - "name": "count", - "type": "integer", - "values": [ - 30, - 300 - ], - "description": "Alias for page_size, and it WINS when both are sent. Same 30-or-300 restriction." - } - ], - "intent": "Read what was recorded for one test case in one test run — every execution logged against it, with status, notes, linked defects, custom fields and configuration", - "guidance": [ - "Returns the case's result history in this run, one entry per recorded execution — not just the latest outcome. Take the newest entry if you only want the current status.", - "Only genuinely logged executions appear here. A case that has been added to the run but never executed has no entry at all, even though the run's case grid shows it with a result id — that id is the run row, not a result.", - "`result_status` on this endpoint is deliberately cut down to `field_value` alone (display case, e.g. 'Passed'). There is no id, internal_name, field_name or colour here, unlike the v1 result reads. Match on the display-case name, or resolve the project's configured statuses separately if you need the id or colour.", - "`custom_fields` is an ARRAY of entries, each carrying a field_name and a value — not an object keyed by field name. It is empty when the result has no custom field values. field_name is the field's system name, and for dropdown / single-select fields the value is the selected option's id rather than its label, so resolve the option separately if you need text.", - "Both ids are identifiers: TR- for the run, TC- for the case. Passing internal numeric ids does not match the route (run) or does not resolve (case).", - "Page size is a closed set: 30 or 300, nothing in between. The page parameter is `p`.", - "`urls.self` is a deep link to the case's row in the run. It is omitted (the key comes back null) when the result cannot be tied to a project, run, case and run row all at once.", - "`step_result` is null for case-level results and only populated for step-level ones; `configuration_id` is null when the run row has no configuration; `dataset` is empty for non-data-driven runs.", - "Results are listed under `test_results` with an underscore here — the v1 list uses `test-results` with a hyphen, so a client written against v1 reads an empty list and reports no results." - ], - "returns": [ - "success", - "test_results", - "info", - "id", - "created_at", - "updated_at", - "created_by", - "description", - "test_case_id", - "test_run_id", - "result_status", - "issues", - "custom_fields", - "step_result", - "configuration_id", - "execution_id", - "dataset", - "urls" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "V2TestResultListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "integer", - "required": true - } - ], - "intent": "Get a test run by INTEGER id (v1) — full detail + its TR-NNN identifier", - "guidance": [ - "The run's id field is the display identifier string, while uuid is the integer. Take uuid whenever you carry this run into another call: the display string is rejected downstream." - ], - "returns": [ - "id", - "identifier", - "name", - "uuid" - ], - "responses": { - "200": { - "description": "Test run detail; data.test_run.identifier is the TR-NNN form.", - "schema": { - "$schema": "V1TestRunDetail" - } - }, - "401": { - "description": "Unauthorized — authentication missing or invalid." - }, - "404": { - "description": "No test run with that integer id (or not visible to this user)." - } - } - }, - { - "name": "get_test_run_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/detail", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Either the TR-NNN identifier or the bare integer — the v1 run routes accept both forms in the path." - } - ], - "intent": "Open a test run's detail view — its header, progress counts, linked defects and the dynamic-filter rule behind it. This does NOT return the run's test cases; list those separately", - "shape": "discovered", - "responses": { - "200": { - "$schema": "TestRunResponse__ListTestRunCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the path, this returns NO per-case rows. It is the run header enriched with linked issues and the dynamic-filter rule; the run's cases come from the run's test-cases listing.", - "This is the only read that returns the run's filter rule, and it returns it twice under two names — `dynamic_filter` and `filters` — with identical content. Read either.", - "It also returns `issues` (defects linked to the run) and `test_result_issues` (defects linked to individual results), which the plain run read omits.", - "The run's `id` is the TR-NNN STRING; the integer is in `uuid`. Take `uuid` whenever a numeric run id leaves this read.", - "The run sits under `data.test_run` — with an underscore, inside a `data` envelope. The write endpoints on this entity use `data.testrun` instead, and the v2 run read has no `data` at all.", - "`trtc_folder_progress_enabled` is a group feature flag, and a lookup failure is reported as false — treat false as 'not available', not as a confirmed setting.", - "Needs the test-run view permission, with the automation variant demanded on automation runs." - ], - "returns": [ - "data", - "success", - "test_run", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "attachments", - "metadata", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "created_by", - "updated_by", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ] - }, - { - "name": "list_test_run_test_cases", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." - }, - { - "name": "page_size", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page. Defaults to 30 when omitted. Above 300 the request errors ('Page limit exceeds the maximum limit of 300'); with fetch_steps=true the ceiling drops to 30." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "description": "Alias for page_size and read first when both are sent. Same ceilings." - }, - { - "name": "fetch_steps", - "type": "boolean", - "default": false, - "description": "Include each case's steps. Pass the literal string 'true'. Doing so lowers the maximum page size from 300 to 30." - }, - { - "name": "minify", - "type": "boolean", - "default": false, - "description": "Compact row shape. Pass the literal string 'true'." - }, - { - "name": "is_shared", - "type": "boolean", - "description": "Restrict to cases shared in from another project." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project, to narrow shared cases to one source project." - }, - { - "name": "include_subfolders", - "type": "boolean", - "default": false, - "description": "Make folder_id include all descendant folders." - }, - { - "name": "match", - "type": "object", - "description": "Per-field operator, e.g. match[tags]=all&match[assignee]=none. Multi-value fields (tags) take all|any|none|only; single-value fields take only any|none — anything else is a 400. match[global]=all (default) ANDs the fields, match[global]=any ORs them. `only` needs a group entitlement, else 403." - }, - { - "name": "latest_status", - "type": "string", - "description": "Comma-separated latest execution statuses (untested, passed, failed, retest, blocked, skipped, in_progress, plus any custom group status). Unknown values are a 400 listing what is valid." - }, - { - "name": "assignee", - "type": "string", - "description": "Comma-separated assignee emails. Use match[assignee]=none for unassigned cases. An email that does not resolve in the group is a 400, never a silently ignored filter." - }, - { - "name": "priority", - "type": "string", - "description": "Priority label, resolved to an id inside the group." - }, - { - "name": "tags", - "type": "string", - "description": "Comma-separated tags. Combine with match[tags]=all|any|none|only." - }, - { - "name": "folder_id", - "type": "string", - "description": "Folder id. Pair with include_subfolders=true to include descendants." - }, - { - "name": "configuration", - "type": "string", - "description": "Configuration label, resolved to an id inside the group." - }, - { - "name": "name", - "type": "string", - "description": "Substring match on the test case name." - }, - { - "name": "identifier", - "type": "string", - "description": "Exact test case identifier, e.g. TC-42." - }, - { - "name": "query", - "type": "string", - "description": "Free-text search across name and identifier." - }, - { - "name": "test_case_status", - "type": "string", - "description": "Test case status label, resolved to an id inside the group." - }, - { - "name": "test_case_owner", - "type": "string", - "description": "Comma-separated test case owner emails." - }, - { - "name": "case_type", - "type": "string", - "description": "Category / type label, resolved to an id inside the group." - }, - { - "name": "automation_status", - "type": "string", - "description": "Automation status label, resolved to an id inside the group." - }, - { - "name": "created_by", - "type": "string", - "description": "Comma-separated creator emails." - }, - { - "name": "updated_by", - "type": "string", - "description": "Comma-separated last-updater emails." - }, - { - "name": "template", - "type": "string", - "description": "Template label, resolved to an id inside the group." - }, - { - "name": "created_between", - "type": "string", - "description": "Two comma-separated dates (start,end) bounding creation time." - }, - { - "name": "updated_between", - "type": "string", - "description": "Two comma-separated dates (start,end) bounding last-update time." - } - ], - "intent": "List the test cases inside a run with their latest result, assignee and configuration — the read to answer \"what is still untested / failing in this run?\"", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size` (or `count`). The default page is 30 rows and the hard ceiling is 300; asking for more errors instead of clamping.", - "fetch_steps=true drops the ceiling to 30 rows per page, so a run of any size takes many more calls with steps on. It also adds a `steps` array to each row, which is otherwise absent.", - "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 'request_limit_exceeded'. Filter the run down rather than walking a huge run to the end.", - "Filters fail closed — an unknown status, an unresolvable email or an invalid match mode returns 400 listing the valid values, so a filtered read never quietly degrades into the whole run.", - "Rows are per case-and-configuration mapping: a case mapped to three configurations comes back as three rows, each with its own configuration_id and latest_status. configuration_id is null for cases with no configuration.", - "Rows carry more than the core execution fields: is_shared, source_project_name, execution_id, created_at, last_updated_at, created_by and updated_by arrive on every row, plus a urls object. The created/updated stamps belong to the row's place in the run, not to the underlying test case record.", - "Use latest_result_id from a row when you need to fetch or amend that case's recorded result. It is populated even on rows that have never been executed — the result row is minted when the case is mapped into the run — so a non-null id does NOT mean the case has run. Detect 'never executed' from latest_status being untested instead.", - "The `urls.self` deep link on these rows is currently emitted without the run segment, so it does not resolve. Build run links from the run identifier you already hold rather than following it." - ], - "returns": [ - "success", - "info", - "test_cases", - "identifier", - "name", - "description", - "latest_status", - "latest_result_id", - "assignee", - "priority", - "status", - "case_type", - "configuration_id", - "folder_path", - "dataset", - "project_id", - "source_project_id", - "source_project_name", - "is_shared", - "execution_id", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "TestRunTestCasesV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "query": [ - { - "name": "minify", - "type": "boolean", - "default": false, - "description": "Return the compact run payload (drops the heavier analytics/detail fields). Pass the literal string 'true'." - } - ], - "intent": "Look up one test run — its state, owner, tags, configurations, linked plan and per-status progress counts", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Both ids are prefixed strings here (PR-1 / TR-14). The v2 route only matches PR-\\d+ and TR-\\d+, so the integer ids the v1 run capabilities take give a 404 on this path.", - "This returns the run header only — progress is aggregated counts. To see which cases are in the run, or their latest results, read the run's test cases instead.", - "403 means the caller lacks view permission for this run type (automation runs are gated separately from manual ones).", - "The run is at the top level of the response as `test_run`; there is no `data` envelope here. The v1 run endpoints wrap their payload in `data` — these v2 ones do not.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "test_cases", - "urls" - ], - "responses": { - "200": { - "$schema": "TestRunV2Response__ListTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_runs_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/form-fields", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "all", - "type": "boolean", - "default": false, - "description": "Include the project's custom result fields as well as the defaults. PRESENCE-checked, not value-checked: `all=false` and `all=0` behave exactly like `all=true`. The only way to get the compact shape is to omit the parameter entirely." - } - ], - "intent": "Discover the fields available when recording a test RESULT in this project — the built-in status options and, on request, the project's custom result fields — so a result can be submitted with valid values", - "returns": [], - "responses": { - "200": { - "$response": "TestRunFormFieldsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These are TEST RESULT fields, not test run fields — despite the path. Use them to build a valid result submission (status id, custom field ids and their allowed option values).", - "The response SHAPE changes with `all`: omit it and you get {success, default_fields} only; send it and `custom_fields` appears alongside. Code that reads custom_fields must send the parameter.", - "`default_fields` is an object keyed by field name (today just `status`), not an array. Each entry lists its allowed values with their numeric ids — those ids, not the display labels, are what result writes take.", - "The import-only pseudo-fields (owner_imported, owner_imported_email) are stripped from custom_fields, so a field visible in an exported CSV can be missing here.", - "The response is flat — {success, default_fields, custom_fields} with no `data` envelope." - ] - }, - { - "name": "list_test_runs_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/selection", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Select every case in the project. When false, `folders` decides the set.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 2, - "description": "The caller's own count of the selection. Echoed through; it does not constrain the result.", - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. A folder with test_case_select_all takes the whole folder minus deselected_test_cases; otherwise only selected_test_cases counts.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "show", - "type": "string", - "values": [ - "all", - "filtered" - ], - "default": "filtered", - "description": "`filtered` (the default, and what ANY value other than the literal 'all' means) returns only the cases matching both the selection and `q`. `all` returns the whole selection and adds a `selected` array naming which of those rows also match `q`." - }, - { - "name": "q", - "type": "object", - "description": "Test-case filter/search object, intersected with the selection. Omit it to get the selection as-is. Free-text goes in as a search string matched against case name and identifier." - }, - { - "name": "count", - "type": "integer", - "maximum": 20000, - "default": 30, - "description": "PAGE SIZE, despite the name — not a count to return. Defaults to 30 when absent or unparseable, and a value above 20000 is refused with a page-size error." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "shared_selection", - "type": "object", - "description": "Accepted by the parameter filter but NOT read by this action — cases shared in from other projects are not resolved here. Use the count endpoint if you need shared selections accounted for." - } - ], - "intent": "Resolve a folder/case selection into the actual list of test cases it covers — use it to preview or page through what a run create or edit would pull in before committing it", - "returns": [ - "success", - "info", - "test_cases", - "selected", - "id", - "identifier", - "name", - "description", - "type", - "owner", - "folder_id", - "template", - "template_id", - "project_id", - "group_id", - "is_shared", - "is_project_level", - "review_status", - "estimated_duration_seconds", - "automation_status", - "archived_at", - "trashed_at", - "created_at", - "updated_at", - "preconditions", - "expected_result" - ], - "responses": { - "200": { - "$response": "TestRunsSelectionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This returns TEST CASES, not test runs, despite sitting under the test-runs path and despite the older wording of its name. It is the preview step for a run's case selection.", - "`selection` is effectively mandatory: the backing service reads straight into it and a body without it fails rather than defaulting to everything.", - "Page size is the `count` field, not `per_page`, and it caps at 20000; leaving it out gives 30 rows, which is how a selection preview looks far smaller than the run it will create.", - "`selected` only appears when show='all'. In the default mode the rows already ARE the filtered set, so its absence is not a failure.", - "Folder ids are STRING keys in the `folders` map, while the case ids inside are integers. Mixing those up drops the folder silently.", - "shared_selection is ignored here. A selection that pulls cases from other projects will preview short.", - "The response is flat — {success, info, test_cases, selected} with no `data` envelope.", - "Needs the test-run write permission even though it only reads — a viewer-only role is 403'd.", - "The rows here are the RAW case record, not the shared test-case serializer used by every other test-case route. `automation_status` is a plain string, the folder key is `folder_id` (not `test_case_folder_id`), case type arrives as the bare string `type`, and priority, status, case_type, tags, issues, custom_fields and steps are NOT returned at all. Read the case detail route if you need those." - ], - "paginated": true, - "max_page_size": 20000 - }, - { - "name": "list_test_runs_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no page-size parameter: the page is fixed at 30 rows (5000 when filter_mapping is set)." - }, - { - "name": "ignore_run_type", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' to stop the backing service filtering by run type, so manual and automation runs are listed together. Any other value, including boolean true in some encodings, leaves the default filtering in place." - }, - { - "name": "include_test_plan", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' to include each run's linked test plan on the row, so you can tell which runs are already attached to a plan without a second read." - }, - { - "name": "filter_mapping", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' for the lightweight picker shape: the page grows to 5000 rows, progress analytics are skipped, and every run is reduced to {id, identifier} ONLY. Every other field disappears — do not use it when you need run state, owner or counts." - } - ], - "intent": "List a project's active test runs with their progress counts, owner and linked plan — the starting point for finding a run to read, execute or report on", - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "build_meta", - "assignee_imported", - "environment", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListTestRunsByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Closed runs are NOT in this list. A run count or an 'all our runs' answer built from this endpoint alone silently omits every finished cycle — read the closed-runs listing too.", - "Page size is fixed at 30 and there is no per_page; only `p` moves. The one exception is filter_mapping=true, which raises it to 5000 and strips every field but id and identifier.", - "Each run's `id` is the TR-NNN STRING, and the integer id is in `uuid`. Take `uuid` whenever a numeric run id leaves this read — passing the string into a test plan's run list reaches the backing service uncoerced and fails there.", - "The three boolean query params are compared against the literal string 'true'; anything else, including 1 or the word yes, is treated as not set.", - "The response is flat — {success, test_runs, info, count} with no `data` envelope. `count` is the project-wide per-state totals, not this page's size; the page's size is in `info`.", - "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent — do not read a zeroed `overall_progress` as 'nothing was executed'.", - "To filter or search runs rather than page through them, use the project entity search with the test-runs entity; this endpoint takes no filter parameters.", - "Needs the test-run view permission, with the automation variant demanded for automation runs." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "import_dataset_csv", - "method": "POST", - "path": "/api/v1/projects/{project_id}/datasets/import_csv", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Parse an uploaded CSV file into column headers and rows — NOT INVOKABLE from this profile: it takes a multipart file upload, and it does not create a dataset", - "responses": { - "200": { - "description": "Parsed CSV contents, wrapped in a `data` envelope (unlike every other dataset endpoint, which returns at the top level). Nothing was written.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "headers": { - "type": "array", - "description": "The CSV header row, in file order — the candidate column names.", - "items": { - "type": "string" - } - }, - "rows": { - "type": "array", - "description": "Data rows as header-keyed objects, not positional arrays — they are NOT in the shape the dataset create call expects.", - "items": { - "type": "object" - } - } - } - } - } - } - }, - "400": { - "description": "{error:'No file uploaded'} — what every invocation from this profile returns, because no file part can be sent." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, error:...} — oversized file, non-.csv extension, blank headers, too many rows or columns, or malformed CSV. Also the Team Pro gate, which renders {success:false, message:'Feature not available'}." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "QUARANTINE CANDIDATE — do not attempt to call this. The only input is a multipart/form-data file part; a byte stream has to cross the boundary and there is no way to declare it here, so this record has no parameter for the one input it needs and any invocation fails with 400 {error:'No file uploaded'}.", - "The name is misleading: this does not import anything. It validates and parses the CSV and returns the parsed headers and rows to the caller. Nothing is written — no dataset is created or modified by this call.", - "To get the same result without a file upload: parse the CSV yourself and call the dataset create capability with the header row as `variables` and the data rows as `rows`. That is what the UI does with this endpoint's output.", - "If it is ever reachable, the limits enforced are: .csv extension only, 50 MB max (the published spec's 10 MB is wrong), every header non-blank, and at most 40 columns / 100 rows for a standard group (100 / 200 for groups on the raised-limit flag). Failures come back as 422 {success:false, error:...}.", - "Still behind the Team Pro plan gate and the test-case write permission like the rest of the dataset family." - ], - "disabled": true - }, - { - "name": "list_activity_events", - "method": "GET", - "path": "/api/v1/activities", - "mode": "read", - "entity": "activity", - "query": [ - { - "name": "project_id", - "type": "integer", - "description": "Narrow the group-wide feed to one project. A query filter, not a path segment." - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque cursor from a previous page's pagination.next_cursor. There is no page number." - }, - { - "name": "limit", - "type": "integer", - "description": "Page size, forwarded to the backing service which owns the default and ceiling; the empty-starred short-circuit reports 50. Trust pagination.limit for what you actually got." - }, - { - "name": "actor_id", - "type": "integer", - "description": "Filter to one actor, as a BrowserStack user id (the same id events carry in actor_id)." - }, - { - "name": "starred_only", - "type": "boolean", - "description": "Send the literal string 'true' to restrict to the caller's starred projects. With none starred the response is {events: [], no_starred_projects: true} — that is 'nothing starred', not 'no activity'." - }, - { - "name": "entity_type", - "type": "array", - "description": "Filter by subject entity type. An array parameter — repeat the key rather than sending a comma-joined string." - }, - { - "name": "origin_channel", - "type": "string", - "values": [ - "ui", - "api" - ], - "description": "Restrict to events raised from the UI or from the API. Any other value is silently DROPPED rather than rejected, so a typo quietly widens the result instead of erroring." - } - ], - "intent": "Read the activity feed (audit trail) — CURSOR paged, 15-day window", - "returns": [ - "success", - "events", - "pagination", - "projects", - "no_starred_projects", - "next_cursor", - "has_next_page", - "limit", - "actor_id", - "actor_name" - ], - "responses": { - "200": { - "description": "A cursor page of activity events.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "events": { - "type": "array", - "items": { - "type": "object" - } - }, - "pagination": { - "type": "object", - "properties": { - "next_cursor": { - "type": "string", - "nullable": true - }, - "has_next_page": { - "type": "boolean" - }, - "limit": { - "type": "integer" - } - } - }, - "projects": { - "type": "object", - "description": "Project id → metadata, for resolving event project references." - }, - "no_starred_projects": { - "type": "boolean", - "description": "Present and true only when starred_only was set and the user has none starred." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Cursor paging only: follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no page number and no total count, so you cannot state how many events exist or jump to a page.", - "The server clamps every query to the last 15 DAYS regardless of what you ask for. For anything older, say the history is not retrievable through the API and point at the Test Management UI — reporting 'no activity' for an older period is a false negative.", - "Group-wide, not project-scoped: `project_id` is a query filter, not a path segment. The response's `projects` map (project id to metadata) resolves the project each event refers to without a second read.", - "`starred_only` is compared against the literal string 'true'. When the caller has no starred projects the reply is {events: [], no_starred_projects: true} — that means 'nothing is starred', NOT 'no activity'. Check that flag before reporting emptiness.", - "Gated by a group feature flag. A 403 {success:false, error:'Activity feed is not enabled'} means the feed is off for the group, not that the caller lacks permission — say so rather than reporting no activity.", - "Every event is enriched with `actor_name`; an actor who cannot be resolved inside the caller's group renders as 'Unknown User' rather than being dropped, so an unfamiliar name is not necessarily a data gap.", - "`entity_type` is an array filter — repeat the key rather than sending a comma-joined string. `origin_channel` accepts only 'ui' or 'api'; any other value is dropped silently instead of erroring, which widens the result rather than narrowing it.", - "Read-only by design: activity rows are emitted as a side effect of other writes. There is no create, update or delete, so an audit entry cannot be edited or removed on request.", - "Per-project visibility is applied downstream — the feed is already scoped to projects the caller can access, so an empty page can mean 'no accessible projects had events' rather than 'nothing happened'." - ], - "disabled": true - }, - { - "name": "list_archived_test_plans", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/archived_test_plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. Page size is fixed at 30; there is no per_page." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match on the archived plan's name or identifier." - }, - { - "name": "plan_status", - "type": "string", - "description": "Status filter for the archived view, forwarded upstream as given (a comma-joined list such as 'new,started' is accepted). Unlike the active listing, an unrecognised value is not rejected here — it simply matches nothing." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Forwarded WITHOUT validation on this route (the active listing validates the same parameter), so a typo is passed upstream rather than returning a 400." - } - ], - "intent": "Page through the test plans that have been ARCHIVED in a project — the place to find a plan to restore; archived plans do not appear in the normal plan listing at all", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "V1ArchivedTestPlanListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a fixed literal route on the plan collection — it takes no plan id. It is a different endpoint from the plan detail read that sits on the same path prefix.", - "Gated on the group archive flag: when it is off the call is 404 {success: false, errors: 'Feature not available'}, which means the feature is unavailable, not that nothing is archived.", - "Needs the test-plan DELETE permission, not view — a user who can list plans normally can still be 403'd here.", - "Archived SUB-plans are not listed; there is no include_sub_plans switch on this route. Only top-level archived plans come back.", - "Rows are serialized exactly like the active listing (same fields, plus record_status: 'archived'), so anything that consumes the normal plan list works unchanged here.", - "Page size is fixed at 30; page with `p` and stop using the `info` envelope. There is no `count` object on this response, unlike the active listing.", - "To put a plan back, pass its integer id to the bulk-retrieve capability." - ], - "returns": [ - "success", - "test_plans", - "info", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "record_status", - "start_date", - "end_date", - "owner", - "assignee", - "tags", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "test_plan_progress_v2", - "project_id", - "created_at", - "updated_at", - "links" - ], - "max_page_size": 30 - }, - { - "name": "list_custom_field_dataset_options", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces as a bodyless 404." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Not validated by this handler; a dataset belonging to another field returns the same bodyless 404." - } - ], - "intent": "List the option values a dataset offers, with the numeric option id each one carries — the ids you need to rename, re-default or delete an option", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is where option ids come from. The dataset read returns options only when asked and without a stable contract; this call always returns them with their ids.", - "Each row is {id, option_value, is_default, colour, parent_option_id}. When any row carries a parent_option_id the whole list is rebuilt as a tree and children appear under a `children` key on their parent instead of at the top level — a flat scan of `data` then misses them.", - "Exception: if every row in the page has a parent (a single level of a nested_dropdown read on its own), the tree build finds no root and falls back to returning the rows flat.", - "Paging is `p` only; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata. On a nested field the tree is built per page, so a parent and its children can land on different pages.", - "Neither path id is validated here, so a wrong field/dataset pair reaches storage and returns a bare 404 {success:false} with no message." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control — page size is fixed at 30 and `per_page` is ignored." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "info", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id", - "children" - ] - }, - { - "name": "list_datasets", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[name]", - "type": "string", - "description": "Partial, case-insensitive match on dataset name. Use this to find a dataset you only know by name." - }, - { - "name": "q[uuid]", - "type": "string", - "description": "Exact match on dataset uuid." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort order, forwarded unchanged to the backing store; the accepted values are not validated or enumerated here, and an unrecognised one is not rejected by this service. Omit it unless you know the store's vocabulary." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Validated globally: a non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}; p=0 is treated as page 1. There is no page-size parameter — see max_page_size." - } - ], - "intent": "Find datasets in a project — browse them or look one up by name or uuid to get the uuid the other dataset calls need", - "returns": [ - "success", - "datasets", - "info", - "uuid", - "name", - "rows_count", - "test_cases_count", - "created_by", - "created_at", - "updated_at" - ], - "paginated": true, - "responses": { - "200": { - "description": "A page of datasets, with paging metadata under `info`.", - "schema": { - "$schema": "DatasetsListResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are the compact form: name, uuid, counts, tags, metadata, creator and timestamps — but NOT the columns or rows. Read a single dataset by uuid to get its contents.", - "Page size is fixed at 30 and cannot be requested; ask for successive `p` values and stop when `info.next` is null.", - "`test_cases_count` here is the cheapest way to tell whether a dataset is in use before deleting it.", - "A creator no longer in the group resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", - "422 {message:'Feature not available'} means the group lacks the Team Pro plan — every dataset endpoint answers this way, and it is an entitlement answer, not a payload error.", - "`success`, `datasets` and `info` are all top level; there is no `data` envelope and no second nesting under `datasets`." - ], - "max_page_size": 30 - }, - { - "name": "list_duplicates", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `p` is accepted as an alias and `page` WINS if both are sent. Unlike `p`, `page` is NOT validated: a non-numeric value silently becomes 0 and is forwarded as page 0. Send a positive integer." - }, - { - "name": "entity_type", - "type": "string", - "description": "Accepted and IGNORED — the handler never reads it. Every pair links test cases; there is no other entity type. Sending it changes nothing." - }, - { - "name": "entity_id", - "type": "integer", - "description": "Integer test-case id. Restricts the listing to pairs involving that case AND strips that case out of each returned pair, so each row's `test_cases` holds only the OTHER case. Use it to answer 'what is this test case a duplicate of'." - }, - { - "name": "duplicates", - "type": "string", - "values": [ - "exact", - "semantic" - ], - "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim to the backing store and not validated here, so an unrecognised value narrows to nothing rather than returning an error. Omit for both types." - }, - { - "name": "owner", - "type": "string", - "description": "The 'My duplicates' tab. ANY non-empty value scopes the listing to pairs involving the CALLER's own test cases — the value itself is discarded, so this cannot select another user. Omit for all duplicates in the project." - } - ], - "intent": "List the AI-suggested duplicate test-case pairs awaiting review in a project, highest-confidence first — optionally only those involving one test case, or only the caller's own", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "Suggested duplicate pairs. This exact shape with an empty `duplicates` is also what comes back when the group lacks the dedupe feature flag, so an empty list is not evidence the project has none.", - "schema": { - "$schema": "V1DuplicatesListResponse", - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "last_scan_time": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When the dedupe scan that produced these groups last ran. NOTE: this can be populated while `get_dedupe_status` reports NOT_ENABLED for the same project - the two disagree." - }, - "duplicates": { - "type": "array", - "items": { - "$schema": "DuplicateGroup" - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "An empty list is ambiguous. When the group lacks the dedupe feature flag this returns 200 with exactly {success:true, duplicates:[], info:{count:0,total_pages:0}} — indistinguishable from a genuinely clean project. Read the dedupe status capability to tell 'feature off / never run' from 'nothing found'.", - "Only pairs still in `suggested` status appear. Once a pair is merged, archived or discarded it leaves this listing permanently; there is no filter to bring it back.", - "Page size is fixed at 10 and cannot be requested. Pairs are ordered by confidence (ai_score descending, then id descending), NOT by age — the oldest suggestion is not on page 1 and a bigger page cannot be asked for.", - "`info.count` can exceed the rows you actually receive: a pair whose test case cannot be resolved is dropped from the page after the count is taken. Do not derive page counts from row arithmetic; use `info.total_pages` / `info.next`.", - "Carry each row's `id` forward — that integer is the duplicate id the read, resolve and discard calls take.", - "`last_scan_time` at the top level is the project's last dedupe run, not a property of this page; it is fetched separately and can be null when no scan has completed.", - "Duplicate REVIEW is all that is exposed here. There is no way to create a suggestion or trigger a scan through this API." - ], - "returns": [ - "success", - "duplicates", - "info" - ], - "max_page_size": 10 - }, - { - "name": "list_exploratory_session_defects", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. The only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 30." - } - ], - "intent": "List the defects and issues raised during an exploratory session — the external tracker tickets linked to the session and to its individual log entries — to review what the exploration found", - "returns": [ - "success", - "defects", - "info", - "issue_id", - "issue_type", - "jira_id", - "created_at" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionDefectsV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ONLY LOG-LEVEL DEFECTS APPEAR HERE. A defect linked on a session LOG shows up immediately; an issue linked on the SESSION itself (the `issues` array on create/update_exploratory_session) NEVER appears, though it persists and is readable from the session's own detail.", - "The row is FOUR fields - {issue_id, issue_type, jira_id, created_at}. It is NOT the full issue-link record: no id, entity_type, entity_id, entity_uuid, issue_status, priority, record_status, project_id, metadata or updated_at, all of which an earlier version of this contract promised.", - "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected as 400 {success:false, error:'Invalid Params'}.", - "`per_page` is accepted and ignored - the page size is fixed at 30. Walk `p` until a page comes back short.", - "No filters: no status, tracker or date narrowing. Filter client-side.", - "There is no project-wide defect listing in this index. 'What defects exist in this project' means listing sessions and issuing one request per session.", - "Everything is at the top level: defects, info. There is no `data` envelope.", - "An unknown session_id returns a bare {success:false} with 404 and no message." - ] - }, - { - "name": "list_exploratory_session_logs", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. The only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 50, - "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 50." - }, - { - "name": "q[status]", - "type": "string", - "example": "fail,bug", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "description": "Comma-separated list of statuses as a single string (e.g. 'retest,block') — not a repeated array parameter. Every value is allowlisted before the upstream call; anything else, or an empty list, returns 400 'Invalid status value.'. Does not affect status_counts." - } - ], - "intent": "Read the observations recorded during an exploratory session — the timestamped notes, their pass/fail/bug status, elapsed time, linked defects and embedded screenshots — optionally narrowed to particular statuses", - "returns": [ - "success", - "session_logs", - "info", - "status_counts", - "id", - "session_id", - "hashed_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "attachment_relations", - "created_at", - "updated_at" - ], - "max_page_size": 50, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionLogListV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Logs are children of one session: session_id in the path scopes the whole listing, and a log id is only meaningful together with the session it belongs to.", - "Paging uses `p`, NOT `page` — `page` is read by nothing here. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'}.", - "`per_page` is accepted and ignored: the handler overwrites it with the fixed page size of 50. Walk `p` until a page comes back short. Compare against session_log_count on the session to know when you have them all.", - "q[status] is a COMMA-SEPARATED STRING, not an array, and it is allowlisted before the upstream call: any value outside pass, fail, bug, retest, block, note, skipped returns 400 'Invalid status value.'. Note `skipped` — it is a real status that older documentation of this entity omits.", - "status_counts is meant to be the whole-session per-status totals, deliberately unfiltered by q[status]. Do not rely on it today: the typed upstream response object carries no such field, so this key is currently rendered as an empty object on every call.", - "There is no sort parameter on this endpoint and the upstream default is not ascending — the internal session-copy path has to ask for ascending order explicitly. Sort by created_at yourself rather than assuming chronological order.", - "`content` is sanitised HTML, not plain text, and embedded images are TM-hosted attachment URLs. What is stored can differ from what was written: links and images pointing outside the project or group are stripped on write.", - "Each log's `defects` are the issue links on that log. The session-level defects listing is a different, broader set — read that one for everything linked to the session.", - "Everything is at the top level: session_logs, info, status_counts. There is no `data` envelope." - ] - }, - { - "name": "list_exploratory_sessions", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. This is the only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 30. There is no way to widen the page from the caller side." - }, - { - "name": "q[session_state]", - "type": "string", - "values": [ - "active", - "closed" - ], - "example": "active", - "default": "active", - "description": "Lifecycle state. Defaults to 'active' when omitted, so closed sessions are invisible unless you ask for them. Not validated locally — an unrecognised value is forwarded upstream rather than rejected." - }, - { - "name": "q[assignee]", - "type": "integer", - "example": 42, - "description": "TCM user id of the session assignee; translated to a BrowserStack user id before the query is sent. Wins over q[owner] when both are given. An id that resolves to no user is sent as null instead of erroring." - }, - { - "name": "q[owner]", - "type": "integer", - "example": 42, - "description": "Alias for q[assignee], used only when q[assignee] is absent. Same TCM user id semantics." - }, - { - "name": "q[created_by]", - "type": "integer", - "example": 10, - "description": "TCM user id of the session creator; translated to a BrowserStack user id. An unresolvable id is sent as null rather than rejected." - }, - { - "name": "q[created_at_from]", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Created on or after this date. Forwarded verbatim — not parsed or validated here." - }, - { - "name": "q[created_at_to]", - "type": "string", - "format": "date", - "example": "2026-01-31", - "description": "Created on or before this date. Forwarded verbatim — not parsed or validated here." - }, - { - "name": "q[tags][]", - "type": "array", - "example": [ - "regression", - "payment" - ], - "description": "Tag names to match. Forwarded verbatim." - }, - { - "name": "q[configuration_ids][]", - "type": "array", - "example": [ - 1, - 2 - ], - "description": "Configuration ids (integers) the session runs against." - }, - { - "name": "q[search]", - "type": "string", - "example": "checkout", - "description": "Free-text match, forwarded upstream as-is." - }, - { - "name": "q[query]", - "type": "string", - "description": "Second free-text key the handler forwards alongside q[search]; both are passed through independently. Undocumented upstream — prefer q[search]." - }, - { - "name": "q[status]", - "type": "string", - "example": "fail", - "description": "Filter by the log statuses recorded inside the session. Forwarded verbatim and not allowlisted here, unlike the same-named filter on the session logs listing." - }, - { - "name": "q[test_plan_id]", - "type": "integer", - "example": 77, - "description": "Only sessions linked to this test plan. Takes the integer test plan id, not a TP-NNN identifier." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "id-asc", - "id-desc", - "title-asc", - "title-desc" - ], - "description": "Combined field+direction token. There is no separate sort_order parameter. Any other value is rejected with 400 'Invalid sort_by value.' before the upstream call." - } - ], - "intent": "Find exploratory testing sessions in a project — filter by state, assignee, creator, tags, configurations, linked test plan, date range or free text — to pick the session whose logs or defects you want to read next", - "returns": [ - "success", - "exploratory_sessions", - "info", - "count", - "id", - "identifier", - "title", - "session_state", - "timebox_duration", - "assignee", - "created_by", - "tags", - "attachments", - "created_at", - "updated_at", - "project_id", - "group_id", - "test_plan_id", - "summary", - "test_plan" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionListV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The default listing hides closed work: when q[session_state] is omitted the handler substitutes 'active'. Pass q[session_state]=closed explicitly to find a finished session, and expect a session to vanish from the default list the moment it is closed.", - "Paging is driven by `p`, NOT `page`. `page` is read by nothing on this path. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'} before the action runs.", - "`per_page` is accepted and ignored — the handler overwrites it with the fixed limit of 30 rows. There is no way to ask for a larger page; walk `p` until a page comes back short.", - "q[assignee] / q[owner] / q[created_by] take TCM user ids (integers) and are translated to BrowserStack user ids before the query is sent. q[owner] is only a fallback for q[assignee] — if both are present, q[assignee] wins. An id that matches no user in this group is not an error: the filter is sent as null and you get back a result set that silently does not mean what you asked for.", - "sort_by is a single combined token ('id-asc', 'id-desc', 'title-asc', 'title-desc') — there is no separate sort_order. Anything else is 400 'Invalid sort_by value.'.", - "`count` is an OBJECT of per-state totals ({active, closed}), not the number of rows matching your filter. The row total lives in info.count. Do not use `count` to size a pagination loop.", - "Rows are NOT put through the custom-field normaliser that the single-session read applies, so custom_fields on a list row is the raw upstream shape (or absent). Re-read the session by id if you need normalised custom fields.", - "assignee / created_by / closed_by come back as user objects resolved from the local user table, and resolve to null when the underlying user is not a TM user in this group — a null assignee here does not prove the session is unassigned.", - "Take `id` (integer) from a row and carry it as session_id into the logs, defects, close, clone, update and delete calls. `identifier` (ES-NNN) is display-only and is not accepted by any route on this entity.", - "Everything is at the top level: exploratory_sessions, info, count. There is no `data` envelope.", - "This listing is a NARROW projection, not the full session. It does not return charter, description, duration, actual_duration, session_log_count, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations — read a single session for those. It is also the one session endpoint that does NOT normalise custom_fields." - ] - }, - { - "name": "list_filters", - "method": "GET", - "path": "/api/v1/projects/{project_id}/filter", - "mode": "read", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "entity", - "required": true, - "type": "string", - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Required — which list's saved views to return, one type per call. Omitting it is a 400 'Invalid entity type'; to see every view, call once per type. An unlisted value is rejected with 422 `{success:false, message:\"Invalid payload\"}` — 422 here, not 400." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. This is the only page parameter the handler reads — a `page` query parameter is ignored and you silently get page 1. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - } - ], - "intent": "List the saved filter views available in a project — yours and the project-wide ones — to find the filter_id to read, apply, update or delete", - "returns": [ - "success", - "data", - "info", - "id", - "name", - "is_project_level" - ], - "paginated": true, - "responses": { - "200": { - "$response": "FilterListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "max_page_size": 30, - "guidance": [ - "Page with `p`. The previously published `page` parameter is never read — sending it returns page 1 and looks like a successful call.", - "Page size is fixed at 30 and cannot be changed; there is no per_page or limit here. Walk pages until `info.next` is null.", - "The list mixes your private views with the project-level ones. Each row carries only `id`, `name` and `is_project_level` — no owner, entity_type or conditions — and `is_project_level` is the only thing that tells private views from project-wide ones.", - "An invalid `entity` returns 422, not the 400 the other filter endpoints use for validation failures.", - "Unlike reading a single filter, this listing does no custom-field validation and never rewrites anything — but it does not return a view's conditions either, only its id, name and scope.", - "Not permission-gated — the filter controller has no RBAC entry." - ] - }, - { - "name": "list_folder_contents", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Open one folder — its immediate subfolders, the folder's own record, and optionally its ancestor breadcrumb — to walk the tree a level at a time or work out where a folder sits", - "returns": [ - "success", - "folders", - "self", - "parent_id", - "ancestors", - "test_cases", - "info", - "id", - "name", - "notes", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "contents", - "links" - ], - "responses": { - "200": { - "$response": "FolderDetailsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the SUBFOLDERS. A non-positive or non-numeric value returns 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "detail", - "type": "boolean", - "default": false, - "description": "Send the literal string \"true\" to populate `ancestors` with the folder's breadcrumb up to the root. Without it `ancestors` comes back null. Only the exact string \"true\" enables it." - }, - { - "name": "nested_sub_folders", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" asks for the whole descendant subtree rather than one level. It is additionally gated on a group flag, so it silently does nothing when the group is not enabled. When it does take effect, `self.contents` is stripped from the response." - }, - { - "name": "case_count", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" to include per-folder test-case counts. Read only on the nested_sub_folders path; ignored on a plain one-level listing." - }, - { - "name": "total_count", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" to include the total row count. Read only on the nested_sub_folders path; ignored on a plain one-level listing." - }, - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Subfolder ordering. Comma-separated combinations accepted; anything else returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort gains a secondary created_at-asc tiebreak." - } - ], - "paginated": true, - "max_page_size": 300, - "guidance": [ - "Page size is fixed at 300 subfolders and cannot be changed: `per_page` from the caller is discarded and the ceiling is applied unconditionally. Only `p` moves you through the pages. (A group-level flag can lift the limit to unbounded, which you cannot detect from the response.)", - "`test_cases` is ALWAYS null. The key exists but this endpoint never returns test cases — fetch them from the folder's test-case listing instead.", - "`folders` holds the immediate children only. The folder you asked for is at `self`, its parent id is duplicated at the top level as `parent_id`, and `ancestors` is null unless you passed detail=true.", - "Folder notes come back HTML-escaped with signed image URLs injected, at every level of the response — do not treat them as the literal text that was stored.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500. A folder id from another project is a bare 404 {success:false} with no message.", - "This action has no RBAC entry, so it is not permission-gated the way the other folder reads are; it is also the action reachable through a public share link, scoped to the shared folder." - ] - }, - { - "name": "list_folder_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Walk pages until `info.next` is null." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", - "minimum": 1, - "maximum": 100, - "default": 30 - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." - }, - { - "name": "required[custom_fields]", - "type": "string", - "example": "121,119", - "description": "Comma-separated custom field ids to include on each row." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-ASC", - "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "Send the string `true` to include cases from this folder's subfolders, and add a `folders` block to the response." - }, - { - "name": "shared", - "type": "boolean", - "description": "Scope the listing to cases shared in from other projects." - }, - { - "name": "for_test_run", - "type": "boolean", - "description": "Send the string `true` when building an 'add cases to run' picker: on a project with review gating configured it hides cases whose review status makes them ineligible, so the same folder returns FEWER rows than a plain read." - }, - { - "name": "hasDuplicates", - "type": "boolean", - "description": "When true, fills `duplicates_tc_count` on each row from the dedupe store; otherwise it is null." - } - ], - "intent": "List the test cases in one folder — the repository browser read, with optional extra columns and custom fields.", - "shape": "discovered", - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "$response": "TestCaseFolderListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Folder-scoped and non-recursive by default: pass `include_subfolders_tc=true` to reach cases in child folders. To read the whole project use the project-level listing with all_folders=true instead.", - "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else — so trust `info.page_size`, not your request. Walk `p` until `info.next` is null.", - "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", - "`for_test_run=true` deliberately returns FEWER rows on a review-gated project — do not use it for a plain repository read or you will under-report the folder.", - "A backend 404 is swallowed into an empty list with `success: true`, so an empty result is not proof the folder is empty or that the id is right.", - "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null." - ], - "returns": [ - "success", - "test_cases", - "info", - "folders" - ] - }, - { - "name": "list_projects", - "method": "GET", - "path": "/api/v1/projects", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "q", - "type": "string", - "example": "checkout", - "description": "Free-text match on project name. Must be a flat string — a nested q[...] value returns 400 'Invalid Params: q should be a String'." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive, non-numeric or array values return 400 'Invalid Params'." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Any other value returns 400 echoing the allowed list." - }, - { - "name": "starred", - "type": "boolean", - "description": "Restrict to starred projects. Forwarded to the backing service verbatim, so sending the string 'false' is still a filter, not 'no filter' — omit the key instead." - }, - { - "name": "shared", - "type": "boolean", - "description": "Restrict to projects that have shared test cases. Any present value is treated as true; omit the key to not filter." - }, - { - "name": "is_hidden_projects", - "type": "boolean", - "default": false, - "example": true, - "description": "Include projects hidden from the dashboard. Only the exact string 'true' turns this on." - } - ], - "intent": "List the group's projects with their PR-NNN identifiers, case/run counts and duplicate badge — the listing to use when you need each project's identifier; for a plain name lookup or a count, get_projects_basic is leaner", - "returns": [ - "success", - "projects", - "info", - "id", - "identifier", - "name", - "description", - "created_at", - "import_id", - "links", - "page", - "page_size", - "count", - "prev", - "next" - ], - "paginated": true, - "responses": { - "200": { - "description": "Projects at the top level as `projects` (no `data` envelope), with pagination under `info`. Each row also carries duplicates_tc_count; project_creator is NOT emitted despite being on the shared Project schema.", - "schema": { - "$schema": "ProjectsListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Page size is fixed at 30 on this route. There is no size parameter — `count` and `page_size` are ignored here (only the basic and minify project listings honour `count`). Walk pages with `p` and stop when `info.next` is null.", - "`q` must be a flat string on this route, not a nested object: `q=checkout`. Sending q[query]=... returns 400 'Invalid Params: q should be a String'. The nested q[query]/q[identifier] form belongs to get_projects_basic.", - "`sort_by` is validated against a closed set; anything else returns 400 listing the allowed values. Omitting it leaves the backing service's default order.", - "Unlike the other project listings, each row carries `identifier` (PR-NNN) — this is the cheapest way to resolve project names to the v2 id form in bulk.", - "`duplicates_tc_count` is a decorative badge and is zero whenever the AI duplicate-detection feature is off for the group; a failure computing it is swallowed and also reads as 0, so never treat 0 as 'no duplicates'.", - "`project_creator` is documented on the shared Project schema but is NOT emitted by this endpoint — it is commented out in the serializer. Do not branch on it.", - "`starred` and `shared` are forwarded to the backing service verbatim, including the string 'false', so send them only when you mean to filter.", - "Requires the project_view permission under RBAC." - ], - "max_page_size": 30 - }, - { - "name": "list_schedules", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/schedules", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[query]", - "type": "string", - "description": "Case-insensitive substring search over report type, title and owner full name. Also enables the `owner_id` and `created_between` filters, which are ignored without it." - }, - { - "name": "q[scheduled]", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Only the exact string `true` filters (to scheduled reports). `false` and every other value are ignored and return all reports." - }, - { - "name": "owner_id", - "type": "integer", - "description": "Restrict to one owner. Only honoured alongside `q[query]`; supplying it also drops the owner name from the search match." - }, - { - "name": "created_between", - "type": "array", - "description": "Two timestamps, [from, to], bounding created_at. Only honoured alongside `q[query]`, and only when exactly two entries are given — otherwise ignored." - }, - { - "name": "per_page", - "type": "integer", - "description": "Accepted and then IGNORED — the page size is fixed at 30 on both code paths." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, 30 per page. A page past the last one returns 400 `page_out_of_bounds`, not an empty list." - } - ], - "intent": "List a project's reports — scheduled and unscheduled alike, with type, window, cadence and owner — the way to find a report_id", - "returns": [ - "data", - "scheduled_report_count", - "total_report_count", - "info" - ], - "paginated": true, - "responses": { - "200": { - "description": "A successful response containing a list of schedules.", - "schema": { - "$schema": "ReportScheduleGetResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the discovery call for the report entity: every other report capability needs an integer `report_id`, and this is where it comes from (`data[].id`; `identifier` is the SC-NNN display string and is not accepted as an id anywhere).", - "It lists ALL of the project's reports, not only scheduled ones — the route name is misleading. `scheduled_report_count` and `total_report_count` are counts over the whole project, not over the returned page.", - "`q[scheduled]` only acts on the exact string `true`. `q[scheduled]=false` is silently ignored and returns everything, so you cannot filter for unscheduled reports — filter client-side on `frequency == null` instead.", - "Page size is a hard-coded 30 and cannot be changed: `per_page` is read from the request and then never applied. Page with `p`; requesting a page past the end returns 400 `page_out_of_bounds` rather than an empty list.", - "Ordering is always newest first by id. An `order_by` parameter is parsed and then ignored.", - "`q[query]` is a case-insensitive substring match over report type, title, and (unless `owner_id` is also given) the owner's full name. Supplying it switches to a different code path that additionally honours `owner_id` and `created_between` — those two do nothing without `q[query]`.", - "Requires the report_view permission on the project (403 otherwise)." - ], - "max_page_size": 30 - }, - { - "name": "list_templates", - "method": "GET", - "path": "/api/v2/templates", - "mode": "read", - "entity": "template", - "query": [ - { - "name": "name", - "type": "string", - "description": "Narrow the list to templates whose name contains this value. It is a substring filter, not an exact lookup, so it can return several templates or none." - }, - { - "name": "p", - "type": "integer", - "description": "Page number. This is the page parameter for this endpoint — `page` is ignored. A value that is not a positive integer is rejected with a 400 before the search runs.", - "minimum": 1, - "default": 1 - }, - { - "name": "count", - "type": "integer", - "description": "Page size. This is the size parameter for this endpoint — `per_page` is ignored. Above 300 the request is REJECTED with a 400, it is not clamped. A non-numeric value silently falls back to 30.", - "maximum": 300, - "default": 30 - } - ], - "intent": "List the workspace's test-case templates — start here to find a template's id before creating a test case from it or before editing the template itself.", - "guidance": [ - "Asking for more than 300 per page fails the whole call with a 400 rather than returning a clamped page; page through at 300 or below instead.", - "List items carry only template metadata and counts — no field detail. Read a single template when you need its description or property fields.", - "Only test-case templates are returned. The entity type is fixed server-side and cannot be widened from the request, so result/plan/exploratory-session templates are not reachable here.", - "project_count and the field counts can come back null: on some workspaces the expensive per-project count is skipped server-side. Treat a null count as 'not computed', not as zero." - ], - "returns": [ - "success", - "templates", - "info", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "TemplateListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/tags-v2", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. IGNORED whenever `q` is also sent: the search branch drops the pagination options entirely and always returns the first page. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - }, - { - "name": "q", - "type": "string", - "description": "Free-text match on tag name. Trimmed; an all-whitespace value is treated as absent. Sending it silently disables paging (see `p`)." - } - ], - "intent": "List a project's test-case tag vocabulary with usage counts — the set of tag names to pick from when tagging a case or filtering a list by tag", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "TagsSearchResponse" - }, - "401": { - "description": "Unauthorized." - } - }, - "guidance": [ - "`tags` is a plain array of tag NAMES; `tag_objects` carries the same tags with `created_at` and `usage_count`. Tags are referenced by name everywhere else in this product, so `tags` is usually the field you want and `usage_count` is what tells you which are actually in use.", - "Combining `q` with `p` does not work: when `q` is present the handler forwards only the query and never the page, so you always get page 1 and a second page request returns the same rows. To walk the whole vocabulary, page with `p` and no `q`; to search, accept the first page and narrow the query.", - "Page size is fixed at 30 and there is no per_page parameter.", - "This is the TEST-CASE tag vocabulary only. Run, plan and shared-step tags are separate vocabularies with separate listings, and a name existing here says nothing about the others.", - "No tag ids are returned here. If you need an id (the tag merge takes integer ids), use the group-level tag search.", - "Not permission-gated — the tags controller has no RBAC entry." - ], - "returns": [ - "tags", - "tag_objects", - "info", - "name", - "created_at", - "usage_count" - ], - "max_page_size": 30 - }, - { - "name": "list_test_case_templates", - "method": "GET", - "path": "/api/v1/admin-v2/settings/templates", - "mode": "read", - "entity": "template", - "query": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "TestCase", - "TestRunResult" - ], - "example": "TestCase", - "description": "Which kind of template to list. Effectively required — omitting it errors rather than defaulting. Only these two values are accepted; TestPlan and ExploratorySession templates are not listable here." - }, - { - "name": "project", - "type": "integer", - "description": "Integer project id — restricts the list to templates linked to that project." - }, - { - "name": "name", - "type": "string", - "description": "Fuzzy match on template name (not an exact match)." - }, - { - "name": "paginated", - "type": "boolean", - "default": false, - "description": "Must be the literal string 'true' to turn paging on. Without it `p` is ignored, every matching template is returned in one array, and `info` is absent from the response." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Only read when paginated=true is also sent; page size is then fixed at 10." - } - ], - "intent": "List the workspace's test-case (or test-result) templates with their ids — the lookup to run before creating a case from a template or editing a template's field layout", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "`{success, templates: [{id, name, entity, enabled, step_type, is_default, is_system, future_projects_applicable, field_count, linked_to_all_projects, project_count, created_at, updated_at}]}`. `info` is present only when paginated=true was sent.", - "schema": { - "type": "object" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`entity_type` is effectively REQUIRED even though it reads as optional: it is forwarded straight to the backing client, which refuses a missing value outright rather than defaulting to TestCase. Always send it.", - "Only two values are accepted: `TestCase` and `TestRunResult`. `TestResult` is not the accepted spelling, and TestPlan / ExploratorySession templates cannot be listed from here at all — anything outside the pair errors rather than returning an empty list.", - "Paging is opt-in and easy to miss: `p` is IGNORED unless `paginated=true` is sent alongside it. With paging on, page size is fixed at 10 and `info` appears in the response; with it off you get every matching template in one array and no `info`.", - "`project` narrows to templates linked to that integer project id; `name` is a fuzzy match on the template name, not an exact one.", - "Rows are the summary shape — id, name, entity, step_type, is_system, is_default, enabled, project_count, field_count, linked_to_all_projects. The field layout itself is not here; read a single template for that.", - "Templates belong to the workspace, not to a project: the same id is valid in every linked project, so do not look for a per-project id.", - "Responses are served from a short-lived per-group cache, so a template created or edited through a different surface can take a moment to show up here.", - "A validation failure from the backing service is passed through raw at its original status rather than being reshaped into the usual {success:false} envelope." - ], - "max_page_size": 10, - "returns": [ - "success", - "templates", - "info", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at" - ] - }, - { - "name": "list_test_plans_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page — the page size is fixed at 30." - }, - { - "name": "include_sub_plans", - "type": "boolean", - "default": false, - "description": "Include sub-plans in the page. Off by default, so a sub-plan the user named looks missing until this is set. Pass the literal string 'true'." - }, - { - "name": "status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "Plan status filter. Anything outside the three values returns HTTP 200 carrying {success: false, error: 'Check query params for test plan status'} and NO plans — it does not 400." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Validated: anything else is 400 with the allowed list in the message. Must be a string — an array form is 400 'Invalid Params: sort_by should be a String'." - }, - { - "name": "minify", - "type": "boolean", - "description": "Ask for the slim plan rows. A group-level flag can force this on regardless of what you send, so rows may be slimmer than requested." - }, - { - "name": "q", - "type": "object", - "description": "Filter object (must be a nested object, not a string, or the call is 400). Recognised keys include query (free text), tags, ids, parent_plan_id, owner, team, created_at / created_between, days_left, overdue_days, tp_custom_fields, issue_type + issue_ids (both needed together), and match. Keys outside that set are ignored silently.", - "fields": [ - { - "name": "query", - "type": "string" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "ids", - "type": "array" - }, - { - "name": "parent_plan_id", - "type": "integer" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "team", - "type": "array" - }, - { - "name": "created_at", - "type": "string" - }, - { - "name": "created_between", - "type": "array" - }, - { - "name": "days_left", - "type": "string" - }, - { - "name": "overdue_days", - "type": "string" - }, - { - "name": "tp_custom_fields", - "type": "object" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "issue_ids", - "type": "array" - }, - { - "name": "match", - "type": "object" - } - ] - }, - { - "name": "ignore_type", - "type": "boolean", - "description": "Drop the implicit plan_status narrowing that the filter path otherwise applies (active plans unless status=completed). Pass the literal string 'true'." - }, - { - "name": "filter_details", - "type": "boolean", - "description": "Add a filter_details object resolving the owner and custom-field filters you sent in `q` back into displayable values. Only populated for q.owner and q.tp_custom_fields." - }, - { - "name": "filter_mapping", - "type": "boolean", - "description": "Id-picker mode: raises the page size to 5000 but returns ONLY {id, identifier} per plan — no name, no status, no dates. Do not use it to read plan detail." - } - ], - "intent": "Page through a project's test plans — filter by status, search and sort, and optionally include sub-plans; this is how you resolve a plan name a user mentioned to the integer id every other v1 plan route needs", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "V1TestPlanListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Sub-plans are hidden by default. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", - "An invalid `status` is a 200 trap: the response is HTTP 200 with {success: false, error: 'Check query params for test plan status'} and no plans. Branch on `success`, not on the status code.", - "Page size is fixed at 30 and there is no per_page parameter. Page with `p` and stop using the `info` envelope. filter_mapping=true is the only way to get more rows and it strips every row down to {id, identifier}.", - "`count` is {active, completed} for the whole project (active = every plan not completed), not for the current page — do not use it as a page total.", - "Each row carries both ids: `id` is the integer every v1 plan route takes, `identifier` is the TP-NNN / STP-NNN for display. Carry the integer forward.", - "Rows are full plan objects, same serializer as the single-plan read, unless a minify/skip-analytics path trims them — a group-level flag can turn that on without you asking, so check for the fields you need rather than assuming.", - "`q` must be sent as a nested object; a scalar q is 400 'Invalid Params: q should be a ActionController::Parameters'. Custom-field filters inside q.tp_custom_fields are validated and a bad field id fails the whole request.", - "Archived plans are NOT in this listing under any filter — they have their own archived listing.", - "Needs the test-plan VIEW permission." - ], - "returns": [ - "success", - "test_plans", - "info", - "count", - "filter_details", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "record_status", - "start_date", - "end_date", - "owner", - "assignee", - "created_by", - "tags", - "issues", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "total_test_runs_count", - "test_plan_progress_v2", - "project_id", - "created_at", - "updated_at", - "links" - ], - "max_page_size": 30 - }, - { - "name": "list_workspace_fields", - "method": "GET", - "path": "/api/v1/admin-v2/settings/fields", - "mode": "read", - "entity": "custom_field", - "query": [ - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Which entity's fields to list. Any other value is a 400 'Invalid Params'. TestCaseStep returns an empty list when the group's step-custom-fields flag is off." - }, - { - "name": "field_source", - "type": "string", - "values": [ - "system", - "custom", - "all" - ], - "default": "all", - "description": "Case-insensitive. `system` returns every system field on a single page (total_pages 1) and no custom fields; `custom` skips the system fields entirely. Anything else is a 400 naming the valid values." - }, - { - "name": "field_type", - "type": "string", - "description": "Filter custom fields by data type, using the canonical vocabulary (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown). System fields all report as `dropdown`, so any other value silently excludes every one of them." - }, - { - "name": "q", - "type": "string", - "description": "Case-insensitive substring match on the field name. Applied to system fields in this layer and forwarded as a search for custom fields." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control — page size is fixed at 10 and cannot be raised. Must be a scalar; `p[]=1` is a 400." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name", - "field_name", - "created_at", - "id" - ], - "description": "Sort column. Also accepts a combined `-` form, e.g. `name-desc`, in which case sort_order is ignored. `name` is an alias for field_name. Any unrecognised column falls back to sorting by id." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "default": "desc", - "description": "Direction, used only when sort_by does not already carry one. Compared case-insensitively; anything that is not `desc` sorts ascending." - }, - { - "name": "selected_ids", - "type": "array", - "description": "Custom field ids to EXCLUDE from the custom-field half of the result. Has no effect on the system fields injected on page 1." - } - ], - "intent": "Browse or search every field configured for the workspace — built-in system fields and user-defined custom fields together — to find a field's numeric id, type and how many projects it reaches", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "WorkspaceFieldsListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Start here. This is the only listing that returns both system and custom fields, and the numeric `id` it gives is what every other custom-field call takes in its path.", - "The page size is fixed at 10 and there is no way to raise it: only `p` is read. Expect to page. `info` carries {count, page, page_size, total_pages}.", - "System fields are injected on page 1 ONLY, ahead of the custom fields, and they eat into that page's 10 slots. Page 2 onward is custom fields only. Row order is therefore not stable across pages unless you also send sort_by.", - "Every system field is reported as field_type `dropdown`, so a field_type filter with any other value silently drops all of them. Set field_source=custom when you only want user-defined fields, or field_source=system to get them all on one page.", - "An unsupported field_source is a 400 naming the three valid values; an unsupported entity_type is a 400 'Invalid Params'. entity_type=TestCaseStep returns an empty list (not an error) when the group lacks the step-custom-fields flag.", - "sort_by also accepts a combined form like `name-desc`; `name` is an alias for field_name. Sorting is applied in this layer AFTER the system fields are merged in, so it reorders only the rows on the page you asked for.", - "`q` matches on field name only, case-insensitively, and is applied to both system and custom fields.", - "Rows carry `dataset_count` and a derived `linked_to_all_projects`, but no option values and no project list — read the field, then its datasets, for those." - ], - "max_page_size": 10, - "returns": [ - "success", - "data", - "info", - "id", - "field_name", - "field_type", - "entity_type", - "system_name", - "place_holder_text", - "linked_projects_count", - "dataset_count", - "is_system_field", - "type", - "field_values", - "linked_to_all_projects", - "count", - "page", - "page_size", - "total_pages" - ] - }, - { - "name": "merge_tags", - "method": "POST", - "path": "/api/v1/projects/{project_id}/tags/merge", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Needed to match the route, and nothing else — it is never sent to the backing service. The merge is NOT scoped to this project; it applies across the whole workspace." - } - ], - "body": [ - { - "name": "source_id", - "type": "integer", - "required": true, - "description": "The tag that LOSES. It is absorbed into the target and ceases to exist; every entity carrying it is re-pointed at the target tag. Integer id — the project tag listings return names only, so resolve the id from the group tag search first." - }, - { - "name": "target_id", - "type": "integer", - "required": true, - "description": "The tag that SURVIVES. It keeps its own name and picks up all of the source tag's usages. Nothing about the target's name or metadata changes." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run", - "test_plan", - "shared_step" - ], - "description": "Which tag vocabulary the two ids belong to. NOT validated here — only checked for presence — so an unknown or mismatched value is forwarded and comes back as an opaque 400 from the backing service." - } - ], - "intent": "Merge one tag into another — everything tagged with the source tag ends up carrying the target tag instead, and the source tag stops existing", - "responses": { - "200": { - "description": "The backing service's merge result, passed through unchanged. No `success` flag and no envelope are added by this API; the exact keys are owned by the tag service.", - "schema": { - "type": "object" - } - }, - "400": { - "description": "Missing source_id/target_id/entity_type, or any failure reported by the backing service (including an unknown tag id), as `{error: \"\"}`." - }, - "401": { - "description": "Unauthorized." - }, - "403": { - "description": "Production only: caller is not an owner or admin — `{error: \"Only admins can manage tags\"}`." - }, - "500": { - "description": "Unexpected failure — `{error: \"Internal server error\"}`." - } - }, - "guidance": [ - "CURRENTLY IMPOSSIBLE TO COMPLETE, and not because of the field names here. The server accepts `source_id` exactly as declared, then forwards it to the backing service, which rejects the request asking for a differently-named plural field. So a correctly-formed call is refused downstream and no input from this surface succeeds. Do not work around it by renaming the field — the server's own check would then reject the call first. This needs a product fix, not a contract change.", - "Which tag loses: `source_id`. After the merge the source tag no longer exists as a tag, and every test case, run, plan or shared step that carried it carries the target's tag instead. `target_id` is unchanged apart from gaining those usages. Getting the two the wrong way round destroys the tag you meant to keep.", - "There is no unmerge. The response does not list what was re-tagged, so nothing here tells you which entities to fix afterwards — capture the source tag's usage list (search by that tag) BEFORE calling, or the mapping is unrecoverable.", - "Not project-scoped despite the path: `project_id` is used for routing only and never reaches the backing service, so the merge applies across the entire workspace and can touch projects you were not looking at.", - "Both ids are integers. Every project-level tag read in this profile returns names rather than ids — resolve the ids through the group-level tag search, and never send a tag name here.", - "`entity_type` must match the vocabulary both tags live in. It is not validated locally, so a mismatch is not caught until the backing service rejects it.", - "Tag management is restricted to account owners and admins. Check who is asking before promising a merge, and expect a refusal for anyone else.", - "Every downstream failure — unknown tag id, mismatched entity_type, permission problem — is flattened to 400 `{error: \"\"}`. There is no 404 and no `success` flag; the message text is the only signal.", - "On success the body is the backing service's raw merge result passed straight through, with no envelope added by this API." - ], - "disabled": true - }, - { - "name": "move_folder_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mv", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "new_base_folder_id", - "type": "integer", - "example": 123, - "description": "Destination parent folder. NOT required despite what the name suggests: omit it or send null to move the folder to the project ROOT. It must exist in the destination project." - }, - { - "name": "new_base_project_id", - "type": "integer", - "example": 456, - "description": "Destination project; omit for a same-project move. Send it as a JSON NUMBER — the cross-project test is a raw inequality against the integer project id, so the string \"456\" against project 456 is misread as a cross-project move and takes the slow async path with all its extra restrictions." - }, - { - "name": "auto_rename", - "type": "boolean", - "default": true, - "description": "Defaults to TRUE here, unlike create and rename. A name clash in the destination is silently renamed rather than rejected; send false explicitly if you would rather the move fail." - }, - { - "name": "prev", - "type": "integer", - "description": "Optional: after the move, place the folder immediately after this sibling id. Must be an integer; sending prev or next triggers a second reorder call whose failure is not reported in this response." - }, - { - "name": "next", - "type": "integer", - "description": "Optional: after the move, place the folder immediately before this sibling id. Must be an integer." - } - ], - "intent": "Move a folder — with everything under it — to a different parent, to the project root, or into another project; same-project moves complete immediately, cross-project ones run in the background", - "returns": [ - "data", - "success", - "async", - "unique_id", - "folder", - "source_path_folders", - "destination_path_folders", - "parent_id", - "links" - ], - "responses": { - "200": { - "description": "Folder moved successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder__MoveFolderByIntegerId" - }, - "source_path_folders": { - "type": "array", - "nullable": true, - "items": { - "$schema": "Folder" - } - }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "PathFolder__MoveFolderByIntegerId" - } - }, - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean" - }, - "unique_id": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Invalid input or move not allowed", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - } - } - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Two completely different responses come out of this one route. A same-project move is synchronous and returns {data:{folder, source_path_folders, destination_path_folders, success:true, async:false}}. A cross-project move returns only {data:{success:true, async:true, unique_id}} and has done nothing yet — branch on `data.async` before reading `data.folder`.", - "For the async path there is no HTTP status endpoint. `unique_id` is the X-Request-ID echoed back and progress goes over a websocket the API does not expose; confirm the move by re-reading the destination folder.", - "Omit new_base_folder_id to move to the project root. That is the only way to promote a nested folder to root level.", - "Moving a folder into one of its own descendants is NOT blocked. The only self-move check compares the folder id to the destination id, so a move into a child is accepted here (the copy endpoint does check for this — the move endpoint does not).", - "Cross-project moves carry two hard limits: the subtree may contain at most 50 folders counting all nesting, and the folder must contain no shared test cases at all. Either one fails the request with 422 {success:false, error:'...'} before anything moves.", - "The error shape varies by stage: local validation failures are 422 {success:false, error}, upstream rejections are 400 {success:false, error, message} (both keys, same text), and anything unexpected is 500 {success:false, error:'An unexpected error occurred. Please try again.'}.", - "`user_action` appears nowhere in the codebase — it is accepted and ignored. Do not build anything on it.", - "This action has no RBAC entry, so it is not permission-gated the way rename and reorder are.", - "A move is reversible by moving the folder back, but the async cross-project path recreates folders rather than relocating them, so ids on the far side are new." - ] - }, - { - "name": "rename_folder_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rename", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/folder/name", - "description": "New folder name. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves. Not strictly required: a notes-only edit is accepted, but the `folder` object itself must be present and non-empty." - }, - { - "name": "notes", - "type": "string", - "json_path": "/folder/notes", - "description": "New description, HTML allowed; unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. The key is `notes` — `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Replaces the folder's attachment set with these blob ids. Sending the key at all switches the handler into attachment mode and re-reads the folder's relations; an invalid set returns 422 with the bare string \"attachment is invalid\"." - } - ], - "intent": "Rename a folder or rewrite its notes — despite the name this is the general folder-edit call, and it changes whichever of the two fields you send", - "returns": [ - "data", - "success", - "folder", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__RenameFolderByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `name` flat. The `folder` wrapper is added for you from the field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key. Sending no fields at all fails on the server for an empty wrapper.", - "Sibling-name uniqueness is always checked. A clash relays the entity store's own message and status verbatim rather than the generic masked text most folder endpoints return — read `message` for the real reason. auto_rename:\"true\" turns the rejection into a silent rename.", - "There is no undo. Folders keep no version history, so an overwritten name or notes value is gone and the only way back is to send the old value again — capture it with a read first if it matters.", - "The updated folder is at `data.folder`. The `cases_count` and `sub_folders_count` in that echo are copied from the read taken BEFORE the update, not re-derived — do not treat them as fresh counts.", - "This endpoint does NOT return a request_trace_id; the only keys are `data.folder` and `data.success`.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500.", - "Requires the folder-edit permission." - ] - }, - { - "name": "reorder_folders", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folders/reorder", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "current", - "type": "array", - "required": true, - "example": [ - 21 - ], - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "description": "The folder ids being repositioned. Must be a JSON array of NUMBERS — the check is a strict integer type test, so [\"21\"] or a form-encoded current[]=21 fails with 422 {success:false, message:'test_case_folders must be an array of integers.'}." - }, - { - "name": "prev", - "type": "integer", - "example": 101, - "description": "Id of the sibling the moved folders should land AFTER. Integer or null only — a string is 422. Null means 'place at the top', but see next: prev and next cannot both be omitted." - }, - { - "name": "next", - "type": "integer", - "example": 103, - "description": "Id of the sibling the moved folders should land BEFORE. Integer or null only. At least one of prev/next must be a real id." - } - ], - "intent": "Change the display order of sibling folders by dropping one or more of them between two named neighbours — ordering only, it never changes nesting", - "returns": [ - "data", - "success", - "message" - ], - "responses": { - "200": { - "description": "Folders reordered successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Folders reordered successfully" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`current` alone is not enough, whatever the required flags say. Sending neither prev nor next fails with 422 {success:false, message:'Both top_folder_id and bottom_folder_id cannot be nil.'} — you must anchor the move against at least one neighbour.", - "Every id is type-checked as a Ruby Integer before anything is sent upstream. Numeric strings are rejected, so send a JSON body with real numbers rather than form-encoded parameters.", - "All the validation failures come back as 422, not 400, even though they are plainly bad-request conditions — the status the code intended is not the status that is sent.", - "The response is only {data:{success:true, message:'Folders reordered successfully'}}. It does not echo the new order and it does NOT contain a request_trace_id. Re-read the folder listing to confirm the result.", - "Nothing about the ids is validated locally — existence, project ownership and sibling-ness are all the upstream service's problem, so a wrong-but-well-typed id can return success-shaped output without moving what you meant.", - "This reorders siblings within their existing parent. To change a folder's parent, use the move call instead.", - "Requires the folder-edit permission." - ] - }, - { - "name": "reorder_test_cases_by_folder", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "top_test_case", - "type": "string", - "description": "Integer id of the case the moved block should sit BELOW. Omit it to move the block to the top of the page.", - "json_path": "/re_order/top_test_case" - }, - { - "name": "bottom_test_case", - "type": "string", - "description": "Integer id of the case the moved block should sit ABOVE. Omit it to move the block to the bottom of the page.", - "json_path": "/re_order/bottom_test_case" - }, - { - "name": "page", - "type": "integer", - "description": "Page of the folder listing the move happens on. Ordering is resolved against that page's window, so a neighbour that is not on this page cannot anchor the move.", - "json_path": "/re_order/page" - }, - { - "name": "test_cases", - "type": "array", - "required": true, - "description": "Integer ids of the cases to move, in the order they should end up in. These are moved as one block between `top_test_case` and `bottom_test_case`.", - "json_path": "/re_order/test_cases" - } - ], - "intent": "Change the manual order of test cases within a folder by dropping a block of them between two neighbours.", - "returns": [ - "success", - "test_cases", - "info" - ], - "paginated": true, - "responses": { - "200": { - "description": "Test cases reordered successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__ReorderTestCasesByFolder" - } - }, - "info": { - "type": "object", - "description": "Additional metadata about the reorder operation." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The wrapper applied for you here is `re_order`, NOT `test_case` like every other test-case write on this surface — that difference is handled by the json_paths, so you do not need to act on it, but it is why a hand-built `test_case` wrapper fails here in a different way than elsewhere.", - "Position is expressed by NEIGHBOURS, not by index: `top_test_case` and `bottom_test_case` are the case ids the moved block lands between. Omit `top_test_case` for the top of the page, `bottom_test_case` for the bottom.", - "Everything is integer case ids — the anchors and the moved list alike. TC-NNN identifiers are not accepted here.", - "Ordering is resolved within the page named by `page`, so both anchors must be on that page of the folder listing for the move to mean what you intend.", - "A rejection comes back as 400 `{errors: \"\"}` — not the usual `{success:false}` — and covers 'ids not in this folder' as well as malformed input.", - "The 200 body is the folder's listing in its NEW order, so you can confirm the result without a second read. Ordering is not versioned: the only way back is another reorder." - ] - }, - { - "name": "replace_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' — it is not skipped." - }, - { - "name": "run_state", - "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it.", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "json_path": "/test_run/run_state" - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings; the list you send replaces the run's tags." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read. Duplicates are collapsed." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^$|^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^$|^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "The run's COMPLETE intended case list, not a list to add. The partial-update call on this same path REPLACES membership rather than unioning it, and this full-replace call is no gentler: assume a case missing from the list is removed with its results and assignee. Send the run's existing case identifiers plus the new ones — or, to add cases, use bulk_update_test_run_test_cases with add_test_cases, which is truly additive." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders" - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list — cases you list elsewhere are still included, with a null-configuration row." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "json_path": "/test_run/filter_scope", - "description": "Accepted by the parameter filter but NOT honoured on either update path — the selection is always built in global mode here. Only create reads it." - } - ], - "intent": "Rewrite a test run from a complete payload — every metadata field you leave out is reset, so the run ends up as whatever this one body says. Use the partial update instead when you only mean to change some fields.", - "guidance": [ - "Two different operations share this path. This POST rebuilds the whole run payload; the PATCH applies only the keys you send. If you are renaming a run, closing it, or linking a plan, pick the PATCH.", - "Omission is destructive here: no run_state resets the run to new_run, no test_plan_id/sub_test_plan_id unlinks the plan it was under, and no issues clears its linked defects. Read the run first and re-send its current values for anything you do not mean to change.", - "An invalid run_state is not rejected on this call — it is silently coerced to new_run. The partial-update call on the same run returns 400 instead, so use that one if you want the error.", - "shared_test_cases and filter_scope are accepted by the parameter filter and then ignored on this path. Cross-project cases only land through the PATCH.", - "Case membership is REPLACED, not unioned: whatever test_cases / folder_ids / include_all resolve to should be treated as the run's whole case list. Always send the existing cases plus the new ones; a case left out loses its results.", - "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "400 'invalid data' also covers an assignee email that cannot be resolved in this group — the run is left untouched in that case.", - "There is no undo: test runs keep no version history, so a field this call clears cannot be restored.", - "The updated run comes back at the top level as `testrun` — one word, no underscore, and no `data` envelope.", - "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", - "`urls.self` is a link to the run in the web UI, not an API path — it is built from the frontend base URL, so calling it returns HTML.", - "`assignee` comes back as a plain email string — or null when the owner cannot be resolved — and `configurations` as a list of configuration ids. Neither is a list of objects.", - "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", - "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", - "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", - "The run's case list is not on this reply — `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", - "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write. Re-read the run when the exact stored values matter.", - "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunWriteV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "resolve_duplicate", - "method": "POST", - "path": "/api/v1/projects/{project_id}/ai-duplicates", - "mode": "write", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "description": "The duplicate to resolve, from the `id` of a listing row. Passed in the BODY, not the path — this endpoint posts to the collection path, unlike discard which puts to the item path." - }, - { - "name": "merge_action", - "type": "string", - "required": true, - "values": [ - "merge", - "archive" - ], - "description": "Exactly `merge` or `archive`. There is no default and no fallback: any other value, including a missing one, is rejected with 400 {error:'Invalid action'} and nothing happens." - }, - { - "name": "source_testcase_id", - "type": "integer", - "description": "Required when merge_action is `merge`. The integer id of the case that will be archived and folded into the other. Must be one of the pair's two test cases." - }, - { - "name": "target_testcase_id", - "type": "integer", - "description": "Required when merge_action is `merge`. The integer id of the case that survives and receives the merge comment. Must be one of the pair's two test cases." - }, - { - "name": "archive_testcase_id", - "type": "integer", - "description": "Required when merge_action is `archive`, and ignored otherwise. The integer id of the single case to archive; must be one of the pair's two test cases. Without it the archive path fails with 'Test case not found in duplicates'." - } - ], - "intent": "Act on a duplicate suggestion — merge one test case into the other (archiving the source and commenting on the survivor), or archive one of the pair outright", - "responses": { - "200": { - "description": "Duplicate resolved. Body is {success:true} only — nothing about the affected test cases is returned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "400": { - "description": "{error:'Invalid action'} when merge_action is neither `merge` nor `archive`, or {errors:'Invalid Input'} when the backing store rejects the merge/archive." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "The caller lacks the test-case delete permission (tc_delete)." - }, - "404": { - "description": "{errors:'Duplicate not found.'} — no such pair in this project, or it is no longer in `suggested` status." - }, - "409": { - "description": "{errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} — a dedupe data migration is in progress. Nothing was changed; retry later." - }, - "422": { - "description": "A test case id that is not part of this pair. Body is {errors:'Test case not found in duplicates'} on the merge path and {success:false, message:'Test case not found in duplicates'} on the archive path." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Two different operations behind one field. `merge` archives source_testcase_id, tags it 'Archived as Duplicate', posts a comment on target_testcase_id recording the merge, stores a snapshot of the source, and marks the pair merged. `archive` archives archive_testcase_id only — no comment, no snapshot — and still marks the pair merged.", - "Ids are not interchangeable: the SOURCE is the case that disappears. Getting source and target the wrong way round archives the case you meant to keep, and the only route back is unarchiving it manually.", - "Archived is not deleted — a merged-away or archived case is recoverable from the project's archived cases, findable by the 'Archived as Duplicate' tag on the merge path. Nothing here is a hard delete, but the merge comment, the snapshot and the pair's status change are not undone by unarchiving.", - "Resolving requires the test-case DELETE permission (tc_delete), not write. A caller who can edit test cases may still be refused here with 403.", - "The pair must still be `suggested`. An already-resolved pair is 404 {errors:'Duplicate not found.'}, which usually means someone else handled it, not that the id is wrong.", - "Undeclared until now, and retryable: while a dedupe data migration is in progress the call is refused with 409 {errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. It is deliberately not a 200 — nothing was merged. Retry later.", - "A test case id that is not part of the pair is rejected AFTER the pair lookup and reports 422, not 404, with two different bodies depending on the action: merge gives {errors:'Test case not found in duplicates'} and archive gives {success:false, message:'Test case not found in duplicates'}.", - "Success is just {success:true} — the affected test cases are not echoed back. Re-read the surviving case if you need its post-merge state, and note the pair leaves every duplicates listing at this point.", - "After a merge the source case's pre-merge contents are readable only through the merged pair's source-snapshot call. Keep the duplicate id if you might need it; it is no longer discoverable from any listing." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "restore_history", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}/restore", - "mode": "write", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "history_id", - "type": "integer", - "required": true - } - ], - "intent": "Roll a test case back to an earlier revision — reapplies that snapshot's name, description, preconditions, steps, status, priority, owner, tags, defects, custom fields, attachments and reviewers to the live case", - "responses": { - "200": { - "description": "Restore applied. Body is only {success: true} — read the test case back to see the restored content.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type is not restorable ({success:false, message:'Not supported'} / 'Not supported for build')." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is changed.", - "`history_id` is the numeric revision id from the listing, not the V1/V2 position label.", - "It is a full OVERWRITE of the snapshotted fields, not a merge. A custom field that has a value today but was empty in the snapshot is CLEARED. Steps are rewritten from the snapshot, so steps added since are removed, and steps whose ids no longer exist come back with new ids. The attachment set is rewritten too, unlinking files added after that revision.", - "There is a way back, but only one: the restore is applied as an ordinary edit of the live case, so the pre-restore state remains in the revision trail and can itself be restored. There is no undo call.", - "Say what will be lost before calling. The caller cannot see the delta from this endpoint — read the diff (or the two revisions) first and tell the user which fields will change.", - "Only test cases are restorable on this route; the same shape against another entity type answers 422 'Not supported'.", - "Success is only {success: true}. Read the case back to confirm what the restore actually produced — in particular, custom fields whose definition no longer exists in the project are not recreated and stay absent." - ], - "returns": [ - "success" - ] - }, - { - "name": "search_duplicates", - "method": "POST", - "path": "/api/v1/projects/{project_id}/ai-duplicates/search-v2", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "Test-case filter object, the same grammar the test-case search takes; a pair matches when its test cases match. Sharp edges here: `owner`/`reviewers` take TM user ids and are translated server-side (an id resolving to nobody filters to nothing, never to everything); date filters must use `date_range` or the relative `created_at`/`updated_at` tokens, and `all_time` is dropped rather than applied.", - "fields": [ - { - "name": "query", - "type": "string", - "description": "Free-text match on test case title / identifier." - }, - { - "name": "folder_ids", - "type": "string", - "description": "Folder ids, comma-separated string or array." - }, - { - "name": "owner", - "type": "string", - "description": "TM user ids, comma-separated. Translated to backing-store ids before the search." - }, - { - "name": "reviewers", - "type": "string", - "description": "TM user ids, comma-separated. Translated like owner." - }, - { - "name": "tags", - "type": "string", - "description": "Tag names, comma-separated string or array." - }, - { - "name": "priority", - "type": "string", - "description": "Priority option ids, not display labels." - }, - { - "name": "status", - "type": "string", - "description": "Status option ids, not display labels." - }, - { - "name": "case_type", - "type": "string", - "description": "Case-type option ids." - }, - { - "name": "automation_state", - "type": "string", - "description": "Option ids, or the literals `automated` / `manual` which the server resolves." - }, - { - "name": "issue_ids", - "type": "string", - "description": "Linked issue ids, comma-separated." - }, - { - "name": "custom_fields", - "type": "object", - "description": "Keyed by custom-field id, e.g. {\"179720\": [\"Yes\"]}." - }, - { - "name": "match", - "type": "object", - "description": "Per-field operator map (all / any / none). Values stay beside it under their own key — a value placed inside `match` applies no filter at all." - }, - { - "name": "empty", - "type": "object", - "description": "Is-empty filter: {system_fields:[...], custom_fields:[...]}." - }, - { - "name": "date_range", - "type": "string", - "description": "Absolute created-at range; takes precedence over created_at when both are sent." - }, - { - "name": "created_at", - "type": "string", - "description": "Relative token such as `7_day`. `all_time` is dropped, applying no bound." - }, - { - "name": "updated_at", - "type": "string", - "description": "Relative token, same form as created_at. `all_time` is dropped." - } - ] - }, - { - "name": "match_scope", - "type": "string", - "values": [ - "any", - "all" - ], - "default": "any", - "description": "Whether ONE test case of a pair matching `q` is enough (`any`) or BOTH must match (`all`). Defaults to `any` — with `all` a filter that matches only one side returns nothing." - }, - { - "name": "duplicates", - "type": "string", - "values": [ - "exact", - "semantic" - ], - "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim and not validated here; an unrecognised value narrows to nothing." - }, - { - "name": "owner", - "type": "string", - "description": "The 'My duplicates' tab. ANY non-empty value scopes results to the CALLER's own test cases; the value is discarded, so it cannot select another user. This is separate from the `owner` filter inside `q`, which does take user ids." - }, - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `p` is an accepted alias and only `p` is validated (a bad `p` is 400 {success:false, error:'Invalid Params'}); `page` wins when both are sent and a non-numeric `page` silently becomes 0." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Send the literal string 'true' (with a non-empty `q`) to get a `filter_details` object back that resolves the filter's ids to display labels. Any other value, or a missing `q`, omits the key entirely." - } - ], - "intent": "Search the AI-suggested duplicate pairs with the full test-case filter grammar — narrow the review queue by folder, owner, tags, priority, status, custom fields or free text", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "Matching suggested duplicates, plus `filter_details` when it was requested. The same shape with an empty `duplicates` is returned when the dedupe feature flag is off.", - "schema": { - "$schema": "V1DuplicatesListResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric `p`." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "description": "{success:false, message:...} — the backing store refused the filter combination (usually because it resolves to too many test cases). This is NOT 'no results'." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A POST, but a read — it changes nothing. It exists because the filter object is too long for a query string.", - "Same feature gate as the plain listing: with the dedupe flag off this returns 200 {success:true, duplicates:[], info:{count:0,total_pages:0}} and never reaches the filter. An empty result is not evidence that nothing matched.", - "'Nothing matched' and 'the filter could not be evaluated' are different answers. A filter the backing store refuses (typically one resolving to too many test case ids) comes back 422 {success:false, message:}, not an empty list — do not treat that as zero results.", - "Page size is fixed at 10, ordering is by confidence (ai_score descending), and `info.count` can exceed the rows on the page because pairs with an unresolvable test case are dropped after counting.", - "Send only the filter keys you mean. `q` is forwarded to the search engine, which discards keys that would take it off the duplicate-pair scope, so an unexpected key fails silently rather than erroring.", - "Carry each row's `id` forward as the duplicate id for the read, resolve and discard calls.", - "Response adds two keys the plain listing has: `filter_details` (only when requested) and, as there, `last_scan_time` for the project's last dedupe run." - ], - "returns": [ - "success", - "duplicates", - "info" - ], - "max_page_size": 10 - }, - { - "name": "search_group_tags", - "method": "GET", - "path": "/api/v1/group/{entity_type}/tags", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run" - ], - "example": "test_case", - "description": "Which tag vocabulary to search. Hyphens are normalised to underscores, so `test-case` and `test_case` both work. Not validated against a list here — an unrecognised value is forwarded to the backing service rather than rejected." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search, trimmed. Omit it to get the first page of the whole vocabulary." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - } - ], - "intent": "Search the workspace-wide tag vocabulary for one entity type and get each tag's id — the only tag read here that returns ids, so it is where you resolve the source_id and target_id a tag merge needs", - "returns": [ - "tags", - "tag_objects", - "info", - "name" - ], - "paginated": true, - "responses": { - "200": { - "$schema": "TagsSearchResponse__SearchGroupTags" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Group-scoped: no project is involved and none can be supplied, so tags from every project in the workspace come back together. A name you find here may not be in use in the project you care about.", - "This read returns the tag `id` alongside the name — that is what makes it the resolution step for a tag merge, which takes integer ids. The project-level tag reads return names only.", - "Page size is fixed at 30 and there is no per_page parameter; page with `p`.", - "`created_at` and `usage_count`, which the project-scoped tag search returns, are not guaranteed on this group-scoped path — treat `id` and `name` as the fields you can rely on.", - "Not permission-gated — the search-filter controller has no RBAC entry, and this route deliberately skips project resolution.", - "Neither `info.count` nor `info.next` can be trusted to size a fetch. Confirmed live: a listing that returns 30 rows on page 1 and 30 more distinct rows on page 2 reports a count of 31 on both, and `next` is correct on page 1 and then null on page 2 with 60 rows already returned. Page until a page comes back empty rather than until the count is satisfied or `next` is null, and never report the count to a user as a total." - ], - "max_page_size": 30 - }, - { - "name": "search_project_entities", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity}/search", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "test-runs", - "trtc", - "test-plans" - ], - "example": "test-cases", - "description": "What to search. 'trtc' means the test cases inside one test run and needs test_run_id. 'test-plans' matches the route but has no handler branch and always returns an empty list. 'reports' is NOT a valid value — it fails the route constraint with a 404." - } - ], - "query": [ - { - "name": "q[tags][]", - "type": "array", - "example": [ - "2FA", - "delivery" - ], - "description": "Tag filter. Send it as a REPEATED BRACKETED parameter - `q[tags][]=2FA&q[tags][]=delivery` - one entry per tag name. Multiple tags are OR'd: a case matching any of them is returned. DO NOT use `q[test_case_tags]`: it is accepted, returns 200, and is SILENTLY IGNORED - the full unfiltered set comes back looking like a successful search. A bare `q[tags]` carrying a JSON string matches nothing (0 rows), and `q[tags]` with some other shapes 500s." - }, - { - "name": "q[query]", - "type": "string", - "example": "login", - "description": "Free-text match on title/identifier. Special characters are stripped before the match." - }, - { - "name": "q[folder_ids]", - "type": "string", - "example": "3306878,3669741", - "description": "Comma-separated folder ids to restrict to (test-cases). Add include_subfolders_tc=true to descend into sub-folders." - }, - { - "name": "q[folder_id]", - "type": "integer", - "example": 29529465, - "description": "Single folder id; read on the trtc path only." - }, - { - "name": "q[status]", - "type": "string", - "example": "9319,9321", - "description": "Comma-separated numeric status IDs, not display names — resolve them first. Renamed to status_id internally." - }, - { - "name": "q[priority]", - "type": "string", - "example": "550376,9261", - "description": "Comma-separated numeric priority IDs, not display names. Renamed to priority_id internally." - }, - { - "name": "q[case_type]", - "type": "string", - "example": "9379,9375", - "description": "Comma-separated numeric case-type IDs, not display names. Renamed to category_id internally." - }, - { - "name": "q[automation_state]", - "type": "string", - "example": "automated", - "description": "Either the literal word 'automated' / 'manual' (resolved to ids for this project) or comma-separated numeric ids. The only filter here that accepts words." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (test-cases and test-runs). Pass use_bstack_id=1 if you are supplying BrowserStack ids instead." - }, - { - "name": "q[assignee]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids; the trtc equivalent of owner (mapped to owner internally)." - }, - { - "name": "q[reviewers]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids; translated to BrowserStack ids server-side because reviews are stored against the BrowserStack id." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative window as _[_gte], or 'all_time' for no bound. Converted to an absolute range server-side." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, on last-updated date." - }, - { - "name": "q[review_status]", - "type": "string", - "example": "in_review", - "description": "Comma-separated review-status tokens (approved, rejected, in_review, pending, changes_requested), test-cases only." - }, - { - "name": "q[configuration_id]", - "type": "string", - "example": "101,102", - "description": "Comma-separated configuration ids; trtc path." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page — this is the size parameter, NOT page_size or count. Above 300 on test-cases the call silently switches to id-only compact rows; on test-runs it is overridden to 30 regardless." - }, - { - "name": "required[fields]", - "type": "string", - "example": "count", - "description": "Comma-separated list of extra columns to hydrate. The single value 'count' switches the endpoint into count-only mode: the response becomes {success, count} with no rows." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-asc", - "description": "Column and direction as -. For test-cases the column must also appear in required[fields] when that is used, or the request fails." - }, - { - "name": "use_bstack_id", - "type": "string", - "values": [ - "0", - "1" - ], - "description": "Set to '1' when the user ids in q[owner]/q[assignee]/q[reviewers]/q[last_executed_by] are BrowserStack ids; they are then translated to TM ids. Without it BrowserStack ids simply match nothing." - }, - { - "name": "test_run_id", - "type": "integer", - "example": 45123, - "description": "Required when entity=trtc — the run whose test cases you are searching. Omitting it yields an empty page, not an error." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "Descend into sub-folders of q[folder_ids] (test-cases). Only the exact string 'true' enables it." - }, - { - "name": "include_test_plan", - "type": "boolean", - "default": false, - "description": "Decorate each run with its linked test plans (test-runs only). Only the exact string 'true' enables it." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Also return the filters you sent, resolved into display objects, under `filter_details`. Ignored when q is empty." - }, - { - "name": "compact", - "type": "boolean", - "default": false, - "description": "Force compact mode for test-cases: id-only stub rows grouped by folder, empty filter_details. This is the same mode per_page>300 triggers implicitly." - } - ], - "intent": "Search and filter a project's test cases, test runs, or the test cases inside one run — the general-purpose finder: free text plus status / priority / type / owner / folder / tag / date filters, with an id-only count mode", - "shape": "discovered", - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "description": "Top-level result; the collection key depends on entity (test_cases for test-cases and trtc, test_runs for test-runs) and there is no `data` envelope. Compact mode and count-only mode return reduced shapes — see guidance.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "description": "Present for entity=test-cases and entity=trtc. In compact mode the rows degrade to {id, test_case_folder_id, project_id} stubs.", - "items": { - "$schema": "V1TestCaseSearchRow__SearchProjectEntities" - } - }, - "test_runs": { - "type": "array", - "description": "Present for entity=test-runs.", - "items": { - "$schema": "V1TestRunListRow" - } - }, - "folders": { - "type": "object", - "description": "KEYED BY FOLDER ID — {\"764093\": [{id, name, parent_id, ui_position}, …]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." - }, - "count": { - "type": "integer", - "description": "Total matches. Always present for entity=test-runs; the sole payload (with success) in count-only mode." - }, - "filter_details": { - "type": "object", - "description": "The filters you sent, resolved to display objects. Empty {} unless filter_details=true and q was non-empty, and always empty in compact mode." - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "TAG FILTERING USES `q[tags][]`, REPEATED ONCE PER TAG. The previously documented `q[test_case_tags]` does not filter at all - it returns 200 and the complete unfiltered list, which reads as 'no tag filtering available'.", - "Four entity values route here: test-cases, test-runs, trtc (the test cases inside one test run) and test-plans. Anything else — 'reports' included — does not match the route constraint and is a 404 before the API is reached.", - "entity=test-plans is accepted by the route and by the controller but has no implementation behind it: it always renders {test_plans: [], info: null} with a 200. It is a dead branch, not an empty result — use the test-plan listing capabilities instead.", - "entity=trtc additionally requires a `test_run_id` query param. Without it the run filter is built from 0 and you get an empty page rather than an error.", - "The page-size parameter is `per_page`, not `page_size` or `count`; `page_size` is ignored and you silently get the 30-row default.", - "Ceiling behaviour differs per entity and this is the sharpest edge here. For test-cases, asking for more than 300 rows (or passing compact=true) flips the call into compact mode: the rows degrade to id-only stubs {id, test_case_folder_id, project_id}, filter_details comes back empty, and the page size jumps to 20000. For test-runs, per_page is overridden to 30 no matter what you ask for. Only trtc forwards per_page unchanged.", - "Count-only mode: send required[fields]=count (exactly that one field) and the response collapses to {success, count} with no rows — far cheaper than paging for a total.", - "Filter keys are renamed on the way through: q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id. They take numeric IDs, not display labels — resolve them with the system/custom field value capabilities first. q[automation_state] is the exception: it accepts the literal words 'automated' or 'manual' as well as ids.", - "Any key under q that the backing allow-list does not recognise is dropped silently. A misspelled filter therefore widens the result set to everything instead of erroring — always sanity-check the count against an unfiltered call.", - "q[owner] / q[assignee] / q[reviewers] take TM user ids by default. Pass use_bstack_id=1 if your ids are BrowserStack ids and they will be translated first; without it, BrowserStack ids match nothing and the response is simply empty.", - "Response shape depends on the entity: test-cases returns {success, test_cases, folders, info, filter_details}; trtc returns {success, info, test_cases, filter_details}; test-runs returns {success, test_runs, info, count} plus filter_details only when filter_details=true and q is non-empty. There is no `data` envelope on any of them.", - "For test-cases, `sort_by` must name a column that is also present in required[fields] when required[fields] is used, or the call fails with 'Sort by column X is not present in required fields'.", - "Binned (recycle-bin) and archived test cases are excluded from this search; they have their own listing capabilities." - ], - "returns": [ - "success", - "test_cases", - "test_runs", - "folders", - "info", - "filter_details", - "count", - "page", - "page_size", - "prev", - "next" - ] - }, - { - "name": "search_project_entities_by_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/{entity}/search-v2", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "trtc", - "test-runs", - "test-plans" - ] - } - ], - "body": [ - { - "name": "query", - "type": "string", - "example": "tc", - "description": "Search query string", - "json_path": "/q/query" - }, - { - "name": "owner", - "type": "string", - "example": "14", - "json_path": "/q/owner" - }, - { - "name": "reviewers", - "type": "string", - "example": "14,15", - "description": "Filter by reviewer, same form as `owner` — comma-separated TM user ids as a string, `$none` for none.", - "json_path": "/q/reviewers" - }, - { - "name": "last_executed_by", - "type": "string", - "example": "14", - "description": "Filter by who last executed the case, same form as `owner`. A non-string value raises server-side rather than returning empty.", - "json_path": "/q/last_executed_by" - }, - { - "name": "folder_id", - "type": "string", - "example": 29529465, - "description": "Filter by folder ID (single value or array)", - "json_path": "/q/folder_id" - }, - { - "name": "folder_ids", - "type": "string", - "example": [ - 125382, - 125385, - 125386 - ], - "description": "Filter by multiple folder IDs (comma-separated string or array)", - "json_path": "/q/folder_ids" - }, - { - "name": "priority", - "type": "string", - "example": [ - 1268, - 1270, - 1269, - 1267 - ], - "description": "Filter by priority IDs (comma-separated string or array)", - "json_path": "/q/priority" - }, - { - "name": "status", - "type": "string", - "description": "Filter by status IDs (comma-separated string or array)", - "json_path": "/q/status" - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Filter by issue type (e.g., jira, github)", - "json_path": "/q/issue_type" - }, - { - "name": "issue_ids", - "type": "string", - "description": "Filter by issue IDs (comma-separated string or array)", - "json_path": "/q/issue_ids" - }, - { - "name": "tags", - "type": "string", - "description": "Filter by tags (comma-separated string or array)", - "json_path": "/q/tags" - }, - { - "name": "case_type", - "type": "string", - "description": "Filter by case-type option ids (comma-separated string or array).", - "json_path": "/q/case_type" - }, - { - "name": "automation_state", - "type": "string", - "description": "Option ids, or the literals `automated` / `manual` which the server resolves.", - "json_path": "/q/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Filter by automation status.", - "json_path": "/q/automation_status" - }, - { - "name": "review_status", - "type": "string", - "description": "Filter by review status (only meaningful where review/approval is configured).", - "json_path": "/q/review_status" - }, - { - "name": "created_at", - "type": "string", - "example": "7_day", - "description": "Relative token `_` with a SINGULAR unit (`day`, `hour`, `week`, `month`,\n`year`) — e.g. `7_day`, `24_hour`; `_gte` inverts it (`7_day_gte` = older than).\nAlso accepts `all_time` or an absolute `\"YYYY-MM-DD YYYY-MM-DD\"` range. A PLURAL\nunit such as `7_days` is an unhandled server error (500), not a 400.", - "json_path": "/q/created_at" - }, - { - "name": "updated_at", - "type": "string", - "example": "1_month", - "description": "Same form as `created_at`.", - "json_path": "/q/updated_at" - }, - { - "name": "date_range", - "type": "string", - "description": "Absolute created-at range as epoch milliseconds: `\",\"`.", - "json_path": "/q/date_range" - }, - { - "name": "ids", - "type": "string", - "description": "Fetch specific cases by integer id (comma-separated string or array).", - "json_path": "/q/ids" - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Restrict to archived (or non-archived) cases.", - "json_path": "/q/is_archived" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "179720": [ - "Yes" - ] - }, - "json_path": "/q/custom_fields" - }, - { - "name": "match", - "type": "object", - "example": { - "tags": "all", - "custom_fields": { - "179720": "none" - } - }, - "description": "Per-field **operator** map — how to combine a field's values, NEVER the values\nthemselves. The value stays beside `match` under its own key; `match` only says\nhow to read it. A value placed inside `match` sets a meaningless operator and\napplies NO filter, which is the silent no-op described on `q`.\n\nAny filterable field may appear here, plus `custom_fields` keyed by field id.\nModes: `all`, `any`, ", - "json_path": "/q/match" - }, - { - "name": "empty", - "type": "object", - "example": { - "system_fields": [ - "priority" - ], - "custom_fields": [ - "179720" - ] - }, - "description": "Is-empty / is-unset filter. `system_fields` accepts the payload names `status`,\n`priority`, `case_type`, `automation_state`; `custom_fields` takes field ids.", - "fields": [ - { - "name": "system_fields", - "type": "array" - }, - { - "name": "custom_fields", - "type": "array" - } - ], - "json_path": "/q/empty" - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page number", - "minimum": 1, - "default": 1 - }, - { - "name": "per_page", - "type": "integer", - "example": 5, - "description": "Results per page. Rows are large and vary with the data, so probe rather than assume: send p=1 with a moderate per_page and take the rows that actually come back as your ceiling (for entity=test-cases a row is ~40 keys / 5-9 KB, so start around 5). Then keep per_page FIXED for the whole walk — the offset is (p-1)*per_page, so changing it part-way shifts the window and SKIPS rows (page 1 at 5 then ", - "minimum": 1, - "maximum": 100, - "default": 25 - }, - { - "name": "fields", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated JOINED columns to hydrate (owner, tags, issues, reviewers, priority, status, case_type, automation_state, defects; unlisted names pass through). It selects joins ONLY — it can never remove the base fields (name, description, preconditions, steps, test_case_steps, custom_fields, attachments), so naming fewer changes how many rows fit per page, NOT the order of magnitude, and it cann", - "json_path": "/required/fields" - }, - { - "name": "custom_fields", - "type": "string", - "example": "121,119", - "json_path": "/required/custom_fields" - }, - { - "name": "result_custom_fields", - "type": "string", - "description": "Same idea for test-RESULT custom fields, on the result-bearing listings.", - "json_path": "/required/result_custom_fields" - } - ], - "intent": "Search for entities within a project (v2 - POST with body)", - "guidance": [ - "Tag and status filtering WORK here. Send `tags` (and `status`) as ordinary body fields - their json_path maps them under `q`, which is the shape the server wants.", - "The general way to find cases by attribute. Ask for a large page when collecting ids to feed into another call, so the follow-up gets the whole set rather than the first page." - ], - "shape": "discovered", - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "description": "Search results. Flat body, no `data` envelope: {success, test_cases, folders, info, filter_details}. The rows are the same shared test-case record the v1 search returns, including `attachments` and the per-value `valueDetails` bindings.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "description": "Matching rows for entity=test-cases.", - "items": { - "$schema": "V1TestCaseSearchRow__SearchProjectEntitiesByFilter" - } - }, - "folders": { - "type": "object", - "description": "KEYED BY FOLDER ID — {\"764093\": [{id, name, parent_id, ui_position}, …]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." - }, - "info": { - "$schema": "PageInfo" - }, - "filter_details": { - "type": "object", - "description": "The filters you sent, resolved to display objects. `{}` unless filter_details was requested and q was non-empty." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "returns": [ - "success", - "test_cases", - "folders", - "info", - "filter_details" - ] - }, - { - "name": "search_project_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users/search", - "mode": "read", - "entity": "user", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Matches FULL NAME only, case-insensitive contains. Email is not searched — a query that looks like an address returns nothing. Omit it to list everyone in the workspace." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Paging overflows to the last page rather than returning an empty one." - }, - { - "name": "sort", - "type": "string", - "values": [ - "created_at", - "id", - "identifier" - ], - "default": "created_at", - "description": "Sort column. The allowlist is shared across entities — `identifier` is not a user column and has no effect. An unlisted value is ignored rather than rejected, leaving the default order." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "default": "desc", - "description": "Lower-case only; an unlisted value silently falls back to the default ordering." - } - ], - "intent": "Find people by name and read their role, permissions and product access — the search behind owner and assignee pickers; note it searches the whole workspace, not just this project's members", - "returns": [ - "users", - "info", - "id", - "browserstack_user_id", - "full_name", - "role", - "productRole", - "permissions", - "has_access", - "onboarded", - "group_id", - "reports_and_notification_enabled", - "customisable_dashboard_accessible", - "webhook_accessible", - "is_build_listing_enabled", - "is_delighted_visible", - "is_product_intro_visible", - "is_onboarding_project_header_visible", - "is_lcnc_explore_dismissed", - "is_new_project_settings_visible", - "is_jira_app_project_panel_visible" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectUsersSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This is NOT project-scoped, despite the path and the name. The query filters on the caller's group only — `project_id` resolves the route and is never used in the lookup — so every user in the workspace comes back, including people with no access to this project.", - "`has_access` on each row is the user's product-access flag, not project membership. Do not read it as 'is on this project'; there is no project-membership field in this response at all.", - "`q` matches full_name only, case-insensitive contains. Searching by email returns an empty list, which looks identical to 'no such person'.", - "`id` is the Test Management user id — the one owner and assignee fields take. `browserstack_user_id` is the BrowserStack account id for the same person and is a different number.", - "Page size is fixed at 30: the `page_size` parameter that was published here is not read, and asking for 100 still returns 30.", - "Paging overflows to the LAST page — a `p` beyond the end returns the final page again rather than an empty list. Stop on `info.next == null`, never on 'the page came back empty', or you will loop forever on the last page.", - "Default order is creation date descending, so the newest members appear first; it is not alphabetical.", - "`permissions` is computed per request from the user's product role and this workspace's feature flags, so the same person can show a different permission list in a different workspace, and the list is not a stored attribute.", - "Not permission-gated — the search-filter controller has no RBAC entry." - ] - }, - { - "name": "search_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/tags/search", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search, trimmed; an all-whitespace value is treated as absent and you get the backing service's default first page. This is the ONLY query parameter the handler reads." - } - ], - "intent": "Check whether a test-case tag name exists in a project, or complete a partial one, before tagging a case or building a tag filter", - "returns": [ - "tags", - "tag_objects", - "info", - "name", - "created_at", - "usage_count" - ], - "max_page_size": 100, - "paginated": false, - "responses": { - "200": { - "$response": "TagsSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This search cannot be paged. `p` and `page_size` are accepted by the URL and never read — no page parameter reaches the backing service, so every call returns its first page and a request for page 2 silently returns the same rows. If a project has more tags than one page, narrow with `q` rather than paging.", - "The response still carries an `info` block; it describes the one page you were given, not a page you can navigate away from.", - "`tags` is an array of names, `tag_objects` the same tags with `created_at` and `usage_count`. No tag ids here — use the group-level tag search when you need an id.", - "If the project cannot be resolved the handler returns 200 with `{tags: []}`. An empty vocabulary and an unknown/inaccessible project are indistinguishable, so do not read an empty result as 'this project has no tags'.", - "Test-case tags only. Run tags are a separate vocabulary on a separate path, and a name present in one says nothing about the other.", - "Not permission-gated — the search-filter controller has no RBAC entry." - ] - }, - { - "name": "search_test_cases_by_folder", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/search", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "id", - "type": "integer", - "description": "Exact integer test case id. Blank values are skipped." - }, - { - "name": "name", - "type": "string", - "description": "EXACT case title match — not a substring search. Blank values are skipped." - }, - { - "name": "identifier", - "type": "string", - "description": "Exact identifier match. Blank values are skipped." - } - ], - "intent": "Legacy id/name/identifier lookup against the deprecated local test-case table — do NOT use it to find test cases; use the project search or the folder listing instead.", - "returns": [ - "id", - "name", - "identifier" - ], - "responses": { - "200": { - "$response": "LegacyTestCaseSearchResponse" - }, - "400": { - "$response": "BadRequest" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DEPRECATED AND EFFECTIVELY DEAD. It queries the legacy local test-case table, which is no longer the system of record — for any project whose data lives in the current backend it returns an empty list with 200, whatever you search for. An empty result here is not evidence that a case does not exist.", - "It does NOT accept a free-text `query` parameter. The only filters read are `id`, `name` and `identifier`, each matched for EXACT equality, and a blank value is skipped.", - "The response is a BARE JSON ARRAY of legacy rows — no `success`, no `info`, no `test_cases` wrapper, and the rows are the old table's columns, not the serialised test case the rest of this surface returns.", - "The folder id in the path is not used to scope the query; only the project and group are.", - "Use the project-wide entity search for finding cases by text, or the folder listing to enumerate a folder." - ], - "disabled": true - }, - { - "name": "send_report_email_now", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/send_email_now", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "emails", - "type": "array", - "required": true, - "example": [ - "qa-leads@company.com" - ], - "description": "Recipient addresses. MUST be an array, even for one address. 1-10 entries, each matching a standard address syntax — invalid entries fail the whole call 400 and are listed in the message. Any address is accepted; recipients need not be TM users." - }, - { - "name": "file_type", - "type": "array", - "values": [ - "csv", - "pdf", - "xlsx" - ], - "example": [ - "pdf" - ], - "default": [ - "pdf" - ], - "description": "Format to attach. MUST be an array with EXACTLY ONE entry — two entries, or an explicit empty array, crash the allow-list check and return 500. Omit the field entirely to default to [\"pdf\"]." - } - ], - "intent": "Email an existing report to specific people right now, as a PDF/CSV/XLSX attachment, without changing its schedule", - "responses": { - "200": { - "description": "Generation job enqueued; the mail is sent asynchronously.", - "schema": { - "type": "object", - "required": [ - "success", - "message" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Report generation started. Email will be sent shortly." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the only way to get a report's file out of the product through the API — the download capability hands back a channel id you cannot resolve. Mail it to yourself to obtain the file.", - "Send a SINGLE entry in `file_type`. The server checks the allow-list with a splat, so an array of two formats (or an explicit empty array) raises an ArgumentError and comes back 500, not 400. Call the endpoint once per format you need.", - "Both fields must be JSON arrays even for one value — a bare string is 400 `emails should be an array` / `file_type should be an array`.", - "`emails` is effectively required: it defaults to an empty array which then fails the count check with 400 `Please provide between 1 and 10 email addresses.` Between 1 and 10 addresses, each syntactically valid; invalid ones are named in the 400 rather than skipped.", - "Recipients are arbitrary addresses — they need not be TM users, and the mail goes out immediately with no further confirmation. Treat this as an irreversible external side effect.", - "The 200 only means the job was enqueued (`Report generation started. Email will be sent shortly.`). Generation or delivery can still fail afterwards and there is no job id and no status endpoint — you cannot confirm the mail was sent.", - "Unlike every other report call, this one is NOT permission-gated: any user with access to the project can share a report. An unknown report id is 404 `Report not found`.", - "The report's own `mail_to` recipients are untouched: this does not add them, and it does not affect the schedule." - ], - "returns": [ - "success", - "message" - ] - }, - { - "name": "delete_folder_and_subtree", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm", - "mode": "destructive", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "DOES NOT restore a deleted folder — this route currently deletes the folder and its entire subtree, identically to the delete call; there is no folder-restore API", - "returns": [ - "data", - "success", - "folder", - "path_folders", - "id", - "name", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "description": "Folder successfully restored", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder" - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "success": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Do not call this to undo a deletion. The handler is a one-line alias that forwards to the delete action, and the two arguments it passes to signal 'restore' are never read by that action — the request that reaches the backing service is exactly the same DELETE the remove endpoint sends.", - "The effect is therefore identical to deleting the folder: the folder, every subfolder beneath it and every test case in the subtree are removed, with test cases landing in the recycle bin only if the group's Move to Bin flag is on.", - "It is also the cheaper way to cause that damage: this route is gated on the folder-EDIT permission while the real delete route requires folder-DELETE, so an edit-only caller can destroy a folder through it.", - "There is no folder-restore capability anywhere in this API. Binned TEST CASES can be listed and restored from the project bin; folders cannot be brought back at all. If you need the structure again, recreate it.", - "The response is the delete response: {data:{folder, path_folders, success}}, with the echoed folder's counts zeroed." - ], - "disabled": true - }, - { - "name": "unlink_test_runs_from_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/unlink", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_runs", - "type": "array", - "required": true, - "description": "Run ids to unlink, as the INTEGER `uuid` from a run read — NOT the TR-NNN string that run endpoints return in `id`. Sent at the TOP LEVEL of the body: this call has no `test_plan` wrapper, unlike plan create/update." - } - ], - "intent": "Detach test runs from a test plan without deleting them — the runs survive and become plan-less, ready to be linked somewhere else", - "responses": { - "200": { - "description": "`{\"success\": true}` on an unlink. Also returned with `{\"success\": false, \"error\": \"Please check test runs\"}` when `test_runs` is missing or not an array — a 200 that did nothing.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is FLAT here — {\"test_runs\": [...]} at the top level. Wrapping it in `test_plan` the way create and update require means the handler sees nothing and the call silently no-ops.", - "A missing test_runs, or one that is not an array, returns HTTP 200 with {success: false, error: 'Please check test runs'} and changes nothing. This is the main failure mode and it does not look like a failure.", - "Use each run's integer `uuid`. Run endpoints put the TR-NNN string in `id`, and passing that fails upstream rather than being rejected here.", - "The 200 is returned without checking what actually happened: ids that were never linked to this plan, or that do not exist, are absorbed silently. Read the plan's run list before and after if you need to report what changed.", - "This only breaks the plan↔run link. The runs themselves, and their test cases and results, are untouched — they just stop belonging to any plan.", - "To re-link runs, send them in the plan update's test_runs list (which REPLACES the whole link set) rather than looking for a link endpoint here." - ], - "returns": [ - "success", - "error" - ], - "disabled": true - }, - { - "name": "update_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id holding the option. Not validated by this handler." - }, - { - "name": "option_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric option id, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." - } - ], - "body": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label. REQUIRED even when unchanged — a missing or null value is a 400. Must stay unique within the dataset." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "REQUIRED even when unchanged — a missing or null value is a 400. Setting it true does not clear any other option's default." - } - ], - "intent": "Rename one dropdown choice, or make it the default — the option id stays the same, so cases already holding this value follow the new label", - "responses": { - "200": { - "$response": "CustomFieldAdminOptionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A full replace of the option's label and default flag: both option_value and is_default must be sent, even when only one is changing. A missing or null either way is a 400 {success:false, error:'Invalid Params'}.", - "`parent_option_id` cannot be changed here — the handler does not forward it, so an option cannot be re-parented in a nested_dropdown. Delete and recreate instead, which drops the stored values.", - "The option id is stable across a rename, and stored values reference the id, so renaming does not lose data and can be undone by renaming back.", - "The rename lands in every project the dataset is linked to, with no per-project variant.", - "Making an option the default does not clear the previous default; on a single-select dropdown clear the old one yourself. On a multi_dropdown every option should stay is_default false.", - "Neither path id nor the option id is validated in this layer — a wrong triple surfaces as a bare 404 {success:false} with no message. A duplicate label is relayed verbatim from upstream; every other failure is masked as 400 'The API request is invalid or improperly formed'." - ], - "returns": [ - "success", - "data", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id" - ] - }, - { - "name": "update_custom_field_dataset_project_mapping", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/project-mappings/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Validated only on the synchronous path; an async (>300 project) request does not check it at all." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Validated only on the synchronous path; an async request does not check it at all." - } - ], - "body": [ - { - "name": "link_projects", - "type": "array", - "description": "Integer project ids to ADD. Note the spelling — `linked_projects` is silently dropped here. Send [] rather than omitting it: unless select_all is present, a missing array makes the request 500." - }, - { - "name": "unlink_projects", - "type": "array", - "description": "Integer project ids to REMOVE. Destroys those projects' stored values for this field, irreversibly. Send [] rather than omitting it — unless select_all is present, a missing array makes the request 500." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Auto-link projects created after this call. Forwarded as-is; omitting it sends null." - }, - { - "name": "select_all", - "type": "boolean", - "description": "true = link every project in the workspace except unlink_projects; false = UNLINK every project except link_projects, deleting their stored values. Only literal true/false are recognised — any other value falls back to the two explicit lists. Over 300 projects the call goes async." - } - ], - "intent": "Change which projects a custom field's dataset applies to — link projects so they see the field, or unlink them, which deletes the values those projects already stored", - "responses": { - "200": { - "$response": "CustomFieldDatasetProjectMappingUpdateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send BOTH `link_projects` and `unlink_projects`, even when one is empty. Unless `select_all` is present, the handler adds their two lengths together, and a missing array raises an unhandled error — the call comes back 500, not 400.", - "The spelling is link_projects / unlink_projects here. The create calls use linked_projects / linked_to_all_projects; those names are silently dropped on this path, so the call returns 200 with nothing changed.", - "Unlinking is destructive to data: the values every case in those projects stored for this field are removed, and there is no undo. Name and count the projects before proceeding.", - "`select_all: true` links every project in the workspace except those in unlink_projects. `select_all: false` is the inverse and UNLINKS every project except those in link_projects — a whole-workspace data deletion from a single boolean. Only the literal strings/values true and false are recognised.", - "Above 300 affected projects the call switches to a background job and returns {success: true, async: true, unique_id: \"\"} with NO dataset body. The unique_id is a job handle only; there is no endpoint here to poll it, so verify by re-reading the dataset.", - "In that async case the path ids are never validated, so a bad field or dataset id is accepted with a 200 and the work fails silently in the worker.", - "A project may belong to only one dataset per field. Linking a project already held by a sibling dataset fails with the upstream message relayed verbatim (e.g. 'Projects already linked to a different dataset for this custom field...', 'invalid project mapping', 'Dataset id is invalid').", - "On the synchronous path the response is the re-read dataset under `data`, with its options included." - ], - "returns": [ - "success", - "data", - "async", - "unique_id", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects", - "options" - ] - }, - { - "name": "update_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "field_name", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Display label. REQUIRED even when unchanged. Stripped before comparison and storage." - }, - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "REQUIRED for validation. Send the field's CURRENT type — this surface does not compare it to what is stored, it forwards it." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "description": "REQUIRED, literal JSON boolean. Omitting it, or sending the string \"true\", is a 400." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Used only to select the valid field_type set and the reserved-name list; never sent upstream, so it cannot move the field to a different entity. Send the field's real entity_type." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "Hint text. Omitting it sends null and CLEARS the stored value — resend it to keep it." - }, - { - "name": "default_value", - "type": "boolean", - "description": "Applied only when field_type is `boolean`. For any other type the handler sends null regardless of what you pass, clearing any stored default." - } - ], - "intent": "Rename a custom field, or change whether it is mandatory, its placeholder or its boolean default — a full replace of the field's attributes, not a partial edit", - "responses": { - "200": { - "$response": "CustomFieldAdminWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a REPLACE, not a patch. field_name, field_type and is_required must all be present or the call is 400 'Invalid Params' — read the field first and resend the whole set.", - "Omitting place_holder_text sends null and clears the stored hint text. Likewise, for any non-boolean field the handler always sends default_value as null, so a stored default on a string/int/date field does not survive this call. Resend every value you want to keep.", - "field_type must be sent and must be valid for the entity_type, but this surface does NOT compare it against the stored type — it is forwarded upstream. Send the field's CURRENT type. (Its sibling on the v2 custom-fields surface rejects a changed type outright with 400 'Field type cannot be changed'.)", - "entity_type is used only to pick the valid field_type list and the reserved-name list; it is never sent upstream, so a field cannot be moved between TestCase / TestResult / TestPlan. Send the field's real entity_type or the validation applies the wrong rules.", - "Datasets, options and project links are NOT touched here — this call carries no `datasets` key upstream. Change option values through the dataset option calls and project scope through the dataset project-mapping call.", - "Renaming onto a reserved name (Title, Description, Priority, State, Owner, Tags, …) is a 400, except when the field already carries that name — an import-created field keeps its name and stays editable.", - "There is no version history for a field definition: an overwritten label, placeholder or default cannot be restored. A rename is workspace-wide and lands in every project the field's datasets link to.", - "Upstream duplicate-name errors come back verbatim; every other upstream failure is masked as 400 {success:false, message:'The API request is invalid or improperly formed'}, and a field id that does not exist comes back as a bare 404 {success:false} with no message." - ], - "returns": [ - "success", - "data", - "id", - "field_name", - "field_type", - "is_required", - "default_value", - "place_holder_text" - ] - }, - { - "name": "update_dataset", - "method": "PUT", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "uuid", - "type": "string", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "json_path": "/dataset/name", - "description": "Dataset name. REQUIRED on update despite reading like a partial edit — update shares the create request schema. A name already used by another dataset in the project returns 400 {success:false, message:'Dataset name already exists.'}." - }, - { - "name": "variables", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 40, - "json_path": "/dataset/variables", - "description": "The complete new column list — this REPLACES the existing columns, it does not merge. Required. Send each column's existing `uuid` (from a read) for the columns you are keeping; omit it only for genuinely new columns. Blank-named or entirely-empty columns are silently dropped. 40 is the default cap, 100 for groups with the raised-limit flag.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", - "description": "Letters, digits and underscores only, must start with a letter. Duplicate names (case-insensitive) fail the call." - }, - { - "name": "uuid", - "type": "string", - "description": "The existing column's uuid, taken from a read. Forwarded to the store so the column keeps its identity; leave it out and you are creating a new column." - } - ] - }, - { - "name": "rows", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 100, - "json_path": "/dataset/rows", - "description": "The complete new row set — REPLACES the existing rows. Required. Same normalisation as create: blank rows dropped, cells trimmed, duplicate rows collapsed, row_number reassigned 1..N. 100 is the default cap, 200 for groups with the raised-limit flag.", - "fields": [ - { - "name": "row_number", - "type": "integer", - "required": true, - "description": "Required by the schema, then overwritten with the row's final position." - }, - { - "name": "row_data", - "type": "array", - "required": true, - "minItems": 1, - "description": "Strings only, positional against `variables`; length must match the surviving column count after empty-column pruning." - }, - { - "name": "uuid", - "type": "string", - "description": "The existing row's uuid from a read, for rows you are keeping." - } - ] - }, - { - "name": "tags", - "type": "array", - "maxItems": 20, - "json_path": "/dataset/tags", - "description": "Replaces the dataset's tags when the key is present; omit the key entirely to leave existing tags alone. Sending [] clears them. Each tag is a string of at most 50 characters, at most 20 tags.", - "fields": [ - { - "name": "tag", - "type": "string", - "maxLength": 50 - } - ] - } - ], - "intent": "Replace a dataset's contents — rename it, or write a new set of columns and rows over the existing table", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "created_at", - "created_by" - ], - "responses": { - "200": { - "description": "Dataset updated; the full stored dataset is returned at the top level as `dataset`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__UpdateDataset" - } - } - } - }, - "400": { - "description": "Request-schema rejection ({error:'Invalid JSON data', details:[...]}) — including a body missing name/variables/rows — or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" - }, - "422": { - "description": "{success:false, message:...}. 'Feature not available' = no Team Pro entitlement; any other message is a dataset-shape validation failure." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a full replace, not a patch, and `name`, `variables` and `rows` are ALL required — the update reuses the create request schema verbatim. A body with only `name` is rejected with 400 {error:'Invalid JSON data'}; the spec marking these optional is wrong.", - "To rename without losing data: read the dataset first and send its existing `variables` and `rows` (uuids included) back alongside the new name.", - "There is no undo. Datasets keep no version history and there is no restore endpoint, so an overwritten column or row set is gone. Read the current contents before writing.", - "`metadata` passes validation here and is then silently discarded — the handler forwards only name, columns, rows and tags. Sending metadata returns 200 with metadata unchanged. It can only be set at creation.", - "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key. Nothing beyond the declared keys is permitted anywhere in the body (additionalProperties:false at every level) — an extra or misspelled key fails the whole call with 400.", - "The `uuid` in the path is the dataset's uuid string, resolved by looking it up in the project's dataset list; an unknown one is 404 {success:false, message:'Dataset not found.'}.", - "Rows and columns come back normalised (blank rows and empty columns removed, cells trimmed, duplicate rows collapsed, row_number renumbered) — trust the response, not your payload.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad body; any other 422 message is a real validation failure.", - "Needs the test-case write permission (tc_write)." - ] - }, - { - "name": "update_exploratory_session", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "body": [ - { - "name": "title", - "type": "string", - "example": "Updated checkout exploration", - "description": "New title. `name` is accepted by the request schema and silently dropped, so use this field.", - "json_path": "/exploratory_session/title" - }, - { - "name": "description", - "type": "string", - "description": "Replacement description.", - "json_path": "/exploratory_session/description" - }, - { - "name": "charter", - "type": "string", - "description": "Replacement testing charter.", - "json_path": "/exploratory_session/charter" - }, - { - "name": "timebox_duration", - "type": "integer", - "example": 90, - "description": "Planned duration. Rendered as minutes by TM's own exports but documented upstream as seconds; passed through unconverted.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/timebox_duration" - }, - { - "name": "duration", - "type": "integer", - "example": 45, - "description": "Tracked duration. Same unit ambiguity as timebox_duration.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/duration" - }, - { - "name": "actual_duration", - "type": "integer", - "example": 50, - "description": "Final actual duration. This is the ONLY place it can be set — the close call ignores any duration you send it — so write it here before closing the session.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/actual_duration" - }, - { - "name": "folder_id", - "type": "integer", - "example": 789, - "description": "Move the session to a different folder; a numeric string is also accepted.", - "json_path": "/exploratory_session/folder_id" - }, - { - "name": "test_plan_id", - "type": "integer", - "example": 77, - "description": "Integer id of the test plan to link (not a TP-NNN identifier). Only reachable through the wrapped body.", - "json_path": "/exploratory_session/test_plan_id" - }, - { - "name": "owner", - "type": "integer", - "example": 55, - "description": "TCM user id of the new owner; translated to the upstream user id. Wins over `assignee` when both are sent. An unresolvable id clears the assignee instead of failing.", - "json_path": "/exploratory_session/owner" - }, - { - "name": "assignee", - "type": "integer", - "example": 55, - "description": "Genuine alias for `owner` on this endpoint — both are translated here. (On create only `owner` is, so prefer `owner` in code shared between the two.)", - "json_path": "/exploratory_session/assignee" - }, - { - "name": "tags", - "type": "array", - "example": [ - "smoke", - "payment" - ], - "description": "REPLACES the session's tags with this list; an empty array clears them. Non-string entries are rejected by the request schema.", - "json_path": "/exploratory_session/tags" - }, - { - "name": "configurations", - "type": "array", - "example": [ - 2, - 4 - ], - "description": "REPLACES the session's configuration ids (integers or numeric strings). An empty array clears them.", - "json_path": "/exploratory_session/configurations" - }, - { - "name": "attachments", - "type": "array", - "description": "REPLACES the attached blob ids. Every id you leave out is detached and its blob freed for reuse. Ids must be integer blob ids from a prior generic attachment upload; an invalid set returns 422 with the bare string body \"attachment is invalid\".", - "json_path": "/exploratory_session/attachments" - }, - { - "name": "issues", - "type": "array", - "description": "REPLACES the linked issues. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the whole list to empty. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. A per-entry metadata object is accepted. Applied asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/exploratory_session/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "101": "Regression" - }, - "description": "Custom field values as a map of {custom_field_id: value} for entity type ExploratorySession, converted server-side. Not the array shape returned on read. Only sent when non-empty — an empty object is ignored rather than clearing values.", - "json_path": "/exploratory_session/custom_fields" - } - ], - "intent": "Change fields on an existing exploratory session — retitle it, revise the charter, record the time spent, reassign it, move it to another folder, or replace its tags, configurations, attachments and linked issues", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "description": "Exploratory session updated successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key.", - "Scalar fields are patched by presence: only title, description, charter, timebox_duration, duration, actual_duration, folder_id and test_plan_id keys actually present in the body are sent. Omitted keys are left alone.", - "The collection fields are NOT patched, they are replaced. Sending `tags`, `configurations`, `issues` or `attachments` overwrites the whole list; sending an empty array clears it. Omitting the key is the only way to leave a collection untouched.", - "Replacing `attachments` detaches every blob you left out and frees it for reuse. An invalid set is rejected with 422 whose body is the bare JSON string \"attachment is invalid\" — not the usual {success:false, message} envelope, so do not parse it like one.", - "`owner` and `assignee` are both translated to the upstream user id here (unlike create, where only `owner` is). `owner` wins when both are sent. An id that resolves to no user clears the assignee rather than failing.", - "There is no way to change `session_state` through this call, and no reopen endpoint exists — closing a session is one-way.", - "`name` is accepted by the request schema and then dropped by the parameter filter; retitle with `title`.", - "Exploratory sessions keep no version history and there is no undo: an overwritten title, description, charter or tag list cannot be restored.", - "Issue linking is applied asynchronously after the 200, so the session echoed back may not yet reflect the new links.", - "Upstream validation failures all arrive as 400 {success:false, message:'The API request is invalid or improperly formed'} — the same text for a bad configuration id, a folder in another project or a malformed value." - ] - }, - { - "name": "update_exploratory_session_log", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - }, - { - "name": "log_id", - "type": "integer", - "required": true, - "example": 789 - } - ], - "body": [ - { - "name": "content", - "type": "string", - "example": "

Confirmed the spinner issue on iOS 17 as well.

", - "description": "Replacement rich-text HTML. Sanitised on write. Re-sending it re-derives the log's image attachments from the blob ids in the HTML, so an image removed from the HTML is detached. A null is stripped rather than clearing the field — send an empty string to blank it.", - "json_path": "/session_log/content" - }, - { - "name": "status", - "type": "string", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "example": "bug", - "description": "Replacement outcome. `skipped` is valid; anything outside the seven is rejected by the request schema. A null is stripped rather than clearing the status.", - "json_path": "/session_log/status" - }, - { - "name": "elapsed_time", - "type": "integer", - "example": 180, - "description": "Replacement time spent, in seconds. Must be an integer. A null is stripped rather than clearing the value.", - "json_path": "/session_log/elapsed_time" - }, - { - "name": "defects", - "type": "array", - "description": "REPLACES the log's defect links when non-empty; omit the key to leave them untouched. An empty array is treated as 'not supplied' and does NOT clear the links. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the list to empty; a bad jira id fails the whole call. Applied asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/session_log/defects" - } - ], - "intent": "Revise an observation already recorded in an exploratory session — correct the note, change its pass/fail/bug outcome, adjust the time spent or restate which defects it raised", - "returns": [ - "success", - "session_log", - "id", - "session_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "description": "Session log updated successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_log": { - "$schema": "ExploratorySessionLog__UpdateExploratorySessionLog" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Both ids matter and both are integers: log_id identifies the entry, session_id in the path identifies its parent session. A log_id that belongs to a different session is not addressable here.", - "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. Only content, status, elapsed_time and defects are read, and only the keys present are forwarded — everything else is left as it is.", - "You cannot blank a field out: nulls are stripped from the outgoing payload, so sending content:null or status:null leaves the existing value in place. Send an empty string for content instead, and expect status and elapsed_time to be un-clearable.", - "`defects`, when non-empty, REPLACES the log's defect links; omit the key to leave them alone. The same coercion applies as on create — bare id strings become jira issues, malformed entries silently reduce the list to empty, and a bad jira id fails the whole call. Linking is applied asynchronously after the 200.", - "You cannot unlink every defect from a log: sending defects:[] is treated as 'not supplied' and the existing links survive. This differs from the session update, where an empty tags/configurations/issues array does clear the list.", - "Re-sending `content` re-derives the log's image attachments from the blob ids in the HTML: an image you drop from the HTML loses its attachment link, and if any referenced blob id is not visible to your group or project then ALL of the call's attachment links are silently dropped while the update still succeeds.", - "`content` is sanitised on write, so the stored HTML can differ from what you sent.", - "Session logs keep no version history and there is no undo — an overwritten note cannot be recovered.", - "`status` must be one of pass, fail, bug, retest, block, note, skipped; anything else is a 400 from the request schema." - ] - }, - { - "name": "update_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "write", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "description": "Required on every update, even when you are only changing the conditions. Read the filter first and resend its current name rather than inventing one — the value you send is what it is renamed to." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Required on every update, but not applied: a saved view's list type cannot be changed, and a different value returns success while changing nothing. Resend the filter's existing entity_type. An unlisted value returns 400 'Invalid JSON data'." - }, - { - "name": "is_project_level", - "type": "boolean", - "description": "Forwarded whenever the key is present, including `false` — this is how you demote a project-wide view back to private. Omit the key to leave the current scope alone." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement condition set, in the same UI filter-state shape as create_filter's `filters` (systemFields option objects, extraSystemFields.issue_type, matchAllMatchAny) — send the whole set, not just the change. Omit to leave the stored conditions untouched. `{}` is treated as absent, so conditions cannot be cleared to empty through this call." - } - ], - "intent": "Rename a saved filter view, replace its conditions, or switch it between private and project-wide — resending name and entity on every call", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterUpdateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a POST, not a PATCH or PUT, and it is only half a partial update: `name` and `entity` are required by the request-body schema on every call, while `is_project_level` and `filters` are forwarded only when you send them. Read the filter first and resend name + entity_type unchanged.", - "The body is flat and closed — an extra key returns 400 'Invalid JSON data' before the handler runs.", - "`filters: {}` is dropped as blank, so there is no way to empty a view's conditions here; delete and recreate instead.", - "Saved filter views keep no version history, so an overwritten name or condition set cannot be restored. Read the filter before you overwrite it if you may need the old value.", - "Changing is_project_level to true publishes a previously private view to the whole project immediately.", - "An unknown filter_id comes back as 400 `{success:false, message:...}`, not 404 — this action only translates the backing service's 400, and every other failure from it becomes a 500.", - "Not permission-gated: the filter controller has no RBAC entry, so a project-level view can be edited by anyone with product access to the project, not only its owner." - ] - }, - { - "name": "update_latest_test_result_for_test_case", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "configuration_id", - "type": "string" - }, - { - "name": "mapping_id", - "type": "string" - } - ], - "body": [ - { - "name": "status_id", - "type": "integer", - "description": "Result status id (resolve via the statuses-and-states concept — this path takes ids, not names; a `status` name is accepted by the filter and then discarded).", - "json_path": "/test_result/status_id" - }, - { - "name": "description", - "type": "string", - "description": "Rich text note on the result. Links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent.", - "json_path": "/test_result/description" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom field values keyed by custom field id (not by field name — that is the v2 contract).", - "json_path": "/test_result/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects. Each entry is an OBJECT — a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_result/issues" - }, - { - "name": "attachments", - "type": "array", - "example": [ - 10288829 - ], - "description": "Integer blob ids from a prior generic attachment upload — not filenames and not file bytes.", - "json_path": "/test_result/attachments" - }, - { - "name": "elapsed_time", - "type": "integer", - "description": "Accepted by the parameter filter and then DROPPED before the write on this path — setting it here does nothing and reports no error.", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration's latest result to patch. TOP level — the server does not read it from inside `test_result`." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target a specific run/case mapping. Top level." - } - ], - "intent": "Amend the most recent result logged for a test case in a run — correct its status, note, linked defects or custom fields in place, without adding another attempt to the case's history", - "returns": [ - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "custom_fields_count", - "test_run_step_result", - "attachments", - "issues", - "links" - ], - "responses": { - "200": { - "description": "Test result successfully updated", - "schema": { - "$schema": "TestResult" - } - }, - "400": { - "description": "Bad request - Test run is closed or invalid parameters", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "status": { - "type": "integer", - "example": 400 - }, - "error": { - "type": "string", - "example": "Cannot Update Closed Test Run" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No test results found", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "status": { - "type": "integer", - "example": 404 - }, - "message": { - "type": "string", - "example": "No test results found" - } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This EDITS the latest existing result; it never creates one. With nothing logged yet it answers 404 {success:false, message:'No test results found'} — log a result first, then amend it.", - "A closed test run is refused with 400 {success:false, error:'Cannot Update Closed Test Run'} before any field is written. A closed run cannot be reopened, so its results are final — record new results in a clone of the run instead.", - "Send the fields flat. The `test_result` wrapper is added for you from each field's json_path, and sending your own `test_result` object is rejected before the call as an unknown body key. Sending no fields at all is legal and re-saves the current values unchanged.", - "Only description, status_id, custom_fields, attachments and issues survive to the write. `status` (the name) and `elapsed_time` are accepted by the parameter filter and then dropped on this path — set the status by id here, and use the v2 result surface if you only have a status name.", - "Keys you omit keep their current values (the handler re-reads the result and fills them in), so this is a genuine partial update.", - "configuration_id and mapping_id are read at the TOP level — query string or body root, never from inside `test_result`. Without one of them 'latest' means latest across all configurations, which on a configuration matrix is usually not the row you meant.", - "Unlike the id-addressed result update, this path applies NO authorship check — only the standard project permission gate runs, so it will overwrite a result logged by someone else. The edit is in place and the previous values are not recoverable; log a new result instead if you want the earlier outcome preserved.", - "The 200 body is the bare updated result object at the TOP level — no `data` envelope and no `success` key, unlike the create-result endpoint on the same path." - ], - "disabled": true - }, - { - "name": "update_project_settings", - "method": "POST", - "path": "/api/v1/projects/{project_id}/settings", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Turn a project's configuration flags on or off — review/approval gating, the custom test-case id prefix, test-case sharing, the manual-execution timer, Part 11 re-authentication, Jira/Azure linking and test-plan review", - "responses": { - "200": { - "description": "Written settings echoed back as one object per setting. A body containing no recognised setting name also returns 200, with success:false and an empty array — nothing was written.", - "schema": { - "type": "object", - "required": [ - "success", - "project_settings" - ], - "properties": { - "success": { - "type": "boolean", - "description": "False (with HTTP 200) when nothing was written — including when the body contained no recognised setting name." - }, - "project_settings": { - "type": "array", - "description": "One single-key object per setting written, {settingName: value}. Note this differs from the read, which returns a single flattened object at index 0.", - "items": { - "type": "object" - } - } - } - } - }, - "400": { - "description": "Invalid test_case_id_prefix (format, reserved, or already used elsewhere) or an unknown review-status token." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Timer settings written without the project-settings write permission." - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "New project settings not enabled for this group/user, or a plan gate (Team Pro / Enterprise) not met." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Settings are sent as FLAT top-level body keys, one per setting — there is no wrapper object and no generic {key, value} pair. Only the names listed below are read; everything else is ignored.", - "The silent no-op to watch for: if the body contains none of the known setting names, the request still returns HTTP 200 with {success:false, project_settings:[]} and nothing is written. A 200 here is not confirmation — check `success` and check that the setting you sent comes back in project_settings.", - "This is a partial update: only the keys you send are touched, and the others keep their stored values. It does NOT reset omitted settings.", - "There is no undo and no history — the previous value is gone the moment the write lands. Re-read get_project_settings first if you need to be able to restore it.", - "Feature-gated for the whole endpoint: without the new-project-settings flag for the group or user the call fails 422 'Feature not available' before any key is examined.", - "Per-setting gates, all raised before the write: review_approve and the two eligible-review-status settings need a Team Pro plan (422); part_11_reauth and part_11_reauth_send_for_review need an Enterprise plan (422); the four timer settings need the project-settings write permission (403 'Only admins can update Time Tracking settings').", - "test_case_id_prefix is validated twice — locally against 2-10 uppercase alphanumerics starting with a letter and not equal to 'TC', then against the backing service for reserved prefixes and cross-project collisions. Either failure returns 400 with the reason, and it is checked before anything else in the body is written.", - "The two eligible-review-status settings take a comma-separated list drawn from approved / rejected / in_review / pending / changes_requested. An unknown token fails the whole request with 400; an empty string is valid and means gating is OFF.", - "Values are stored as strings in an EAV table, so send booleans as the strings \"true\"/\"false\" and numbers as numeric strings; that is also how they read back.", - "The response echoes one object PER setting written ([{name: value}, ...]) — a different shape from the read, which flattens everything into a single object at project_settings[0].", - "Removing a setting entirely is a different operation (a delete on the same path with the attribute name) and is not exposed as a capability here; writing an empty string is not the same as unsetting it." - ], - "body": [ - { - "name": "review_approve", - "type": "string", - "description": "Enable the test-case review/approval workflow. Requires a Team Pro plan — otherwise 422 'Feature not available'. Send \"true\"/\"false\" as strings." - }, - { - "name": "test_run_eligible_review_statuses", - "type": "string", - "description": "Comma-separated review statuses a test case must be in before it can be added to a run. An unknown token fails the whole request with 400. Empty string = no gating. Requires a Team Pro plan." - }, - { - "name": "test_execution_eligible_review_statuses", - "type": "string", - "description": "Comma-separated review statuses a test case must be in before results can be logged against it. Same validation and plan gate as the run variant." - }, - { - "name": "default_test_case_approval_state", - "type": "string", - "description": "The review state newly created test cases start in." - }, - { - "name": "test_case_id_prefix", - "type": "string", - "description": "Custom identifier prefix for this project's test cases. 2-10 characters, uppercase letters and digits, must start with a letter, and cannot be \"TC\". Also checked against reserved prefixes and other projects' prefixes; either check failing returns 400 with the reason." - }, - { - "name": "tc_sharing", - "type": "string", - "description": "Allow this project's test cases to be shared into other projects." - }, - { - "name": "part_11_reauth", - "type": "string", - "description": "Require re-authentication for regulated (21 CFR Part 11) actions. Requires an Enterprise plan — otherwise 422." - }, - { - "name": "part_11_reauth_send_for_review", - "type": "string", - "description": "Require re-authentication specifically when sending for review. Requires an Enterprise plan — otherwise 422." - }, - { - "name": "preferred_jira_host", - "type": "string", - "description": "Default Jira site URL used when linking issues from this project." - }, - { - "name": "preferred_azure_org", - "type": "string", - "description": "Default Azure DevOps organisation used when linking work items from this project." - }, - { - "name": "sync_jira_defect_attachments", - "type": "string", - "description": "Copy attachments to the linked Jira defect when one is raised." - }, - { - "name": "link_jira_requirements", - "type": "string", - "description": "Enable linking Jira requirements from this project." - }, - { - "name": "link_jira_test_case_requirement", - "type": "string", - "description": "Enable linking a Jira requirement to a test case." - }, - { - "name": "link_jira_test_run_requirement", - "type": "string", - "description": "Enable linking a Jira requirement to a test run." - }, - { - "name": "jira_create_allowed_hosts", - "type": "string", - "description": "Allow-list of Jira site URLs that may create or update requirements, as a JSON-ENCODED ARRAY STRING (e.g. the JSON text of an array of site URLs). Omitting the key means all hosts are allowed; the string \"[]\" means none." - }, - { - "name": "time_tracking_enabled", - "type": "string", - "description": "Per-project master switch for the manual-execution timer; the feature is on only when this is exactly \"true\". Requires the project-settings write permission — 403 otherwise." - }, - { - "name": "timer_auto_start", - "type": "string", - "description": "Start the execution timer automatically when a tester opens a case. Requires the project-settings write permission." - }, - { - "name": "timer_elapsed_required", - "type": "string", - "description": "Require an elapsed time before a result can be submitted. Requires the project-settings write permission." - }, - { - "name": "timer_min_threshold_seconds", - "type": "string", - "description": "Minimum elapsed seconds before a result is accepted, as a numeric string. Requires the project-settings write permission." - }, - { - "name": "test_plan_review_enabled", - "type": "string", - "description": "Enable the review workflow for test plans." - }, - { - "name": "test_plan_review_sub_plan_mode", - "type": "string", - "description": "How sub-plans participate in test-plan review." - }, - { - "name": "test_plan_review_default_state", - "type": "string", - "description": "The review state newly created test plans start in." - } - ], - "returns": [ - "success", - "project_settings" - ] - }, - { - "name": "update_report", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/reports/{report_id}", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "example": 10000, - "description": "Integer project id the rewritten filter rows are filed under. Not enforced by the server: omit it and rows are written against project 0 and become invisible, at 200. Send the report's own project id." - }, - { - "name": "project_ids", - "type": "array", - "example": [ - 10000, - 10001 - ], - "description": "Integer project ids for a cross-project report. Only applied when present — omitting it leaves the stored set alone. Max 60 (5 for User Workload Report), and multi-project only for the seven supported types; the cap is checked against the report_type in THIS body." - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary", - "description": "Report type as its DISPLAY NAME. Required, and it also selects which filter-rewrite branch runs — omit it and the report_type column is nulled AND the filters are silently left untouched. `Defects Summary` / `Defects Detailed Report` have no data branch and no filter branch." - }, - { - "name": "title", - "type": "string", - "example": "Updated Title", - "description": "Written unconditionally — omit it and the title is set to null." - }, - { - "name": "description", - "type": "string", - "example": "", - "description": "Written unconditionally — omit it and the description is set to null." - }, - { - "name": "report_timeframe", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "The window the report covers. `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500); any other value NULLS the stored custom_date_range. The five entity timeframes carry no dates and each reads one matching `report_filters` key." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "example": "custom_report", - "description": "Written unconditionally. Unlike create, `system_generated` here does NOT re-fetch requirements for a traceability report — send `report_filters.requirements` yourself." - }, - { - "name": "is_dynamic", - "type": "boolean", - "description": "Written unconditionally from the body — omit it and the report's dynamic flag is set to null (unlike create, it is not inferred from the filters)." - }, - { - "name": "grain", - "type": "string", - "values": [ - "test_case", - "test_plan" - ], - "description": "Immutable after creation. Send the report's persisted value or omit it; anything different is 400 'grain cannot be changed after report creation'." - }, - { - "name": "test_run", - "type": "object", - "fields": [ - { - "name": "created_at", - "type": "string", - "required": true - }, - { - "name": "status", - "type": "array", - "required": true - }, - { - "name": "owner", - "type": "array", - "required": true - }, - { - "name": "automation_status", - "type": "array", - "required": true - }, - { - "name": "type", - "type": "array", - "required": true - } - ], - "json_path": "/dynamic_filters/test_run", - "description": "Dynamic run selection, accepted under either `dynamic_filters` or `filters`. Only rewritten for a Requirement Traceability Report; on other types it is read but the JQL config is not updated." - }, - { - "name": "status", - "type": "array", - "json_path": "/report_filters/status" - }, - { - "name": "priority", - "type": "array", - "json_path": "/report_filters/priority" - }, - { - "name": "case_type", - "type": "array", - "json_path": "/report_filters/case_type" - }, - { - "name": "assignee", - "type": "array", - "json_path": "/report_filters/assignee" - }, - { - "name": "automation_status", - "type": "array", - "json_path": "/report_filters/automation_status" - }, - { - "name": "automation_state", - "type": "array", - "json_path": "/report_filters/automation_state" - }, - { - "name": "test_runs", - "type": "array", - "description": "Run ids as INTEGERS — a quoted id or TR-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored run selection.", - "json_path": "/report_filters/test_runs" - }, - { - "name": "test_plans", - "type": "array", - "description": "Plan ids as INTEGERS — a quoted id or TP-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored plan selection.", - "json_path": "/report_filters/test_plans" - }, - { - "name": "test_cases", - "type": "object", - "description": "Case selection — must be an OBJECT (an array is 400), folder-keyed like SelectionData: folder ids are STRING keys, case ids INTEGERS. Read the current value from get_selected_report_testcases and send it back; omitting the key deletes the saved selection.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "required": true - }, - { - "name": "folders", - "type": "object", - "required": true - }, - { - "name": "unique_test_case_count", - "type": "integer", - "required": true - } - ], - "json_path": "/report_filters/test_cases" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cross-project case selection: { \"\": } for non-primary projects. A project id outside the report's project set is dropped silently.", - "json_path": "/report_filters/shared_selection" - }, - { - "name": "session_ids", - "type": "array", - "description": "Exploratory session ids — pairs with report_timeframe=specific_sessions.", - "json_path": "/report_filters/session_ids" - }, - { - "name": "requirements", - "type": "object", - "description": "{ issue_type: [issue_id, ...] } — must be an object, not an array. Pairs with report_timeframe=requirements and is NOT auto-populated on edit even for system_generated reports.", - "json_path": "/report_filters/requirements" - }, - { - "name": "test_plan_selection", - "type": "object", - "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. A malformed entry is discarded silently rather than rejected.", - "json_path": "/report_filters/test_plan_selection" - }, - { - "name": "test_runs_by_project", - "type": "object", - "description": "Cross-project run selection: { \"\": [run_id, ...] }. Numeric-string keys and numeric-id arrays only, else 400. Replaces the flat `test_runs` key when present.", - "json_path": "/report_filters/test_runs_by_project" - }, - { - "name": "test_plans_by_project", - "type": "object", - "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Replaces the flat `test_plans` and `test_plan_selection` keys when present.", - "json_path": "/report_filters/test_plans_by_project" - }, - { - "name": "builds", - "type": "array", - "json_path": "/report_filters/builds", - "description": "Internal build ids paired with `test_runs`. The report read returns them as `build_ids`; send them back unchanged or the paired rows are wiped on the next write." - }, - { - "name": "projects", - "type": "array", - "json_path": "/report_filters/projects" - }, - { - "name": "folder", - "type": "array", - "json_path": "/report_filters/folder" - }, - { - "name": "test_case_tags", - "type": "array", - "json_path": "/report_filters/test_case_tags" - }, - { - "name": "tc_custom_fields", - "type": "object", - "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity`; elsewhere it is dropped silently. Persisted and read back under the name `custom_fields`.", - "json_path": "/report_filters/tc_custom_fields" - }, - { - "name": "custom_date_range", - "type": "string", - "example": "2025-04-16 2025-04-24", - "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED when report_timeframe is `custom` (omitting it is a 500); NULLED on the record for every other timeframe." - }, - { - "name": "users", - "type": "array", - "description": "TM user ids to mail the report to. Every id must be in your group or the request is 400. Only applied when the report is already scheduled.", - "json_path": "/mail_to/users" - }, - { - "name": "external_mails", - "type": "array", - "description": "Email addresses outside the group. Only applied when the report is already scheduled.", - "json_path": "/mail_to/external_mails" - }, - { - "name": "frequency", - "type": "string", - "values": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly", - "description": "Send with `frequency_details` to keep or change the schedule. Omitting EITHER unschedules the report — both columns are nulled and it stops emailing. `once` is not a value: it passes validation and then raises a 500." - }, - { - "name": "time", - "type": "string", - "example": "08:00", - "description": "24-hour HH:MM, UTC. Required whenever frequency is sent.", - "json_path": "/frequency_details/time" - }, - { - "name": "day", - "type": "string", - "example": "monday", - "description": "Required for weekly (lowercase English day name) and monthly (integer 1-28). Omit for daily.", - "json_path": "/frequency_details/day" - } - ], - "intent": "Rewrite a saved report — its type, window, filters, schedule or recipients — replacing the whole definition in one call", - "returns": [ - "data", - "report_data", - "report_filters", - "dynamic_filters" - ], - "responses": { - "200": { - "$response": "UpdateReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This PATCH is a FULL REPLACE, not a merge. `title`, `description`, `report_type`, `report_timeframe`, `report_creation_mode` and `is_dynamic` are written from the body unconditionally, so any one you omit is set to NULL. Read the report first and send the complete object back with your change applied.", - "Omitting `frequency` / `frequency_details` UNSCHEDULES the report: both columns are nulled and it stops emailing. Resend the persisted values to keep the schedule. Likewise `custom_date_range` is nulled whenever `report_timeframe` is not `custom`.", - "`projectId` in the body is effectively required even though it is not marked so: it is the project every rewritten filter row is filed under. Omit it and the rows are written against project 0, which no read ever finds — the call still returns 200 with a report whose filters have silently vanished.", - "`report_filters` is authoritative: a key you omit has its stored rows deleted. Send the full filter set, not a delta. Use the report read and get_selected_report_testcases to recover the current values first.", - "Filters are only rewritten for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Plan Detailed Report, Test Case Activity, User Workload Report, Requirement Traceability Report and Exploratory Session Summary. On `Defects Summary` / `Defects Detailed Report`, or when `report_type` is omitted, the filter update is skipped entirely and `report_filters` comes back null — at 200.", - "There is no undo and no version history. The previous definition and its filter rows are gone once this returns.", - "`grain` cannot be changed after creation — a differing value is 400. Resending the persisted value is accepted.", - "`mail_to` is only applied when the report is already scheduled AND `mail_to` is present in the body; on an unscheduled report recipients are ignored.", - "The response is NOT the create envelope: there is no `success` key. The report is at `data`, the computed figures at `report_data`, the resolved filters at `report_filters`. `report_data` is computed inline here, so a large report makes this call slow.", - "The report is resolved by id within your group — the project in the path does not scope the lookup, so a report belonging to a sibling project still resolves (404 `Schedule not found` only when no such report exists in the group). Requires the report_update permission.", - "The same cross-project caps as create apply, and they are re-checked against the incoming `report_type`: 60 projects overall, 5 for User Workload Report, and multi-project only for the seven supported types." - ] - }, - { - "name": "update_shared_step", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "write", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Required on every call even when you are only changing steps. Read the shared step first and resend its current title; whatever you send becomes the new title." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Shared-field folder to move the block into. Forwarded only when the key is present, so omitting it leaves the block where it is; send null to move it to the root (Unassigned)." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "description": "Plain tag name strings; the list you send replaces the block's tags. The key is always forwarded, so omitting it sends a null tags value rather than preserving the current tags — resend the existing tags if you want to keep them." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The FULL replacement step list, not a patch. Rows you leave out are removed from the block. Required — omitting the key fails with a flat 400 'Validation Error'.", - "fields": [ - { - "name": "id", - "type": "integer", - "description": "Existing step-row id, from a prior read. Rows sent with an id are updated in place and keep their identity; rows sent without one are created fresh, which breaks the shared_step_detail_id that referencing test-case steps already hold." - }, - { - "name": "order", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Required here too, not optional: local validation demands order >= 1 on every row, so a row sent without an order fails the whole update." - }, - { - "name": "step", - "type": "string", - "description": "Step body, HTML. Each row must carry at least one of step or result." - }, - { - "name": "result", - "type": "string", - "description": "Expected result, HTML. Each row must carry at least one of step or result." - }, - { - "name": "test_data", - "type": "string" - } - ] - } - ], - "intent": "Replace a shared step's title, folder and its whole ordered step list — the edit lands at once in every test case that embeds the block", - "returns": [ - "success", - "data", - "id", - "title", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$schema": "SharedStepsIDResponse__UpdateSharedStep" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Blast radius first: a shared step is embedded by reference, so this edit changes the steps rendered by every test case that embeds the block — immediately, without touching or versioning those cases. Read the block (or find it in the listing) and check `test_case_count` before you write: that is how many test cases you are about to change.", - "shared_step_details is a full replacement, not a merge. Any existing row you omit is deleted, and deleting a row orphans the `shared_step_detail_id` that referencing test-case steps point at. Read the current rows, edit that array, send it back whole.", - "Rows carrying their existing `id` are updated in place; rows without one are created new. Dropping the ids and resending the same text rebuilds every row with fresh ids and breaks every reference into the block.", - "It is a PATCH but not a partial one: title and shared_step_details are both required on every call, and an omitted `tags` is sent as null rather than left alone.", - "Shared steps keep no version history — only test cases, test plans and builds do — so there is no undo for an overwritten title or step list, and no restore-previous-version call to fall back on.", - "The handler reads the block before writing, so a wrong or deleted shared_step_id returns 404 `{data:null, success:false}` before anything changes.", - "Every local validation failure is the same opaque 400 `{data:null, success:false, message:\"Validation Error\"}` — no field name. The usual causes are a missing shared_step_details, a row with no order (or order 0), or a row with neither step nor result.", - "Requires tc_write." - ] - }, - { - "name": "update_template", - "method": "PATCH", - "path": "/api/v2/templates/{id}", - "mode": "write", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id — a plain integer with no prefix, discovered from the template list.", - "pattern": "^[0-9]+$" - } - ], - "body": [ - { - "name": "name", - "type": "string", - "description": "New template name. Only applied when the key is present; omit it to leave the name alone.", - "minLength": 1 - }, - { - "name": "enabled", - "type": "boolean", - "description": "true re-activates the template, false disables it. Read as truthiness: send a real JSON boolean, because the STRING \"false\" is truthy and ENABLES the template. Disabling is reversible and is the alternative to deleting." - }, - { - "name": "is_default", - "type": "boolean", - "description": "Make this the workspace default template for its entity type. Setting a new default displaces the current one; the system default template cannot be disabled or deleted." - }, - { - "name": "description_fields", - "type": "array", - "description": "REPLACES the description section wholesale — it is not merged. Anything you leave out is removed from the template. Read the template first and resend every field you want to keep.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - }, - { - "name": "properties_fields", - "type": "array", - "description": "REPLACES the properties section wholesale. Sending this key also silently re-adds the Review & Approve system fields (reviewers, review status), so the stored set is a superset of what you sent and those two cannot be removed by omission.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - } - ], - "intent": "Rename a template, enable or disable it, make it the workspace default, or replace its description and property field layout.", - "guidance": [ - "A name-only PATCH works and returns a bare {success:true} - unlike the create, the update does NOT echo the template back, so re-read it if you need the result.", - "Sparse update: only the keys actually present in the body are touched, everything else keeps its current value. Sending an empty body is accepted and changes nothing.", - "Field arrays REPLACE, they do not merge. Templates have no version history in this product, so the previous layout cannot be restored — read the template, edit the returned arrays, and send them back whole.", - "Editing the field layout changes the fields shown on every test case using this template, in every linked project. Establish how many projects are affected before writing.", - "Sending properties_fields re-injects the Review & Approve system fields, and reads filter those same fields out, so a read-modify-write round-trip neither loses them nor can remove them. Do not report them as deleted.", - "enabled is evaluated for truthiness, so the string \"false\" enables rather than disables. Use a JSON boolean.", - "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is changed — check the flag, not the status code.", - "Success returns only success:true with no template body. Re-read the template to confirm the new layout.", - "Project links are not changed here; linking and unlinking projects is a separate operation on the template." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TemplateWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_test_plan_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/update", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/test_plan/name", - "description": "Plan name. EFFECTIVELY REQUIRED on any content edit: the server's edit branch is gated on name being present, so omitting it rejects the whole call with 400 'Invalid Update Params' however valid the rest is. Read the plan first and echo the current name back unchanged." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text; rewritten server-side, so the stored value can differ from what you sent. Dropped entirely when the same request also changes plan_status." - }, - { - "name": "start_date", - "type": "string", - "description": "ISO-8601 date. Only settable while the plan is still `new` — once started, the stored start date is used and this field is ignored. REQUIRED in the same request when moving plan_status to 'started'.", - "format": "date", - "json_path": "/test_plan/start_date" - }, - { - "name": "end_date", - "type": "string", - "description": "ISO-8601 date. Must not be earlier than the start date (400 'Invalid end_date, should be greater than start_date'). REQUIRED in the same request when moving plan_status to 'completed'.", - "format": "date", - "json_path": "/test_plan/end_date" - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "Target status. Only moves forward one step: new -> started -> completed. Sending a value that differs from the current one switches the call into transition mode, in which EVERY other content field in the body is discarded.", - "json_path": "/test_plan/plan_status" - }, - { - "name": "owner", - "type": "integer", - "description": "Owner as the internal TM user id (the `id` from the project users read — not a BrowserStack user id or email). An id that does not resolve inside the caller's group clears the owner instead of erroring. Only applied on a content edit, never alongside a status transition.", - "json_path": "/test_plan/owner" - }, - { - "name": "parent_plan_id", - "type": "integer", - "description": "Re-parent a SUB-plan under a different parent plan (integer id). It CANNOT convert a top-level plan into a sub-plan (400 'Cannot update a parent test plan to a sub plan') nor promote a sub-plan to top level (400 'Cannot update a sub test plan to a parent plan'), and the move is only applied while the plan is still `new`.", - "json_path": "/test_plan/parent_plan_id" - }, - { - "name": "tags", - "type": "array", - "description": "Plain tag strings; the list you send REPLACES the plan's tags. Discarded when the same request changes plan_status.", - "json_path": "/test_plan/tags" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer user ids; the list REPLACES the current reviewers. Omit the key to leave reviewers untouched, send [] to clear them. A reviewers-only body is the one edit that does not need `name`.", - "json_path": "/test_plan/reviewers" - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment ids already uploaded via the attachments surface. An attachment that does not verify returns 422 with the verifier's own body rather than the usual envelope.", - "json_path": "/test_plan/attachments" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom-field values keyed as the test-plan form-fields read returns them. Unlike the other content fields, these ARE applied even alongside a plan_status transition. A bad key or value is 400 {success: false, error: }.", - "json_path": "/test_plan/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked tracker issues. Structured — NOT a flat array of keys. Only url and ticket_id survive inside metadata. Linking is processed on a background job, so the plan echoed back may not show them yet.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_plan/issues" - }, - { - "name": "test_runs", - "type": "array", - "description": "The plan's linked test runs, as run INTEGER `uuid`s. This REPLACES the whole link set rather than appending; [] unlinks everything; omitting the key leaves the linkage alone. Applied even alongside a status transition.", - "json_path": "/test_plan/test_runs" - } - ], - "intent": "Edit a test plan or sub-plan — rename it, change its dates, owner, tags, custom fields or reviewers, move its status forward, or replace the set of test runs it groups", - "responses": { - "200": { - "description": "The updated plan, re-read and returned under a top-level `test_plan` key (no `data` wrapper) — same shape as the plan detail read.\n", - "schema": { - "$schema": "V1TestPlanDetail__UpdateTestPlanByIntegerId" - } - }, - "400": { - "description": "A local rule violation: `{success: false, error: ...}` where error is one of 'Invalid Update Params', 'Cannot update completed test plan', 'Cannot revert started test plan', 'Cannot directly complete test plan', 'Cannot update a parent test plan to a sub plan', 'Cannot update a sub test plan to a parent plan', 'Invalid start_date', 'Invalid end_date', 'Invalid end_date, should be greater than start_date', 'Invalid field plan_status', 'Invalid Test Plans Params'. Also a rejected custom field.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Upstream rejected the update (for example the review gate on completing an unapproved plan): `{success: false, error_code, error}`. Attachment verification failures also land on 422 with the verifier's own body.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error_code": { - "type": "string" - }, - "error": { - "type": "string" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Everything goes inside a `test_plan` object. A flat body is rejected before any field is read.", - "name IS REQUIRED on every content edit, including one that only touches test_runs or tags. Omit it and the call is 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt.", - "A status transition and a content edit are mutually exclusive branches, not one merged write. When plan_status differs from the plan's current status, ONLY the dates and the new status are applied — name, description, tags, issues, owner and parent_plan_id in the same body are silently discarded, with a 200 and no warning. Rename first, then transition.", - "Moving to 'started' requires a valid start_date in the SAME request (400 'Invalid start_date'); moving to 'completed' requires end_date (400 'Invalid end_date'). The plan's stored dates are not used as a fallback.", - "Status only moves forward one step: new -> started -> completed. new -> completed is 400 'Cannot directly complete test plan' and started -> new is 400 'Cannot revert started test plan'.", - "A completed plan is FROZEN — even a rename is refused with 400 'Cannot update completed test plan'. Complete it last.", - "Run ids in test_runs are the INTEGER `uuid` from a run read, NOT the TR-NNN string that every run endpoint puts in `id`. And linking MOVES a run: a run belongs to at most one plan, so linking silently unlinks it from whichever plan held it, with nothing in the 200 to say so. Read the runs' current membership first.", - "test_runs REPLACES the link set — send the full list you want, not just the additions. An empty array unlinks every run.", - "parent_plan_id cannot promote or demote: a top-level plan can never become a sub-plan and a sub-plan can never become top-level. Moving a sub-plan between parents only takes effect while the plan is still `new`.", - "Plans keep no version history. An overwritten name, description, date or tag list is gone for good — there is no prior revision to restore.", - "Upstream rejections (for example the review gate that blocks completing an unapproved plan) come back as 422 {success: false, error_code, error}, while the local rule violations above are 400 {success: false, error}.", - "The success body is the freshly re-read plan under a top-level `test_plan` key — the same shape as the plan detail read, with no `data` wrapper." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "assignee", - "created_by", - "test_runs_count", - "total_test_runs_count", - "links" - ] - }, - { - "name": "update_test_run", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' — it is not skipped." - }, - { - "name": "run_state", - "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it. The partial update: on a closed run it answers 404 'Test Run ID is invalid' rather than a clear refusal.", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "json_path": "/test_run/run_state" - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings; the list you send replaces the run's tags." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read. Duplicates are collapsed." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^$|^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^$|^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "The run's COMPLETE intended case list — NOT a list to add. Any case already in the run and missing from this list is removed, with its run row, assignee and results; the run's own fields (owner, tags, description) are kept. To ADD cases, use bulk_update_test_run_test_cases with add_test_cases instead — it is the only truly additive call. If you must use this field, send the run's current case identifiers (list_test_run_test_cases) plus the new ones: retained cases then keep their run row id and assignee, and only the new cases are inserted." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders" - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list — cases you list elsewhere are still included, with a null-configuration row." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "json_path": "/test_run/filter_scope", - "description": "Accepted by the parameter filter but NOT honoured on either update path — the selection is always built in global mode here. Only create reads it." - }, - { - "name": "shared_test_cases", - "type": "array", - "fields": [ - { - "name": "project_id", - "type": "integer" - }, - { - "name": "test_case_ids", - "type": "array" - } - ], - "json_path": "/test_run/shared_test_cases", - "description": "Cases shared in from other projects, as [{project_id, test_case_ids: [...]}]. Only this partial-update call resolves them; the full-replace call on the same run accepts the key and ignores it." - }, - { - "name": "is_automation", - "type": "boolean", - "json_path": "/test_run/is_automation", - "description": "Only honoured on this partial-update call (and only for the literal values true/false); the full-replace call on the same run ignores it. It turns an existing manual run into an automation run — this is the only way to get one, since both run creates ignore the flag. The reply carries no is_automation, so confirm with the v1 run read." - } - ], - "intent": "Change just the fields you name on a test run — rename it, move its state on, link or unlink a test plan, attach configurations or a dynamic filter — leaving everything you omit untouched", - "guidance": [ - "Two different operations share this path. This PATCH applies only the keys present in `test_run`; the POST rebuilds the whole payload and resets what you omit. This is the safe default for a targeted edit.", - "Unlinking is explicit: send test_plan_id (or sub_test_plan_id) as null or \"\" to detach the plan. Omitting the key keeps the current link.", - "run_state is validated here: an unknown value returns 400 echoing it. Both 'new_run' and 'new' are accepted. Omit the key to leave the state alone.", - "is_dynamic lives inside filter_test_cases and nowhere else. true needs at least one other criterion in the same object or the call 400s; false flips a dynamic run back to static; omitting it leaves the flag as it is.", - "Composition keys interact: sending only configurations re-reads the run's existing case selection so the case list survives, and sending selection keys without configurations re-reads the existing configuration list. Send neither and the run's composition is left completely untouched.", - "Case membership is REPLACED, not unioned: the selection you send becomes the run's whole case list, and any case missing from it is removed with its run row, assignee and results — with a 200. Only the run's own fields follow the partial rule (omitted owner, tags and description are kept). To add cases use bulk_update_test_run_test_cases (add_test_cases); if you use this call anyway, read the current cases and send existing + new, then re-read to confirm the count.", - "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "There is no undo: test runs keep no version history, so an overwritten name, description or tag list cannot be restored.", - "The updated run comes back at the top level as `testrun` — one word, no underscore, and no `data` envelope. This differs from the read endpoints, which use `test_run`.", - "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", - "`urls.self` is a link to the run in the web UI, not an API path — it is built from the frontend base URL, so calling it returns HTML.", - "`assignee` comes back as a plain email string — or null when the owner cannot be resolved — and `configurations` as a list of configuration ids. Neither is a list of objects.", - "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", - "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", - "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", - "The run's case list is not on this reply — `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", - "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write, and `tags` can come back [] while the stored tags are unchanged. Re-read the run when the exact stored values matter.", - "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunWriteV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "upload_ai_attachments", - "method": "POST", - "path": "/api/v1/projects/{project_id}/generic/attachments/ai_uploads", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Upload a document as AI context for a project — the same store as a generic upload but with the restricted type and size rules that the test-case generation flow requires", - "returns": [ - "id", - "name", - "content_type", - "download_url", - "size", - "url" - ], - "responses": { - "200": { - "$response": "GenericAttachmentsResponse" - }, - "400": { - "description": "A file failed the AI type allowlist or its per-type size cap. All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "NOT INVOCABLE through this interface. Identical handler to the generic upload: it reads `attachments` as an array of uploaded FILES from a multipart form, so the one input it needs cannot be expressed as a JSON argument and this record carries no body parameter.", - "With no files it returns 200 and `generic_attachment: []` rather than an error — a call from here looks successful and uploads nothing.", - "Only the path differs from the generic upload, and the difference is validation. Allowed types: pdf, txt, csv, json, rtf, doc, docx, xls, xlsx, pptx, jpeg, png. Anything else is rejected with 'Invalid file type for AI uploads'.", - "Size caps are PER TYPE, not the generic 50 MB: 500 KB for csv/txt/json/xls/xlsx, 5 MB for jpeg/png, 10 MB for pptx, 25 MB for pdf/doc/docx/rtf. Passing linked_jira_data=true replaces all of them with a flat 3 MB cap and files under a separate storage prefix.", - "Any text/* file that is byte-sniffed as something outside the allowlist (markdown read as text/html, for example) is silently normalised to text/plain and accepted.", - "On a real multipart upload the status is 200, not the 201 the spec claims. `id` is the blob id the test-case creation flow consumes; `url`/`download_url` are pre-signed for 30 minutes and `size` is a human-readable string." - ], - "disabled": true - }, - { - "name": "upload_generic_attachments", - "method": "POST", - "path": "/api/v1/projects/{project_id}/generic/attachments", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Upload a file to a project and get back its blob id plus pre-signed view/download URLs — the first half of the attach flow, needed before a file can be linked to a test case, result or rich-text field", - "returns": [ - "id", - "name", - "content_type", - "download_url", - "size", - "url" - ], - "responses": { - "200": { - "$response": "GenericAttachmentsResponse" - }, - "400": { - "description": "At least one file failed validation (size). All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "NOT INVOCABLE through this interface. The handler reads `attachments` as an array of uploaded FILES from a multipart form and streams their bytes to storage; a file cannot be expressed as a JSON argument, which is why this record carries no body parameter at all.", - "Worse than an error: with no files the handler takes the empty path and answers 200 with `generic_attachment: []`. A call from here looks like it succeeded and uploads nothing — do not report a successful upload on that response.", - "If you need a blob id, the file has to be uploaded out of band (the product UI, or a direct multipart request built by the caller). Once it exists, the blob id is what the test-case, test-result and step attach capabilities take.", - "On a real multipart upload the status is 200, not the 201 the spec claims, and the body is {generic_attachment: [...]}: `id` is the blob id to carry forward, `url` and `download_url` are pre-signed and expire after 30 minutes, and `size` is a HUMAN-READABLE string ('300 KB') rather than a byte count.", - "No type allowlist is enforced on this generic path; the only limit is 50 MB per file. SVG/HTML/XML uploads are served with a forced-download disposition rather than inline.", - "A single invalid file fails the whole request: every blob already written is deleted, and the 400 body reports only the LAST failing file's errors, under errors.message." - ], - "disabled": true - }, - { - "name": "link_attachments_to_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Link already-uploaded files to a test case by their blob ids — the second half of the two-step attach flow; it does not carry file content", - "returns": [ - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "description": "Attachments uploaded successfully", - "schema": { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentResponse" - } - }, - "success": { - "type": "boolean" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DOES NOT WORK. A well-formed request returns a bare 500. The handler calls an attach method that the object it holds does not have, so the call raises before anything is stored. There is no request that makes it succeed — do not retry with a different body.", - "Despite the name and the published spec this is NOT a multipart file upload. The body is JSON and `attachments` is a list of INTEGER blob ids returned by a prior generic attachment upload. No file bytes cross this boundary.", - "Send the list flat. The `test_case` wrapper is added for you from the field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "Each blob must belong to the caller's group or to this project, and must not already be attached to anything. Reusing a blob that is attached elsewhere fails the WHOLE call with 422 'the attachment has been referenced elsewhere' — upload a fresh copy for each attachment point.", - "An id that resolves to no blob gives 422 'attachment not found'. An EMPTY array is accepted, does nothing, and still answers 200 with the case's existing attachments — it does not clear them.", - "The write is additive: the ids you list are added to whatever the case already has. Removal is the delete-attachment capability, one id at a time.", - "`folder_id` and `test_case_id` must both be integers or the route does not match; folder_id itself is not used to locate the case.", - "Success returns the case's attachments (id, filename, content_type, byte_size) at the top level alongside `success` — no `data` envelope." - ], - "body": [ - { - "name": "attachments", - "type": "array", - "required": true, - "json_path": "/test_case/attachments", - "example": [ - 10288829, - 10288830 - ], - "description": "Integer blob ids from a prior generic attachment upload — NOT filenames and NOT file content. Each blob must be unused; one already attached elsewhere fails the whole request with 422. An empty array is a silent no-op, not a clear." - } - ], - "disabled": true - }, - { - "name": "get_user_workload_drilldown", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/drilldown", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "user_id", - "type": "integer", - "required": true, - "example": 12345, - "description": "BrowserStack user id to break down — the id carried by the report's `user_table` rows, NOT the TM user id. Missing or <= 0 is 422. A Member-role caller may only pass their own id (403 otherwise)." - } - ], - "intent": "Break one person's workload out of a User Workload Report — the test cases they executed, the runs they took part in, and their day-by-day activity", - "paginated": false, - "responses": { - "200": { - "$response": "UserWorkloadDrilldownResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only works on a report whose stored `report_type` is `User Workload Report`. Any other report is 400 `Drill-down is only available for User Workload Reports`; an id not in your group is 404.", - "`user_id` is the BrowserStack user id, the same id the report's `user_table` rows carry — not the TM-internal user id. A missing or non-positive value is 422 (not 400), with `user_id is required for drill-down`.", - "A caller whose role is Member may only drill into themselves: any other `user_id` is 403 `Forbidden: cannot drill into another user`. Admins and above can drill into anyone.", - "It is a POST but it changes nothing — safe to call repeatedly. The window and run scope are taken from the REPORT's saved timeframe and filters; you cannot override them from the body.", - "Response is the analytics payload verbatim — there is no `success` flag and no envelope. Expect `tcs` (cases executed), `trs` (runs participated in) and `days` (daily timeline). An upstream failure surfaces as 422 `User Workload drill-down failed: ...`, so do not read an empty body as 'this person did nothing'.", - "NOT paginated: page and per-page values in the body are ignored — the full breakdown comes back in one response. Do not loop pages.", - "A report saved on a relative or custom timeframe that resolves to no dates falls back to a five-year window when it has specific runs selected, and is 422 otherwise.", - "Requires the report_view permission." - ], - "returns": [ - "tcs", - "trs", - "days" - ], - "disabled": true - }, - { - "name": "update_test_case", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id the v1 test-case endpoints take does NOT resolve here and comes back 404 'Test case not found'." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Checking the login functionality", - "json_path": "/test_case/name", - "description": "Case title. Omit to leave it alone; sending an empty or whitespace-only string is rejected 422 'Test case name cannot be empty' rather than clearing it. Note the response calls this field `title`, not `name`." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description", - "description": "Rich-text field. Links are stripped server-side, so the stored value can differ from what you sent." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions", - "description": "Rich-text field, same server-side link stripping as description." - }, - { - "name": "test_case_folder_id", - "type": "integer", - "example": 21, - "json_path": "/test_case/test_case_folder_id", - "description": "Move the case to this folder as part of the edit. This is the name the parameter filter accepts — `folder_id`, the name the response and the published schema use, is DROPPED here and the case silently stays put. Unlike the v1 edit path, a value equal to the current folder is not stripped before the write." - }, - { - "name": "priority", - "type": "string", - "example": "Medium", - "json_path": "/test_case/priority", - "description": "The project's display value for priority (e.g. 'Low', 'Medium', 'High') or that value's numeric field id. Matched on the display value first, then on the id; anything else fails the WHOLE call with 400 'Invalid default field value priority'. Resolve the allowed set from the project's system field values first — it is per project, not a fixed list." - }, - { - "name": "status", - "type": "string", - "example": "Active", - "json_path": "/test_case/status", - "description": "The project's display value for test-case status, or its numeric field id. Same matching and same 400 'Invalid default field value status' as priority. Not the run/result status — this is the case's own lifecycle field." - }, - { - "name": "case_type", - "type": "string", - "example": "Smoke & Sanity", - "json_path": "/test_case/case_type", - "description": "The project's display value for case type, or its numeric field id. Stored internally as the category, and a wrong value fails the whole call with 400 'Invalid default field value case_type'." - }, - { - "name": "automation_status", - "type": "string", - "values": [ - "not_automated", - "automated", - "automation_not_required", - "cannot_be_automated", - "obsolete" - ], - "example": "automated", - "json_path": "/test_case/automation_status", - "description": "Internal name, not a display label. Validated against this closed list before the request leaves the API with 400 'Invalid automation status', and then matched against the project's own automation-state options." - }, - { - "name": "owner", - "type": "string", - "example": "jane@acme.com", - "json_path": "/test_case/owner", - "description": "Owner as an email address, or a TM user id. DANGER: an address that does not resolve to a user in this group is NOT an error — it is written as null, clearing the owner, and the call still returns 200. Verify the user before sending." - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "smoke" - ], - "json_path": "/test_case/tags", - "description": "Plain tag-name strings. The list you send REPLACES the case's tags, so read the current tags and send the union if you mean to add one; `[]` clears them all; omit the key to leave them untouched." - }, - { - "name": "issues", - "type": "array", - "example": [ - "TES-204" - ], - "json_path": "/test_case/issues", - "description": "Linked issue/defect ids as plain STRINGS on this path — the parameter filter here permits only scalars, so the {issue_id, issue_type} objects the v1 edit body takes are dropped and the links never happen. The list replaces the case's linked issues." - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "Sprint": "24", - "Component": [ - "auth", - "billing" - ] - }, - "json_path": "/test_case/custom_fields", - "description": "Keyed by the custom field's NAME here — not by its numeric id, which is what the v1 edit and bulk paths use. A key the project does not define is skipped SILENTLY and the call still returns 200; a defined field with an unusable value fails the whole call with 400 'Custom field validation failed for '. Dropdown values are the option label; a user-type field takes an email." - }, - { - "name": "test_case_steps", - "type": "array", - "example": [ - { - "step": "Open the login page", - "result": "The login form is shown" - } - ], - "fields": [ - { - "name": "step", - "type": "string" - }, - { - "name": "result", - "type": "string" - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer" - }, - { - "name": "order", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps", - "description": "The case's ordered step list. `order` is overwritten by array position, so the array order is what counts. Omit the key to keep the existing steps. Rejected with 422 for a BDD-template case — send feature/background/scenario instead. This is the only surface that writes steps: the bulk edit refuses them outright." - }, - { - "name": "template", - "type": "string", - "example": "test_case_steps", - "json_path": "/test_case/template", - "description": "Template NAME, not an id. Omit it and the case's current template is kept — the action fills it in from the stored case before the write." - }, - { - "name": "template_id", - "type": "integer", - "example": 42, - "json_path": "/test_case/template_id", - "description": "Custom-template id. Must be a TestCase-entity template visible to the caller's group; an unknown or cross-group id is rejected 400 rather than quietly falling back to the system default. Explicit null is a no-op, not a clear." - }, - { - "name": "feature", - "type": "string", - "json_path": "/test_case/feature", - "description": "BDD-template cases only; 422 on a non-BDD case. Omit to keep the stored value — sending an EMPTY string is rejected 400 'Cannot clear feature and scenario for BDD test cases.'" - }, - { - "name": "background", - "type": "string", - "json_path": "/test_case/background", - "description": "BDD-template cases only; 422 on a non-BDD case. Omitted or blank keeps the stored background." - }, - { - "name": "scenario", - "type": "string", - "json_path": "/test_case/scenario", - "description": "BDD-template cases only; 422 on a non-BDD case. Like feature, an empty string is rejected 400 rather than clearing it." - }, - { - "name": "reviewers", - "type": "array", - "example": [ - "jane@acme.com" - ], - "json_path": "/test_case/reviewers", - "description": "Reviewer EMAILS. An array replaces the whole reviewer set ([] unassigns everyone). An object form {value: [...], operation: replace|add|remove} is also accepted for an additive or subtractive change. A bare string or null is dropped by the parameter filter and can never become a reviewer write. Revoked reviewers are emailed. Naming the case owner is rejected 422." - }, - { - "name": "send_for_review", - "type": "boolean", - "json_path": "/test_case/send_for_review", - "description": "Moves the case into review and mails the reviewers, independently of `reviewers`. String booleans ('true'/'false'/'1'/'0'/'on'/'off') are cast; an ambiguous string such as 'yes' is rejected 422 rather than guessed." - }, - { - "name": "review_status", - "type": "string", - "values": [ - "pending", - "in_review" - ], - "json_path": "/test_case/review_status", - "description": "Assignment states only — a verdict (approved / rejected / changes_requested) is one reviewer's decision and is rejected 422. OMIT this on a metadata-only edit: sending `pending` on an approved case invalidates the approval and writes an audit row. The pending/in_review narrowing only applies once the reviewer feature flag is on; with it off the value is passed through unvalidated." - }, - { - "name": "identifier", - "type": "string", - "json_path": "/test_case/identifier", - "description": "External id for the case, used only when the workspace is configured for project-level ids; it is stored as the case's third-party identifier. On any other workspace it is ignored. `third_party_identifier` is accepted as a synonym." - }, - { - "name": "expected_result", - "type": "string", - "json_path": "/test_case/expected_result", - "description": "ACCEPTED BUT NOT WRITTEN on this path — the parameter filter permits it and the request builder never copies it, so it is dropped before the write and you still get 200. The v1 edit paths do persist it. Never report an expected result as saved from here." - }, - { - "name": "estimate", - "type": "string", - "json_path": "/test_case/estimate", - "description": "ACCEPTED BUT NOT WRITTEN — passes validation and is then dropped before the write, exactly as on the v1 create/edit paths. Never report an estimate as saved." - }, - { - "name": "test_case_dataset", - "type": "object", - "json_path": "/test_case/test_case_dataset", - "description": "ACCEPTED BUT NEVER APPLIED on this path: the builder only regenerates dataset mappings when the modified-dataset flag is set, and that flag is not accepted by this action, so the existing mappings are always kept. Returns 200 with the dataset unchanged." - }, - { - "name": "old_id", - "type": "string", - "required": false, - "description": "Legacy id carried over from an import; free text, not validated against anything.", - "json_path": "/test_case/old_id" - }, - { - "name": "expectation", - "type": "string", - "required": false, - "description": "Expected outcome, the counterpart to `preconditions`. Distinct from `expected_result`, which belongs to a step.", - "json_path": "/test_case/expectation" - }, - { - "name": "attachment_id", - "type": "integer", - "required": false, - "description": "Id of a previously stored attachment to associate with the case.", - "json_path": "/test_case/attachment_id" - }, - { - "name": "ids", - "type": "array", - "required": false, - "description": "Ids of other cases this write applies to, for the shared-write path.", - "json_path": "/test_case/ids" - }, - { - "name": "issue_tracker", - "type": "object", - "required": false, - "description": "Tracker context for `issues` — which external system the ids belong to. Validated together with `issues`.", - "json_path": "/test_case/issue_tracker" - }, - { - "name": "created_at", - "type": "string", - "required": false, - "description": "Explicit creation timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", - "json_path": "/test_case/created_at" - }, - { - "name": "updated_at", - "type": "string", - "required": false, - "description": "Explicit update timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", - "json_path": "/test_case/updated_at" - } - ], - "intent": "Change one existing test case — rename it, rewrite its steps, preconditions or description, set priority/status/type/owner, retag it, relink issues or put it into review — sending only the fields you want changed", - "guidance": [ - "This is the SINGLE-case update, addressed by one identifier. The bulk edits take a selection (ids or a filter) instead, refuse step lists, and key custom fields by numeric id — so when you are changing one case, and always when you are changing its steps, this is the one to use.", - "Both ids in the path are prefixed strings (PR-1 / TC-42). The case id is resolved by an identifier lookup inside the project, so the internal integer id that the v1 edit endpoints take returns 404 'Test case not found' here.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key. Send at least one field: a 400 saying 'Invalid request format: test_case parameter is missing or malformed' means the body reached the server empty, not that you must nest it.", - "Only the keys you send are written — omitted fields keep their stored values. There is no undo, but the case keeps version history, so a bad edit can be recovered by restoring the previous version.", - "Accepted and then silently discarded on this path, each returning 200: expected_result, estimate, is_shared, the dataset payload, and `folder_id` (use test_case_folder_id to move). Re-read the case before telling anyone these landed.", - "priority, status and case_type take the PROJECT's display value or that value's numeric id; one bad value fails the whole call with 400 'Invalid default field value '. Resolve the allowed values from the project's system field values first rather than guessing 'P1' or 'Open'.", - "custom_fields is keyed by field NAME here (the v1 paths key by numeric id). An unknown name is skipped without error, so a typo means a 200 that changed nothing — confirm by reading the case back.", - "An owner that does not resolve to a user in this group is written as null and CLEARS the owner instead of erroring.", - "BDD and step-based cases are mutually exclusive: steps on a BDD case are 422, feature/background/scenario on a non-BDD case are 422, and clearing feature or scenario to an empty string is 400.", - "reviewers, send_for_review and review_status run a gate ladder (Team-Pro plan, the project's Review & Approve setting, 21 CFR Part 11) and are applied AFTER the case update has committed. A 502 with error_code reviewer_write_failed and test_case_updated true means the edit landed but the reviewer change did not — retry with only the reviewer field, never with the whole body.", - "Changing anything here can move the case out of an approved review state, and reviewer removals send email immediately.", - "Sharing cannot be changed here. `is_shared` is checked before the write — sending it on a project without test-case sharing enabled returns 400 — and is then discarded, so the value never takes effect. Use the bulk edit to change sharing.", - "Four fields are entitlement-gated and are dropped without error when the workspace lacks the entitlement: `created_at` and `updated_at` need external-API timestamps, and `identifier` applies only to project-level-id workspaces. Read the case back if you need to confirm one landed.", - "Parts of the echoed case are conditional, so test for the key rather than for an empty value: `feature`, `background` and `scenario` come back only on a Gherkin case (where `steps` is then an empty array), and `reviewers` and `review_status` appear only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps`, `custom_fields` and `attachments` come back as empty arrays when nothing is set — note that `attachments` is empty here, not missing, unlike the create response, and that editing a case never adds one: files are attached through the attachment upload capability." - ], - "returns": [ - "success", - "test_case", - "identifier", - "title", - "folder_id", - "priority", - "status", - "case_type", - "automation_status", - "owner", - "tags", - "template", - "description", - "preconditions", - "steps", - "custom_fields", - "issues", - "attachments", - "is_shared", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "responses": { - "200": { - "description": "The updated test case, in the v2 public shape (title, not name; no integer id).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "V2PublicTestCase" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "The test case WAS updated but the reviewer change could not be applied (error_code reviewer_write_failed, test_case_updated true). Also returned with error_code review_approve_unknown, in which case nothing was written at all." - } - } - }, - { - "name": "move_test_case", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/move", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API. It is also the only project involved — the move cannot leave it." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id returns 404 'Test case not found'." - } - ], - "body": [ - { - "name": "destination_folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 5, - "description": "Target folder, as a plain positive INTEGER folder id — folders are not identified by a prefixed string. Sent at the TOP LEVEL of the body, not inside a `test_case` object (the bulk move is the opposite and nests it). Missing, non-numeric, zero or negative is refused before anything is read. A numeric string is accepted and coerced." - } - ], - "intent": "File one test case in a different folder — use this to reorganise the tree, move a case out of an inbox/triage folder, or put a case where a run or plan expects to find it", - "guidance": [ - "Within ONE project only: there is no destination-project field, and the case is looked up in the same project it is moved inside. A cross-project move is not available here.", - "One case per call. To move many, use the bulk move, which takes a selection under a `test_case` object and runs asynchronously past ~30 cases; this one is always synchronous and is the cleaner choice for a single case.", - "The destination is a plain integer folder id and is NOT guessable — resolve it from the project's folder tree first. Nothing in this layer checks that the folder belongs to this project, so a stray id from another project is only caught (or not) downstream.", - "Moving a case into the folder it is already in is accepted and returns 200 with the case unchanged. Unlike the v1 edit path, the same-folder value is not stripped before the write, so the upstream write still happens.", - "Capture the case's current folder before you call if you may want to undo: the response reports only the NEW folder, and moving back means calling this again with the old id.", - "Nothing but the folder changes — the identifier, steps, tags, owner and review state are untouched — and the response carries the whole case in the v2 public shape.", - "The same folder change can be made as part of a single-case update by sending test_case_folder_id in its body; use this endpoint when the move is all you want, because it needs no other field and cannot accidentally rewrite one." - ], - "returns": [ - "success", - "test_case", - "message", - "identifier", - "title", - "folder_id", - "priority", - "status", - "case_type", - "automation_status", - "owner", - "tags", - "template", - "description", - "preconditions", - "steps", - "custom_fields", - "issues", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "responses": { - "200": { - "description": "The moved test case in the v2 public shape, with folder_id now the destination, plus a top-level message 'Test case moved successfully'.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "V2PublicTestCase" - } - } - }, - "message": { - "type": "string" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case_histories", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/history", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case identifier: TC-NNN, or the third-party identifier set on the case. The route deliberately leaves this segment unconstrained, but it is resolved through identifier lookup before anything is read — a bare internal numeric case id is not a match and comes back 404 'Test case not found'." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "example": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read — you silently get page 1 again. Page size is fixed at 30 and is not caller-controllable here: `per_page` and `page_size` are accepted by the URL but ignored. A `p` that is not a positive integer (blank, zero, negative, non-numeric, repeated) is refused with 400 'Invalid Params'." - } - ], - "intent": "See what changed on a test case, when, and who changed it — the revision trail, newest first, naming the fields touched in each edit; the read to answer 'what did this case look like before that edit?' or 'which revision should we roll back to?'", - "guidance": [ - "Rows come newest first. Each row's `version_name` is a positional label — V1 is the oldest revision still visible, and the highest number is the newest edit on page 1.", - "`version_name` is the only handle a row gives you: the v2 rows carry no history record id. Strip the leading V and pass the number as the version number to the single-version read to pull that revision's full test case snapshot.", - "Only the newest row on each page is enriched with the per-field old/new `modified` map — the rest carry `modified_fields` (the field names only), so a per-field before/after for an older edit needs the single-version read for that version. The creating revision never carries `modified` at all, even when it is the only row on the page, because a create has nothing to diff against — so a single-revision history is expected to come back with `modified_fields` null and no `modified` map.", - "Without an Enterprise or Team Pro plan the caller sees only the last 30 days of history. Older revisions are simply absent and nothing in the response flags that the window was applied, so a short list is not proof that the case was rarely edited. Version numbers count only what is visible, so they renumber as old revisions age out.", - "Listing degrades gracefully across plans, but comparing two versions and restoring one are paid-plan only — fall back to listing plus single-version reads when those are refused.", - "Each record carries `source` ('create' on the first revision, 'update' for an edit, 'restore' after a rollback), `created_at`, `modified_fields`, and `updated_by` — the editor's email, null when that user cannot be resolved in this group. The raw `user_id` is the BrowserStack user id, not a Test Management one.", - "Page with `p`; the page size is fixed at 30, so a long-lived test case is walked 30 revisions at a time and the total is in `info.count`.", - "Every row also carries `origin_channel`, recording how that edit arrived: ui, api, jira_app, azure, import, automation or ai. Anything written through this API is recorded as api, so the field only discriminates once a case has also been touched in the web app, through an issue-tracker app, or by an import." - ], - "returns": [ - "success", - "history", - "info", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "comment", - "created_at", - "updated_by", - "user_id", - "testcase_id", - "origin_channel" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "V2HistoryListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_runs", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (50, 100, 301) is refused with a 400 'parameter_error' rather than clamped, so ask for 300 directly when you want a large project in few calls." - }, - { - "name": "include_closed", - "type": "boolean", - "default": false, - "description": "Include closed runs. Closed runs are EXCLUDED from this listing by default, so counts and 'all runs' answers are silently short without this. Pass the literal string 'true'." - }, - { - "name": "run_state", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "description": "Filter by lifecycle state. Supplying it also lifts the default closed-run exclusion on its own, so run_state=closed works without include_closed." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com,lead@acme.com", - "description": "Comma-separated owner emails, resolved against this group. This filter FAILS OPEN: an address that matches no user in the group is silently dropped from the filter instead of erroring, so a typo widens the result set and still returns 200. Verify the emails resolve before trusting a filtered count." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^TP-\\d+$", - "example": "TP-1", - "description": "Restrict to runs linked to this test plan. An identifier that matches no plan in the project is a 404 ('Test Plan ID is invalid'), not an empty list." - }, - { - "name": "created_after", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Lower bound on creation date, YYYY-MM-DD. May be sent on its own. A value in a different format is a 400; a date later than tomorrow is a 400." - }, - { - "name": "created_before", - "type": "string", - "format": "date", - "example": "2026-03-31", - "description": "Upper bound on creation date, YYYY-MM-DD. May be sent on its own. Sending a created_after later than this is a 400 naming the start date." - }, - { - "name": "closed_after", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Lower bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." - }, - { - "name": "closed_before", - "type": "string", - "format": "date", - "example": "2026-03-31", - "description": "Upper bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." - } - ], - "intent": "Find test runs in a project — filter by owner, lifecycle state, linked plan or date to locate a run, count how many there are, or collect the run ids you need before linking runs to a test plan", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 — any other number is a 400 'parameter_error', not a clamp — so pass 300 when you want the project in as few calls as possible.", - "Closed runs are excluded by default. Without include_closed=true (or a run_state filter, which lifts the exclusion by itself) a 'how many runs do we have?' answer quietly omits every closed run and still comes back 200.", - "Rows carry the run's `identifier` (TR-14) and nothing numeric — this listing's serializer emits no `id` and no `uuid` field at all. TR-NNN is exactly what the newer single-run and run-test-case capabilities want in their paths, so for reading a run onward this is all you need.", - "Linking runs to a test plan is the exception: a plan's linked-run array takes the INTEGER run id. The TR-NNN string reaches the backing service uncoerced and 502s there, and this listing does not give you the integer. Resolve it first — the v1 project run listing (list_test_runs_by_integer_id) carries the integer as `uuid` on each run — and send that, never the TR-NNN string.", - "Rows are a summary projection, not the single-run read. They never carry `issues` or `issue_tracker`, however many issues a run actually has linked — read the run itself when you need those.", - "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none', and read the run itself to see them.", - "`test_plan` appears only on runs that are linked to a plan, and the key is omitted entirely rather than sent as null when there is no link. `overall_progress` behaves the same way — omitted, not zeroed, for a run with nothing executed yet.", - "The assignee filter fails open while the plan and date filters fail closed: an unresolvable email is dropped silently, but an unknown TP-NNN is a 404 and a malformed or out-of-order date is a 400. Only the assignee filter can quietly widen your result.", - "closed_after / closed_before are not just a date window — either one restricts the whole result to closed runs.", - "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 with error_code 'request_limit_exceeded'. Narrow with a filter rather than walking a large project to the end.", - "Rows are run headers only — progress is aggregated counts. To see which cases are in a run, read that run's test cases." - ], - "returns": [ - "success", - "test_runs", - "info", - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "is_dynamic", - "overall_progress", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "TestRunsListV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "429": { - "description": "Deep-paging guard. Returned when page x page_size exceeds the account's row ceiling (30,000 by default).", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "message": { - "type": "string", - "example": "Page count exceeds limit. Requests with more than 1000 pages are temporarily disabled. Please reduce the page count and try again." - }, - "error_code": { - "type": "string", - "example": "request_limit_exceeded" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Optional in practice despite the published schema marking it required: omitting it names the run 'Test Run DD/MM/YYYY' from today's date rather than failing." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, as an email resolved inside this group. Unlike the run edit paths, an address that does not resolve is NOT an error here — it is dropped and the run is created owned by the calling user, so a typo produces a wrongly-owned run with a 200." - }, - { - "name": "run_state", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "default": "new_run", - "example": "new_run", - "json_path": "/test_run/run_state", - "description": "Lifecycle state, checked against this fixed list before any case selection is built. On create an absent or unrecognised value is not rejected — it is silently stored as new_run — so send the state you want explicitly rather than relying on a validation error." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "sprint-24" - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "example": [ - 101, - 102 - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read, and not the display names the published schema's example shows. Duplicates are collapsed. Overwritten entirely when configuration_map is also sent." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link at creation. Resolved by identifier — a TP-NNN that matches no plan in this project fails the whole create with 404, the run is not created. Mutually exclusive with sub_test_plan_id; when both are sent test_plan_id wins." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link at creation. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "Test case identifiers to put in the run. This, shared_test_cases, folder_ids and include_all are the ONLY inputs that actually place cases in a run — filter_test_cases alone does not." - }, - { - "name": "shared_test_cases", - "type": "array", - "fields": [ - { - "name": "project_id", - "type": "integer" - }, - { - "name": "test_case_ids", - "type": "array" - } - ], - "json_path": "/test_run/shared_test_cases", - "description": "Cases shared in from other projects: a list of {project_id, test_case_ids: [...]} objects. The flat/scalar form is stripped by the parameter filter and silently yields zero mapped cases, so always send the object shape. Not present in the published schema." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Put every test case in the project into the run." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "example": [ - 1, - 2 - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs (integers) whose cases go into the run." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders", - "description": "Descend into sub-folders of folder_ids. Sending this key with folder_ids absent or empty is a hard 400 ('Cannot process nested folders of empty folder array'), so only send it alongside a non-empty folder_ids." - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "match", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Forward-matching rule for the run, and the only place is_dynamic is read. Sending it ALONE does not select cases: the run is created empty and marked dynamic. Labels ('High', 'Draft') are resolved to ids; an owner email that does not resolve in this group fails the create with 400. is_dynamic:true needs at least one other key here or it is a 400." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "default": "global", - "json_path": "/test_run/filter_scope", - "description": "How filter_test_cases combines with folder_ids: 'global' unions them across the project, 'within_folders' intersects the filter with the listed folders. Honoured on create only — the run update paths accept it and ignore it." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Sending it REPLACES the top-level configurations list with the ids used here. It is an overlay, not the case list — cases selected elsewhere still come in, with a null-configuration row." - }, - { - "name": "test_case_assignee", - "type": "string", - "example": "tester@acme.com", - "json_path": "/test_run/test_case_assignee", - "description": "Default assignee applied to the cases placed in the run, as an email. Applied by the asynchronous population step, not at create time. Not present in the published schema." - }, - { - "name": "is_automation", - "type": "boolean", - "default": false, - "json_path": "/test_run/is_automation", - "description": "Accepted and then DROPPED — it does not make the run an automation run. It is still read to decide which write permission is demanded, so sending true can 403 a caller who could have created a manual run. To get an automation run, create it without this flag and then call update_test_run with is_automation: true on the returned identifier; confirm with the v1 run read." - } - ], - "intent": "Create a test run in a project and load it with the test cases to be executed — the starting point for recording results, and what you make before assigning testers or linking the run to a plan", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Every field goes inside a `test_run` object. A flat body is rejected before a single field is read.", - "Only test_cases, shared_test_cases, folder_ids or include_all actually put cases in the run. filter_test_cases is a forward-sync rule, NOT a selection: a create carrying only filter_test_cases returns 200 with an EMPTY run and silently marks it dynamic even though you did not ask. If the user named cases, list them.", - "Create runs STATIC unless the user explicitly asked for one that keeps picking up new cases. A dynamic run matches on nine fields only — priority, status, category, owner, automation_status, automation_state, folder, tags, issues — so a 'cases created in the last 7 days' filter is stored, echoed back on read, and never matched, and the run over-collects forever.", - "is_dynamic belongs inside filter_test_cases and nowhere else; a top-level is_dynamic is stripped by the parameter filter and has no effect. is_dynamic:true with no other key inside filter_test_cases is a 400 saying filter criteria are required.", - "A bare 400 'invalid data' is about the run's own fields — it is raised while converting the run header, before any case selection is built — most often an owner or reviewer inside filter_test_cases that resolves to no user in this group. It is never a complaint about the shape of your selection.", - "Populating the run is asynchronous. A 200 means the run exists, not that its cases are attached yet; re-read the run's test cases to confirm. A 202 with build_pending:true means the run was created but is not readable back yet — poll for it, never retry the create, which would make a second run.", - "include_sub_folders is only meaningful next to a non-empty folder_ids; on its own it is a hard 400.", - "A run created here keeps no version history. If the wrong cases land in it, adding is easy but removing is a separate operation, and the run's own metadata cannot be rolled back — only overwritten or the run deleted.", - "The new run comes back at the top level as `test_run`, not under `data` — unlike the v1 create, which wraps its payload in `data`.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunV2Response" - }, - "202": { - "description": "The run was created but is not yet readable back — its build is still being provisioned and case population is in progress. Poll for the run rather than retrying the create.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "build_pending": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Test run was created; its build is still being created and population is in progress." - }, - "test_run": { - "$schema": "TestRun" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_plans", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." - }, - { - "name": "count", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page; read first when both this and page_size are sent. Defaults to 30. Above 300 the call is refused with 400 'Page limit exceeds the maximum limit of 300' rather than clamped." - }, - { - "name": "page_size", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Alias for count, read only when count is absent. Same 300 ceiling and the same 400 above it." - } - ], - "intent": "List the test plans in a project — the read that turns a plan name the user said into the TP-NNN id every other plan call needs, and answers 'what plans do we have?'", - "guidance": [ - "Page with `p` (NOT `page`) and size with `count` (or `page_size`). Default 30, hard ceiling 300 — asking for more errors instead of clamping, so ask for 300 in one go rather than walking 30 at a time.", - "Deep paging is capped on rows, not pages: once p x page_size passes the account's limit (30,000 by default) the call returns 429 'request_limit_exceeded'.", - "There is NO filtering on this route — no name search, status, owner, tag or sort parameter is read here. Page through and match client-side, or use the v1 plan listing, which does take status/sort/include_sub_plans.", - "Whether sub-plans (STP-NNN) appear among these rows is decided by the backing service and is not steerable from here. Read each row's parent_plan_id rather than assuming, and list a parent's children through the sub-plans listing.", - "Rows are the same plan shape the single-plan read returns, minus the embedded runs. Carry `identifier` (TP-NNN / STP-NNN) forward — it is what the v2 plan paths take, not the numeric id the v1 plan capabilities use.", - "Page against the `info` envelope (page, page_size, count, next, prev) instead of probing for an empty page." - ], - "returns": [ - "success", - "test_plans", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls", - "info" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "TestPlanV2ListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Release 2.0 plan", - "json_path": "/test_plan/name", - "description": "Plan name, and the only required field. Blank or missing fails the whole call with 400 'Name field cannot be blank' before anything is created." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/test_plan/start_date", - "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. An unparseable value is dropped silently rather than refused." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-01-31", - "json_path": "/test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "release-2.0" - ], - "json_path": "/test_plan/tags", - "description": "Plain tag strings. A non-string entry makes the whole list fall back to empty — no error, just an untagged plan." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123", - "JIRA-456" - ], - "json_path": "/test_plan/issues", - "description": "Plain issue keys, not objects: the v1 structured {issue_id, issue_type, metadata} form is filtered out here. Each key is stored as a jira-type link unless issue_tracker says otherwise. Linking is queued asynchronously, so the issues may not yet appear on an immediate re-read." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker, adding ticket metadata. Needs BOTH name ('jira') and host; a host that matches no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'. Omit it and issues are stored as bare jira references." - }, - { - "name": "created_at", - "type": "string", - "format": "date-time", - "json_path": "/created_at", - "description": "Backdated creation time, for migrating plans in from another tool. Sits at the TOP level of the body, not inside test_plan, and is only honoured for groups with the external-API timestamp feature enabled — otherwise it is ignored silently. Unparseable is 400." - }, - { - "name": "updated_at", - "type": "string", - "format": "date-time", - "json_path": "/updated_at", - "description": "Backdated last-update time. Top level like created_at, same feature gate. Must be >= created_at (or >= now when created_at is absent), else 400." - } - ], - "intent": "Create a test plan to group the test runs for a release, sprint or milestone — the TP-NNN it returns is what runs and sub-plans then hang off", - "guidance": [ - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own wrapper — `test_plan`, `plan` or `sub_test_plan` — is rejected before the call as an unknown body key.", - "The plan is always created in the `new` state: a plan_status in the create body is not read at all. Move it to started/completed afterwards with the update capability.", - "Owner is always the calling user — there is no owner field on this route, unlike the v1 create.", - "This route always creates a TOP-LEVEL plan: a parent_plan_id you send is filtered out before the plan is built, so the created plan has no parent and the response never carries a parent_plan_id key at all - not even as null. To create a child, use the sub-plan create under its parent; parentage cannot be changed afterwards on v2.", - "Reviewers, attachments and custom fields are v1-only on plans; sending them here changes nothing and is not reported.", - "Recoverable: a plan created by mistake can be archived or deleted afterwards, so nothing here is one-way.", - "The response is the created plan under `test_plan`; take `identifier` (TP-NNN) from it. There is no numeric id field, but the numeric plan id and project id are both embedded in the web-app link under urls.self - that is where to recover the numeric id the v1 plan capabilities take." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$schema": "TestPlanV2Response__CreateTestPlan" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_plan", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan identifier in TP-NNN form, or STP-NNN for a sub-plan — both match this route. The integer plan id the v1 plan capabilities take does NOT match here and gives a routing 404." - } - ], - "intent": "Read one test plan or sub-plan — dates, status, owner-facing description, tags, linked issues, how many runs and sub-plans it holds, and a first glimpse of the runs inside it", - "guidance": [ - "Pass an STP-NNN to read a sub-plan: the same route serves both, and the plan still comes back under the `test_plan` key (never `sub_test_plan`).", - "The embedded `test_runs` array is a preview, not the membership list: only identifier and name, and only the first 30 runs of the plan. Use the plan's test-runs read to page through them or to see run state and progress.", - "If the upstream run fetch fails, the call still returns 200 with the `test_runs` and `links` keys ABSENT. Absent is not the same as an empty plan — check test_runs_count before concluding a plan has no runs.", - "test_runs_count splits active/closed and counts this plan's own runs only. parent_plan_id appears ONLY when the plan you read is a sub-plan, and carries the parent's TP-NNN, not its numeric id; on a top-level plan the key is omitted from the payload entirely rather than returned as null, so test for the key's presence, not for a null value. sub_plans_count comes back on both - it is the number of children, and reads 0 on a sub-plan.", - "urls.self is a web-app deep link built from numeric ids, and is the only place those numeric ids appear; urls.sub_test_plans, the address of this plan's children listing, is added only when the plan has no parent. links.self and links.test_runs are relative API paths and come and go with the test_runs preview.", - "active_state is derived: 'completed' once the plan is completed, otherwise 'active'. It does not distinguish a new plan from a started one.", - "A wrong, mistyped or other-project id is a 404 'Test Plan ID is invalid. Please enter valid Test plan ID and try again' — the same message for every not-found case.", - "Whether an archived plan is readable here is not settled by this route; treat a 404 on a plan the user insists exists as a possible archive and check the archived listing." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "test_runs", - "sub_plans_count", - "parent_plan_id", - "start_date", - "end_date", - "created_at", - "project_id", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestPlanV2DetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_sub_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/sub-test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "The PARENT plan, as TP-NNN. This is where the new child hangs; it is not the id of the plan being created. An unknown parent is a 404 before anything is created." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Payments — phase 1", - "json_path": "/sub_test_plan/name", - "description": "Sub-plan name, and the only required field. Blank or missing fails the call with 400 'Name field cannot be blank'." - }, - { - "name": "description", - "type": "string", - "json_path": "/sub_test_plan/description", - "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/sub_test_plan/start_date", - "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. Not validated against the parent plan's dates — a child may sit entirely outside its parent's window." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-01-15", - "json_path": "/sub_test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression" - ], - "json_path": "/sub_test_plan/tags", - "description": "Plain tag strings. Tags are not inherited from the parent plan. A non-string entry makes the whole list fall back to empty, with no error." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123" - ], - "json_path": "/sub_test_plan/issues", - "description": "Plain issue keys, not objects. Stored as jira-type links unless issue_tracker says otherwise, and queued asynchronously so they may lag an immediate re-read." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/sub_test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." - } - ], - "intent": "Break a test plan into a child plan — use this when a release plan needs per-team, per-phase or per-platform slices that each hold their own runs", - "guidance": [ - "Send the fields flat. The wrapper applied for you here is `sub_test_plan`, NOT `test_plan`; that difference is handled by the json_paths, and sending either wrapper yourself is rejected before the call as an unknown body key.", - "The parent comes from the path; there is no parent_plan_id field, and a sub-plan cannot be re-parented or promoted afterwards on v2.", - "Every response from this route carries parent_plan_id - the parent's TP-NNN, not its numeric id - because the route only ever creates children. It also carries sub_plans_count, which is 0 on a plan that was just created; that field is not suppressed for sub-plans. urls.self is a web-app deep link and the only place the new plan's numeric id appears; the sub_test_plans link is added to top-level plans only, so a sub-plan does not get one.", - "The created plan comes back under `sub_test_plan` (not test_plan) and its identifier is STP-NNN. That STP id is accepted wherever a plan id is taken on these v2 plan routes — read it, edit it, list its runs.", - "Runs are not moved here: a new sub-plan starts empty, and a run is attached by setting its plan on the run itself, which unlinks it from whichever plan held it before.", - "Recoverable: an unwanted sub-plan can be deleted or archived afterwards.", - "Completed sub-plans are hidden from the sub-plan listing, so a child you create and later complete stops appearing there even though it still exists." - ], - "returns": [ - "success", - "sub_test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$response": "SubTestPlanV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_plan_test_runs", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/test-runs", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan identifier in TP-NNN form, or STP-NNN to list a sub-plan's own runs. The integer plan id the v1 capabilities take does not match this route." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. This is the ONLY paging control here — the page size is fixed." - } - ], - "intent": "List the test runs that belong to a test plan, with each run's state, assignee and pass/fail progress — the read behind 'how is this release going?' and 'which runs are in this plan?'", - "guidance": [ - "Page with `p` only. The page size is FIXED at 30 and cannot be raised: `count` and `page_size` are documented on this route but not read, so asking for 100 still returns 30. Page against the `info` envelope.", - "Rows carry `identifier` (TR-NNN) but NO numeric run id — the integer uuid some plan-linking calls need is not in this payload, so read the run itself when you need it.", - "Rows here omit `test_plan` entirely — the plan is already the scope you asked for. Do NOT read its absence as the run being unlinked; the project-wide run listing does carry the plan link for the same run.", - "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none'.", - "Each row also carries a `urls` object whose `self` is a deep link to the run in the web app, alongside the `links` object of API references.", - "There is no include-sub-plan-runs switch here (v1 has one). To cover a parent and its children, list each sub-plan's runs separately.", - "When the upstream run service fails, the body STILL says success: true with test_runs null while the HTTP status carries the real failure. Check that test_runs is an array before reading 'no runs' as an empty plan.", - "Progress arrives as overall_progress, a map of status name to count for the run's cases; a run with nothing executed may omit the key entirely rather than report zeros.", - "This lists runs, not results or cases — drill into a run's test cases for case-level state." - ], - "returns": [ - "success", - "test_runs", - "info", - "identifier", - "name", - "description", - "run_state", - "active_state", - "assignee", - "tags", - "configurations", - "filter_test_cases", - "is_dynamic", - "overall_progress", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestRunsListV2Response" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/update", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan to edit, as TP-NNN — or STP-NNN to edit a sub-plan through this same route. The integer plan id the v1 capabilities take does not match here." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Release 2.0 plan", - "json_path": "/test_plan/name", - "description": "New name. Optional here, unlike the v1 plan edit which rejects any body without it. An empty string is ignored and the current name is kept — the name cannot be cleared." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "New description. An empty string is ignored and the stored description is kept, so a description cannot be cleared here. Sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/test_plan/start_date", - "description": "Plain date (YYYY-MM-DD). Applied only when non-empty; omitted leaves the current date. Once the plan is started this is fixed and further values for it are ignored." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-02-28", - "json_path": "/test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Applied only when non-empty. Earlier than the plan's start date is refused with 400 'Invalid end_date, should be greater than start_date' and nothing is saved." - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "example": "started", - "json_path": "/test_plan/plan_status", - "description": "Lifecycle move. Only forward, one step at a time: new -> started -> completed. 'started' stamps start_date (today unless you send one) and 'completed' stamps end_date the same way." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "release-2.0" - ], - "json_path": "/test_plan/tags", - "description": "REPLACES the plan's tags with this list. An empty array is treated as 'not sent' and leaves the existing tags in place, so tags cannot be cleared through this call." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123" - ], - "json_path": "/test_plan/issues", - "description": "Plain issue keys, not the v1 structured objects. Sent non-empty they replace the linked set; an empty array is ignored, so links cannot be removed here. Processing is queued, so an immediate re-read may lag." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." - } - ], - "intent": "Change an existing test plan or sub-plan — rename it, move its dates, retag it, relink issues, or start/complete it; note that attaching or detaching a test run is NOT done from the plan on v2, it is set on the run", - "guidance": [ - "Merge, not replace: only the fields you send change and anything omitted keeps its current value, so a one-field edit is safe. (The v2 run 'update' on the same path shape behaves the opposite way — do not carry that habit here.)", - "Linking runs is the trap. A `test_runs` list in this body is SILENTLY DISCARDED — 200, plan unchanged, nothing linked, no warning. It is a v1-only field. On v2 you set the run's plan from the run side (its test_plan_id / sub_test_plan_id), and that MOVES the run: a run belongs to at most one plan, so the plan that held it loses it with nothing in the response to say so. Check the runs' current plans first and tell the user which plans will lose runs.", - "Unlike the v1 plan edit, name is NOT required — an update carrying only dates, tags or a status move is accepted.", - "Lifecycle is one-way and one step at a time: new -> completed is 400 'Cannot directly complete test plan', started -> new is 400 'Cannot revert started test plan', and once completed the plan is FROZEN — every later edit, including a rename, is 400 'Cannot update completed test plan'. Complete a plan last.", - "No undo: plans keep no version history, so a name, description or date this call overwrites cannot be restored and there is nothing to roll back to. Read the plan first if the old value matters.", - "Clearing is not possible through this call: empty strings and empty arrays are read as 'not sent' for name, description, tags and issues.", - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path — including when the target is a sub-plan (STP-NNN), where `sub_test_plan` is NOT the key this route reads. Sending your own wrapper is rejected before the call as an unknown body key.", - "parent_plan_id comes back only when the plan you edited is a sub-plan, carrying the parent's TP-NNN; a top-level plan's response omits the key entirely rather than returning null. This call cannot change parentage, so the field never appears or disappears as a result of the edit. sub_plans_count is always present, and test_runs and links are not - they belong to the single-plan read.", - "Owner, reviewers, attachments, custom fields and re-parenting are v1-only on plans; sending them here changes nothing and is not reported.", - "A rejected edit returns 400 with {success: false, error: ...} and saves nothing — the plan is left exactly as it was." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$schema": "TestPlanV2Response__UpdateTestPlan" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_custom_fields", - "method": "GET", - "path": "/api/v2/custom-fields", - "mode": "read", - "entity": "custom_field", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page` is not read on this listing: send it and you silently get page 1 again. A zero, negative, non-numeric or repeated value is refused with 400 'Invalid Params' rather than coerced." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Rows per page. Defaults to 30 and the real ceiling is 300 — a larger value is REFUSED with 400 'Page limit exceeds the maximum limit of 300', not clamped, so ask for 300 directly on a large workspace. The published spec claims a maximum of 100; the code enforces 300." - } - ], - "intent": "Discover the test-case custom fields defined in this workspace — list each field with its name, type, required flag and linked-project count, and pick up the numeric field id needed before reading, editing or filling in a field", - "guidance": [ - "Page with `p` and `count` — not `page` / `per_page`, which this listing ignores. `count` above 300 is a 400 naming the limit, so request 300 when you want the workspace in as few calls as possible.", - "Only TestCase fields are listed. The entity type is fixed server-side, so test-result, test-plan and exploratory-session fields never appear here whatever you send — the workspace field listing on the admin-settings surface is the one that covers those.", - "Rows are field metadata only: no dropdown options, no dataset and no list of linked projects, just a linked_projects_count. Read the field's datasets when you need the option values or the actual project links.", - "This is the newer public custom-fields surface. The admin-settings custom-field capabilities read the same stored definitions but answer in a different envelope and cover every entity type — prefer this one for test-case fields, and fall back to those for result/plan fields and for dataset or option editing.", - "Carry the numeric `id` from each row forward: that is exactly what the single-field read and the field edit take in their path.", - "Needs the field-view permission when role-based access is on. Without it the answer is 403 'This action is not permitted for the user', not an empty list." - ], - "returns": [ - "success", - "custom_fields", - "info", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "placeholder", - "system_name", - "is_bulk_editable", - "is_filterable", - "linked_projects_count", - "group_id" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "CustomFieldsV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_custom_field", - "method": "POST", - "path": "/api/v2/custom-fields", - "mode": "write", - "entity": "custom_field", - "body": [ - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "example": "dropdown", - "description": "The kind of field. Mandatory in practice even though the body schema marks nothing required — anything outside this list is a flat 400 'Invalid Params'. It is fixed for the life of the field: the edit capability will not change it.", - "minLength": 1 - }, - { - "name": "field_name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 256, - "example": "Test Environment", - "description": "Label shown on the test case. Mandatory; trimmed before storage, so ' Priority' is stored as 'Priority'. Over 256 characters or blank is a 400 'Invalid Params'. Names that collide with a system field are refused separately (see guidance)." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "example": false, - "description": "Whether a test case must fill this field. Mandatory, and it must be a real JSON boolean: the string \"true\" fails the parameter check with a flat 400 'Invalid Params' that names no field." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "example": "Select an environment", - "description": "Hint text shown in the empty field. Trimmed before storage; over 256 characters is a 400 'Invalid Params'." - }, - { - "name": "default_value", - "type": "string", - "description": "Accepted by the schema but NOT settable here: for every non-boolean type it is discarded before the write, and for boolean the checks contradict each other (a JSON boolean fails the body schema, the string 'true' fails the parameter check). Set it afterwards with the field edit capability." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase" - ], - "default": "TestCase", - "description": "What the field attaches to. Only TestCase exists on this route — any other value is 400 'Only TestCase entity type is supported'. Result, plan and exploratory-session fields are created on the admin-settings surface instead." - }, - { - "name": "options", - "type": "array", - "description": "Choices for dropdown / multi_dropdown. Persisted ONLY when the request also carries applies_to_all_projects or assigned_projects — sent alone they are silently dropped while still being echoed in the response. For multi_dropdown every is_default is forced to false; for dropdown only the first is_default:true survives.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The choice as the user sees it. Must be non-empty.", - "minLength": 1 - }, - { - "name": "is_default", - "type": "boolean", - "description": "Pre-selected choice. At most one per dropdown; ignored for multi_dropdown." - } - ] - }, - { - "name": "applies_to_all_projects", - "type": "boolean", - "default": false, - "description": "Link the new field to every project that exists today. Heavy: the server enumerates the whole workspace (stopping around 3000 projects) and links each one. NOTE this is not the same flag as on the edit capability, where it only toggles future-project linking." - }, - { - "name": "assigned_projects", - "type": "array", - "example": [ - "PR-12345", - "PR-67890" - ], - "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ], - "items": { - "type": "string" - } - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "default": false, - "description": "Automatically offer this field in projects created later. Only meaningful alongside applies_to_all_projects or assigned_projects, because it is stored on the dataset those build." - }, - { - "name": "levels", - "type": "array", - "minItems": 2, - "description": "Hierarchy tiers, REQUIRED for field_type nested_dropdown and ignored for every other type. At least two entries, each with name and level_type; the schema refuses a nested_dropdown create without them.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Tier label, e.g. COUNTRY." - }, - { - "name": "level_type", - "type": "string", - "required": true, - "description": "Widget for the tier, e.g. dropdown." - }, - { - "name": "index", - "type": "integer", - "description": "Position of the tier, top tier first." - } - ] - }, - { - "name": "parent_custom_field_id", - "type": "integer", - "minimum": 1, - "description": "For nested_dropdown the KEY must be present even when the value is null — the upstream nested-create path is chosen on the key's presence. Set to an existing nested field's id to add one more tier to it, in which case only field_name is validated and the rest is copied from the parent." - }, - { - "name": "datasets", - "type": "array", - "description": "Advanced, forwarded upstream as sent. Lets one call define option values and project links together, including a recursive children tree for nested_dropdown. Prefer options + assigned_projects / applies_to_all_projects; reach for this only when you need nested option trees or explicit unlinks.", - "fields": [ - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "Expand to every project in the workspace before sending." - }, - { - "name": "linked_projects", - "type": "array", - "description": "Numeric project ids to link on this dataset (this path takes ids, not PR-NNN identifiers)." - }, - { - "name": "unlinked_projects", - "type": "array", - "description": "Numeric project ids to unlink on this dataset." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Link projects created later." - }, - { - "name": "options", - "type": "array", - "description": "Option rows; each may carry a children array one tier down for nested_dropdown." - } - ] - } - ], - "intent": "Define a new test-case custom field — choose its type, name it, say whether filling it is mandatory, and optionally give a dropdown its choices and link it to the projects that should offer it", - "guidance": [ - "field_type, field_name and is_required are all mandatory although the body schema marks nothing required, and is_required must be a real JSON boolean. Every failure of that validator comes back as the same 400 'Invalid Params' with no field named, so check all three before retrying.", - "options are only stored when the same request also carries applies_to_all_projects or assigned_projects — the choices live on a dataset, and no dataset is built without one of those. Sent alone they are dropped, and the response still echoes them, so a dropdown can look configured while holding no values. Re-read the field's datasets to confirm.", - "The returned custom_field is partly an echo of the request: options, assigned_projects, applies_to_all_projects and link_to_future_projects are replayed from what you sent (or defaulted to false / empty), not read back from storage. Only id, field_name, field_type, entity_type and is_required are authoritative.", - "assigned_projects takes PR-NNN identifiers, not numeric project ids, and one unresolvable identifier fails the whole create with 400 — resolve the projects first. applies_to_all_projects:true instead walks every project in the workspace (up to about 3000) and links them all, which is a slow call on a large account.", - "default_value cannot be set at create time for any type. Create the field, then set it through the field edit capability, where a boolean default is sent as the string 'true' or 'false'.", - "These names are reserved and rejected with 400 'Field with same name already exists. Use a unique name.': Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status.", - "Choose the type carefully — it can never be edited. Changing a field's type in practice means deleting it and creating it again, which destroys every value already stored on test cases.", - "nested_dropdown needs levels with at least two entries (name + level_type each) and the parent_custom_field_id key present even when null. To append one more tier to an existing nested field, send parent_custom_field_id plus field_name and nothing else.", - "Same stored fields as the admin-settings create capability, different contract: this route is TestCase-only, takes PR-NNN project identifiers rather than numeric ids, and answers with a serialized custom_field object instead of the raw upstream record.", - "Needs the field-write permission; 403 otherwise. The new field can be removed again, but any values captured on test cases in the meantime go with it.", - "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null — and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "options", - "applies_to_all_projects", - "assigned_projects", - "link_to_future_projects", - "system_name", - "is_bulk_editable", - "is_filterable", - "placeholder", - "group_id", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2DetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_custom_field", - "method": "GET", - "path": "/api/v2/custom-fields/{id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The field's numeric id, as the custom-fields listing returns it. It is validated as a positive integer before anything else runs: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer'. The published spec showing a prefixed 'cf-123' string is wrong." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the datasets fetched behind this field, reflected in dataset_ids / dataset_urls. `p` is accepted as a legacy alias for the same thing. Zero or non-numeric falls back to 1 instead of erroring." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 20, - "description": "Datasets per page behind this field. Defaults to 20 here (not 30) and is clamped at 300 rather than refused. Most fields have a single dataset, so this rarely matters." - } - ], - "intent": "Read one custom field by its numeric id — confirm its name, type, placeholder, default and whether it is mandatory, and pick up the ids of the datasets that hold its option values", - "guidance": [ - "applies_to_all_projects and link_to_future_projects come back hardcoded false on this read. They are shape fillers, not the field's real project configuration — never report project scope from this response; read the field's datasets for that.", - "No option values are included. What you get is dataset_ids plus ready-made dataset_urls and datasets_url; follow one of those to see the choices a dropdown offers and which projects it is linked to.", - "An id that exists nowhere is 404 'Custom field not found', while an id that is not a positive integer is 400 — the two are distinguishable, so do not treat a 400 as 'no such field'.", - "The `project` query parameter documented in the spec is not read by this route. Filtering a field's option values by project is done on the dataset listing, not here.", - "Needs the field-view permission when role-based access is on; 403 otherwise.", - "Same stored definition as the admin-settings single-field read, but a different body: that one returns the field with its datasets and options inline, this one returns metadata plus dataset references.", - "Built by the same serializer as the create and update replies, which omits any field without a value — so several keys are absent rather than null, and `is_bulk_editable` and `is_filterable` are omitted when FALSE as well as when unset, which makes those two indistinguishable from unset in the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "placeholder", - "system_name", - "is_bulk_editable", - "is_filterable", - "group_id", - "applies_to_all_projects", - "link_to_future_projects", - "dataset_ids", - "datasets_url", - "dataset_urls", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2WithDatasetsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_custom_field", - "method": "PATCH", - "path": "/api/v2/custom-fields/{id}", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The field's numeric id. Validated as a positive integer before the field is loaded: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer', and an id with no field behind it is 404 'Custom field not found'." - } - ], - "body": [ - { - "name": "field_name", - "type": "string", - "minLength": 1, - "maxLength": 256, - "example": "Test Environment", - "description": "New label. Omit it to keep the current one — this is a partial update. An empty string does NOT clear the name, it leaves the stored value untouched. Renaming onto a reserved system-field name is refused with 400." - }, - { - "name": "is_required", - "type": "boolean", - "description": "Whether the field must be filled. Omit to keep the current setting; false is honoured (unlike the text attributes, this one is applied even when falsy). Must be a real JSON boolean." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "New hint text, trimmed before storage. Omit to keep the current one. An empty string does not clear it — the stored value survives." - }, - { - "name": "default_value", - "type": "string", - "description": "New default. Unlike the create call this one is applied, for every field type. On a boolean field send the STRING \"true\" or \"false\" — it is coerced to \"1\"/\"0\", a real JSON boolean is rejected by the body schema, and an empty or falsy value leaves the stored default in place." - }, - { - "name": "options", - "type": "array", - "description": "Replaces the dropdown's option list WHOLE on the field's existing dataset — options you leave out are removed and the values test cases hold for them are gone, in every linked project, with no undo. Send the full intended list. Ignored on non-dropdown field types.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The choice as the user sees it.", - "minLength": 1 - }, - { - "name": "is_default", - "type": "boolean", - "description": "Pre-selected choice." - } - ] - }, - { - "name": "assigned_projects", - "type": "array", - "example": [ - "PR-12345", - "PR-67890" - ], - "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ], - "items": { - "type": "string" - } - }, - { - "name": "linked_projects", - "type": "array", - "example": [ - "PR-12345" - ], - "description": "Projects to ADD, as PR-NNN identifiers. Sending this (or unlinked_projects) switches off the assigned_projects diff entirely, so existing links you do not mention are left alone. An identifier that resolves to no project fails the call with 400.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ] - }, - { - "name": "unlinked_projects", - "type": "array", - "example": [ - "PR-99999" - ], - "description": "Projects to REMOVE, as PR-NNN identifiers. Unlinking discards the values those projects' test cases hold for this field and cannot be undone. An unresolvable identifier fails the call with 400.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ] - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Whether projects created later automatically offer this field. Applied to the field's dataset; sending it is itself enough to trigger the dataset write." - }, - { - "name": "applies_to_all_projects", - "type": "boolean", - "description": "Legacy alias that here ONLY sets the future-projects flag — it does not link the projects that already exist, unlike the same flag at create time. An explicit link_to_future_projects wins over it. Use linked_projects to reach existing projects." - }, - { - "name": "levels", - "type": "array", - "description": "Nested-dropdown tiers, forwarded upstream but currently ignored there for renames. Rename or retype a tier by editing that tier's own field id instead.", - "fields": [ - { - "name": "id", - "type": "integer", - "description": "Existing tier field id." - }, - { - "name": "name", - "type": "string", - "description": "Tier label." - }, - { - "name": "level_type", - "type": "string", - "description": "Widget for the tier." - }, - { - "name": "index", - "type": "integer", - "description": "Position of the tier." - } - ] - } - ], - "intent": "Change an existing test-case custom field — rename it, make it mandatory or optional, adjust its placeholder or default value, replace a dropdown's choices, or change which projects offer it", - "guidance": [ - "This is a PARTIAL update: every attribute you leave out keeps its stored value, so sending field_name alone is a valid rename. It differs from the admin-settings update capability, which is a full replace demanding field_name, field_type and is_required together.", - "None of it can be undone. Option lists and project links are rewritten in place, there is no bin and no prior revision: an option dropped from `options` is removed from the dataset and every value test cases hold for it disappears across all linked projects.", - "assigned_projects is read as the complete desired set, not an addition — any currently linked project you omit is unlinked, and the values stored there go with it. Count and name the projects you are about to drop before sending, or use linked_projects / unlinked_projects to change links one at a time.", - "field_type is not editable here. A field_type in the body is ignored, the stored type is kept and the call still answers 200, so never report a type change as done. A guard meant to refuse it with 400 exists but cannot fire, because the stored field is loaded after that check runs.", - "Empty strings do not clear anything: field_name, place_holder_text and default_value are applied only when non-empty, so the stored value survives an attempt to blank it. is_required is the exception — false is written.", - "Dataset-side changes (options, assigned_projects, linked_projects, unlinked_projects, link_to_future_projects, applies_to_all_projects) only take effect on dropdown, multi_dropdown and nested_dropdown fields. On any other type they are accepted, answered 200 and quietly ignored. If the field has no dataset yet, one is created.", - "applies_to_all_projects means something different here than at create time: it only toggles the future-projects flag and never links existing projects.", - "The two halves are not atomic. Field attributes are written first, the dataset second, so a failure on the dataset call leaves the rename or required-flag change already applied. Re-read the field before retrying.", - "The response replays options, assigned_projects, applies_to_all_projects and link_to_future_projects from your request rather than from storage — only the field attributes in it are read back. Confirm project links and option values by reading the field's datasets.", - "Needs the field-write permission; 403 otherwise. Unknown id is 404, a non-integer id is 400.", - "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null — and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "options", - "applies_to_all_projects", - "assigned_projects", - "link_to_future_projects", - "system_name", - "is_bulk_editable", - "is_filterable", - "placeholder", - "group_id", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2DetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_reports", - "method": "GET", - "path": "/api/v2/projects/{project_id}/reports", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "example": 2, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again with a 200. Rows come back newest-created first." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (5, 50, 100, 301) is refused with a 400 naming the two allowed values rather than clamped, so ask for 300 directly when you want a whole project in few calls." - }, - { - "name": "is_scheduled", - "type": "boolean", - "example": true, - "description": "Narrows to recurring (scheduled) reports only, and it is a one-way switch: the filter is applied only when the value is exactly the string 'true'. is_scheduled=false applies NO filter and returns both kinds, so it cannot be used to find one-off reports — read the is_scheduled flag on each row for that." - }, - { - "name": "report_type", - "type": "string", - "example": "Test Run Summary", - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Test Case Activity", - "Requirement Traceability Report", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "description": "Exact-match filter on the stored report type DISPLAY NAME, including spaces and capitals. A near-miss ('test run summary', 'Test Runs Summary') is not an error — it returns 200 with an empty list, which reads exactly like 'this project has no such reports'." - }, - { - "name": "q[query]", - "type": "string", - "example": "regression", - "description": "Case-insensitive substring search across report title AND report type, matched together. The parameter is nested and must go on the wire as q[query]=term. A flat q=term is not an error and not a search: the filter is skipped and you get the full unfiltered list with a 200." - } - ], - "intent": "Find a project's reports — search saved and scheduled reports by title or type, see which ones run on a recurring schedule and when each next fires, or pick up the SC-NNN identifier of a report you then want to read or download", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 — any other number is a 400, not a clamp — so pass 300 when you want the project in as few calls as possible.", - "Both one-off and scheduled reports are listed by default; is_scheduled=true narrows to scheduled ones, and nothing narrows to one-off ones.", - "scheduled_report_count and total_report_count describe the WHOLE filtered result set, not the page you are holding — use them to answer 'how many?' without paging, but remember any report_type / q / is_scheduled filter you sent is already baked into both numbers.", - "Each row's `id` is the SC-NNN identifier, and that is what the v2 single-report read and the v2 download take. It is NOT the integer report id the v1 report capabilities (report detail, report sections, drilldown, send-email-now) require: SC-NNN is a per-group sequence number, so stripping the prefix gives the wrong id. Get the integer from the v1 schedules listing, which returns `id` and `identifier` side by side on the same records.", - "Rows carry the report's CONFIGURATION only — type, timeframe, filters, schedule, recipients. No figures, no report data. Reading the numbers a report contains is a separate call.", - "`latest_attachment` is derived from the newest generated file in the whole PROJECT, not from this report — a row can advertise an attachment produced by a different report. Treat it as 'something was generated here recently', not as this report's output.", - "`dynamic_filters` is always null on every row, even for reports created with dynamic filters; only `report_filters` echoes back what was configured.", - "Cross-project reports appear under every project they cover, so the same SC-NNN can show up in more than one project's listing." - ], - "returns": [ - "success", - "reports", - "info", - "scheduled_report_count", - "total_report_count", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "V2ReportListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_report", - "method": "POST", - "path": "/api/v2/projects/{project_id}/reports", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project the report is filed under, in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. For a multi-project report this project is still the owner; the full set goes in project_ids." - } - ], - "body": [ - { - "name": "report_type", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Case Activity", - "Requirement Traceability Report", - "Exploratory Session Summary" - ], - "description": "What the report reports on, as a DISPLAY NAME - the set is closed and matched exactly, spaces and capitals included. Only these six can be created here. Other report types exist in the product and appear on reports created elsewhere - Test Plan Detailed Report, User Workload Report, Defects Summary, Defects Detailed Report - but every one of them is refused by this create. Never substitute a neighbour: an activity question is 'Test Case Activity', not 'Test Run Summary'." - }, - { - "name": "report_timeframe", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_sessions" - ], - "description": "What the report covers: either a rolling window, or a mode that says the scope comes from report_filters (specific_test_runs / specific_test_plans / specific_sessions). 'custom' pairs with custom_date_range. Two further values exist in the stored data and can be seen on reports created elsewhere - a requirements scope and a specific-test-cases scope - but neither is accepted by this create, and both are refused by body validation before any report-specific message is reached." - }, - { - "name": "report_filters", - "required": true, - "description": "Required - a create without this key is refused by body validation before anything else is checked, even when the report needs no scoping. Send an empty object for a rolling-window report. When you do scope it, this is a STRICT ALLOW-LIST of exactly four keys - test_runs, test_plans, session_ids, projects. Any other key, including a typo or an advanced-filter name such as status, priority, assignee, requirements, folder or per-case custom fields, is refused by name. Nothing is silently dropped." - }, - { - "name": "mail_to", - "required": true, - "description": "Required - a create without this key is refused by body validation, even for a report nobody will be emailed. Send both inner lists empty for that case. Recipients are wired up only when the report is genuinely recurring; on a one-off the value is accepted and then ignored, so nobody is emailed." - }, - { - "name": "frequency", - "required": true, - "values": [ - "once", - "daily", - "weekly", - "monthly" - ], - "description": "Required - a create without this key is refused by body validation. 'once' means a one-off, unscheduled report and is what you send when nothing should recur. A recurring value only takes effect when frequency_details is sent with it - frequency alone is silently ignored and you get a one-off back, created successfully." - }, - { - "name": "title", - "description": "Display name. Genuinely optional: omit it and the server fills in the report type followed by today's date, which is easy to mistake later for a report someone else made. Titles are not unique, and this is the field the list search matches on." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "description": "Whether the report is one the caller defines or one the product generates. REQUIRED — the request schema rejects a body without it, so a create that omits it fails before anything is stored. It was missing from this contract entirely, which made the capability impossible to call successfully from here." - } - ], - "intent": "Set up a new report over a project's test data — either a one-off you will download, or a recurring daily/weekly/monthly schedule that emails chosen people; this saves the report's definition (type, window, scope, recipients) and hands back its SC-NNN identifier, it does not produce any figures", - "guidance": [ - "`report_creation_mode` is required and takes `custom_report` or `system_generated`. Sending `system_generated` with a traceability report type is treated specially by the server, so use `custom_report` unless you specifically want the generated form.", - "This returns a report DEFINITION, not report content. The created body is the saved configuration echoed back - identifier, type, timeframe, filters, schedule, recipients - and contains no counts, rows or figures at all. Never present it as the report's findings.", - "Getting the content afterwards is a separate, ASYNCHRONOUS step: request a download for the new report identifier and the server answers with a job id and a status URL, having queued the generation; then poll the matching download-status read until status flips from 'processing' to 'ready' and a file URL appears (pdf or csv only). Nothing in this create waits for that.", - "The alternative route to content is the v1 report reads (report detail and its section/widget reads), but those take the INTEGER report id and this API never exposes it - the public identifier is a per-group sequence, so you cannot derive it. Resolve it through the v1 schedules listing, which carries both ids on the same record.", - "Six fields are required, not three: report_type, report_timeframe and report_creation_mode, plus report_filters, mail_to and frequency. The last three are required even when they carry nothing meaningful, and omitting any of them is refused up front by body validation with a message naming the missing property. An empty object for report_filters, empty lists for both parts of mail_to, and 'once' for frequency are all accepted and are exactly what an unscheduled, unmailed report needs.", - "Scheduling needs frequency AND frequency_details together, with frequency one of daily/weekly/monthly. Send frequency alone, or frequency 'once', and you get an unscheduled one-off back with no warning - check the scheduled flag and the next-run time in the response before telling anyone a schedule exists.", - "Reading a report back always shows a frequency of 'once' when no schedule is stored - that is a stand-in for an empty value, not evidence that a one-time run was scheduled. The scheduled flag and the next-run time are the fields that actually distinguish the two.", - "Recipients are wired up only for a recurring report: recipients on a one-off are accepted and then ignored, so nobody is emailed. When you do send them, include BOTH the in-group users and the external addresses (empty lists are fine, and there is a cap of 50 external addresses) - a missing inner key fails after the record is written, and the server deletes the half-built report and answers with an unprocessable-entity error.", - "Validation happens in two stages with two different error shapes. A missing required key, a value outside a published set, or a wrong type is caught first by body validation, which answers with a generic 'invalid JSON data' message plus a details list naming each problem. Everything after that - unknown emails, unresolvable identifiers, unsupported filter keys - is answered as a single message string prefixed 'Validation error.' Branch on the status, not on the body's shape.", - "Every identifier in the body is resolved before anything is written: an unknown email or an unknown run, plan or project identifier comes back as a 400 with field-level errors, and nothing is persisted.", - "Cross-project: more than one project is allowed only for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Case Activity and Requirement Traceability Report, is capped at 60 projects, and for every one of those except Test Case Activity also needs the cross-project feature enabled for the group - without it the answer is a forbidden error. The product's own multi-project list also names two report types this create will not accept at all, so ignore those here.", - "The only way back from this call is deleting the report, which is itself irreversible - there is no archive or trash for reports. Re-running a create with the same title does not replace anything: titles are not unique and you get a second report.", - "The attachment summary on the response is not this report's: it is the newest generated file anywhere in the project, and its ready flag is set whenever any such file exists. A brand-new report can therefore appear to already have output. Do not use it to decide whether this report has been generated." - ], - "returns": [ - "success", - "data", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "responses": { - "201": { - "$response": "V2ReportDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report", - "method": "GET", - "path": "/api/v2/projects/{project_id}/reports/{report_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. It is authoritative: the report must belong to this project or the read is a 404, even inside the same group." - }, - { - "name": "report_id", - "type": "string", - "required": true, - "example": "SC-42", - "pattern": "^SC-\\d+$", - "description": "Report identifier in SC-NNN form — the `id` field of the v2 report listing. The route is constrained to SC-\\d+, so the integer report id used by the v1 report capabilities gives a routing 404 here, and SC-NNN is a per-group sequence number that cannot be converted to that integer by stripping the prefix." - } - ], - "intent": "Read one report's setup by its SC-NNN identifier — what it covers (type, time window, the runs/plans/sessions it is scoped to), whether it is on a schedule and when it next runs, who receives it, and whether a generated file exists; configuration only, no figures", - "guidance": [ - "This returns the report's CONFIGURATION, never its data — there is no report_data, no counts and no rows in the response. For the numbers inside a report, either fetch the generated file (request a download for this SC-NNN, then poll the download status until it is ready) or use the v1 report detail / section reads, which take the INTEGER report id this API never exposes; the v1 schedules listing is where SC-NNN and that integer appear together.", - "Every miss is the same 404 'Report not found': an identifier that does not exist, one from another group, and one that exists but belongs to a different project. A 404 therefore does not mean the report is gone — retry under the project that actually owns it, and for a cross-project report any of its projects will serve it.", - "`latest_attachment` is NOT this report's attachment. It is the newest generated file anywhere in the project, so it can belong to a different report, and it reports ready:true whenever it is present. Its file_type is a MIME type such as application/pdf, not the 'pdf'/'csv' the spec advertises. Only a download job you started yourself tells you about this report's file.", - "`dynamic_filters` is hard-coded to null on this read even when the report was created with them; only report_filters reflects configured scope.", - "report_filters echoes public identifiers rather than the internal ids stored (TR-NNN, TP-NNN, assignee emails). Two caveats: a sub-plan filter comes back as TP-NNN because the sub-plan marker is not kept, and if the upstream identifier lookup fails the values degrade to prefix+raw-id which may not be a real identifier.", - "`frequency` reads 'once' for an unscheduled report — that is a substitution for a stored null, not something the report was created with. Trust `is_scheduled` and `next_run_at` for whether anything will actually fire.", - "`project_ids` lists every project a cross-project report covers, and `project_id` is only the owning one." - ], - "returns": [ - "success", - "data", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "responses": { - "200": { - "$response": "V2ReportDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_entity_attachments", - "method": "GET", - "path": "/api/v2/projects/{project_id}/{entity_type}/{entity_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "example": "test-results", - "values": [ - "test-cases", - "test-results", - "test-plans" - ], - "description": "Which kind of parent item to read attachments from. A CLOSED set of exactly these three hyphenated plurals, enforced in the route. Singular or snake_case spellings (test-case, test_case, testcase, test_results) do not match the route and come back as a routing 404 with no JSON body, not a validation error naming the field. Test runs and exploratory sessions are not attachable here." - }, - { - "name": "entity_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Form depends on entity_type: TC-NNN identifier for test-cases (resolved by identifier lookup; the internal integer case id gives 404 'Test case not found'), the plain INTEGER result id for test-results (no TR- prefix - that is a run), TP-NNN or STP-NNN for test-plans. The route leaves this segment unconstrained, so a wrong form is a 404 from the resolver rather than a 400." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page. Unlike the v2 run and result listings this is a real range, not an allow-list of 30/300 - any value up to 300 is honoured. Above 300 the call is refused with 400 'parameter_error' ('Page limit exceeds the maximum limit of 300'). A non-numeric or absent value is NOT an error: it silently falls back to 30, so a typo quietly shortens your page." - }, - { - "name": "file_type", - "type": "string", - "example": "image", - "description": "Filter by the attachment's content type, matched as a prefix - `image` keeps every image/* row, `application/pdf` only PDFs. Applied upstream; an unmatched value returns an empty list rather than an error." - }, - { - "name": "uploaded_by", - "type": "integer", - "example": 1234, - "description": "Filter by uploader, as the INTERNAL NUMERIC user id - not an email and not a username. The value is coerced with to_i, so a non-numeric value such as an email address becomes 0 and matches nothing while still returning 200. Resolve the user id from a project users read before filtering." - } - ], - "intent": "List the files attached to one test case, test result or test plan - use this to see what screenshots, logs or evidence are on the item and to get the URL for reading each file", - "guidance": [ - "One route serves all three parent types; entity_type is a closed set of exactly `test-cases`, `test-results` and `test-plans`, spelled hyphenated and plural. Anything else, including the singular or snake_case form, fails to match the route and returns a bare 404 rather than telling you the value was wrong.", - "entity_id is the trap: it is an IDENTIFIER for two of the three types and an integer for the other. test-cases wants TC-NNN (the internal integer id 404s 'Test case not found'), test-plans wants TP-NNN or STP-NNN, and test-results wants the plain integer result id - the one a v2 test-results listing returns as `id`. There is no route constraint on the segment, so the wrong form always surfaces as a 404 from the resolver, never a 400.", - "A test-result id that belongs to another project is rejected with 404 'Test result not found' rather than an empty list, so a 404 here means wrong id or wrong project, not an item with no attachments.", - "Page with `p` (NOT `page`) and `per_page`. per_page defaults to 30 and the real ceiling is 300; ask for 300 directly instead of walking pages of 30. Over 300 is a 400 'parameter_error'; a non-numeric value is silently treated as 30.", - "Rows are deliberately thin: id, name, size in bytes, created_at and url. Despite what the reference spec shows, this listing carries no content_type, byte_size, checksum or download_url. Read the file straight from `url` - there is no separate download capability for these attachments.", - "Inline attachments are always included. Images embedded in a description through the rich-text editor come back alongside real file attachments and carry an extra `data_id`, so this count can exceed what the item's attachment panel shows.", - "Prefer this over the v1 test-case attachments read whenever you hold a TC-/TP- identifier, need a test result's or plan's files, or want paging and file-type / uploader filtering. The v1 read handles test cases only, needs the integer project, folder and case ids, and returns every row in one unpaginated response.", - "`info` carries page, page_size, count, prev and next - use `count` to decide whether another page is worth fetching instead of paging until you get an empty array.", - "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments." - ], - "returns": [ - "success", - "attachments", - "info", - "id", - "name", - "size", - "created_at", - "url", - "data_id" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "AttachmentListV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_results", - "method": "GET", - "path": "/api/v1/test-results", - "mode": "read", - "entity": "result", - "query": [ - { - "name": "issue_id", - "type": "string", - "example": "BUG-123", - "description": "The tracker issue whose linked results you want — the issue KEY as the tracker shows it (Jira 'BUG-123', not a numeric id). Only filters when issue_type is sent with it; on its own it is ignored and you get the unfiltered account-wide list back. Forwarded with no validation, so a wrong or unknown key comes back as an empty list with success:true, never an error. This is the filter the Jira/ADO issue panel uses." - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Which tracker issue_id belongs to. REQUIRED for the issue filter to apply at all — the search only builds the issue predicate when both issue_id and issue_type are present, so sending issue_id alone silently returns every recent result in the account rather than that issue's results. Trackers the product stores links for: jira, azure, asana, linear, clickup, confluence, figma, devrev, gitlab, github, trello, onedrive, google-drive, sharepoint, zoho, custom. Not validated." - }, - { - "name": "priority", - "type": "string", - "example": "high", - "description": "ACCEPTED AND IGNORED. It is forwarded to the search service but is not a filterable field for results, and the underlying query never joins the test case, so there is nothing to match a case priority against. Sending it changes nothing: you get the same rows you would have got without it, not an empty list. Filter by priority yourself from the test_case object you get with compact=false." - }, - { - "name": "compact", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "true", - "example": "false", - "description": "Shape switch, compared as a literal STRING against 'false'. Only compact=false expands each row with its test_run and test_case objects; every other value (including omitting it, and including boolean false serialised oddly) gives the lean shape with issues and configuration only. Rows always carry issues and configuration whatever you send." - }, - { - "name": "include_relations", - "type": "string", - "description": "Read and then thrown away: the action overwrites it unconditionally from the compact switch before the search runs, on BOTH branches. Asking for a relation here has no effect at all and no error. Use compact=false to get test_run and test_case; there is no way to request any other relation on this endpoint." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 2, - "description": "PAGE NUMBER, and the parameter is named `p` — `page=2` is not read and silently returns page 1. A value that is not a strictly positive integer (0, -1, 'abc', or a repeated/array form) is rejected before the action with HTTP 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "description": "NOT a page size — a presence flag, and its value is discarded. Sending per_page at ALL (per_page=100, per_page=1, anything non-blank) SHRINKS the page to 10 rows; omitting it entirely gives 30 rows, the maximum this endpoint can return. Omit it whenever you want the fewest round trips." - }, - { - "name": "q", - "type": "object", - "description": "Optional wrapper for the same filters, e.g. q[issue_id]=BUG-123&q[issue_type]=jira. When q is present the flat top-level filters are IGNORED and only q's contents are read, and they reach the search service in a different (query_object) shape. It must be a nested param hash — a JSON-encoded STRING here raises and 500s. p and per_page are still read from the top level. Prefer the flat form.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "example": "BUG-123", - "description": "Same as the top-level issue_id, and it still needs issue_type beside it to filter anything." - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Same as the top-level issue_type, and equally required for the issue filter to apply." - }, - { - "name": "priority", - "type": "string", - "example": "high", - "description": "Same as the top-level priority: accepted and ignored." - }, - { - "name": "compact", - "type": "string", - "example": "false", - "description": "Same string-literal 'false' switch as the top-level compact." - }, - { - "name": "include_relations", - "type": "string", - "description": "Overwritten from compact exactly as at the top level; it does nothing." - } - ] - } - ], - "intent": "Find test results anywhere in the account without knowing the project, run or case — above all, list every result linked to a tracker issue (a Jira/ADO ticket key) to answer 'what testing covers this bug?', optionally narrowed by case priority", - "guidance": [ - "This is NOT a project read and it takes no project. The action replaces whatever project context arrived with a placeholder before calling the search service, so the search spans every project in the authenticated user's group (account) and is scoped by nothing but that group and your filters. That placeholder also skips the per-project access check, so rows from projects you would not otherwise be able to open can appear here. Passing a project_id query param cannot narrow it — a numeric one you lack access to only makes the call 404 before the search runs, and a PR-NNN one is ignored outright.", - "The ONLY filter that works is issue_id AND issue_type sent together. Either one alone is dropped, and `priority` is accepted but never applied — none of them produce an error, they just widen the call back to an account-wide dump of recent results. If a filtered call returns suspiciously many rows spanning many projects, that is what happened.", - "Use this when the only handle you have is an issue key, or when you need results across projects and runs. When you already know project + run + case and want that case's result history, use the per-case reads (list_test_results_for_test_case_by_integer_id / list_test_results_for_test_case) instead — they are scoped, richer, and cannot silently span the account.", - "Failures are swallowed. An upstream search error is logged and the request STILL answers HTTP 200 with success:true, an empty test_results array and info:null. An empty list is therefore not evidence that nothing matched — treat info:null as 'the search did not run' and retry rather than reporting zero results.", - "Page with `p`, never `page`, and do NOT send per_page: its value is discarded and its mere presence cuts the page from 30 rows to 10. 30 is the hard ceiling here; there is no way to ask for more, so a large issue's results must be walked page by page.", - "Row shape changes with compact. With compact=false you get test_run and test_case objects, and `test_run_id` is then overwritten with the run's TR-NNN identifier string. Without it there are no test_run/test_case objects and test_run_id / test_case_id come from the result mapping as integers — so the same field name carries a different type depending on one query flag. Never assume test_run_id is numeric.", - "Read the outcome from result_status.field_value, which is display case ('Passed'/'Failed'/'Blocked'). The flat `status` beside it is the lowercase internal token and is null on almost every row — it is the more obvious field name and the wrong one to reason over.", - "Several declared fields are structurally empty on this endpoint because the account-wide search does not load them: attachments is ALWAYS null (never an array - guard before iterating), updated_by is ALWAYS null, and started_at, finished_at, time_elapsed and created_by_imported are always null. The per-case and single-result reads do populate them.", - "Rows carry no custom fields and no step results, and `links.self` appears only for rows that came back with a project_id. To read a result's custom fields, follow up with the project-scoped single-result reads using the row's project_id and id.", - "Rows here are NOT the same shape as the project-scoped result reads — a different shape entirely, not a subset. This one carries author, backtrace, links and a full result_status object, plus updated_by and attachments keys that are always null here; the v2 per-case read carries created_by, custom_fields, dataset, step_result, urls and a result_status cut down to field_value alone. Do not write one parser for both.", - "issue_id and issue_type are always forwarded even when absent, so calling with no filters at all is a legal account-wide dump of recent results — expensive and rarely what you want." - ], - "returns": [ - "success", - "test_results", - "info", - "id", - "author", - "updated_by", - "status", - "result_status", - "description", - "error_description", - "backtrace", - "failure_type", - "log_type", - "expanded", - "started_at", - "finished_at", - "time_elapsed", - "created_at", - "updated_at", - "created_by_imported", - "attachments", - "issues", - "configuration", - "test_case", - "test_run", - "test_case_id", - "test_run_id", - "test_run_results_mapping_id", - "project_id", - "links" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestResultSearchResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_test_case_comments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", - "mode": "read", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "pattern": "^(TR-)?[0-9]+$", - "example": "TR-14", - "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The test case's NUMERIC id, not its TC-NNN identifier — the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." - } - ], - "query": [ - { - "name": "mappingId", - "type": "integer", - "minimum": 1, - "example": 90731, - "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "example": 1, - "description": "PAGE NUMBER — the parameter really is named `p`; `page=2` is ignored and you get page 1 again. Page size is fixed at 30 and cannot be changed. A page past the end does NOT error or come back empty: the server serves the LAST page again, so stop when info.next is null rather than when a page is empty. 0 or a non-number is a 400." - } - ], - "intent": "List the comments left on a test case inside a specific test run — use this to read the discussion or review feedback on that execution, find who said what and when, or discover a comment's id", - "guidance": [ - "Needs the test-case VIEW permission (a 403 'This action is not permitted for the user' otherwise) AND a paid plan: on a free plan the call still returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} — check `success` in the body, not the status code.", - "Three ids in the path, two conventions: project_id and test_case_id are plain INTEGERS (a TC-NNN in the case slot never matches the route), while test_run_id takes the TR-NNN identifier or the bare integer. Get the numeric case id from the `id` field of a v1 test case read or of the v1 run test-case listing.", - "A run/case pair that does not resolve is a 400 with message 'TRTC not found for given testrun_id:…' — not a 404 — and an automation (Automate/Observability) run is a 400 'Can't comment on automation test'. A test case that does not exist in your group is a bare {success:false} 404.", - "Comments are threaded per run/case/configuration cell. If the case is mapped to more than one configuration in the run, pass mappingId to pick the cell; without it the server resolves a cell itself and you may read a different thread than the UI shows.", - "Newest first (by id), 30 per page, no page-size control. `info.count` is the total across pages; walk `p` until `info.next` is null — asking for a page beyond the end silently returns the last page again, so an 'empty page' loop never terminates.", - "Each row's `comment` is HTML with @mentions already rendered to display names; `user_id` is an OBJECT {id, full_name, email} whose `id` is the internal TM user id (null for imported comments, which show the original author's name). `edited` is true when the comment was changed after posting.", - "This is the only way to discover comment ids. Reading comments still works on an archived test case; posting does not.", - "Comments on the same test case outside a run are a separate thread with its own capability - nothing posted there appears in this listing, and nothing posted here appears there. Test-plan comments are a further separate surface.", - "Rows also carry group_id, project_id, parent_id, entity_id and entity_type. On this surface entity_type is always TRTC and entity_id identifies the run/case mapping cell, not the test case and not the run; parent_id is always null because replies are not threaded. review_status never appears on this surface - only comments recorded as part of a review verdict carry it, and nothing writes one against a run." - ], - "returns": [ - "comments", - "success", - "info", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "comment", - "edited", - "is_editable" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "CommentsListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run_test_case_comment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", - "mode": "write", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "pattern": "^(TR-)?[0-9]+$", - "example": "TR-14", - "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The test case's NUMERIC id, not its TC-NNN identifier — the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." - } - ], - "query": [ - { - "name": "mappingId", - "type": "integer", - "minimum": 1, - "example": 90731, - "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." - } - ], - "body": [ - { - "name": "comment", - "type": "string", - "required": true, - "json_path": "/comment/comment", - "example": "

Failed on Safari 17 only — retest after the fix for TM-1234 lands.

", - "description": "Comment body as HTML (rich text), nested as {\"comment\": {\"comment\": \"...\"}}. Whitespace-only or tag-only content is a 400 'Please enter valid comment' (an counts). Any other key inside the `comment` object is a 400 'Invalid JSON data'. To @mention: Display Name with the BrowserStack user id — all three attributes, as the product's editor writes them; without data-mention the post succeeds but the name shows as plain text, not a tag. Someone outside the group is a 403." - } - ], - "intent": "Post a comment on a test case within a test run — use this to leave review feedback on an execution, ask the run owner a question, record why a result was marked as it was, or @mention a colleague", - "guidance": [ - "Send the body as application/json with the nested shape {\"comment\": {\"comment\": \"

text

\"}} — the field is named comment inside an object also named comment. Without a JSON content type the schema check is skipped and the call fails later with a less helpful 400.", - "Needs the test-case VIEW permission (not a write permission) and a paid plan. Below a paid plan the call returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} and nothing is posted — check `success` in the body.", - "SIDE EFFECT: the run owner is notified of every new comment, and every @mentioned user gets a mention alert, immediately and outside this API. The author never alerts themselves. Confirm wording with the user before posting on their behalf.", - "Cannot post on an automation run (400 'Can't comment on automation test') or on an ARCHIVED test case (bare {success:false} 400 — reading still works). A run/case pair that does not resolve is a 400 'TRTC not found for given testrun_id:…', not a 404.", - "If the case is mapped to several configurations in the run, pass mappingId so the comment lands on the intended cell; each cell has its own thread and the listing for a different mappingId will not show it.", - "@mentions must be the span markup carrying data-lexical-mention, data-mention (the display name) and data-id (the person's BrowserStack user id). Leaving out data-mention is NOT refused: the post returns 200 and the span is stored, but it shows as plain text and no tag appears. A mention with no id is a 403 'Missing User Id in comment', one for a user not in this group is a 403 'User does not belong to this project', and either fails the whole post. Plain '@name' text mentions nobody.", - "Link markup in the HTML is normalised server-side and mention spans are rewritten, so the stored `comment` returned in the response can differ from what you sent; use the returned `id` for any follow-up.", - "Success is HTTP 200 (not 201) with the new comment under data.comment. Recoverable: the author can edit or delete the comment afterwards in the product (delete hides it rather than destroying it) — those operations are not part of this pass.", - "A comment posted here attaches to the run/case cell, not to the test case itself: entity_type comes back as TRTC and entity_id is the mapping cell's id, never the test case id or the run id. It is visible only in the in-run comment listing and never in the test case's own comment thread, which is a separate capability. Test-plan comments are a further separate surface.", - "The returned comment never carries review_status on this surface - nothing writes a review verdict onto a comment attached to a run. On a freshly created comment edited is always false, is_editable is always true, and parent_id is always null because replies are not threaded." - ], - "returns": [ - "data", - "success", - "comment", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "edited", - "is_editable" - ], - "responses": { - "200": { - "$response": "CommentResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "export_test_run_csv", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/download", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." - } - ], - "body": [ - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv" - ], - "example": "csv", - "description": "Export format. Only 'csv' is accepted; any other value is a 422 'Only CSV file type is supported.' before anything is queued." - }, - { - "name": "required", - "type": "object", - "description": "Optional column selection keyed by entity: {test_run_test_case:{fields:[..]}, test_run:{fields:[..]}, test_result:{fields:[..]}}. Omit to export EVERY supported column. `fields` may be an array or comma-separated string; 'all' expands to every supported column. Unknown column names are NOT rejected: the call still returns 200 and the export fails later on the realtime channel.", - "fields": [ - { - "name": "test_run_test_case", - "type": "object", - "description": "{fields:[...]} — supported: id, title, priority, test_case_status, owner, folder_id, folder_path, case_type, automation_status, description, preconditions, template, steps, issues, tags, created_at, created_by, updated_at, updated_by, attachments, result_status, assignee, configurations, dataset_values, project, estimated_duration_seconds." - }, - { - "name": "test_run", - "type": "object", - "description": "{fields:[...]} — supported: id, title, created_at, created_by, state, tags, owner, type, updated_by, updated_at, requirements." - }, - { - "name": "test_result", - "type": "object", - "description": "{fields:[...]} — supported: attachments, defects, updated_at, updated_by, description, time_elapsed." - } - ] - } - ], - "intent": "Start a CSV export of a test run's cases and their latest results — use this when the user wants the run's contents as a downloadable spreadsheet file rather than data returned inline", - "guidance": [ - "The response carries no file and no download link. It only confirms that a background job was queued and returns a `channel` uuid. The CSV is built asynchronously, stored, and its time-limited download link is pushed to the requesting user's web session over a realtime channel; that push carries the same uuid as `id` so the UI can match it. Nothing in this HTTP API returns the link afterwards, so tell the user the file will appear in their Test Management browser session (download notification) rather than promising to hand it back.", - "A 200 means 'queued', not 'exported'. Failures inside the job (bad column names, upstream errors) surface only as a failure message on that realtime channel, never as an HTTP error, so this call cannot be polled for completion.", - "The whole run is exported: no filter or search criteria are honoured, and the export is capped at 20,000 rows fetched 300 at a time — a larger run is silently truncated at 20,000 rows.", - "Column selection defaults to ALL supported columns when `required` is omitted. Each test-case step after the first becomes its own extra row under the case, so row counts in the file exceed the case count.", - "This is a write only in the sense that it creates a stored export artifact; it does not change the run. It carries no run-specific permission check in this service (the action is absent from the permissions map), so any user who can reach the project can trigger it; plan or product-access gates still apply as a 403.", - "Works for automation runs as well as manual ones (the run lookup deliberately ignores run type). Closed runs export too.", - "Unlike the newer run endpoints, project_id must be the plain integer; the run may be given as TR-14 or its integer id." - ], - "returns": [ - "channel", - "success" - ], - "responses": { - "200": { - "$response": "DownloadReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Project not found, or the test run identifier resolves to no run in this project ('Test Run ID is invalid. Please enter valid Test Run ID and try again' / 'Test run not found.').", - "schema": { - "$schema": "ErrorResponse" - } - }, - "422": { - "description": "file_type was not 'csv': {success:false, message:'Only CSV file type is supported.'}", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "get_test_run_progress", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/progress", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." - } - ], - "query": [ - { - "name": "configuration_id", - "type": "integer", - "minimum": 1, - "example": 101, - "description": "Narrow to ONE configuration of the run. Changes the response shape: with it you get a single `overall_progress_by_status_id` breakdown (an empty object when there is no data); without it you get a paginated `configurations` array, each row carrying its own breakdown. Configuration ids come from the run detail's `configurations` list." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number for the configurations list, and the parameter really is named `p` (`page` is ignored). Only meaningful without configuration_id. 0, negative or non-numeric is a 400 'Invalid Params'. Page size is fixed at 30 and cannot be changed — there is no per-page parameter on this endpoint." - } - ], - "intent": "Get pass/fail/untested progress of a test run broken down by result status — per configuration (browser/device) across the whole run, or for one configuration — to answer 'how far along is this run on each configuration?'", - "guidance": [ - "Two response shapes from one endpoint, chosen by configuration_id. Without it: {success, configurations:[{id, name, ..., overall_progress_by_status_id}], info}. With it: {success, overall_progress_by_status_id}. Read the shape you asked for.", - "overall_progress_by_status_id is a map keyed by result-status id (as a string), each value {count, result_status:{id, field_value, internal_name, field_name, colour}}. Status ids are project-specific — use result_status.internal_name (e.g. 'passed') to compare across projects, not the numeric key.", - "A configuration row without any executed results may carry no overall_progress_by_status_id key at all, and the single-configuration form returns {} when there is no breakdown. Treat a missing or empty breakdown as 'nothing recorded', not as an error.", - "Paging: pass `p`; the page is always 30 configurations and there is no way to ask for more per page. When `p` is omitted no paging hints are sent upstream and the default page size is decided by the backing service, so send p=1 explicitly when you intend to walk pages. `info` is the backing service's paging block, passed through unchanged.", - "For a run-wide total (not per configuration) use the single-run detail instead — its overall_progress already aggregates across configurations.", - "Any upstream failure inside the calculation is reported as a 500 {success:false, message:'Failed to calculate configuration progress: ...'}, not as a 404 or 400. An unknown run identifier is still a 404 because the run is resolved before the calculation.", - "No run-specific permission is checked in this service (the action is absent from the permissions map); it is also one of the endpoints reachable with a public share-link token for a run." - ], - "returns": [ - "success", - "configurations", - "info", - "overall_progress_by_status_id" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestRunConfigurationProgressResponse" - }, - "400": { - "description": "`p` was 0, negative or non-numeric: {success:false, error:'Invalid Params'}", - "schema": { - "$schema": "ErrorResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Project not found, or the test run identifier resolves to no run in this project.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "description": "Progress calculation failed (including upstream errors): {success:false, message:'Failed to calculate configuration progress: '}", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "generate_test_case_automation", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/lcnc", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer test case id (not TC-NNN). The case is loaded from the entity store first, so an unknown id is a 404 before anything is submitted." - } - ], - "body": [ - { - "name": "version", - "type": "string", - "values": [ - "v1", - "v2" - ], - "default": "v1", - "description": "Payload flavour forwarded to the automation service. Anything other than 'v2' takes the v1 path and forwards `steps`; 'v2' forwards `test_case_details` + `tags` (+ preTestConfig) instead and REQUIRES test_case_details — with version=v2 and no test_case_details the request 500s." - }, - { - "name": "test_name", - "type": "string", - "example": "Login with valid credentials", - "description": "Display name for the generated automation test. Forwarded verbatim; not validated here." - }, - { - "name": "base_url", - "type": "string", - "example": "https://staging.example.com", - "description": "URL the generated automation starts from. Forwarded verbatim (also lifted into batch defaults on the bulk-pipeline path); not validated here." - }, - { - "name": "credentials", - "type": "string", - "description": "Login credentials the automation may use, forwarded verbatim to the automation service. Not stored in Test Management." - }, - { - "name": "local_enabled", - "type": "boolean", - "default": false, - "description": "Run against a BrowserStack Local tunnel. Defaults to false when omitted." - }, - { - "name": "lcnc_link", - "type": "string", - "description": "Existing automation build link (the case's metadata.lcnc_build_map link) when RE-automating. Its mere presence switches the submission from mode 'new' to 'reautomate'; omit it for a first-time automation." - }, - { - "name": "steps", - "type": "array", - "description": "v1 flow only: the test steps as plain strings. Ignored when version=v2." - }, - { - "name": "test_case_details", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "identifier", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "test_case_steps", - "type": "array" - } - ], - "description": "v2 flow only: {name, identifier, description, preconditions, test_case_steps[]} of the case, echoed to the automation service (it does not re-read the case). Required when version=v2 (missing → 500); ignored otherwise." - }, - { - "name": "feedback", - "type": "string", - "description": "v2 flow only: free-text feedback on a previous generation, forwarded inside test_case_details." - }, - { - "name": "score", - "type": "number", - "description": "v2 flow only: rating of a previous generation, forwarded inside test_case_details." - }, - { - "name": "tags", - "type": "array", - "description": "v2 flow only: tags forwarded with the payload. Elements are strings." - }, - { - "name": "preTestConfig", - "type": "object", - "description": "v2 flow only: pre-test configuration object (setup steps/profile) forwarded verbatim when present." - }, - { - "name": "test_case_folder_id", - "type": "integer", - "description": "Folder id of the case. Only read on the bulk-pipeline submission path (environment flag); harmless otherwise." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder id of the case; when sent the folder is loaded for an ownership check and passed to the case lookup. Optional; an unknown folder fails the call." - } - ], - "intent": "Kick off no-code (LCNC / Low Code Automation) generation of an automated test for ONE test case — use this when the user wants a manual case automated or re-automated; it queues work in the automation service and returns immediately", - "guidance": [ - "Submission is handed to a separate automation service, and that service's own failures are not translated before they reach you: they arrive as 422 with the service's raw message copied into the `message` field as text. A 422 here does not mean your request was invalid, and the text in it is not addressed to you — do not retry against it field by field.", - "422 is the status used for that relayed failure regardless of what actually went wrong upstream, so it carries no information about whether the problem is transient. Treat it as 'submission did not happen' and check the case before retrying.", - "Nothing is recorded when submission fails: the case's automation status and its timestamps are untouched, so a failed attempt leaves the case immediately resubmittable rather than locked. Re-reading the case is how you tell a failed submission from a queued one.", - "A queued submission cannot be followed to completion through this index. Re-reading the case exposes its automation status, but no capability here exposes the resulting build link, so completion can only be confirmed in the product." - ], - "returns": [ - "status", - "body" - ], - "responses": { - "422": { - "description": "The automation service refused or failed the submission and its response was relayed as-is: `{\"success\": false, \"message\": }`. The string is frequently itself structured text rather than a sentence. This status is used for every upstream failure, not only for invalid input, and it is not a validation result for the body you sent. Nothing is queued and the case is unchanged.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Always false." - }, - "message": { - "type": "string", - "description": "The automation service's own response, relayed verbatim as text. Not a caller-facing message; do not parse or display it." - } - } - } - } - }, - "disabled": true - }, - { - "name": "list_binned_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/bin", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `page` is accepted as an alias and WINS if both are sent. Only `p` is validated (non-positive / non-numeric → 400 'Invalid Params'); a bad `page` is forwarded as-is." - }, - { - "name": "limit", - "type": "integer", - "minimum": 1, - "description": "Rows per page, forwarded unchanged to the backing service, which owns the default and the ceiling (its client docs say default 25, max 100; not enforced in this codebase — see open questions). Ask for what you need and trust metadata.page_size for what you got." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match on test case title or identifier." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Only cases that lived in this folder when they were binned." - }, - { - "name": "moved_to_bin_by", - "type": "string", - "description": "Filter by who moved the case to the bin, as BrowserStack user id(s), comma-separated. `updated_by` is an accepted alias (moved_to_bin_by wins). These are browserstack_user_ids, NOT the internal user ids the review endpoints take." - }, - { - "name": "owner", - "type": "string", - "description": "Filter by case owner as BrowserStack user id(s), comma-separated." - }, - { - "name": "date_from", - "type": "string", - "example": "2026-09-01", - "description": "Binned on or after this ISO 8601 date." - }, - { - "name": "date_to", - "type": "string", - "example": "2026-09-16", - "description": "Binned on or before this ISO 8601 date." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "trashed_at", - "updated_at", - "name", - "identifier" - ], - "default": "trashed_at", - "description": "Sort column; default is when the case was binned." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "ASC", - "DESC" - ], - "default": "DESC", - "description": "Sort direction; upper-case only." - }, - { - "name": "status", - "type": "string", - "description": "Filter by status as numeric status ID(s), comma-separated — despite the UI-style name this takes ids, not display values (resolve them via get_system_field_values). `status_id` is an accepted alias (status wins)." - }, - { - "name": "priority", - "type": "string", - "description": "Filter by priority as numeric priority ID(s), comma-separated, not display values. `priority_id` is an accepted alias (priority wins)." - }, - { - "name": "type", - "type": "string", - "description": "Filter by case type as numeric category ID(s), comma-separated, not display values. `category_id` is an accepted alias (type wins)." - }, - { - "name": "automation_state", - "type": "string", - "description": "Filter by automation state as numeric ID(s), comma-separated. `automation_state_id` is an accepted alias (automation_state wins)." - }, - { - "name": "tags", - "type": "string", - "description": "Filter by tag name(s), comma-separated." - } - ], - "intent": "List the test cases sitting in a project's recycle bin (moved to bin / soft-deleted, awaiting purge) with who binned them and when — use this to find a case to restore or inspect; for just 'how many are in the bin' use count_binned_test_cases instead", - "guidance": [ - "Rows here are the WIDE v1 case shape, not the trimmed v2 one: `priority` and `status` arrive as objects carrying `field_value`, `colour`, `field_name` and `value_category` rather than as plain strings. Code written against a v2 case listing will read them as objects and compare wrongly.", - "Feature-gated: when the project/user lacks the Move to Bin or New Project Settings flags the call is 403 {success:false, error:'... is not enabled for this user'} — that is a feature gap, not a permission problem. Needs the tc_view permission under RBAC.", - "Paging metadata is under `metadata` {total, page, page_size, total_pages}, NOT the `info` {count, next, prev} envelope other v1 listings use — there is no `next`; stop when page >= total_pages.", - "Rows carry the integer `id`, the TC-NNN `identifier`, `trashed_at`, `days_remaining` until automatic purge, and `folder_name`/`folder_path` of where the case was. `updated_by` and `owner` are raw BrowserStack user ids; the resolved people are in `updator_details` (who binned it) and `owner_details` {id, browserstack_user_id, full_name, email}.", - "priority and status come back as small objects {id, field_value, internal_name} (status also has colour), not strings.", - "Only the filters listed exist. Binned cases are excluded from every other test case listing and search, so this is the only way to see them.", - "A backing-service failure is rendered as {success:false} with that service's status and no message." - ], - "returns": [ - "success", - "test_cases", - "metadata", - "id", - "identifier", - "name", - "priority", - "status", - "owner", - "owner_details", - "tags", - "issues", - "folder_name", - "folder_path", - "updated_by", - "updator_details", - "trashed_at", - "days_remaining", - "total", - "page", - "page_size", - "total_pages", - "template", - "created_by", - "creator_details" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BinnedTestCasesListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "count_binned_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/bin-count", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "intent": "Return just the number of test cases currently in a project's recycle bin — the cheap way to answer 'how many binned/deleted cases are there?' without pulling any rows into context (use list_binned_test_cases when you need the cases themselves)", - "guidance": [ - "Same feature gate as the bin listing: 403 {success:false, error:'... is not enabled for this user'} when Move to Bin / New Project Settings are off for this user. Needs tc_view under RBAC.", - "Counts the whole bin; it takes no filters. For a filtered count, call the listing with the filter and read metadata.total.", - "A backing-service failure is {success:false} with that service's status and no count." - ], - "returns": [ - "success", - "count" - ], - "responses": { - "200": { - "description": "Number of test cases in the bin.", - "schema": { - "type": "object", - "required": [ - "success", - "count" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "count": { - "type": "integer", - "example": 12 - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "submit_test_cases_for_review", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-review", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the test cases to send for review. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", - "type": "object", - "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." - } - ], - "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." - }, - { - "name": "q", - "type": "object", - "description": "SCOPE for select_all — the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project — verify the count before sending." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_case/reviewers", - "example": [ - 4821 - ], - "description": "INTERNAL Test Management user ids of the reviewers (the `id` from search_project_users / list_project_users — not browserstack_user_id, not email). Replaces the current reviewer set. Each id must belong to the caller's account (else 422 reviewer_not_found) and, when the project keeps an allowed-reviewer list, be on it (else 422). Omit to change status only. Elements are integers." - }, - { - "name": "review_status", - "type": "string", - "json_path": "/test_case/review_status", - "values": [ - "in_review" - ], - "example": "in_review", - "description": "The review state to move the selected cases into. Send 'in_review' to submit for review; it is forwarded to the backing store as a replace. Verdicts (approved/rejected/changes_requested) are NOT applied here — use apply_test_case_review_verdict." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Note to the reviewers. It is only carried in the reviewer notification when reviewers are given — it is NOT stored as a comment on the cases." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "format": "uuid", - "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use — a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." - } - ], - "intent": "Send one or many test cases for review — set them to 'in review' and assign the reviewers who must approve them; use it for a single case (ids with one element) or a whole selection/folder at once", - "guidance": [ - "Needs the project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 {success:false, message:'Feature not available'}). No RBAC permission entry exists for this action, so any authenticated project member with those two passes.", - "Part 11 e-signature: when the project setting part_11_reauth_send_for_review is 'true', the caller must first re-authenticate (email + password at the reauth endpoint, not in this index) and pass the returned reauth_token; missing/expired/reused → 401 {success:false, error_code:'reauth_required'} and the block is written to the audit log.", - "Selections over 100 cases run asynchronously: the response is {success:true, async:true, unique_id} with NO testCases — the change lands in the background and you re-read the cases to confirm. Under the threshold the updated cases are returned inline.", - "Reviewer ids are the internal Test Management user id; the same people are addressed by browserstack_user_id in the bin listing filters, so do not carry ids across. A reviewer who is not on the project's allowed-reviewer list is refused (422) for the whole call — nothing is partially applied.", - "Cases locked by another bulk operation are refused with 400 {success:false, message:'TestCases in progress with another operation'}; retry after it finishes.", - "Every other failure collapses to 400 {errors:'An error occurred during bulk review'} — including backing-service errors — so a 400 is not necessarily a bad request.", - "Recoverable: review state is a tracked status; a reviewer verdict or a later edit moves the case on, and each case keeps version history.", - "Sending reviewers notifies them (in-app alert / email) immediately." - ], - "returns": [ - "success", - "testCases", - "async", - "unique_id", - "id", - "identifier", - "name", - "review_status", - "reviewers", - "review_sent_date", - "owner", - "status", - "priority", - "test_case_folder_id" - ], - "responses": { - "200": { - "$response": "TestCaseReviewOutcomeResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "ReauthRequired" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "apply_test_case_review_verdict", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-approve", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the test cases to give a verdict on. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", - "type": "object", - "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." - } - ], - "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." - }, - { - "name": "q", - "type": "object", - "description": "SCOPE for select_all — the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project — verify the count before sending." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." - }, - { - "name": "review_status", - "type": "string", - "required": true, - "json_path": "/test_case/review_status", - "values": [ - "approved", - "rejected", - "changes_requested" - ], - "example": "approved", - "description": "The verdict: 'approved' passes the case, 'rejected' fails it, 'changes_requested' sends it back to the author for edits. Applied to every selected case; there is no per-case verdict in one call." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Review comment. Unlike the submit-for-review comment this one IS stored: one comment is created on each selected case, tagged with the verdict, and echoed back under `comment`. Strongly recommended for rejected/changes_requested." - }, - { - "name": "notify", - "type": "boolean", - "json_path": "/test_case/notify", - "default": false, - "description": "Compared as the string 'true'. Also email the case owners the verdict (in-app personal alerts go out regardless)." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "format": "uuid", - "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use — a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." - } - ], - "intent": "Record a reviewer's verdict on test cases that are under review — approve them, reject them, or request changes — for one case or a whole selection in a single call", - "guidance": [ - "Every selected case must currently be 'in_review'. If any is not (the author edited it and it dropped back to pending, or it was already decided) the WHOLE call is refused with 422 {success:false, error_code:'invalid_review_state'} and nothing is applied — re-read the cases and resubmit only the ones still under review.", - "Needs project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 'Feature not available'). No RBAC permission entry exists for this action beyond project access.", - "Part 11 e-signature: when project setting part_11_reauth is 'true', every verdict value requires a fresh reauth_token from the re-authentication step (not in this index); missing/expired/reused → 401 {success:false, error_code:'reauth_required'}.", - "Selections over 100 cases are applied asynchronously: {success:true, async:true, unique_id} with no testCases; re-read to confirm. Below that the updated cases come back inline, plus `comment` (the created comment objects) when a comment was sent.", - "Cases locked by another bulk operation → 400 {success:false, message:'TestCases in progress with another operation'}.", - "When the backing store rejects the verdict (e.g. the audit-log write fails) its status is passed through with {success:false, error_code, errors}; the verdict was not applied. Any other failure is a generic 400 {success:false, errors:'An error occurred during bulk approval'}.", - "Recoverable: a verdict is a tracked review status — the author can edit and resubmit, a reviewer can decide again, and every case keeps version history. Nothing is deleted.", - "Side effects outside the data: owners get an in-app alert for every verdict, an email too when notify is 'true', and Part 11 projects write an audit-log row." - ], - "returns": [ - "success", - "testCases", - "comment", - "async", - "unique_id", - "id", - "identifier", - "name", - "review_status", - "reviewers", - "owner", - "status", - "priority", - "test_case_folder_id" - ], - "responses": { - "200": { - "$response": "TestCaseReviewOutcomeResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "ReauthRequired" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Verdict refused: a selected case is no longer under review (error_code invalid_review_state), or the account plan lacks the feature.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error_code": { - "type": "string", - "example": "invalid_review_state" - }, - "error": { - "type": "string", - "example": "Test case in Pending state cannot be approved. It was modified and moved back to Pending — refresh to review the latest changes." - }, - "errors": { - "type": "string" - }, - "message": { - "type": "string", - "description": "Present instead of error/errors on the plan gate ('Feature not available')." - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "validate_bdd_export_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/exports/bdd/validate", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the cases you intend to export. Non-BDD cases among them are silently skipped by the validator. Send ONE of ids, select_all or folders." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Validate every BDD case in scope (q + folder_id, or the whole project) minus de_selected_ids. Compared as the string 'true'." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude from a select_all scope." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", - "type": "object", - "description": "{select_all: bool, ids: [int], de_selected_ids: [int]} for that folder." - } - ], - "description": "Cross-folder selection keyed by folder id; the union is validated. Takes precedence over ids/select_all. An empty map validates nothing (400 'No BDD Test Case(s) selected.')." - }, - { - "name": "q", - "type": "object", - "description": "Filter scope for select_all — same filter object as the test-case search, sent as a sibling of test_case. Only read when select_all is true; an unknown key is silently dropped (scope widens)." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all validation to. Ignored for explicit ids." - } - ], - "intent": "Check, before exporting test cases to a BDD feature file, that every selected BDD case shares the same Feature and Background text so they can be merged into one file — returns a pass/fail verdict and message, nothing is exported or changed", - "guidance": [ - "Pure check despite the POST: it only reads the selected cases' steps and compares them; no export job, file or record is created. Run it before the real export when the user wants a single merged feature file.", - "Pass and fail share ONE body shape {success, message}: HTTP 200 + success:true on pass, HTTP 400 + success:false on any failure. Read `message` to tell the failures apart — 'No BDD Test Case(s) selected.' means the selection contained no BDD-template cases at all.", - "Hard cap: a selection resolving to more than 300 BDD cases is refused with 400, but with the MISLEADING message 'Validation failed. Selected test cases belong to different features or backgrounds.' — the same text as a genuine mismatch. Check the selection size yourself before trusting that message.", - "Only BDD-template cases are considered: text/step cases in the selection are filtered out silently and a pass says nothing about them.", - "Comparison is exact after trimming whitespace, on the Feature and Background of each case's FIRST step only.", - "No RBAC permission is attached to this action; project access and authentication suffice." - ], - "returns": [ - "success", - "message" - ], - "responses": { - "200": { - "$response": "BddExportValidationResponse" - }, - "400": { - "$response": "BddExportValidationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "create_test_case_comment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", - "mode": "write", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." - } - ], - "body": [ - { - "name": "comment", - "type": "string", - "required": true, - "json_path": "/comment/comment", - "example": "Checked against the latest build - step 3 still fails on Safari.", - "description": "The comment text, as rich text (HTML); plain text is accepted and kept as sent. Content that is only whitespace or empty tags is rejected as invalid, though an image on its own counts as content. To mention a colleague, send the span exactly as the product's own editor writes it: Display Name, with data-id set to their BrowserStack user id. All three attributes are needed: without data-mention the post still succeeds and the span is stored, but it shows as plain text rather than a tag. Mentioning someone outside the account is refused." - } - ], - "intent": "Post a comment on a test case.", - "guidance": [ - "The comment is attributed to the calling user and the case owner is notified the moment it lands; anyone mentioned in the text is notified separately. Those messages leave the product and cannot be recalled, so confirm the wording before sending.", - "The author can edit or delete their own comment afterwards - each is a separate operation - which is what makes this recoverable; an edit replaces the earlier text and keeps no previous version, and a delete leaves no self-serve way back.", - "Only the author can change a comment, so posting on someone's behalf leaves them unable to correct it.", - "Commenting on an archived test case is refused; restore the case first.", - "This is a paid-plan feature. On a plan without it the call still succeeds at the transport level and reports in the payload that the feature is unavailable, so check the success flag rather than assuming the comment landed.", - "Comments are flat: every one is posted against the case itself, and there is no way to reply beneath an existing comment.", - "Success is HTTP 200, not 201, and the new comment comes back one level down, inside a data envelope that holds a success flag alongside the comment object. Take the comment's id from there for any later edit or delete.", - "The returned comment carries id, user_id, group_id, project_id, parent_id, entity_id, entity_type, created_at, comment, edited and is_editable. user_id is the AUTHOR as an object with id, full_name and email rather than a bare id, and that id is the internal user id, not the BrowserStack one. parent_id is always null because replies are not threaded.", - "On this surface entity_type is always TestCase and entity_id is the test case's own id, so this thread is distinct from the one on the same case inside a test run: a comment posted here never appears in the in-run listing, and one posted there never appears here.", - "Two fields are fixed on a freshly created comment: edited is always false, and is_editable is always true. review_status is never present on a comment posted this way - it only ever appears on comments recorded as part of a review verdict.", - "Link markup in the body is normalised and mention markup is rewritten before the comment is stored, so the text returned can differ from the text sent; treat the returned text as authoritative." - ], - "returns": [ - "data", - "success", - "comment", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "edited", - "is_editable" - ], - "responses": { - "200": { - "$response": "CommentCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_folder", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "description": "Only the literal value true has any effect. When true, a name that clashes with a sibling (case-insensitive, trimmed) is created as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/folder/name", - "example": "Checkout", - "description": "Folder name. Trimmed; must not be empty after trimming and must be unique among siblings ignoring case, otherwise 400 'Folder with same name exists in this location' (unless auto_rename)." - }, - { - "name": "description", - "type": "string", - "json_path": "/folder/description", - "example": "Payment and cart flows", - "description": "Free-text notes shown on the folder. Use this key, not `notes` (the internal-API key): `notes` and any other unknown key inside `folder` are rejected with 400 'Invalid JSON data'. HTML is sanitised on save." - }, - { - "name": "parent_id", - "type": "integer", - "json_path": "/folder/parent_id", - "example": 12, - "description": "Integer id of the existing folder to nest under. Omit it to create a root folder (null cannot be sent through this transport). An id not in this project returns 404. 0 is not an alias for root." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "description": "Array of integer ids of already-uploaded attachment blobs to attach to the folder. Any invalid id fails the whole create with 422." - } - ], - "intent": "Create a folder, or a sub folder under a parent.", - "guidance": [ - "Resolve the project's PR- identifier first via the projects list; the numeric project id used by v1 capabilities is not accepted here.", - "Send the description as `description`. The `notes` key used by the internal API is rejected, and so is any key this capability does not declare: the body is schema-validated with unknown keys forbidden and the request fails with 400 'Invalid JSON data'.", - "Sibling names must be unique ignoring case and surrounding whitespace; a clash returns 400. auto_rename=true creates ' (1)' with the name lowercased instead, so read the returned name rather than assuming the one you sent.", - "parent_id 0 is not root: omit the field for a root folder. An unknown parent_id returns 404.", - "The response carries the new folder's integer id — carry it forward as parent_id for sub folders, as folder_id when creating test cases, or as the move destination.", - "cases_count and sub_folders_count come back null on creation rather than 0, even though a folder is empty the moment it is made. Read them as 0; every other folder capability reports the same brand-new folder as 0, and the rename and move capabilities return real integers.", - "A folder whose name already exists in the same parent is refused with a 400 and `{success: false, message: ...}` naming the collision — the only body on that path, and the reason a repeated create with a fixed name fails after the first. Vary the name or read the parent's children first." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_case", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-12", - "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 105, - "description": "Integer id of the folder the case is created in. This is authoritative: a different folder id in the body is ignored. Discover it from the folder listing." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Login with valid credentials", - "description": "Title of the test case. The only always-required field; comes back as `title` in the response.", - "json_path": "/test_case/name" - }, - { - "name": "description", - "type": "string", - "example": "Verifies the happy-path login flow.", - "description": "Rich-text body. Send plain text or HTML, not a JSON document.", - "json_path": "/test_case/description" - }, - { - "name": "preconditions", - "type": "string", - "example": "User account exists and is verified.", - "json_path": "/test_case/preconditions" - }, - { - "name": "template", - "type": "string", - "values": [ - "test_case_steps", - "test_case_bdd" - ], - "default": "test_case_steps", - "example": "test_case_steps", - "description": "Step style. `test_case_steps` takes `test_case_steps` rows; `test_case_bdd` takes Gherkin in `feature` / `background` / `scenario` and refuses step rows (422). Mixing the two is refused with 422.", - "json_path": "/test_case/template" - }, - { - "name": "template_id", - "type": "integer", - "example": 42, - "description": "Id of a test-case template from list_templates. Omit or send null for the project's default template. An unknown id, or one from another workspace, is refused with 400 rather than falling back to the default. Choose one whose step_type matches `template`.", - "json_path": "/test_case/template_id" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "Ordered step rows for a `test_case_steps` case. `order` is assigned by array position, so any `order` you send is overwritten. Not allowed for `test_case_bdd` (422). A differently named key such as `steps` is dropped and the case is created with NO steps, so verify against `steps` in the response.", - "fields": [ - { - "name": "step", - "type": "string", - "description": "Action the tester performs." - }, - { - "name": "result", - "type": "string", - "description": "Expected result of this step." - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer", - "description": "Reuse a shared step instead of inline text." - }, - { - "name": "shared_step_detail_id", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps" - }, - { - "name": "feature", - "type": "string", - "description": "Gherkin Feature text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", - "json_path": "/test_case/feature" - }, - { - "name": "background", - "type": "string", - "description": "Gherkin Background text; optional, BDD cases only (422 otherwise).", - "json_path": "/test_case/background" - }, - { - "name": "scenario", - "type": "string", - "description": "Gherkin Scenario text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", - "json_path": "/test_case/scenario" - }, - { - "name": "owner", - "type": "string", - "example": "jane@acme.com", - "description": "Owner as a workspace user's email (a user id is also accepted). An owner that cannot be resolved silently falls back to the caller — check `owner` in the response.", - "json_path": "/test_case/owner" - }, - { - "name": "status", - "type": "string", - "example": "Active", - "description": "Matched against the project's configured status display values (defaults: Active, Draft, In Review, Rejected, Outdated; a numeric option id also matches). Unknown value → 400 'Invalid default field value'. Omit for the project default. Resolve the live set with get_system_field_values.", - "json_path": "/test_case/status" - }, - { - "name": "priority", - "type": "string", - "example": "High", - "description": "Matched against the project's configured priority display values (defaults: Low, Medium, High, Critical; a numeric option id also matches). Unknown value → 400. Omit for the project default.", - "json_path": "/test_case/priority" - }, - { - "name": "case_type", - "type": "string", - "example": "Functional", - "description": "Matched against the project's configured type display values (defaults include Functional, Regression, Smoke & Sanity, Acceptance, Security, Performance, Usability, Accessibility, Compatibility, Destructive, Automated, Manual, Other). Unknown value → 400. Omit for the project default.", - "json_path": "/test_case/case_type" - }, - { - "name": "automation_status", - "type": "string", - "default": "not_automated", - "example": "not_automated", - "description": "Built-in values: not_automated, automated, automation_not_required, cannot_be_automated, obsolete (matched case-insensitively by internal name or display label). Workspaces that manage their own automation statuses accept their custom labels too. Unknown value → 400.", - "json_path": "/test_case/automation_status" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login", - "smoke" - ], - "description": "Free-text tag names; created if new.", - "json_path": "/test_case/tags" - }, - { - "name": "issues", - "type": "array", - "example": [ - "TES-204" - ], - "description": "Linked issues. Either bare issue keys (treated as Jira) or objects {issue_id, issue_type}. Linking is processed asynchronously after the create returns.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "description": "Issue key, e.g. TES-204." - }, - { - "name": "issue_type", - "type": "string", - "description": "Tracker name, e.g. jira." - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "Component": "Checkout", - "Regression candidate": true - }, - "description": "Map of custom-field DISPLAY NAME → value. Dropdown: option label; multi-dropdown: array of labels; user: email; boolean: true/false; date, int, url, text as typed. A name that is not a custom field on this project is SILENTLY SKIPPED (200, value absent); a reserved system-field name (e.g. 'Automation Status') is refused; a value failing its type check fails the whole call with 400.", - "json_path": "/test_case/custom_fields" - }, - { - "name": "identifier", - "type": "string", - "example": "LOGIN-001", - "description": "Custom identifier for the case. Honoured ONLY when the workspace uses project-level custom ids; otherwise ignored. A duplicate is refused with 400 'Test Case ID already exists'.", - "json_path": "/test_case/identifier" - }, - { - "name": "review_status", - "type": "string", - "description": "Review assignment state. Reviewer write: gated on a Team Pro plan and the project's Review & Approve setting when the reviewer API is enabled for the workspace. Omit unless the user asked to put the case in review. Known states include pending and in_review; the accepted set is not enforced here, so an unknown value is refused by the server.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "example": [ - "jane@acme.com" - ], - "description": "Reviewer emails. Requires Team Pro plan and Review & Approve enabled on the project (422/400 otherwise). The owner cannot be a reviewer (422). A non-array value is silently ignored.", - "json_path": "/test_case/reviewers" - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Moves the new case into review and emails reviewers. Same plan/project gates as `reviewers`. Ambiguous strings like 'yes'/'no' are refused with 422.", - "json_path": "/test_case/send_for_review" - }, - { - "name": "created_at", - "type": "string", - "format": "date-time", - "description": "Backdated creation timestamp for migrations. Honoured only when the workspace has the external-API timestamp feature enabled; otherwise dropped silently.", - "json_path": "/test_case/created_at" - }, - { - "name": "updated_at", - "type": "string", - "format": "date-time", - "description": "Backdated update timestamp for migrations. Same feature gate as `created_at`; must not be earlier than `created_at` (400).", - "json_path": "/test_case/updated_at" - } - ], - "intent": "Create a test case in a folder.", - "guidance": [ - "Resolve the project's PR-NNN identifier and the destination folder's integer id first; the folder in the path is authoritative and a folder id in the body is ignored.", - "Decide the step style before writing: a Gherkin case needs template=test_case_bdd plus feature and scenario and must carry no test_case_steps; a classic case must carry no feature/background/scenario. Either mix is refused with 422, and a template_id whose step_type disagrees is the usual cause.", - "status, priority and case_type take the project's DISPLAY values (e.g. 'Active', 'High', 'Functional'), which a workspace can rename or extend — resolve them with get_system_field_values rather than guessing; unknown values are refused with 400.", - "custom_fields is keyed by display name and an unknown name is dropped without error, so confirm each field exists on the project (get_project_form_fields) and verify the response's custom_fields before reporting them saved.", - "The response echoes the case with `title` (not name), `steps` as [{step, result}] and `owner` as an email, and carries NO integer id — carry data.test_case.identifier forward for later reads, updates and moves.", - "Recoverable: the new case can be deleted or archived afterwards, so this is an ordinary write.", - "The echoed case omits keys rather than returning them empty. A freshly created case has no `attachments` key at all — not an empty array — because nothing can be attached yet; `feature`, `background` and `scenario` are present only on a Gherkin case; `reviewers` and `review_status` only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps` and `custom_fields` are always present, empty when nothing is set, so only those three are safe to iterate unguarded." - ], - "returns": [ - "identifier", - "title", - "description", - "preconditions", - "template", - "folder_id", - "status", - "priority", - "case_type", - "automation_status", - "owner", - "tags", - "issues", - "steps", - "feature", - "background", - "scenario", - "custom_fields", - "attachments", - "is_shared", - "review_status", - "reviewers", - "created_at", - "created_by", - "last_updated_at", - "updated_by", - "urls" - ], - "responses": { - "200": { - "$response": "V2TestCaseResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "error_code=review_approve_unknown — the project's Review & Approve setting could not be read, so nothing was written. Retry.", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "edit_project", - "method": "POST", - "path": "/api/v1/projects/{project_id}/edit", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "example": 11046, - "description": "Integer project id (the numeric `id`, not the PR-NNN identifier). Discover it through the projects list. A project the caller cannot see returns 404, not 403." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/project/name", - "example": "Checkout Regression", - "description": "New project name. Leading/trailing spaces are trimmed. Must be unique within the account and must not contain | ; > < ` + ? % \" / or backslash; violations return 400 with the reason in `error`. Omit to keep the current name." - }, - { - "name": "description", - "type": "string", - "json_path": "/project/description", - "example": "Owns all checkout-flow test cases", - "description": "New description. HTML is escaped on the way in and anything past 16 KB is silently truncated. Send an empty string to clear it; omit to keep the current one." - }, - { - "name": "access_type", - "type": "string", - "json_path": "/project/access_type", - "values": [ - "private", - "restricted", - "org_wide" - ], - "example": "org_wide", - "description": "Project visibility: `private` (owner only), `restricted` (owner plus named users), `org_wide` (everyone in the account). Other values return 400 'Invalid access type'. Honoured only on accounts with project-level access control; elsewhere silently ignored and the response still says success. Server may adjust it: private with named users becomes restricted, restricted with none becomes private." - }, - { - "name": "users", - "type": "array", - "json_path": "/project/users", - "fields": [ - { - "name": "user", - "type": "string", - "required": true, - "description": "Email address of a user in the same account." - }, - { - "name": "role", - "type": "string", - "required": true, - "description": "Product role name to grant on this project (account-configured, e.g. Product Admin, Tester, Viewer)." - } - ], - "example": [ - { - "user": "qa.lead@example.com", - "role": "Tester" - } - ], - "description": "Per-user access grants. Processed only when `access_type` is also sent, otherwise ignored. Each entry is added or has its role changed; users not listed keep their existing access, so this does not replace the list. Entries whose email is not in the account or whose role is not recognised are skipped silently, not rejected." - } - ], - "intent": "Rename a project, or change its description or visibility.", - "guidance": [ - "There is no undo. The previous name and description are overwritten; the change is logged in the project's activity feed and version history, so the old value can be looked up and re-entered by hand, but nothing restores it.", - "Send at least one of name, description or access_type inside the `project` object; an empty `project` object is rejected 400 before anything is looked up.", - "Only account owners/admins or the project owner may edit; anyone else gets 403 with the reason in `error`. A project the caller cannot see comes back 404, not 403.", - "Visibility is silently ignored on accounts without project-level access control: the call returns 200 and the unchanged value. Read `access_type` back from the response to confirm the change took.", - "The `users` list is only read alongside `access_type`. To change one user's role without touching visibility, resend the current access_type with the users entry.", - "Name conflicts and blocked characters are enforced by the backend and surface as 400 with a human-readable `error`; validation runs after the permission check, so a 403 means permissions, not the name." - ], - "returns": [ - "id", - "thProjectId", - "identifier", - "name", - "description", - "created_at", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "links", - "normalisedName", - "starred", - "jira_mapped", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project edited. Envelope is data.success and data.project, same as the single-project read; project also carries access_type, normalisedName and projectVisibilityBanner, and the counts are carried over from before the edit rather than recomputed.", - "schema": { - "$schema": "V1ProjectDetail" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_folder_attachments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "List the files attached to a folder itself.", - "guidance": [ - "This is the folder's own shelf of files. Files attached to the test cases in the folder are a separate list and none of them appear here.", - "It does not look inside subfolders, so walk the folder tree and ask for each folder you care about.", - "Each entry carries links to the stored file; read the file from there, as there is no separate download step.", - "A folder with nothing attached comes back as an ordinary success with an empty list, so an empty list is an answer and not a failure.", - "Alongside the exact byte count each entry also carries a ready-formatted size for display; use the byte count when you need to compare or add sizes up.", - "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments.", - "This lists attachments held on the FOLDER itself, which is a different collection from the attachments on the cases inside it — attaching a file to a case never makes a row appear here." - ], - "returns": [ - "success", - "attachments", - "id", - "name", - "content_type", - "byte_size", - "key", - "checksum", - "url", - "download_url", - "product_id" - ], - "responses": { - "200": { - "$response": "FolderAttachmentsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_exploratory_session_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/form-fields", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "values", - "type": "boolean", - "default": false, - "description": "Set to true to get each field's configured choices alongside its definition. Left off, the reply is a compact list of the field definitions with the choice lists omitted, which is enough to learn the field names but not the values they accept.", - "example": true - } - ], - "intent": "List the form fields for authoring an exploratory session.", - "guidance": [ - "Asking for the configured choices does not currently work: any truthy value for the choices flag is answered with a not-found, on every project, while leaving it off succeeds. Call it without that flag and expect field names and types but no choice lists, and resolve a dropdown's accepted values another way.", - "The choices flag is matched against the exact lowercase word true; anything else, including a capitalised spelling, is treated as off.", - "Only the fields your team added are listed. The built-in session details such as name, charter and status are never described here - the built-in list is always returned empty - so an empty reply means nothing extra was configured, not that the session has no fields.", - "Both lists can be empty on a perfectly valid project. Do not read that as a failure.", - "Configuration is per project, so fetch it again for each project - a field set up in one project need not exist in another.", - "Each field carries both an internal name and the display name your team sees; when a person names a field in plain words, match it against the display name.", - "A field describing a dependent choice points at the field it hangs off, so resolve the parent's value first when one is present.", - "Errors here do not carry the enveloped error object described elsewhere in this registry: a not-found comes back as nothing more than a success flag set to false, with no code, message or details to branch on." - ], - "returns": [ - "success", - "custom_fields", - "default_fields", - "id", - "field_name", - "field_user_name", - "field_type", - "entity_type", - "is_required", - "optional", - "option_values", - "field_values", - "default_value", - "placeholder", - "is_bulk_editable", - "is_filterable", - "parent_custom_field_id", - "links" - ], - "responses": { - "200": { - "$response": "ExploratorySessionFormFieldsResponse" - }, - "404": { - "description": "Returned for a project that cannot be read, and - currently - for every request that asks for the configured choices. The body carries only a false success flag: no error object, no code, no message.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - } - } - } - } - } - }, - { - "name": "get_lcnc_user_test_config", - "method": "GET", - "path": "/api/v1/projects/{project_id}/lcnc/user-test-config", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project the setup is read for." - } - ], - "intent": "Get the caller's saved low-code test setup for a project.", - "guidance": [ - "The setup itself belongs to BrowserStack's low-code automation product and is handed back exactly as that product produced it. Test Management does not define, validate or reshape those fields, so they are not guaranteed here and may change without a change in Test Management - read them each time rather than relying on a remembered shape.", - "The status reported inside the payload is the one the other product returned, echoed as the transport status as well, so its failure surfaces as that same code here with the detail in the payload.", - "The setup is per person: it is the calling user's own default, so two people on the same project can correctly get different answers, and it is not a project-wide setting you can read on someone else's behalf." - ], - "returns": [ - "status", - "body" - ], - "shape": "discovered", - "responses": { - "200": { - "description": "The low-code automation service's own response, passed through unchanged. `status` echoes that service's status code and `body` is its parsed payload, whose fields are owned by that service and are not guaranteed by Test Management.", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "integer", - "description": "Status code echoed from the upstream service." - }, - "body": { - "type": "object", - "description": "Upstream payload, passed through unchanged. Field set may vary; see the `discovered` shape." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_shared_components", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-components", - "mode": "read", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project whose shared fields are listed." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, starting at 1. The page size is fixed by the service and cannot be raised from here; read the returned page details to see the total and whether another page exists." - }, - { - "name": "q[query]", - "type": "string", - "description": "Text matched against the shared field's title only - its step content is not searched. Send it in this bracketed form; a bare q value is ignored and the unfiltered list comes back looking like a successful search." - }, - { - "name": "q[tags]", - "type": "string", - "example": "smoke,regression", - "description": "Comma-separated tag names. Only shared fields carrying at least one of them are listed." - }, - { - "name": "q[empty][system_fields][]", - "type": "array", - "values": [ - "tags" - ], - "example": [ - "tags" - ], - "fields": [ - { - "name": "system_field", - "type": "string" - } - ], - "description": "Lists shared fields that have nothing set for a system field. Only tags is understood; any other name is dropped without an error, which silently widens the result. Combines with a tag filter as an either/or rather than narrowing it." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Restrict the listing to one kind of shared field. Omit to list every kind." - }, - { - "name": "folder_id", - "type": "string", - "example": "null", - "description": "Collection to list from: the collection's numeric id, or the literal four-character text null to list the Unassigned root where uncollected fields sit. That is the word null as a value, not an absent one. Omit the parameter entirely to list the project's shared fields regardless of collection." - }, - { - "name": "include_sub_collections", - "type": "boolean", - "default": false, - "description": "Set true to include the chosen collection's sub-collections as well as the collection itself. Only the exact value true turns it on, it does nothing without a collection, and it never applies to the Unassigned root, which has no children." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort order for the listing, passed straight through to the service that stores shared fields. Omit it for the default order; an unrecognised value is not reported as an error here." - }, - { - "name": "pre_fetch", - "type": "boolean", - "default": false, - "description": "When true the response also carries the steps of the first shared field on the page, saving a follow-up call. Honoured only on page 1 and only when the page has at least one row." - } - ], - "intent": "List or search a project's shared fields.", - "guidance": [ - "Shared fields and shared steps are different objects with separate listings and separate id sets; an id taken from one will not resolve in the other, so confirm which the user means before acting.", - "The listing carries each field's title, type and usage count but not its step content - fetch a single shared field when the actual steps are needed.", - "The usage count is what tells you how wide the blast radius of an edit would be; read it before suggesting a change to a field.", - "Filtering by collection is the only way to reproduce what the user sees in a collection view - an unfiltered list spans the whole project and will look like the filter was ignored." - ], - "returns": [ - "data", - "id", - "title", - "field_type", - "step_count", - "test_case_count", - "tags", - "created_by", - "updated_by", - "info", - "pre_fetched_shared_step", - "success" - ], - "paginated": true, - "responses": { - "200": { - "$response": "SharedComponentsGetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_case_comments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", - "mode": "read", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, starting at 1. Each page holds at most 30 comments and the size cannot be raised; the response reports the total and the next page number." - } - ], - "intent": "Read the comments on a test case.", - "guidance": [ - "The newest comment is first on page 1, so a 'what was said last' question is answered without paging; page on only when the reported total exceeds what you already hold.", - "Comments deleted by their author are gone from every page and from the total, so a thread can be shorter than a screenshot or an email suggests.", - "Bodies are rich text (HTML). A mention of a colleague arrives as markup whose visible text is that person's display name, so strip tags before showing the text plainly.", - "An archived test case still returns its comments, even though nothing new can be added to it.", - "Needs the test-case view permission and a paid plan. Below a paid plan the call still comes back as HTTP 200 with a false success flag and a message saying the feature is for paid customers, so read the success flag in the body rather than trusting the status code.", - "The page parameter is named p, and page size is fixed at 30 with no way to raise it. Asking for a page past the end neither errors nor returns an empty list - it serves the last page again - so stop when the reported next page is null, never when a page comes back empty.", - "Each row's user_id is the AUTHOR as an object with id, full_name and email rather than a bare id; that id is the internal user id and is null for comments brought in by an import, where full_name carries the original author's name instead. Rows also carry group_id, project_id, parent_id, entity_id and entity_type, and parent_id is always null.", - "On this surface entity_type is always TestCase and entity_id is the test case's own id. Comments left on the same case inside a test run are a separate thread with its own capability - nothing posted there shows up here.", - "review_status appears on a row only when that comment was recorded as part of a review verdict, which is how the bulk review flow on test cases writes it; a comment posted through the ordinary comment endpoint never carries it. Treat the field as optional and absent by default.", - "This listing is the only way to discover a comment's id, which every later edit or delete needs." - ], - "returns": [ - "comments", - "success", - "info", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "comment", - "edited", - "is_editable" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-12", - "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-1024", - "description": "The case's TC-NNN identifier, or its customer-defined identifier when the workspace uses project-level custom ids; matched exactly within the project. A bare integer id is NOT accepted here (404) — use get_test_case_by_integer_id for integer ids." - } - ], - "intent": "Get one test case in full by its TC-NNN identifier.", - "guidance": [ - "Returns `title` rather than name, `steps` as [{step, result}], custom_fields as [{name, value|values}] and owner as an email; there is no integer id in the body.", - "feature/background/scenario are present only for a Gherkin (test_case_bdd) case and `steps` is then empty; reviewers/review_status appear only when the project has Review & Approve enabled.", - "A 404 means the identifier does not resolve in THIS project; a case moved to another project keeps its identifier but must be read there." - ], - "returns": [ - "identifier", - "title", - "description", - "preconditions", - "template", - "folder_id", - "status", - "priority", - "case_type", - "automation_status", - "owner", - "tags", - "issues", - "steps", - "feature", - "background", - "scenario", - "custom_fields", - "attachments", - "is_shared", - "review_status", - "reviewers", - "created_at", - "created_by", - "last_updated_at", - "updated_by", - "urls" - ], - "responses": { - "200": { - "$response": "V2TestCaseResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "Upstream read failed with an unknown status; retry.", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "search_all_projects", - "method": "GET", - "path": "/api/v1/global/search", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "entity", - "type": "string", - "required": true, - "example": "test_case,test_run,project", - "description": "Comma-separated list of the entity types to search. Allowed tokens: test_case, test_run, test_plan, project, shared_step, report. They are snake_case and SINGULAR — the hyphenated plurals used by the project-scoped search (test-cases, test-runs) are rejected here with a 400 naming the invalid token. Omitting this is a 400, not a search of everything." - }, - { - "name": "q[query]", - "type": "string", - "required": true, - "example": "login", - "description": "The text to search for. Required — a blank or missing value is a 400. %, _ and \\ are literal characters here and are not escaped or treated as wildcards." - }, - { - "name": "q[project_ids]", - "type": "string", - "example": "101,102", - "description": "Comma-separated NUMERIC project ids to restrict the search to. These are integer ids, not PR-NNN identifiers. Omit to search every project the caller can see." - }, - { - "name": "q[owner]", - "type": "string", - "example": "14,15", - "description": "Comma-separated Test Management user ids; the server converts them to the underlying user ids. Applied to test cases, runs and plans, and to reports. Deliberately NOT applied to project or shared_step hits, whose stored user is a creator rather than an owner, so an owner filter does not narrow those two at all." - }, - { - "name": "q[tags]", - "type": "string", - "example": "regression,smoke", - "description": "Comma-separated tag ids applied to every entity type being searched. A per-entity tag filter overrides it for that entity." - }, - { - "name": "q[issue_ids]", - "type": "string", - "example": "JIRA-1,JIRA-2", - "description": "Comma-separated linked-issue ids. Applied to test cases only at the shared level; use the per-entity form for runs." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "7_day", - "description": "Relative token `_` with a SINGULAR unit (day, hour, week, month, year) — e.g. 7_day, 24_hour; a `_gte` suffix inverts it (7_day_gte = older than). Also accepts `all_time` (resolved as one year) or an absolute \"YYYY-MM-DD YYYY-MM-DD\" range. A plural unit such as 7_days raises server-side rather than returning a 400." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "1_month", - "description": "Same form as q[created_at]." - }, - { - "name": "q[created_between]", - "type": "array", - "example": [ - "2026-04-05T00:00:00.000Z", - "2026-05-05T23:59:59.999Z" - ], - "minItems": 2, - "maxItems": 2, - "fields": [ - { - "name": "0", - "type": "string" - }, - { - "name": "1", - "type": "string" - } - ], - "description": "Absolute creation range as a positional 2-element array: index 0 is the start, index 1 the end, ISO 8601. Takes precedence over q[created_at] when both are sent." - }, - { - "name": "q[updated_between]", - "type": "array", - "example": [ - "2026-04-05T00:00:00.000Z", - "2026-05-05T23:59:59.999Z" - ], - "minItems": 2, - "maxItems": 2, - "fields": [ - { - "name": "0", - "type": "string" - }, - { - "name": "1", - "type": "string" - } - ], - "description": "Absolute last-updated range, same positional 2-element form as q[created_between]. Takes precedence over q[updated_at]." - }, - { - "name": "q[test_case][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, test cases only. Overrides the shared q[tags] for this entity." - }, - { - "name": "q[test_case][priority_id]", - "type": "string", - "example": "1268,1270", - "description": "Comma-separated priority option ids. Ids, not labels — resolve them from the project's system field values first." - }, - { - "name": "q[test_case][status_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated status option ids." - }, - { - "name": "q[test_case][automation_state_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated automation-state option ids." - }, - { - "name": "q[test_case][category_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated case-type (category) option ids." - }, - { - "name": "q[test_case][issue_ids]", - "type": "string", - "example": "JIRA-1,JIRA-2", - "description": "Comma-separated linked-issue ids, test cases only." - }, - { - "name": "q[test_run][tags]", - "type": "string", - "example": "sprint-1", - "description": "Comma-separated tag ids, test runs only." - }, - { - "name": "q[test_run][issue_ids]", - "type": "string", - "example": "JIRA-3", - "description": "Comma-separated linked-issue ids, test runs only." - }, - { - "name": "q[test_plan][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, test plans only." - }, - { - "name": "q[project][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, project hits only. Projects and shared steps accept tags and the date ranges only — no owner, priority or status filter reaches them." - }, - { - "name": "q[shared_step][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, shared-step hits only." - }, - { - "name": "q[match][tags]", - "type": "string", - "example": "all", - "description": "How to combine the tag values supplied elsewhere — an OPERATOR, never the values themselves: all (has every tag), any, none. A value placed here instead of beside it sets a meaningless operator and applies NO filter, silently widening the result. The operator is broadcast to every entity being searched." - }, - { - "name": "combine", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "true merges all entity types into one list sorted newest-first with an entity_type on each row. Case-insensitive. It forces page 1 and caps the merged list at 5 rows while info.count still reports the real total, so it is a preview, not a way to read results." - }, - { - "name": "typeahead", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "true takes a fast path returning only entity_type, id, identifier, name, project_id and owner per hit, with no folder path, status or description enrichment. It also DROPS reports from the search entirely even when they are listed in `entity`. Case-insensitive." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "Exactly the lowercase string `true` adds a filter_details block resolving the filter ids you sent back to names. Any other spelling, including True, is ignored. If resolution fails the block is simply absent — the search itself still succeeds." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number, applied to every entity type at once. A value below 1 becomes 1. `page` is accepted as an alias. Ignored when combine is true." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 30, - "default": 30, - "example": 30, - "description": "Rows per entity type per page, ceiling 30. A larger value is silently reduced to 30 rather than refused. `count` is accepted as an alias." - } - ], - "intent": "Search every entity type across the account at once.", - "guidance": [ - "Account-wide by default. There is no project path segment; narrow it with the numeric project-ids filter when the user did name a project, otherwise every project the caller can see is searched.", - "Entity tokens here are snake_case and singular (test_case, test_run, test_plan, project, shared_step, report). This is a different vocabulary from the project-scoped searches, which take hyphenated plurals - passing those gets a 400 that names the invalid token.", - "Both the entity list and the query text are mandatory, and each missing one comes back as HTTP 400 whose body is a single bare error string and nothing else - no success flag, and no code, message or details sub-fields. Detect the failure from the status, not from the body's shape.", - "Pagination is per entity type, not overall: page 2 advances every type together, so a type with 3 hits contributes nothing to page 2 while another is still producing rows. Read each type's own info block to know when that type is exhausted.", - "Reports are the exception to the page-size setting: that bucket is fixed at 30 rows per page no matter what you ask for, and its own info block reports 30 back. Page through reports rather than trying to widen the page.", - "Combining results is a preview only - it pins you to page 1 and returns at most 5 rows across all types while still reporting the true total in the count, so a count of 200 beside 5 rows is expected and not a bug. Leave it off and read the per-type results when you actually need the matches.", - "Combining also replaces the reply: the per-entity buckets are gone and you get one merged list with a single info block, plus the project lookup and the test-case folder lookup when there is anything in them. That info block's page size is the per-type page sizes added together, so a three-type search reports 90 - it is not a row count and not a limit you can use.", - "Priority, status, automation-state and case-type filters take option ids, not the labels a user says. Resolve them first; a wrong-form value matches nothing and still returns a successful empty result.", - "Projects and shared steps only honour tags and the date ranges. An owner filter does not narrow them at all, so a query scoped by owner still returns every matching project.", - "Freshly created records are not findable here immediately - indexing lags creation by minutes. If you have just made something and need to confirm it exists, read it back directly instead of searching for it." - ], - "returns": [ - "test_case", - "test_run", - "test_plan", - "project", - "shared_step", - "report", - "project_details", - "data", - "info", - "test_case_folders", - "filter_details" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "GlobalSearchResponse" - }, - "400": { - "description": "Missing entity list, missing query text, or an unrecognised entity token. The body is a single bare error string - there is no success flag and no nested error object.", - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string", - "description": "The reason, as plain text.", - "example": "No entities provided for search" - } - } - } - } - } - }, - { - "name": "initiate_export", - "method": "POST", - "path": "/api/v1/projects/{project_id}/{entity_type}/exports/{file_type}", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric project id, not the PR-NNN identifier." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-cases" - ], - "example": "test-cases", - "description": "Only test cases can be exported through this capability. Dashboard analytics has its own export capability; asking for it here is accepted, records an export and returns an id, then the background job fails." - }, - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv", - "bdd" - ], - "example": "csv", - "description": "csv produces a spreadsheet of every field, custom field and step. bdd produces Gherkin .feature output and silently restricts the export to BDD-template cases only." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "example": [ - 101, - 102, - 103 - ], - "fields": [ - { - "name": "id", - "type": "integer" - } - ], - "json_path": "/test_case/ids", - "description": "The exact test cases to export, by NUMERIC id. A TC-NNN identifier or a customer-defined id is not matched here. Ignored when select_all is true or when a folder selection map is sent. Omit the whole test_case object at your peril: with no selection form at all the export job is started against an empty id set." - }, - { - "name": "select_all", - "type": "boolean", - "example": true, - "json_path": "/test_case/select_all", - "description": "true exports everything matching the q filters within folder_id instead of an explicit id list, minus de_selected_ids. Sent as false (or omitted) the explicit id list is used instead." - }, - { - "name": "de_selected_ids", - "type": "array", - "example": [ - 104 - ], - "fields": [ - { - "name": "id", - "type": "integer" - } - ], - "json_path": "/test_case/de_selected_ids", - "description": "Numeric ids to exclude from a select_all export. Has no effect unless select_all is true." - }, - { - "name": "folders", - "type": "object", - "example": { - "125382": { - "select_all": true - }, - "125385": { - "ids": [ - 9001 - ] - } - }, - "json_path": "/test_case/folders", - "description": "Cross-folder selection: an object KEYED BY FOLDER ID, each value carrying that folder's own select_all / ids / de_selected_ids choice; the server resolves it to a union of case ids. Present, it WINS over both select_all and ids at the top level. An empty map resolves to no cases, never to the whole project.", - "fields": [ - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "ids", - "type": "array" - }, - { - "name": "de_selected_ids", - "type": "array" - } - ] - }, - { - "name": "folder_id", - "type": "integer", - "example": 29529465, - "description": "Folder to scope a select_all export to. Only the folder itself unless subfolders are opted into." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "example": true, - "description": "true also pulls cases from the folder's descendants. Off by default, so a folder export silently returns only the cases sitting directly in that folder." - }, - { - "name": "q", - "type": "object", - "example": { - "priority": "1268", - "tags": "12,13" - }, - "fields": [ - { - "name": "priority", - "type": "string" - }, - { - "name": "status", - "type": "string" - }, - { - "name": "tags", - "type": "string" - }, - { - "name": "owner", - "type": "string" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "description": "Filters selecting which cases a select_all export covers. Same vocabulary and same id-based values as the project test-case search, so resolve option ids first. An unrecognised key is dropped rather than refused, which widens the export instead of erroring." - }, - { - "name": "merge_into_single_file", - "type": "boolean", - "example": true, - "json_path": "/metadata/merge_into_single_file", - "description": "BDD exports only: merge every selected case into one .feature file instead of one file per case. The merge needs a single shared feature and background across the selection." - }, - { - "name": "common_feature", - "type": "string", - "json_path": "/metadata/common_feature", - "description": "BDD merge only: the Feature block to write at the top of the merged file. Omit it and the first exported case's own feature is used." - }, - { - "name": "common_background", - "type": "string", - "json_path": "/metadata/common_background", - "description": "BDD merge only: the Background block for the merged file. Omit it and the first exported case's background is used." - } - ], - "intent": "Start a background export of test cases to CSV or Gherkin.", - "guidance": [ - "THE FILE NEVER COMES BACK THROUGH HERE. This only queues the job and hands you an export id. Downloading the finished file is deliberately not part of this catalogue — a file download cannot be served over this transport — and there is no status or polling capability either. So after a successful call, tell the user plainly that the export is being prepared and that they must collect it from the exports list in the Test Management UI. Do not promise to fetch, attach or summarise the file.", - "A 200 with an export id means the job was accepted, not that it succeeded. The work runs afterwards and can still fail, in which case the id stays valid and no file ever appears — which the caller cannot observe from here.", - "Selection has three forms and the server picks one in a fixed order: a folder-keyed selection map beats everything; otherwise select_all exports everything matching the filters within the folder minus the de-selected ids; otherwise only the listed ids. Send exactly one form — the losing ones are silently ignored, not merged.", - "Explicit ids are matched on the numeric primary key only. Feeding TC-NNN identifiers there exports nothing and still returns a successful export id.", - "Choosing BDD restricts the selection to BDD-template cases behind your back, so exporting a folder of step-template cases as BDD produces an empty file with no error. Validate a BDD selection for a shared feature and background before merging into one file.", - "This creates an export record against the caller's account each time it is invoked; repeating it produces a new id and a new job rather than reusing the previous one." - ], - "returns": [ - "export_id" - ], - "responses": { - "200": { - "$response": "DashboardExportInitiatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "link_entities_to_jira_issue", - "method": "POST", - "path": "/api/v1/integration/jira-app/jira-ticket/link-entities", - "mode": "write", - "entity": "issue", - "body": [ - { - "name": "issue_id", - "type": "string", - "required": true, - "example": "TES-121", - "description": "The external tracker's own ticket key, e.g. the Jira key TES-121. It is not a Test Management id and is not minted here — take it from the tracker. A missing value is refused with 400 'Missing issue_id'; a key that does not exist in the tracker is NOT validated and the link is stored anyway." - }, - { - "name": "project_id", - "type": "string", - "required": true, - "example": "10001", - "description": "Integer id of the Test Management project that owns the entities being linked, sent as a string. A missing value is refused with 400 'Missing project_id'. This endpoint does not check it is numeric, so a non-numeric value fails deeper as a 500 rather than a clean 400." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-case", - "test-run" - ], - "example": "test-case", - "description": "What is being linked. Matched case-insensitively. Anything outside the list is refused with 400 'Unsupported entity_type'. This also decides which other body field is required: 'test-case' requires `selection`, 'test-run' requires `entity_ids`." - }, - { - "name": "entity_ids", - "type": "array", - "example": [ - 27912, - 27399, - 27398 - ], - "description": "Integer ids of the test runs to link. Required ONLY when entity_type is 'test-run' (400 'Missing entity_ids for testrun' otherwise). For 'test-case' this field is read but never used — cases are resolved from `selection`, so ids sent here are silently discarded." - }, - { - "name": "selection", - "type": "object", - "example": { - "select_all": false, - "unique_test_case_count": 12, - "folders": {} - }, - "description": "Which test cases to link, in the same folder/filter selection shape the selection preview returns. Required ONLY when entity_type is 'test-case' (400 'Missing selection for testcase' otherwise). Ignored for 'test-run'.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "description": "Select every case matching the selection rather than an explicit list." - }, - { - "name": "unique_test_case_count", - "type": "integer", - "description": "Caller-supplied count of cases this selection resolves to. The server trusts it: it is the only input to the >200 async decision and is never recomputed against the selection." - }, - { - "name": "folders", - "type": "object", - "description": "Folder-keyed selection map, taken verbatim from the selection preview." - } - ] - }, - { - "name": "shared_selection", - "type": "object", - "description": "Additional selection covering test cases shared in from other projects. Only read on the synchronous test-case path; the queued path above 200 cases drops it, so shared cases are not linked in an async batch." - }, - { - "name": "build_ids", - "type": "array", - "description": "Build ids matching `entity_ids` one-for-one, for accounts on build-based run ingestion. Omit the field entirely to let the server resolve runs the legacy way. If you do send it, it must hold exactly one positive integer per entry in `entity_ids` with no duplicates, or the call is refused with 400." - } - ], - "intent": "Link test cases or runs to a tracker ticket.", - "guidance": [ - "Which body field carries the entities depends on what you are linking: cases are selected by a selection object, runs by an explicit list of ids. Sending ids alongside a case selection links nothing extra.", - "Above 200 entities the work is queued to a background worker and the response comes back immediately with async true and no per-entity outcome. Nothing has been linked yet at that point, and a worker failure is never reported back — re-read the ticket's linked test cases to confirm.", - "The 200 threshold for a case selection is read from the count you supplied, not from the selection itself. Understate it and the call takes the synchronous path, which resolves at most 200 cases and links only those — reporting success for the whole selection.", - "Build the case selection from the selection preview for the same ticket and project rather than assembling it by hand; the preview returns exactly the object this field expects.", - "The ticket key is not verified against the tracker. A typo produces a link to a ticket that does not exist, with a 200 and a success message.", - "Linking is additive and idempotent per entity; relinking an already-linked entity does not duplicate it.", - "Every failure on the argument-validation path comes back as 400 with a plain message, including cases that are really 'not found'." - ], - "returns": [ - "success", - "message", - "async" - ], - "responses": { - "200": { - "description": "The linking was accepted. Nothing about WHICH entities were linked comes back - no ids, no per-entity results, no failure list - so the only way to know what the ticket now holds is to read its linked entities back.\n", - "schema": { - "type": "object", - "required": [ - "success", - "message", - "async" - ], - "properties": { - "success": { - "type": "boolean", - "description": "True on this response. It reports that the request was accepted, not that every entity was linked - when `async` is true nothing has been linked yet. A refusal comes back as a 4xx or 5xx with success false and the reason in `message`.", - "example": true - }, - "message": { - "type": "string", - "description": "Human-readable outcome: 's linked successfully' when the work was done in-request, 's successfully queued for linking' when it was handed to a background worker. The entity type is echoed from your request verbatim, so it is your own wording plus an 's' - do not parse it, read `async` instead.", - "example": "test-runs linked successfully" - }, - "async": { - "type": "boolean", - "description": "False means the entities were linked before this response was sent, and reading the ticket's linked entities now will show them. True means NOTHING has been linked yet: the batch was over 200 entities and was handed to a background worker, and this response carries no job id and no way to poll it. Treat true as 'accepted, not done' - an empty read straight afterwards is expected and is NOT a failure. Do not resend the same request on the strength of an empty read: there is no de-duplication and the resend simply queues the same linking work again. Wait and re-read instead. Note also that for a test-case batch the 200-entity threshold is measured from the count YOU supplied inside the selection, not from a count the server recomputes, so a wrong count there decides this flag." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_linked_test_case_selection", - "method": "GET", - "path": "/api/v1/integrations/{issue_type}/test-cases/selection", - "mode": "read", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the caller is acting from. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." - } - ], - "query": [ - { - "name": "issue_id", - "type": "string", - "required": true, - "example": "TES-121", - "description": "The external tracker's ticket key whose current test-case selection is wanted. Not validated here: a blank or unknown key returns an empty selection rather than an error." - }, - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer id of the Test Management project the selection is scoped to. This endpoint does not check it is present or numeric, so a missing or non-numeric value fails deeper as a 5xx instead of a clean 400 — always send a real project id." - } - ], - "intent": "Preview the test cases a tracker ticket would link.", - "guidance": [ - "This is the step before linking, not the list of what is already linked. The selection object it returns is the exact object the link call expects, folder structure and all — pass it straight through rather than rebuilding it.", - "The count the link call uses to decide between the immediate and the queued path is the one you carry from this selection. Change the selection by hand and that count stops matching what it resolves to.", - "Unlike its Jira-specific twin, this route validates neither the ticket key nor the project id, so a missing argument surfaces as a server error rather than a 400.", - "During a platform downtime window this returns an empty test-case list rather than a selection object." - ], - "returns": [ - "success", - "selection" - ], - "responses": { - "200": { - "description": "The ticket's linked test cases expressed as a reusable selection - hand this object straight back as the case selection when linking. While the service is unavailable an empty test-case collection is returned in its place: `{success: true, test_cases: []}`, with no `selection` key at all, so check for `selection` before using it.\n", - "schema": { - "type": "object", - "required": [ - "success", - "selection" - ], - "properties": { - "success": { - "type": "boolean", - "description": "True on this response. A fixed acknowledgement, not a report on the selection - an empty selection still comes back as true.", - "example": true - }, - "selection": { - "$schema": "SelectionData", - "description": "The selection as passed through from the linking service. `folders` is an empty map and `unique_test_case_count` is 0 when the ticket has no linked cases in this project." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_configuration_groups", - "method": "GET", - "path": "/api/v1/configurations/groups", - "mode": "read", - "entity": "configuration", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. Page 0 is treated as page 1." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "example": 30, - "description": "Rows per page, ceiling 30. Anything above 30 — and 0, a negative, or a non-numeric value — is silently replaced with 30 rather than refused, so a big page never arrives and you must walk pages. Above 300 the call is refused outright with a page-size error instead." - } - ], - "intent": "List the account's configuration groups and their ids.", - "guidance": [ - "A group carries only the dimension's name — the selectable values live under it as its options and are fetched separately using the group id returned here, so this is step one of two whenever you need a concrete value.", - "Nothing here is project-scoped: the same groups come back whichever project the user is working in, and they cannot be filtered to one project. Do not offer this as a way to see what a particular project can run against.", - "Page number travels as `p`; a `page` parameter is not read on this call and is ignored, which silently pins you to page 1.", - "There is no name search or sort on this listing — walk the pages and match names yourself." - ], - "returns": [ - "id", - "name", - "is_system", - "group_id", - "created_at" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "ConfigurationGroupListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/tags/v3", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run", - "test_plan", - "test_recording", - "shared_step" - ], - "description": "Which kind of record's labels to list. There is no option meaning 'all kinds'. Leaving it out or sending anything outside the listed set is refused outright rather than answered with an empty list.", - "example": "test_case" - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, counting from 1. Anything that is not a positive whole number is refused before the labels are read.", - "example": 1 - }, - { - "name": "page", - "type": "integer", - "minimum": 1, - "description": "Accepted as an alternative spelling of the page number, and used only when the page number was not supplied under the shorter name. Prefer the shorter name, which every other listing here uses." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "How many labels to return per page. The default is small, so ask for the largest page you need. A value above 100, zero, a negative number or anything non-numeric is refused rather than quietly clamped.", - "example": 100 - }, - { - "name": "search", - "type": "string", - "description": "Narrows the list to labels matching this text. Surrounding spaces are trimmed and an all-blank value counts as no filter at all. 'query' and 'q' are accepted as equivalents; whichever of the three is non-blank first is the one used." - } - ], - "intent": "List or search a project's labels, with their ids.", - "guidance": [ - "This is the only label listing that reaches records other than test cases, and the only one that hands back the id a rename or a merge needs. The test-case-only listings return names and usage counts, so start here whenever an id is required downstream.", - "Name the kind of record explicitly every time. There is no way to ask for every kind at once, and a misspelling is refused rather than silently widened.", - "Labels are scoped to a project and to a kind of record, so the same word can exist as separate labels for test cases and for test runs, each with its own id.", - "Ask for a large page in one go rather than walking small pages; the ceiling is 100 per page." - ], - "returns": [ - "tags", - "info", - "id", - "name" - ], - "shape": "discovered", - "paginated": true, - "max_page_size": 100, - "responses": { - "200": { - "description": "A page of labels for the requested kind of record. The body is passed through from the labels service unchanged, so it carries NO success flag - the absence of `success` here is not an error signal. A failure is a 4xx or 5xx with a plain `error` string instead.\n", - "schema": { - "type": "object", - "required": [ - "tags", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "The labels in use on the requested kind of record in this project, one entry per label, narrowed by the search text when one was given. An empty list means the project has no labels of that kind yet - it is not an error.", - "items": { - "type": "object", - "description": "A label. `id` is the handle to use wherever a call takes label ids; `name` is what people see. The entry is passed through from the labels service and may carry extra bookkeeping keys such as a usage count and timestamps - read id and name, and treat anything else as informational.", - "properties": { - "id": { - "type": "integer", - "description": "The label's id - the value to pass wherever a call takes label ids." - }, - "name": { - "type": "string", - "description": "The label's text, as people see it." - } - } - } - }, - "info": { - "$schema": "PageInfo", - "description": "Pagination for the label list. Page size is capped at 100 and defaults to 30." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "list_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users", - "mode": "read", - "entity": "user", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "example": 11046, - "description": "Integer project id. It gates access only; the returned list is not filtered to this project's members." - } - ], - "intent": "List the account's active users, for assigning a case or run.", - "guidance": [ - "Returns all active users of the caller's account, alphabetical by full name, in one unpaginated response. Appearing here does not mean the user has access to the project.", - "No role field. For project membership with roles use search_project_users; for a paged, name-filterable list of account users use list_project_users.", - "Assign owners and assignees with `id` (the TM user id), not `browserstack_user_id`.", - "`myself` is the caller, reduced to id, full_name and reports_and_notification_enabled; look the caller up in `users` for the full record.", - "Deactivated users and users with no name are omitted, so a colleague missing from the list may simply not have completed onboarding." - ], - "returns": [ - "success", - "users", - "myself", - "id", - "browserstack_user_id", - "full_name", - "email", - "group_id", - "onboarded", - "reports_and_notification_enabled" - ], - "responses": { - "200": { - "$response": "UsersListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "move_folder", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}/move", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 54, - "description": "Integer id of the folder to move; everything nested inside it moves with it. Never an FL- string." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "default": true, - "description": "Defaults to true: if a folder with the same name (case-insensitive, trimmed) already exists under the destination, the moved folder is silently renamed to ' (1)' with the name lowercased. Send false to get a 400 on a clash instead." - } - ], - "body": [ - { - "name": "parent_id", - "type": "integer", - "example": 12, - "description": "Integer id of the new parent folder in the SAME project. To move the folder to the project root, omit this field entirely: the server treats a missing key as root, and null cannot be sent through this transport. 0 is not root and is refused with 422. Must differ from folder_id." - } - ], - "intent": "Move a folder and its contents under a different parent.", - "guidance": [ - "Only moves within the same project are possible here; a destination in another project is refused with 422. Cross-project moves exist only in the internal API.", - "To move to root, omit parent_id; the server treats the missing key as root. A body that accidentally lost the key therefore also returns 200 and re-parents the folder to root, so double-check before sending without it. 0 is refused (422) rather than treated as root.", - "Name clashes at the destination are resolved by renaming the moved folder to ' (1)', lowercased, unless auto_rename=false is sent. Read the returned name either way.", - "Every pre-flight failure — same source and destination, unknown destination, unknown source folder, other-project destination — is a 422 whose text sits under `error`, not `message`. Only a failure inside the storage update comes back as 400.", - "Reversible by moving the folder back to its previous parent_id, so read the folder before moving; a name changed by auto-rename is not restored by moving back.", - "Completes synchronously and returns the moved folder; there is no async job id to poll, unlike the internal cross-project move." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "description": "The storage update was refused (e.g. an invalid destination such as the folder's own subtree).", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "A pre-flight validation failed: cross-project destination, destination equal to source, destination not in this project, or source folder not found. The reason is under `error`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "rename_folder", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 54, - "description": "Integer id of the folder to update, as returned by the folder list or create. Never an FL- string. 0 is rejected." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "description": "Only the literal value true has any effect. When true, a new name that clashes with a sibling (case-insensitive, trimmed) is saved as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/folder/name", - "example": "Checkout v2", - "description": "New folder name. Optional server-side despite the spec marking it required — a description-only update is valid. Must be unique among siblings ignoring case, otherwise 400 (unless auto_rename)." - }, - { - "name": "description", - "type": "string", - "json_path": "/folder/description", - "example": "Updated notes", - "description": "New notes for the folder; replaces the existing text. HTML is sanitised on save, so tags may come back stripped or escaped." - } - ], - "intent": "Rename a folder or change its description.", - "guidance": [ - "There is no undo and folders keep no version history: read the folder first and keep the old name and description if you may need to put them back.", - "Neither name nor description is required: send only the one you want to change, and a description-only update is valid.", - "Keys this capability does not declare are silently dropped rather than rejected, so a misspelt field returns 200 with nothing changed. Compare the returned folder to what you sent.", - "A new name matching a sibling's (case-insensitive, trimmed) returns 400 'Folder with same name exists in this location'. auto_rename=true makes the server lowercase the name and append ' (1)' instead — that is a different result from the rename you asked for.", - "Renaming does not move the folder; use the move capability to change its parent." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case_linked_issues", - "method": "GET", - "path": "/api/v1/integrations/{issue_type}/test-case/results/{test_case_id}", - "mode": "read", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the caller is acting from. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Integer id of the test case whose details and execution results are wanted. Must belong to the caller's own group." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of execution results. Sending it also switches the read to include every result rather than the server default set, so send p=1 explicitly when you want the full result history — omitting it quietly narrows what comes back." - }, - { - "name": "per_page", - "type": "integer", - "example": 10, - "description": "Results per page. Only read when `p` is also sent; on its own it has no effect." - } - ], - "intent": "Get a test case's linked tracker tickets.", - "guidance": [ - "Everything is nested one level under a `data` wrapper, with the case itself under `test_case` inside it; reading the top level finds nothing.", - "The linked tickets are on the `issues` field of the returned case, each carrying the tracker's own ticket key. That is the list to read before unlinking, because unlinking does not let you choose between several linked tickets.", - "Ownership is checked against the caller's own group, so a case id from another account comes back as a body saying access was denied while the transport status is still 200 — check the success flag rather than the status.", - "During a platform downtime window this returns a success body with a null case instead of failing; treat a null case as 'unknown', not as 'no such case'." - ], - "returns": [ - "id", - "identifier", - "name", - "description", - "preconditions", - "expected_result", - "project_id", - "test_case_folder_id", - "issues", - "status", - "priority", - "case_type", - "automation_state", - "tags", - "custom_fields", - "steps", - "test_case_steps", - "template", - "assignee", - "creator_details", - "created_at", - "updator_details", - "updated_at", - "links", - "test_run_results", - "test_run_results_count", - "test_run_results_issues_count", - "metadata" - ], - "responses": { - "200": { - "description": "`{\"data\": {\"success\": true, \"test_case\": }}`. The case object also carries `test_run_results`, the page of execution results, and `issues`, the tracker tickets it is linked to. Also returned with `test_case` null during a downtime window.\n", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "TestCase" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "unlink_test_case", - "method": "POST", - "path": "/api/v1/integrations/{issue_type}/unlink-test-case", - "mode": "write", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the link belongs to. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." - } - ], - "body": [ - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Integer id of the test case to unlink. This, the caller's group and the tracker type are the whole match key — the case must belong to the caller's own group or the call is refused." - }, - { - "name": "issue_id", - "type": "string", - "example": "TES-121", - "description": "The tracker ticket key the case should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the case, the group and the tracker type only. Send it for the refreshed list that comes back, and see the guidance if the case has more than one linked ticket." - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of the refreshed linked-test-case list returned on success. Pages are fixed at 10 per page. Must be a plain scalar — an array or hash value is ignored and the first page is returned." - } - ], - "intent": "Unlink a test case from a tracker ticket.", - "guidance": [ - "On the failure path the body is just `success` and `status`, with no collection — identical to the test-run unlink, which shares this render. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", - "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. Always read the success flag — checking the status alone reports every miss as a win.", - "Which link is removed is decided by the test case, the caller's group and the tracker type, not by the ticket you name. If the case is linked to several tickets in the same tracker, the first matching link is removed and it may not be the one you meant — read the case's linked tickets first and expect to verify afterwards.", - "On success the refreshed list of test cases still linked to the ticket is returned, so no follow-up read is needed to confirm.", - "Nothing is deleted: the case, the run and the ticket all survive and the same link can be created again.", - "Nothing is written back to the external tracker — the ticket itself is not modified.", - "During a platform downtime window an empty list with a success flag is returned and nothing is unlinked." - ], - "returns": [ - "success", - "test_cases", - "info", - "recent_folder", - "recent_project" - ], - "responses": { - "200": { - "description": "On success, the refreshed list of test cases still linked to the ticket: `{\"success\": true, \"test_cases\": [...], \"info\": {...}, \"recent_folder\": ..., \"recent_project\": ...}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found, and as `{\"success\": false, \"error\": \"Invalid Access\", \"status\": 401}` when the test case is not in the caller's group — the numbers in these bodies are not the transport status.\nrecent_folder and recent_project are screen state carried alongside the result rather than part of it - see their own descriptions. During service downtime the whole body degrades to `{success: true, test_cases: []}`, with no info and no recent_* keys.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "LinkedIssueTestCase" - } - }, - "info": { - "$schema": "PageInfo" - }, - "recent_folder": { - "type": "object", - "nullable": true, - "description": "The folder this person most recently linked a case from, so a linking panel can reopen where they left off. A convenience for whoever is driving the screen, not part of the unlink result - nothing about it reflects the case you just unlinked, and it is unchanged by the unlink. Null when there is no such history. Returned only alongside the first page of the refreshed list; asking for a later page omits it." - }, - "recent_project": { - "type": "object", - "nullable": true, - "description": "The project this person most recently linked a case from. Paired with recent_folder and carrying the same caveats: screen state, null when there is no history, and returned only alongside the first page." - }, - "status": { - "type": "integer", - "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen — a successful unlink omits it. 404 means no matching link was found, which is also what a run or case that was already unlinked returns." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "unlink_test_run", - "method": "POST", - "path": "/api/v1/integrations/{issue_type}/unlink-test-run", - "mode": "write", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the link belongs to. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." - } - ], - "body": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer id of the test run to unlink. Must belong to the caller's own group, and the run is looked up inside `project_id`, which therefore becomes mandatory as soon as this is sent." - }, - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer id of the project that owns the run. Required whenever `test_run_id` is sent; omitting it is refused with a body saying the project id was not provided." - }, - { - "name": "issue_id", - "type": "string", - "example": "TES-121", - "description": "The tracker ticket key the run should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the run, the group and the tracker type only. Send it for the refreshed list that comes back." - }, - { - "name": "build_id", - "type": "integer", - "description": "Build id for the run, for accounts on build-based run ingestion. Omit the field entirely and the server resolves the run itself — that is the normal call. Sending a value that is not a positive integer is refused with 400." - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of the refreshed linked-test-run list returned on success. Pages are fixed at 10 per page." - } - ], - "intent": "Unlink a test run from a tracker ticket.", - "guidance": [ - "On the failure path the whole body is just `success` and `status` — there is no `test_runs` array. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", - "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. The same is true when the project id is missing or the run belongs to another group. Always read the success flag.", - "Which link is removed is decided by the test run, the caller's group and the tracker type, not by the ticket you name. If the run is linked to several tickets in the same tracker, the first matching link is removed — verify afterwards.", - "The run is resolved inside a project, so the project id must accompany the run id even though the ticket is the subject of the call.", - "On success the refreshed list of test runs still linked to the ticket is returned.", - "Nothing is deleted: the run and the ticket both survive and the same link can be created again. Nothing is written back to the external tracker.", - "Read the link first and look at its entity_type. When it is 'Build', this project stores runs as builds and the call needs build_id set to the run's uuid — omitting it returns a 200 with success false and a 404 body that looks exactly like 'no such link'. Confirmed by invocation: two identical calls without build_id were refused, the same call with it succeeded.", - "During a platform downtime window this refuses with a success flag set to false and a 404 in the body, on a 200 — indistinguishable from 'no such link' without checking platform status." - ], - "returns": [ - "success", - "test_runs", - "info" - ], - "responses": { - "200": { - "description": "On success, the refreshed list of test runs still linked to the ticket: `{\"success\": true, \"test_runs\": [...], \"info\": {...}}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found or during downtime, and as `{\"success\": false, \"error\": ..., \"status\": 400|401|404}` for a missing project id, a run outside the caller's group, or a run that does not exist — the numbers in these bodies are not the transport status.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_runs": { - "type": "array", - "items": { - "$schema": "TestRun" - } - }, - "info": { - "$schema": "PageInfo" - }, - "status": { - "type": "integer", - "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen — a successful unlink omits it entirely. 404 means no matching link was found, which is also what you get for a run that was already unlinked." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_tag", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/tags/{id}", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "id", - "type": "integer", - "required": true, - "description": "The label's own id, which only the listing that returns ids gives you. The label's name will not work in its place, so look the id up first.", - "example": 1 - } - ], - "body": [ - { - "name": "new_name", - "type": "string", - "required": true, - "description": "The label's new wording. Surrounding spaces are trimmed, and a blank or spaces-only value is refused. This is the only thing a rename changes - nothing else about the label can be edited here.", - "example": "smoke-critical" - } - ], - "intent": "Rename a label across a project.", - "guidance": [ - "The new wording replaces the old one everywhere the label is already applied, in one step, with no per-record confirmation.", - "There is no undo and no saved history for labels, so the only way back is to rename it a second time - note the old wording before you start, because nothing else records it.", - "Only account owners and admins may do this; anyone else is refused, so check who is asking before promising it.", - "This changes wording only. Folding two labels into one is a different operation, and renaming one to match another does not combine them.", - "Look up the id first: this addresses a single label by id, and a project can hold same-named labels for different kinds of record, each needing its own rename.", - "The reply is the whole label record, not just what changed: its id and new wording, the workspace it belongs to, its colour palette, and when it was created and last updated. Nothing else about the label can be edited through this call, but everything about it comes back.", - "A value that was never set is left out of the reply rather than returned as empty, so read a missing colour palette or timestamp as 'not configured', not as a failure." - ], - "returns": [ - "id", - "name", - "group_id", - "color_preset", - "created_at", - "updated_at" - ], - "shape": "discovered", - "responses": { - "200": { - "description": "The renamed label, as a complete label record.", - "schema": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer", - "description": "The label's own id, unchanged by the rename." - }, - "name": { - "type": "string", - "description": "The label's wording after the rename, with surrounding spaces trimmed." - }, - "group_id": { - "type": "integer", - "description": "The workspace the label belongs to. Labels are workspace-wide, not per project." - }, - "color_preset": { - "type": "string", - "description": "The label's colour palette, as one comma-separated list of hex colours rather than a single colour. Preserved by a rename. Omitted when the label has no palette set.", - "example": "#FBF6FE,#F7EAFF,#792395,#2C023F,#5C007A,#EFD4FF" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the label was first created. Omitted when not recorded." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "When the label was last changed, which this call advances. Omitted when not recorded." - }, - "deleted_at": { - "type": "string", - "nullable": true - }, - "last_used_at": { - "type": "string", - "nullable": true - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "record_status": { - "type": "string", - "example": "active" - }, - "restore_blocked_reason": { - "type": "string", - "nullable": true - }, - "updated_by": { - "type": "integer", - "example": 6013 - } - } - } - } - } - }, - { - "name": "verify_test_case_tags", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-case/tags/verify_tag", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "tags", - "type": "array", - "required": true, - "description": "The label wordings to check, as a list of plain strings. Omit the whole list and the request is refused. An empty list is accepted and simply reports nothing new.", - "example": [ - "smoke", - "regression" - ] - } - ], - "intent": "Check which label names a project already uses.", - "guidance": [ - "Nothing is saved by this check, despite how it reads. A label only comes into being when it is actually applied to a test case, so follow this with the step that puts the label on the case.", - "Two lists come back: one holds the wordings not yet used on the project's test cases, the other holds the ones already in use. Neither is an error - being already in use is usually the answer you wanted.", - "The accompanying message says to verify the labels whenever any wording is already in use. It reads like a failure and is not one; go by the two lists.", - "Comparison is exact, so a difference in capitalisation or spacing counts as a brand new label and will leave you with two near-identical ones if you apply it.", - "Only labels on test cases are considered. A wording already used on test runs or plans still comes back as new here.", - "Every outcome, including the one where no wording is new, is returned as an ordinary success - read the flag and the lists rather than the status." - ], - "returns": [ - "success", - "tags", - "error_tags", - "error" - ], - "responses": { - "200": { - "description": "The verdict on the submitted wordings. 'tags' holds those not yet used on the project's test cases, 'error_tags' those already in use, and 'error' carries an advisory message whenever any wording was already in use.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "description": "False only when none of the submitted wordings were new." - }, - "tags": { - "type": "array", - "description": "Wordings not yet used on this project's test cases.", - "items": { - "type": "string" - } - }, - "error_tags": { - "type": "array", - "description": "Wordings already in use on this project's test cases. Present only when at least one was.", - "items": { - "type": "string" - } - }, - "error": { - "type": "string", - "description": "Advisory message shown when some or all wordings were already in use. Not a transport failure." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "create_shared_component", - "method": "POST", - "path": "/api/v1/projects/{project_id}/shared-components", - "mode": "write", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project the shared field belongs to." - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the block, shown in the Shared Fields list." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "example": "background", - "description": "Which kind to create. OMITTING IT SILENTLY CREATES A `step` - it is not rejected, so a caller meaning to add a background or a precondition and forgetting this key gets a shared step instead, with no error to notice." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The ordered rows making up the block. REQUIRED. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "description": "1-based position. Required on every row." - }, - { - "name": "step", - "type": "string", - "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." - }, - { - "name": "result", - "type": "string", - "description": "Expected result; send an empty string when the kind has no result." - }, - { - "name": "test_data", - "type": "string", - "description": "Additional data for the row." - } - ] - }, - { - "name": "tags", - "type": "array", - "items": { - "type": "string" - }, - "description": "Plain tag name strings on the block itself." - }, - { - "name": "folder_id", - "type": "integer", - "nullable": true, - "description": "Collection to file it under. Omit or send null for the Unassigned root." - } - ], - "intent": "Create a reusable shared field - a step sequence, a Gherkin background block, or a precondition - that many test cases can then reference.", - "returns": [ - "success", - "data", - "id", - "title", - "field_type", - "test_case_count", - "step_count", - "tags", - "folder_id", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedComponentWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ONE CAPABILITY, THREE WORKFLOWS. `field_type` is the only difference between creating a shared step, a Gherkin background and a precondition - they are the same record and the same endpoint, not three features.", - "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "`field_type` defaults to `step`. There is no error for omitting it, so a create meant as a background or precondition succeeds as the wrong kind. Send it explicitly every time.", - "Sibling of create_shared_step on /shared-steps, which creates the same record but cannot set `field_type`. Use this route when the kind matters.", - "The response echoes `shared_step_details` with each row's assigned id; the LISTING returns only `step_count`. Read the echo if you need row ids.", - "All three field_type values can be created, and each lists back under the matching field_type filter." - ] - }, - { - "name": "update_shared_component", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/shared-components/{shared_component_id}", - "mode": "write", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project." - }, - { - "name": "shared_component_id", - "type": "integer", - "required": true, - "description": "Id of the shared field to change, from list_shared_components." - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the block. REQUIRED ON EVERY CALL even when you are only changing rows - sending rows alone is a 400." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Which kind the block is. Send its existing value unless you intend to change it." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The FULL replacement row list, not a patch - rows you leave out are removed. REQUIRED ON EVERY CALL even when you are only renaming: sending `title` alone is a 400. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "description": "1-based position. Required on every row." - }, - { - "name": "step", - "type": "string", - "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." - }, - { - "name": "result", - "type": "string", - "description": "Expected result; send an empty string when the kind has no result." - }, - { - "name": "test_data", - "type": "string", - "description": "Additional data for the row." - } - ] - }, - { - "name": "tags", - "type": "array", - "items": { - "type": "string" - }, - "description": "Plain tag name strings; the list you send replaces the block's tags." - }, - { - "name": "folder_id", - "type": "integer", - "nullable": true, - "description": "Collection to move it into." - } - ], - "intent": "Change an existing shared field - its title, rows, tags or collection - with the edit propagating to every test case that references it.", - "returns": [ - "success", - "data", - "id", - "title", - "field_type", - "test_case_count", - "step_count", - "tags", - "folder_id", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedComponentWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "BOTH `title` AND `shared_step_details` ARE REQUIRED ON EVERY CALL. Sending only one of them is a 400. There is no partial update here despite the PATCH verb.", - "`shared_step_details` REPLACES the row list. Read the block first and send back every row you want to keep, each with its `order`.", - "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "The edit propagates to every test case referencing the block - `test_case_count` tells you how many that is before you change it.", - "Sibling of update_shared_step on /shared-steps, which edits the same record but cannot set `field_type`." - ] - } - ], - "schemas": { - "TestRun": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "TR-17" - }, - "uuid": { - "type": "integer", - "example": 17 - }, - "identifier": { - "type": "string", - "example": "TR-17" - }, - "name": { - "type": "string", - "example": "Test Run1-20/05/2025" - }, - "description": { - "type": "string", - "example": "" - }, - "owner": { - "type": "integer", - "example": 2 - }, - "assignee": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "environment": { - "type": "string", - "nullable": true - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "detail": { - "type": "string" - } - } - }, - "test_cases_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ] - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ] - }, - "is_automation": { - "type": "boolean" - }, - "observability_url": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "string" - }, - "configurations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - } - }, - "metadata": { - "type": "object", - "nullable": true, - "properties": { - "test_plan_error": { - "type": "string", - "nullable": true, - "description": "Why the run could not be linked to a plan, e.g. `PLAN_LINKED`. Present only when a plan link was attempted and refused.", - "example": "PLAN_LINKED" - } - } - }, - "assignee_imported": { - "type": "boolean" - }, - "is_dynamic": { - "type": "boolean" - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "archived_at": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-18T05:00:22.000Z" - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "example": "2026-10-18T00:00:00.000Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner": { - "type": "integer", - "example": 6013 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_sent_date": { - "type": "string", - "nullable": true - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "example": "2026-09-18T00:00:00.000Z" - }, - "sub_plans_count": { - "type": "integer", - "example": 14 - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T12:53:05.000Z" - }, - "user_id": { - "type": "integer", - "example": 6013 - } - } - } - }, - "attachments": { - "type": "array", - "description": "Files attached to the run. Normally returned as `[]` — present as a key but not populated.", - "items": { - "type": "object" - } - }, - "created_by": { - "$schema": "TestRunUserRef", - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." - }, - "updated_by": { - "$schema": "TestRunUserRef", - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." - }, - "closed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Usually null, even on runs whose `run_state` is `done` — a closed run does not necessarily carry a close timestamp. Do not infer closure from this field; read `run_state`." - }, - "closed_by": { - "type": "integer", - "nullable": true, - "description": "Usually null, even on runs in `run_state: done`. Same caveat as closed_at." - } - } - }, - "Project": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the project." - }, - "id": { - "type": "integer", - "description": "Unique ID for the project." - }, - "description": { - "type": "string", - "description": "A brief description of the project." - }, - "identifier": { - "type": "string", - "description": "A short unique identifier (e.g., PR-674)." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp indicating when the project was created." - }, - "import_id": { - "type": "string", - "description": "An optional import identifier. May be null.", - "example": null, - "nullable": true - } - } - }, - "ErrorResponse": { - "type": "object", - "description": "Standard error response format.", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Application-specific error code.", - "example": "ERR_INVALID_PARAMS" - }, - "message": { - "type": "string", - "description": "Human-readable error message.", - "example": "The provided parameters are invalid." - }, - "details": { - "type": "object", - "description": "Optional field with additional error details." - } - } - } - } - }, - "V1TestPlanBulkResponse": { - "type": "object", - "description": "Dual-shape response. Below the bulk threshold the work is done and you get\n`plans` + `count`. Above it the work was handed to a background job and you get\n`async: true` + `unique_id` — the plans are NOT changed yet at that point; re-read to\nconfirm (see the async-jobs concept).\n", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean", - "description": "Present and true only when the operation was dispatched asynchronously." - }, - "unique_id": { - "type": "string", - "description": "Correlation id of the async job. Present only when async is true." - }, - "plans": { - "type": "array", - "description": "Affected plans. Present only on the synchronous path.", - "items": { - "type": "object" - } - }, - "count": { - "type": "integer", - "description": "Number of affected plans. Present only on the synchronous path." - } - } - }, - "User": { - "type": "object", - "description": "The full user reference used by most TM serializers.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - } - } - }, - "TestCaseStep": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "integer" - }, - "order": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "result": { - "type": "string" - }, - "step": { - "type": "string" - }, - "test_case_id": { - "type": "integer" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "TestCase": { - "type": "object", - "description": "The project-level test-case record: TestCaseCore plus the three fields every project-level route adds. The run-mapping rows of list_test_run_test_cases_by_integer_id do NOT return these three and compose on TestCaseCore instead.\n", - "allOf": [ - { - "$schema": "TestCaseCore" - } - ], - "properties": { - "estimated_duration_seconds": { - "type": "integer", - "nullable": true, - "description": "Estimated duration in seconds. THIS is the real value — `estimate` is always null. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - }, - "template_name": { - "type": "string", - "nullable": true, - "description": "Display name of that template; null for cases on the plain-text template. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - }, - "test_case_dataset": { - "type": "object", - "description": "Dataset bound to this case; `{}` when none. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - } - } - }, - "PageInfo": { - "type": "object", - "description": "Pagination metadata returned with paged API responses.", - "required": [ - "page", - "page_size", - "count", - "prev", - "next" - ], - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "type": "integer", - "description": "Total number of items." - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true - } - } - }, - "TestCaseListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "description": "One row per case-x-configuration mapping in the run.", - "items": { - "$schema": "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId" - } - }, - "info": { - "$schema": "PageInfo" - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level, not inside a row: case ids the review gate excluded from this page. Present only when the gate excluded something.", - "items": { - "type": "integer" - } - } - } - }, - "Folder": { - "type": "object", - "properties": {} - }, - "Configuration": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the configuration." - }, - "group_id": { - "type": "integer", - "description": "ID of the group associated with the configuration." - }, - "name": { - "type": "string", - "description": "Name of the configuration." - }, - "record_status": { - "type": "string", - "description": "Status of the record (e.g., active)." - }, - "is_suggested": { - "type": "boolean", - "description": "Indicates if the configuration is suggested." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp." - }, - "is_valid_on_bstack": { - "type": "boolean", - "example": false - } - } - }, - "Dataset": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "variables": { - "type": "array", - "description": "List of dataset variables/columns.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the variable/column." - }, - "uuid": { - "type": "string", - "description": "Unique identifier for the variable/column." - } - } - } - }, - "rows": { - "type": "array", - "description": "List of dataset rows.", - "items": { - "type": "object", - "properties": { - "row_data": { - "type": "array", - "description": "Array of values for the row.", - "items": { - "type": "string" - } - }, - "row_number": { - "type": "integer", - "description": "Row number in the dataset." - }, - "uuid": { - "type": "string", - "description": "Unique identifier for the row." - } - } - } - }, - "rows_count": { - "type": "integer", - "description": "Total number of rows in the dataset." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was created." - }, - "created_by": { - "$schema": "DatasetUserRef" - } - } - }, - "ExploratorySessionUserRef": { - "type": "object", - "description": "A user reference object returned in exploratory session responses.", - "nullable": true, - "properties": { - "id": { - "type": "integer", - "description": "TCM user ID.", - "example": 1 - }, - "email": { - "type": "string", - "format": "email", - "description": "User email address.", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "description": "User full name.", - "example": "Jane Doe" - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to.", - "example": 10 - } - } - }, - "ExploratorySessionIssue": { - "type": "object", - "description": "A linked issue / defect associated with an exploratory session or session log.", - "required": [ - "issue_id", - "issue_type" - ], - "properties": { - "issue_id": { - "type": "string", - "description": "Identifier of the issue in the external issue tracker (e.g. Jira key).", - "example": "PROJ-123" - }, - "issue_type": { - "type": "string", - "description": "Type of the issue in the external tracker.", - "example": "Bug" - } - } - }, - "ExploratorySessionTimelineEntry": { - "type": "object", - "description": "A single entry on an exploratory session's timeline: {status, elapsed_time, created_by}. It carries no `action` or `timestamp`.", - "properties": { - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "elapsed_time": { - "type": "integer", - "example": 120 - }, - "status": { - "type": "string", - "example": "bug" - } - } - }, - "ExploratorySession": { - "type": "object", - "description": "A full exploratory testing session, as returned by the single-session read, create, update and clone. The LIST route returns a narrower projection — see ExploratorySessionListRow.\n", - "allOf": [ - { - "$schema": "ExploratorySessionCore" - } - ], - "properties": { - "description": { - "type": "string", - "description": "Optional description of the session.", - "example": "Exploring the checkout flow for edge cases.", - "nullable": true - }, - "charter": { - "type": "string", - "description": "Testing charter describing the scope and goals of this session.", - "example": "Explore payment gateway error handling", - "nullable": true - }, - "duration": { - "type": "integer", - "description": "Actual duration tracked during the session in minutes.", - "example": 45, - "nullable": true - }, - "actual_duration": { - "type": "integer", - "description": "Final recorded duration of the session in minutes (set on close).", - "example": 50, - "nullable": true - }, - "session_log_count": { - "type": "integer", - "description": "Number of logs recorded in this session.", - "example": 12 - }, - "closed_by": { - "$schema": "ExploratorySessionUserRef" - }, - "configurations": { - "type": "array", - "description": "Configurations associated with the session.", - "items": { - "type": "object" - } - }, - "issues": { - "type": "array", - "description": "Issues linked to this session.", - "items": { - "$schema": "ExploratorySessionIssue" - } - }, - "timeline": { - "type": "array", - "description": "Ordered list of lifecycle events for this session.", - "items": { - "$schema": "ExploratorySessionTimelineEntry" - } - }, - "owner": { - "type": "integer", - "nullable": true - }, - "record_status": { - "type": "string", - "description": "Lifecycle state of the record, e.g. `active`.", - "example": "active" - }, - "closed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "attachment_relations": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Join rows linking the session to its attachments; parallel to `attachments`." - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - } - } - }, - "ExploratorySessionLog": { - "type": "object", - "description": "A log entry recorded within an exploratory testing session.", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the session log.", - "example": 789 - }, - "session_id": { - "type": "integer", - "description": "ID of the exploratory session this log belongs to.", - "example": 123 - }, - "content": { - "type": "string", - "description": "Rich-text HTML content of the log entry.", - "example": "

Found an issue with the payment button on mobile.

", - "nullable": true - }, - "status": { - "type": "string", - "description": "Test result status for this log entry.", - "enum": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note" - ], - "example": "fail", - "nullable": true - }, - "elapsed_time": { - "type": "integer", - "description": "Time elapsed for this step in seconds.", - "example": 120, - "nullable": true - }, - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "defects": { - "type": "array", - "description": "Defects / issues linked to this log entry.", - "items": { - "$schema": "ExploratorySessionIssue" - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the log was created.", - "example": "2026-01-16T10:35:00Z" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the log was last updated.", - "example": "2026-01-16T10:40:00Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "attachment_relations": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Join rows linking the log to its attachments; parallel to `attachments`." - } - } - }, - "Filter": { - "type": "object", - "description": "Filter object representing a saved filter view", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the filter" - }, - "name": { - "type": "string", - "description": "Name of the filter" - }, - "entity_type": { - "type": "string", - "description": "Entity type the filter applies to", - "enum": [ - "testcase", - "testplan", - "testrun" - ] - }, - "is_project_level": { - "type": "boolean", - "description": "Whether this filter is available at project level" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the filter was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the filter was last updated" - }, - "owner": { - "type": "integer", - "description": "User ID of the creator" - }, - "project_id": { - "type": "integer", - "description": "Project ID this filter belongs to" - } - } - }, - "ProjectCreateRequest": { - "type": "object", - "required": [ - "project" - ], - "properties": { - "project": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - } - } - } - }, - "ReportType": { - "type": "string", - "description": "Report type, sent as its DISPLAY NAME (not a machine slug).\n\nSeven types produce data. `Defects Summary` and `Defects Detailed Report` are\naccepted on create/update but have no data branch on the server, so such a report\nalways reads back with `report_data: null` — never offer them as a way to report\non defects (use `Test Run Summary`, whose sections include `issues_by_priority` /\n`issues_by_status`).\n\nPick by what the user asked about:\n - test cases created / updated / deleted, churn, top creators → `Test Case Activity`\n - run execution + pass rates → `Test Run Summary`\n - per-case results within runs → `Test Run Detailed Report`\n - plan-level rollup → `Test Plan Summary`\n - requirement/issue coverage → `Requirement Traceability Report`\n - exploratory sessions → `Exploratory Session Summary`\n - per-user workload → `User Workload Report`\n", - "enum": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary" - }, - "ReportFrequency": { - "type": "string", - "description": "Scheduling cadence. Send `frequency` and `frequency_details` TOGETHER, or omit\nBOTH — omitting both creates a valid unscheduled, on-demand report.\n\nTwo consequences of omitting them: `mail_to` is only persisted for scheduled\nreports (recipients on an unscheduled report are silently dropped), and\n`next_run_at` stays null.\n\nThere is no `once` value — the server's cadence enum is daily/weekly/monthly only.\n", - "enum": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly" - }, - "ReportTimeframe": { - "type": "string", - "description": "The window a report covers. Also the value of the `reportTimeRange` query param\non the report-detail read.\n\nThe four relative windows compute a date range from \"now\". `custom` computes it\nfrom `custom_date_range` and **requires** that field — sending `custom` without it\nis an unhandled server error, not a 422.\n\nThe five remaining values carry no dates; they select entities through\n`report_filters` instead:\n `specific_test_runs` → report_filters.test_runs\n `specific_test_plans` → report_filters.test_plans\n `specific_test_cases` → report_filters.test_cases\n `specific_sessions` → report_filters.session_ids\n `requirements` → report_filters.requirements\n", - "enum": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week" - }, - "FrequencyDetails": { - "type": "object", - "description": "Shape depends on `frequency` — the server rejects the wrong shape with\n\"Invalid report frequency\", which names `frequency` even when `frequency` itself\nwas fine and only `day` was missing:\n\n daily → { \"time\": \"08:00\" }\n weekly → { \"time\": \"08:00\", \"day\": \"monday\" } day = lowercase day name\n monthly → { \"time\": \"08:00\", \"day\": 15 } day = integer 1-28\n\n`time` is 24-hour `HH:MM`, interpreted as UTC.\n", - "required": [ - "time" - ], - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - } - }, - "ReportOwner": { - "type": "object", - "required": [ - "id", - "email", - "full_name", - "group_id", - "browserstack_user_id", - "onboarded", - "reports_and_notification_enabled" - ], - "properties": { - "id": { - "type": "integer", - "example": 2 - }, - "email": { - "type": "string", - "format": "email", - "example": "fake1@example.com" - }, - "full_name": { - "type": "string", - "example": "Fake Name" - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "browserstack_user_id": { - "type": "integer", - "example": 2 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "MailToUser": { - "type": "object", - "required": [ - "users", - "external_mails" - ], - "properties": { - "users": { - "type": "array", - "items": { - "$schema": "ReportOwner" - } - }, - "external_mails": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ReportDetail": { - "type": "object", - "required": [ - "id", - "report_type", - "created_at", - "description", - "title", - "frequency", - "identifier", - "next_run_at", - "report_timeframe", - "group_id", - "frequency_details", - "project_id", - "report_creation_mode", - "owner", - "mail_to" - ], - "properties": { - "id": { - "type": "integer", - "example": 3 - }, - "report_type": { - "$schema": "ReportType" - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-12T12:52:17.658Z" - }, - "description": { - "type": "string", - "example": "" - }, - "title": { - "type": "string", - "example": "Test Run Summary" - }, - "frequency": { - "type": "object" - }, - "identifier": { - "type": "string", - "example": "SC-3" - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-13T05:00:00.000Z", - "nullable": true - }, - "report_timeframe": { - "$schema": "ReportTimeframe" - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "frequency_details": { - "type": "object", - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - }, - "required": [ - "time" - ] - }, - "project_id": { - "type": "integer", - "example": 10000 - }, - "report_creation_mode": { - "type": "string", - "enum": [ - "custom_report", - "system_generated" - ], - "example": "custom_report" - }, - "custom_date_range": { - "type": "string", - "example": null, - "nullable": true - }, - "owner": { - "$schema": "ReportOwner" - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "included_projects": { - "type": "object" - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "slack_notification_supported": { - "type": "boolean", - "example": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T04:32:35.703Z" - } - } - }, - "CreateReportResponse": { - "type": "object", - "required": [ - "success", - "data", - "project" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "$schema": "ReportDetail__CreateReportByIntegerId" - }, - "project": { - "$schema": "Project__CreateReportByIntegerId" - } - } - }, - "FolderHierarchy": { - "type": "object", - "description": "Folder hierarchy mapping. Each key is a folder ID (as a string), and the value is an array of ancestor folder objects ordered from the folder itself up to the root.\n", - "example": { - "9": [ - { - "id": 9, - "name": "1", - "parent_id": null - } - ], - "10": [ - { - "id": 10, - "name": "1.2", - "parent_id": 9 - }, - { - "id": 9, - "name": "1", - "parent_id": null - } - ], - "11": [ - { - "id": 11, - "name": "1.2.2", - "parent_id": 10 - }, - { - "id": 10, - "name": "1.2", - "parent_id": 9 - }, - { - "id": 9, - "name": "1", - "parent_id": null - } - ] - } - }, - "SharedStepBase": { - "type": "object", - "required": [ - "title", - "id", - "created_by", - "updated_by" - ], - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - } - } - }, - "SharedStepDetail": { - "type": "object", - "required": [ - "id", - "step", - "result", - "order" - ], - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string", - "description": "HTML formatted step content" - }, - "result": { - "type": "string", - "description": "HTML formatted expected result content" - }, - "order": { - "type": "integer" - }, - "test_data": { - "type": "string", - "description": "Optional test data for the step" - } - } - }, - "ResultStatus": { - "type": "object", - "description": "A configured status value. Shared by the v1 result reads, the v1 result create and the report detail; the v2 per-case result list returns a cut-down version of its own and does not use this shape.", - "required": [ - "id", - "field_value", - "internal_name", - "field_name", - "colour" - ], - "properties": { - "id": { - "type": "integer", - "example": 22 - }, - "field_value": { - "type": "string", - "example": "Failed", - "description": "Display-case name. This is the value to match on." - }, - "internal_name": { - "type": "string", - "example": "failed", - "description": "Lowercase internal token for the same status." - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "Attachment": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - } - } - }, - "TestResult": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "author": { - "$schema": "User" - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-25T20:02:05.000Z" - }, - "description": { - "type": "string" - }, - "error_description": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "example": null, - "nullable": true - }, - "failure_type": { - "type": "string", - "example": null, - "nullable": true - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "type": "string", - "example": "passed" - }, - "result_status": { - "$schema": "ResultStatus" - }, - "configuration_id": { - "type": "integer", - "description": "ID of the configuration this test result is associated with", - "example": 16, - "nullable": true - }, - "time_elapsed": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "size": { - "type": "string" - } - } - } - }, - "created_by_imported": { - "type": "boolean", - "nullable": true - }, - "issues": { - "type": "array", - "example": [], - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" - }, - "custom_fields": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473/custom-fields" - } - } - }, - "custom_fields": { - "type": "object" - }, - "test_run_step_result": { - "type": "object", - "nullable": true - }, - "updated_by": { - "type": "string", - "nullable": true - } - } - }, - "Report": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Report ID" - }, - "report_type": { - "$schema": "ReportType" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp indicating when the scheduled report was created" - }, - "description": { - "type": "string", - "description": "Description of the scheduled report" - }, - "title": { - "type": "string", - "description": "Title of the scheduled report" - }, - "frequency": { - "type": "object" - }, - "identifier": { - "type": "string", - "description": "The report's SC-NNN identifier" - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "description": "Next scheduled iteration, or null when the report is not scheduled", - "nullable": true - }, - "report_timeframe": { - "$schema": "ReportTimeframe" - }, - "group_id": { - "type": "integer" - }, - "frequency_details": { - "type": "object", - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - }, - "required": [ - "time" - ] - }, - "project_id": { - "type": "integer" - }, - "report_creation_mode": { - "type": "string", - "enum": [ - "custom_report", - "system_generated" - ] - }, - "custom_date_range": { - "type": "string" - }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Id of the report owner" - }, - "email": { - "type": "string", - "description": "Email to which the report is to be sent" - }, - "full_name": { - "type": "string", - "description": "Name of the owner" - }, - "group_id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "included_projects": { - "type": "object" - }, - "is_dynamic": { - "type": "boolean", - "example": false - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-21T13:41:14.555Z" - } - } - }, - "AttachmentResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "filename": { - "type": "string", - "example": "document1.pdf" - }, - "content_type": { - "type": "string", - "example": "application/pdf" - }, - "byte_size": { - "type": "integer", - "example": 102400 - } - } - }, - "configuration": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the configuration." - }, - "group_id": { - "type": "integer", - "description": "ID of the group associated with the configuration." - }, - "name": { - "type": "string", - "description": "Name of the configuration." - }, - "record_status": { - "type": "string", - "description": "Status of the record (e.g., active)." - }, - "is_suggested": { - "type": "boolean", - "description": "Indicates if the configuration is suggested." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp." - }, - "browser": { - "type": "string", - "example": "Chrome" - }, - "browser_version": { - "type": "string", - "example": "146" - }, - "device": { - "type": "string", - "nullable": true - }, - "is_valid_on_bstack": { - "type": "boolean", - "example": false - }, - "os": { - "type": "string", - "example": "Windows" - }, - "os_version": { - "type": "string", - "example": "10" - } - } - }, - "ConfigurationListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful." - }, - "configurations": { - "type": "array", - "description": "List of configuration objects.", - "items": { - "$schema": "configuration" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "DatasetVariable": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset column/variable." - }, - "name": { - "type": "string", - "description": "Name of the column/variable." - }, - "dataset": { - "type": "object", - "description": "Dataset information that this column belongs to.", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "count_rows": { - "type": "integer", - "description": "Number of rows in the dataset." - } - } - } - } - }, - "DatasetVariablesResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the request was successful." - }, - "variables": { - "type": "array", - "description": "List of dataset columns/variables.", - "items": { - "$schema": "DatasetVariable" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "MyselfUser": { - "type": "object", - "required": [ - "id", - "full_name", - "reports_and_notification_enabled" - ], - "properties": { - "id": { - "type": "integer", - "description": "User ID" - }, - "full_name": { - "type": "string", - "description": "Full name of the current user" - }, - "email": { - "type": "string", - "description": "Email address of the current user" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Whether reports and notifications are enabled for the user" - } - } - }, - "UsersV2Response": { - "type": "object", - "required": [ - "success", - "users", - "myself", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "users": { - "type": "array", - "items": { - "$schema": "User" - } - }, - "myself": { - "$schema": "MyselfUser" - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "V1ProjectDetail": { - "type": "object", - "description": "v1 project-detail envelope (GET /api/v1/projects/{project_id}, by INTEGER id). The translation output is data.project.identifier (the PR-NNN form), alongside data.project.id (the integer id you queried by).\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "project": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "PR-NNN identifier — the v2 {project_id}. (Integer -> PR-NNN translation output.)", - "example": "PR-1" - }, - "id": { - "type": "integer", - "description": "Integer project id — the value passed in the path. Same as thProjectId.", - "example": 11046 - }, - "thProjectId": { - "type": "integer", - "example": 11046 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "description": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "nullable": true - }, - "test_runs_count": { - "type": "integer", - "nullable": true - }, - "test_plans_count": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "starred": { - "type": "boolean" - }, - "jira_mapped": { - "type": "boolean" - }, - "links": { - "type": "object" - }, - "import_id": { - "type": "integer", - "nullable": true, - "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." - }, - "normalisedName": { - "type": "string", - "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." - }, - "projectVisibilityBanner": { - "type": "object", - "nullable": true, - "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." - } - } - } - } - } - } - }, - "ProjectMinifyResponse": { - "type": "object", - "required": [ - "success", - "projects" - ], - "properties": { - "success": { - "type": "boolean" - }, - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "import_id": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "starred": { - "type": "boolean" - }, - "access_via_secondary_team": { - "type": "boolean", - "example": false - } - } - } - } - } - }, - "ProjectsPaginatedResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "import_id": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "starred": { - "type": "boolean" - }, - "access_via_secondary_team": { - "type": "boolean", - "example": false - } - } - } - }, - "info": { - "$schema": "PageInfo" - } - }, - "required": [ - "info", - "projects", - "success" - ] - }, - "ReportFiltersDetail": { - "type": "object", - "required": [ - "custom_fields", - "test_run_ids", - "first_test_run" - ], - "properties": { - "custom_fields": { - "type": "object" - }, - "test_run_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "first_test_run": { - "type": "object", - "required": [ - "id", - "name", - "created_at", - "identifier", - "is_automation", - "run_state", - "updated_at", - "project_id", - "user_id", - "group_id" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "environment": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "run_state": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "user_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - } - } - } - } - }, - "DynamicFilters": { - "type": "object", - "properties": { - "test_run": { - "type": "object", - "required": [ - "created_at", - "status", - "owner", - "automation_status", - "type" - ], - "properties": { - "created_at": { - "type": "string" - }, - "status": { - "type": "array", - "items": { - "type": "integer" - } - }, - "owner": { - "type": "array", - "items": { - "type": "integer" - } - }, - "automation_status": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "ReportData": { - "type": "object", - "required": [ - "test_run_data", - "issues_data", - "test_run_status", - "test_cases_status", - "user_result_stats", - "issues_by_status", - "issues_by_priority" - ], - "properties": { - "test_run_data": { - "type": "object", - "required": [ - "active", - "closed", - "test_cases", - "test_results" - ], - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - }, - "test_cases": { - "type": "integer" - }, - "test_results": { - "type": "integer" - } - } - }, - "issues_data": { - "type": "object", - "required": [ - "total", - "linked_with_test_runs", - "linked_with_test_results" - ], - "properties": { - "total": { - "type": "integer" - }, - "linked_with_test_runs": { - "type": "integer" - }, - "linked_with_test_results": { - "type": "integer" - } - } - }, - "test_run_status": { - "type": "object" - }, - "test_cases_status": { - "type": "array", - "items": { - "type": "object", - "required": [ - "count", - "result_status" - ], - "properties": { - "count": { - "type": "integer" - }, - "result_status": { - "$schema": "ResultStatus" - } - } - } - }, - "user_result_stats": { - "type": "array", - "items": { - "type": "object", - "required": [ - "user_id", - "test_results", - "user_name" - ], - "properties": { - "user_id": { - "type": "integer" - }, - "test_results": { - "type": "integer" - }, - "user_name": { - "type": "string" - } - } - } - }, - "issues_by_status": { - "type": "array", - "items": { - "type": "object" - } - }, - "issues_by_priority": { - "type": "array", - "items": { - "type": "object" - } - }, - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "object" - } - } - }, - "ReportProjectDetail": { - "type": "object", - "required": [ - "id", - "group_id", - "user_id", - "name", - "slug", - "description", - "identifier", - "is_demo", - "publicly_readable", - "created_at", - "source", - "archived", - "team_id", - "default_team_id" - ], - "properties": { - "id": { - "type": "integer", - "example": 10000 - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "user_id": { - "type": "integer", - "example": 2 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "slug": { - "type": "string", - "example": "Demo-Project-8e37e463" - }, - "description": { - "type": "string", - "example": "This project contains test cases and test runs for the demo project" - }, - "identifier": { - "type": "string", - "example": "PR-3" - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2024-06-28T00:00:00.000Z" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "archived": { - "type": "integer", - "example": 0 - }, - "team_id": { - "type": "integer", - "example": 1 - }, - "default_team_id": { - "type": "integer", - "example": null, - "nullable": true - }, - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "import_id": { - "type": "string", - "nullable": true - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "SelectionData": { - "type": "object", - "description": "A folder-keyed test-case selection: which cases, grouped by the folder they live in.\nAll three keys are required. Widen with `select_all` (whole project) or a folder's\n`test_case_select_all` (whole folder), then subtract with `deselected_test_cases`;\notherwise the cases are listed explicitly in `selected_test_cases`.\n", - "required": [ - "select_all", - "folders", - "unique_test_case_count" - ], - "properties": { - "select_all": { - "type": "boolean", - "description": "Select every case in the project.", - "example": false - }, - "folders": { - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection." - }, - "unique_test_case_count": { - "type": "integer", - "description": "Number of distinct cases the selection resolves to.", - "example": 3 - } - } - }, - "V1TestCaseDetail": { - "type": "object", - "description": "v1 test case by INTEGER ids (GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}). Translation output: data.test_case.identifier is the TC-NNN form. The row is the full shared test-case record, not a stub.\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "description": "The full test-case record. `identifier` is the TC-NNN id and `id` is the integer passed in the path. The display field is `name`; there is no `title`.", - "$schema": "V1TestCaseRecordRow__GetTestCaseByIntegerId" - } - } - } - } - }, - "TestCaseTrend": { - "type": "object", - "required": [ - "graphData", - "field", - "value" - ], - "properties": { - "graphData": { - "type": "object", - "properties": { - "Jun": { - "type": "integer" - }, - "Jul": { - "type": "integer" - }, - "Aug": { - "type": "integer" - }, - "Sep": { - "type": "integer" - }, - "Oct": { - "type": "integer" - }, - "Nov": { - "type": "integer" - }, - "Dec": { - "type": "integer" - }, - "Jan": { - "type": "integer" - }, - "Feb": { - "type": "integer" - }, - "Mar": { - "type": "integer" - }, - "Apr": { - "type": "integer" - }, - "May": { - "type": "integer" - } - } - }, - "field": { - "type": "string" - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "History": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "history record ID" - }, - "entity_id": { - "type": "integer", - "description": "test case ID this history belongs to" - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "version_id": { - "type": "string", - "format": "uuid" - }, - "source": { - "type": "string", - "description": "Source of the change (e.g., \"update\", \"create\")" - }, - "modified_fields": { - "type": "array", - "description": "List of fields that were modified in this version", - "items": { - "type": "string" - } - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "comment": { - "type": "string", - "nullable": true - }, - "user_id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - } - }, - "version_name": { - "type": "string", - "nullable": true - }, - "user": { - "$schema": "User" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "description": "API endpoint to retrieve this specific history record", - "example": "/api/v1/projects/2022091/test-cases/36347792/histories/17604265" - } - } - }, - "origin_channel": { - "type": "string", - "example": "api" - } - } - }, - "HistoryListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "histories": { - "type": "array", - "description": "List of history records for the test case", - "items": { - "$schema": "History__ListTestCaseHistories" - } - }, - "info": { - "$schema": "PageInfo" - }, - "plan_restriction": { - "type": "boolean" - } - } - }, - "TestCaseDiffStep": { - "type": "object", - "required": [ - "id", - "step", - "order", - "result" - ], - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string" - }, - "order": { - "type": "integer" - }, - "result": { - "type": "string" - }, - "shared_step_id": { - "type": "integer", - "nullable": true - } - } - }, - "TestCaseConsolidatedResultFolders": { - "type": "object" - }, - "TestCaseConsolidatedListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "V1TestCaseListRow__ListTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "folders": { - "$schema": "TestCaseConsolidatedResultFolders" - } - } - }, - "V1TestPlanDetail": { - "type": "object", - "description": "v1 test-plan detail (GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}, by INTEGER ids). NOTE: there is NO `data` wrapper — the plan sits at the top level under `test_plan`. Translation output: test_plan.identifier (the TP-NNN form).\n", - "properties": { - "success": { - "type": "boolean" - }, - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - } - } - } - } - }, - "ResultCustomField": { - "type": "object", - "required": [ - "id", - "field_type", - "field_name", - "entity_type", - "field_user_name", - "optional", - "is_bulk_editable", - "is_filterable", - "links", - "is_required" - ], - "properties": { - "id": { - "type": "integer" - }, - "field_type": { - "type": "string", - "enum": [ - "field_string", - "field_text", - "field_user", - "field_dropdown", - "field_multi_dropdown", - "field_url", - "field_boolean", - "field_int", - "field_date", - "field_rte" - ] - }, - "field_name": { - "type": "string" - }, - "entity_type": { - "type": "string", - "enum": [ - "TestCase", - "TestResult", - "TestExecution", - "Build" - ] - }, - "field_user_name": { - "type": "string" - }, - "optional": { - "type": "boolean" - }, - "is_bulk_editable": { - "type": "boolean" - }, - "is_filterable": { - "type": "boolean" - }, - "links": { - "type": "object", - "required": [ - "self" - ], - "properties": { - "self": { - "type": "string" - } - } - }, - "placeholder": { - "type": "string" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "object" - }, - "option_values": { - "type": "array", - "items": { - "type": "object" - } - }, - "field_values": { - "type": "array", - "items": { - "type": "object" - } - }, - "sort_options": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - } - } - } - }, - "TestResultCustomFieldsResponse": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "array", - "items": { - "$schema": "ResultCustomField" - } - }, - "info": { - "$schema": "PageInfo__GetTestResultsCustomFields" - } - } - }, - "V1TestRunDetail": { - "type": "object", - "description": "v1 test-run detail (GET /api/v1/projects/{project_id}/test-runs/{test_run_id}, by INTEGER ids). Translation output: data.test_run.identifier (the TR-NNN form). The integer run id is surfaced as data.test_run.uuid.\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_run": { - "$schema": "V1TestRunDetailRow" - } - } - } - } - }, - "TestResultStatusValue": { - "type": "object", - "required": [ - "internal_name", - "is_default", - "colour", - "value", - "name" - ], - "properties": { - "internal_name": { - "type": "string", - "description": "Internal system name for the status" - }, - "is_default": { - "type": "integer", - "description": "Whether this is the default status (1 or 0)", - "enum": [ - 0, - 1 - ] - }, - "colour": { - "type": "string", - "description": "Hex color code for the status" - }, - "value": { - "type": "integer", - "description": "Numeric value identifier for the status" - }, - "name": { - "type": "string", - "description": "Display name of the status" - } - } - }, - "TestResultStatusField": { - "type": "object", - "required": [ - "values", - "total_count", - "links" - ], - "properties": { - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "is_default": { - "type": "integer", - "example": 0 - }, - "name": { - "type": "string", - "example": "Passed" - }, - "value": { - "type": "integer", - "example": 318610 - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "total_count": { - "type": "integer", - "example": 8 - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/test-case/status" - } - } - } - } - }, - "CustomField": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier of the custom field" - }, - "field_type": { - "type": "string", - "description": "Type of the custom field", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date" - ] - }, - "field_name": { - "type": "string", - "description": "Name of the custom field" - }, - "is_required": { - "type": "boolean", - "description": "Whether the custom field is required" - }, - "place_holder_text": { - "type": "string", - "description": "Placeholder text for the custom field", - "nullable": true - }, - "default_value": { - "type": "string", - "description": "Default value for the custom field", - "nullable": true - }, - "entity_type": { - "type": "string", - "description": "Type of entity this field is for", - "example": "TestCase" - }, - "options": { - "type": "array", - "description": "Options for dropdown or multi-dropdown fields", - "items": { - "type": "object", - "properties": { - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - } - } - } - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Whether the custom field applies to all projects" - }, - "assigned_projects": { - "type": "array", - "description": "List of project identifiers this field is assigned to", - "items": { - "type": "string" - } - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Whether to link this field to future projects" - } - } - }, - "TestRunFormFieldsResponse": { - "type": "object", - "required": [ - "success", - "default_fields" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "default_fields": { - "type": "object", - "description": "Default field values for test results. Only 'status' field is included by default.", - "properties": { - "status": { - "$schema": "FormFieldDescriptor" - } - } - }, - "custom_fields": { - "type": "array", - "description": "Custom fields array. Only present when 'all' parameter is true.", - "items": { - "$schema": "CustomField" - } - } - } - }, - "DatasetMini": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "rows_count": { - "type": "integer", - "description": "Total number of rows in the dataset." - }, - "test_cases_count": { - "type": "integer", - "description": "Number of test cases using this dataset." - }, - "created_by": { - "$schema": "DatasetUserRef__ListDatasets" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was last updated." - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } - } - }, - "DatasetsListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the request was successful." - }, - "datasets": { - "type": "array", - "description": "List of datasets.", - "items": { - "$schema": "DatasetMini" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "V1DuplicatesListResponse": { - "type": "object", - "description": "Suggested-duplicate list. NOTE this exact shape with an empty `duplicates` is also what\nthe server returns when the group lacks the dedupe feature flag, so an empty list is not\nevidence that the project has no duplicates — check .../ai-duplicates/status.\n", - "properties": { - "success": { - "type": "boolean" - }, - "duplicates": { - "type": "array", - "items": { - "type": "object" - } - }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "total_pages": { - "type": "integer" - } - } - } - } - }, - "ExploratorySessionLogListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_logs": { - "type": "array", - "items": { - "$schema": "ExploratorySessionLog" - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "per_page": { - "type": "integer", - "example": 50 - }, - "total_count": { - "type": "integer", - "example": 24 - }, - "total_pages": { - "type": "integer", - "example": 1 - } - } - } - } - }, - "ExploratorySessionListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_sessions": { - "type": "array", - "items": { - "$schema": "ExploratorySession" - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "per_page": { - "type": "integer", - "example": 30 - }, - "total_count": { - "type": "integer", - "example": 150 - }, - "total_pages": { - "type": "integer", - "example": 5 - } - } - }, - "count": { - "type": "integer", - "description": "Total number of sessions matching the query.", - "example": 150 - } - } - }, - "ProjectsListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful.", - "example": true - }, - "projects": { - "type": "array", - "description": "List of project objects.", - "items": { - "$schema": "Project__ListProjects" - } - }, - "info": { - "$schema": "ProjectsPageInfo" - } - } - }, - "ReportScheduleGetResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "Contains an array of Scheduled reports", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "The total number of scheduled reports in the project." - }, - "total_report_count": { - "type": "integer", - "description": "The total number of reports in the project." - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "Tag": { - "type": "object", - "required": [ - "name", - "created_at" - ], - "properties": { - "name": { - "type": "string", - "description": "Tag name", - "example": "AI Generated" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the tag was first used", - "example": "2024-10-08T09:25:09.000Z" - }, - "usage_count": { - "type": "integer", - "description": "Number of test cases using this tag (optional)", - "example": 42 - } - } - }, - "ProjectUserDetail": { - "type": "object", - "required": [ - "id", - "full_name", - "onboarded", - "group_id", - "browserstack_user_id", - "role", - "has_access", - "permissions" - ], - "properties": { - "id": { - "type": "integer", - "description": "User ID", - "example": 240620 - }, - "full_name": { - "type": "string", - "description": "User's full name", - "example": "Jasbir Singh" - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status (1 = completed, 0 = pending)", - "enum": [ - 0, - 1 - ], - "example": 1 - }, - "group_id": { - "type": "integer", - "description": "Group this user belongs to", - "example": 1 - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user identifier", - "example": 10328238 - }, - "role": { - "type": "string", - "description": "User role in the system", - "enum": [ - "admin", - "user", - "viewer" - ], - "example": "user" - }, - "has_access": { - "type": "boolean", - "description": "Whether user has access to the project", - "example": true - }, - "permissions": { - "type": "array", - "description": "List of permissions granted to user", - "example": [ - "tc_write", - "project_write", - "test_run_execute" - ], - "items": { - "type": "string", - "enum": [ - "tc_write", - "tc_delete", - "tc_archive_test_cases", - "project_write", - "test_plan_write", - "test_plan_delete", - "test_run_write", - "test_run_delete", - "test_run_execute" - ] - } - }, - "is_delighted_visible": { - "type": "boolean", - "description": "Whether Delighted feedback widget is visible", - "example": false - }, - "is_product_intro_visible": { - "type": "boolean", - "description": "Whether product introduction is visible", - "example": true - }, - "is_onboarding_project_header_visible": { - "type": "boolean", - "description": "Whether onboarding header is visible", - "example": true - }, - "is_lcnc_explore_dismissed": { - "type": "boolean", - "description": "Whether low-code/no-code explore banner is dismissed", - "example": false - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Whether reports and notifications are enabled", - "example": true - }, - "customisable_dashboard_accessible": { - "type": "boolean", - "description": "Whether customizable dashboard is accessible", - "example": true - }, - "webhook_accessible": { - "type": "boolean", - "description": "Whether webhooks are accessible", - "example": true - }, - "is_build_listing_enabled": { - "type": "boolean", - "description": "Whether build listing feature is enabled", - "example": true - }, - "productRole": { - "type": "string", - "description": "Product-level role", - "example": "Product User" - }, - "is_new_project_settings_visible": { - "type": "boolean", - "description": "Whether new project settings UI is visible", - "example": true - }, - "is_jira_app_project_panel_visible": { - "type": "boolean", - "description": "Whether Jira app project panel is visible", - "example": false - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "is_re_arch_enabled": { - "type": "boolean", - "example": true - }, - "is_test_plan_detailed_report_enabled": { - "type": "boolean", - "example": false - } - } - }, - "ReportUpdateResponse": { - "type": "object", - "required": [ - "data", - "report_data", - "report_filters", - "dynamic_filters" - ], - "properties": { - "data": { - "$schema": "ReportDetail__UpdateReport" - }, - "report_data": { - "$schema": "ReportData" - }, - "report_filters": { - "$schema": "ReportFiltersDetail" - }, - "dynamic_filters": { - "$schema": "DynamicFilters" - }, - "filters": { - "type": "object" - }, - "is_dynamic": { - "type": "boolean", - "example": false - } - } - }, - "GenericAttachment": { - "type": "object", - "required": [ - "id", - "name", - "url", - "download_url", - "content_type", - "size" - ], - "properties": { - "id": { - "type": "integer", - "description": "Attachment ID", - "example": 10288829 - }, - "name": { - "type": "string", - "description": "Original filename", - "example": "screenshot.png" - }, - "url": { - "type": "string", - "format": "uri", - "description": "Pre-signed S3 URL for viewing (inline, ~26 min expiry)", - "example": "https://example.com/media/genric/1/2921471/86de8094..." - }, - "download_url": { - "type": "string", - "format": "uri", - "description": "Pre-signed S3 URL for downloading (attachment, ~26 min expiry)", - "example": "https://example.com/media/genric/1/2921471/86de8094..." - }, - "content_type": { - "type": "string", - "description": "MIME type of the file", - "example": "image/png" - }, - "size": { - "type": "string", - "description": "Human-readable file size", - "example": "300 KB" - } - } - }, - "TestRunTestCaseV2": { - "type": "object", - "description": "One test case AS MAPPED INTO A TEST RUN — a case-plus-configuration row, not the project-level test case record. A case mapped to several configurations appears once per configuration. Shape as returned by the v2 run test-case listing.", - "properties": { - "identifier": { - "type": "string", - "example": "TC-42" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true, - "description": "The test case's description. Always present as a key; null for cases without one." - }, - "priority": { - "type": "string", - "description": "Resolved label, not the id." - }, - "status": { - "type": "string", - "description": "Test case status label." - }, - "case_type": { - "type": "string" - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." - }, - "latest_result_id": { - "type": "integer", - "description": "Id of the latest result recorded against this row; use it to read or amend that result. Populated even for never-executed rows — the result row is created when the case is mapped into the run — so it cannot be used to detect 'never executed'." - }, - "assignee": { - "type": "string", - "nullable": true, - "description": "Assignee of this row within the run." - }, - "folder_path": { - "type": "array", - "items": { - "type": "integer" - } - }, - "configuration_id": { - "type": "integer", - "nullable": true, - "description": "Configuration this row is for; null when the case is mapped without a configuration." - }, - "dataset": { - "type": "array", - "items": { - "type": "object", - "properties": { - "variable": { - "type": "string", - "description": "Name of the dataset variable (the dataset's column) this value came from. Only ever present on a data-driven row — see the array description; there are no entries at all otherwise." - }, - "row": { - "type": "string", - "description": "The value bound to that variable for this row. Same condition as `variable`." - } - } - }, - "description": "The dataset values bound to this run row, one entry per dataset variable. CONDITIONAL CONTENT — the key is there, but the array is EMPTY for every row that is not dataset-driven, which is most rows: entries exist only when the case was mapped into the run against a dataset. So `variable` and `row` below are only ever seen on data-driven rows. Asking for the compact/minified row shape drops the key altogether instead of returning it empty." - }, - "project_id": { - "type": "string", - "example": "PR-1", - "description": "Project of the run being read." - }, - "source_project_id": { - "type": "string", - "example": "PR-7", - "description": "The project the case originates from, for a case shared in from elsewhere. On the older run backend the rows embedded in a single-run read report the current project rather than the true origin, because the origin is only resolved when the caller is the standalone case listing; the newer backend resolves it per row on both. Treat it as authoritative only when it differs from the project you asked about." - }, - "source_project_name": { - "type": "string", - "nullable": true, - "description": "Display name of the project the case lives in. CONDITIONAL — only the older run backend returns this key at all; on the newer run backend it is never returned, neither in the run's case listing nor in the rows embedded in a single-run read. Where it is returned it is null unless the case is shared in from another project. `source_project_id` is always present, so use that to identify the origin project and treat this as a display-only extra." - }, - "is_shared": { - "type": "boolean", - "description": "Whether this case is shared in from another project." - }, - "execution_id": { - "type": "string", - "nullable": true, - "description": "Identifier of the current execution attempt for this row." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the case was mapped into this run — the row's creation time, not the test case's." - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "When this run row was last updated. Note the name: it is the row's updated_at, renamed on the way out." - }, - "created_by": { - "type": "string", - "nullable": true, - "description": "Email of whoever created this run row." - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Email of whoever last updated this run row; null when it has not been updated since creation." - }, - "urls": { - "type": "object", - "description": "Web-app deep links for the row. Always present. DEFECT — the link is currently built without the run segment, so it does not resolve; construct run links from the run identifier instead.", - "properties": { - "self": { - "type": "string", - "description": "Intended as an absolute link to this case within the run in the web app; currently malformed, see above." - } - } - } - } - }, - "V2BulkResultOutcome": { - "type": "object", - "description": "Per-test-case outcome of a bulk result submission. Reports whether the entry was accepted for async processing, not whether it was applied.", - "properties": { - "test_case_id": { - "type": "string", - "example": "TC-101" - }, - "status": { - "type": "string", - "values": [ - "ACCEPTED", - "FAILED" - ], - "description": "ACCEPTED means queued, not applied." - }, - "message": { - "type": "string", - "example": "Will be processed." - }, - "error": { - "type": "object", - "description": "Present only when status is FAILED.", - "properties": { - "code": { - "type": "string", - "values": [ - "INVALID_STATUS_VALUE", - "REVIEW_STATUS_GATE" - ] - }, - "message": { - "type": "string" - } - } - } - } - }, - "V2TestResultListItem": { - "type": "object", - "description": "One recorded result for a test case in a test run, as the v2 read returns it. Deliberately narrower than the v1 result shape: no author, backtrace, attachments or full status object.", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_by": { - "type": "string", - "description": "Email of the user who recorded the result; null when the user is not in the caller's group.", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "test_case_id": { - "type": "string", - "description": "Echoed back as the TC- identifier that was requested, not a numeric id.", - "example": "TC-101" - }, - "test_run_id": { - "type": "string", - "example": "TR-14" - }, - "result_status": { - "type": "object", - "description": "Cut down to the display-case status name on this endpoint. The id, internal_name, field_name and colour that the v1 result reads return are deliberately dropped here and never appear.", - "required": [ - "field_value" - ], - "properties": { - "field_value": { - "type": "string", - "example": "Passed", - "description": "Display-case status name." - } - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues; empty when the result has none.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "example": "BUG-123" - }, - "issue_type": { - "type": "string", - "example": "jira" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "custom_fields": { - "type": "array", - "description": "An ARRAY of entries, not an object keyed by field name; [] when the result has no custom field values. `field_name` is the field's system name. For dropdown / single-select fields `value` is the selected option's id rather than its label.", - "items": { - "type": "object", - "properties": { - "field_name": { - "type": "string" - }, - "value": { - "description": "Scalar value, or the selected option's id for option-backed fields.", - "nullable": true - } - } - } - }, - "step_result": { - "type": "object", - "description": "Present only for step-level results; null otherwise.", - "nullable": true, - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" - }, - "result_status": { - "type": "object", - "description": "Same cut-down shape as the parent result_status.", - "properties": { - "field_value": { - "type": "string" - } - } - } - } - }, - "configuration_id": { - "type": "integer", - "nullable": true - }, - "execution_id": { - "type": "string", - "nullable": true - }, - "dataset": { - "type": "array", - "description": "Dataset row this execution used, as {variable, row} pairs. Empty for non-data-driven runs.", - "items": { - "type": "object", - "properties": { - "variable": { - "type": "string" - }, - "row": { - "type": "integer" - } - } - } - }, - "urls": { - "type": "object", - "nullable": true, - "description": "Web link for this result. Null when the result cannot be tied to a project, run, case and run row all at once.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's row inside the run." - } - } - } - } - }, - "V2TestRunResultDetail": { - "type": "object", - "description": "The case-level result written by a v2 result submission, returned under `test-result`. This is the no-step shape: a submission that carried step_results is answered with per-step entries instead and has no `test-result` at all, so nothing here ever describes a step.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the created result. Carry this forward for a follow-up update; it is the id domain the server expects back." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "description": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "example": "Passed", - "description": "Display-case status name, echoing the name that was sent." - }, - "issues": { - "type": "array", - "description": "Linked issues as plain KEY STRINGS, deduplicated — the same shape they are sent in, not objects.", - "items": { - "type": "string", - "example": "JIRA-12" - } - }, - "urls": { - "type": "object", - "description": "Web link for this submission.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's ROW IN THE RUN — its final path segment is the run row's id, not the id of the result just created." - } - } - } - } - }, - "Template": { - "type": "object", - "description": "A test-case template as returned in list responses: metadata and counts, without the field layout.", - "required": [ - "id", - "name", - "entity", - "enabled", - "is_default", - "is_system", - "future_projects_applicable", - "created_at", - "updated_at" - ], - "properties": { - "id": { - "type": "integer", - "description": "Template id — a plain integer." - }, - "name": { - "type": "string" - }, - "entity": { - "type": "string", - "description": "What the template applies to. Always TestCase on this surface.", - "example": "TestCase" - }, - "enabled": { - "type": "boolean", - "description": "True when the template is active. False means disabled — still stored, with its fields and project links, and re-enablable." - }, - "step_type": { - "type": "string", - "nullable": true, - "description": "Step style identifier for test-case templates; null for templates that do not define one." - }, - "is_default": { - "type": "boolean", - "description": "True for the workspace default template for this entity type. The default cannot be disabled or deleted." - }, - "is_system": { - "type": "boolean", - "description": "True for a built-in template. System templates cannot be deleted." - }, - "future_projects_applicable": { - "type": "boolean", - "description": "True when projects created later are linked to this template automatically." - }, - "field_count": { - "type": "integer", - "nullable": true, - "description": "Number of fields in the template. Null when the count was not computed for this response." - }, - "linked_to_all_projects": { - "type": "boolean", - "nullable": true - }, - "project_count": { - "type": "integer", - "nullable": true, - "description": "Number of linked projects. Null when the workspace-wide count was skipped server-side — treat null as 'not computed', not zero." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "TemplateFieldMapping": { - "type": "object", - "description": "One field placed on a template, as returned by a template read or create. Note these are mapping records, not the ids sent when writing a template.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the mapping row, NOT the field id. When resending a field on an update, use custom_field_id or system_field_id, not this. It is not unique within a response: the duplicated automation row shares its original's value." - }, - "field_category": { - "type": "string", - "description": "Which section the field sits in.", - "values": [ - "description", - "properties", - "step_properties" - ] - }, - "custom_field_id": { - "type": "integer", - "nullable": true, - "description": "Set for a custom field; null for a system field. Which of the two it is also decides what field_data contains and which of its keys exist." - }, - "system_field_id": { - "type": "integer", - "nullable": true, - "description": "Set for a built-in field; null for a custom field." - }, - "order": { - "type": "integer", - "description": "Position within its section." - }, - "is_mandatory": { - "type": "boolean", - "description": "Placement-level required flag, defaulting to false when the stored mapping does not carry one. This is NOT the only required-ness signal: a field definition can separately mark itself required, and the two can disagree. Observed false on every row of every template inspected so far." - }, - "field_data": { - "type": "object", - "description": "The underlying field definition, passed through from the field service unchanged apart from two additions (`field_type_raw`, and `parent_custom_field_id` on nested dropdowns). The exact key set therefore varies by field kind — system fields and custom fields carry different subsets, and step fields carry more than the others. Treat the keys listed here as likely-present rather than guaranteed, and do not assume a missing key means an unset value.", - "properties": { - "field_type": { - "type": "string", - "description": "The product-facing type name in its prefixed form, for example field_string or field_multi_dropdown. For a small number of workspaces a nested-dropdown type outside the ordinary set can be substituted here, along with a parent field id." - }, - "system_name": { - "type": "string", - "description": "Internal name of a built-in field. Built-in fields only." - }, - "optional": { - "type": "boolean", - "description": "Built-in field's own removability flag. Built-in fields only." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "field_name": { - "type": "string", - "description": "The field's name as shown to authors. Carried on both system and custom field definitions; it is the value to match on when resolving a field a user named in words." - } - } - } - } - }, - "TemplateWithFields": { - "type": "object", - "description": "A test-case template together with its full field layout, as returned by a single-template read or a create.", - "allOf": [ - { - "$schema": "Template" - } - ], - "properties": { - "description_fields": { - "type": "array", - "description": "Fields in the description section, ordered.", - "items": { - "$schema": "TemplateFieldMapping" - } - }, - "properties_fields": { - "type": "array", - "description": "Fields in the properties section, ordered. The Review & Approve fields (reviewers, review status) are filtered out of this surface even when stored on the template.", - "items": { - "$schema": "TemplateFieldMapping" - } - }, - "step_fields": { - "type": "array", - "description": "Step-level custom fields, ordered. Always an empty array for workspaces without step custom fields enabled; the key is always present.", - "items": { - "$schema": "TemplateFieldMapping" - } - } - } - }, - "V2PublicTestCase": { - "type": "object", - "description": "A test case as the v2 public project API serialises it on read and on write. NOT the same shape as the `TestCase` schema the older endpoints return: the title is `title` (not `name`), priority/status/case_type are plain display strings (not objects), owner is an email string (not an integer id), custom fields are name/value pairs, and there is no integer `id` — the case is identified by `identifier`.", - "properties": { - "identifier": { - "type": "string", - "description": "TC-NNN, or the third-party identifier when the workspace issues project-level ids." - }, - "title": { - "type": "string", - "description": "The case title. Sent as `name` on a write, returned as `title`." - }, - "folder_id": { - "type": "integer", - "description": "Integer id of the folder the case now sits in." - }, - "priority": { - "type": "string", - "description": "The project's display value, e.g. 'Medium'. Null when unset." - }, - "status": { - "type": "string", - "description": "The project's display value for the case's own lifecycle status, e.g. 'Active'." - }, - "case_type": { - "type": "string", - "description": "The project's display value for the case type, e.g. 'Smoke & Sanity'." - }, - "automation_status": { - "type": "string", - "description": "Internal name, e.g. not_automated / automated / obsolete." - }, - "owner": { - "type": "string", - "description": "Owner's EMAIL, or the imported owner name for cases brought in from another tool. Null when the owner does not resolve to a user in this group." - }, - "description": { - "type": "string" - }, - "preconditions": { - "type": "string" - }, - "template": { - "type": "string", - "description": "Template name, e.g. test_case_steps or bdd." - }, - "is_shared": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links.", - "items": { - "type": "object", - "properties": { - "jira_id": { - "type": "string", - "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made — azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." - } - }, - "description": "Only two keys exist on this shape and both are always present when the array is non-empty — nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." - } - }, - "steps": { - "type": "array", - "description": "Step list, flattened to step/result only — test_data, shared-step links and per-step custom fields are not echoed here.", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" - } - } - } - }, - "custom_fields": { - "type": "array", - "description": "Name/value pairs, never the id-keyed map a write sends. A multi-select field carries `values` (array) instead of `value`. Always present, as [] when the case has none.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "attachments": { - "type": "array", - "description": "Files attached to the case. Comes back as [] when the case carries no files. Every entry carries name, size, content_type and url together — these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "File name as uploaded." - }, - "size": { - "type": "string", - "description": "Human-readable size, e.g. '1.2 MB' — not a byte count." - }, - "content_type": { - "type": "string", - "description": "MIME type recorded when the file was uploaded, e.g. image/png." - }, - "url": { - "type": "string", - "description": "Link to the stored file." - } - } - } - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp — named last_updated_at here, not updated_at." - }, - "created_by": { - "type": "string", - "description": "Email of the creating user." - }, - "updated_by": { - "type": "string", - "description": "Email of the last user to write to the case." - }, - "urls": { - "type": "object", - "description": "Web-app link for the case, for showing a human where it lives.", - "properties": { - "self": { - "type": "string" - } - } - } - } - }, - "V2History": { - "type": "object", - "description": "One revision of a test case as returned by the v2 history listing. The internal record id, entity_id, entity_type, project_id, group_id, the nested user object and the links block are stripped before the record is returned.", - "properties": { - "version_id": { - "type": "string", - "format": "uuid", - "description": "Opaque version identifier for this revision." - }, - "version_name": { - "type": "string", - "description": "Positional label for the revision — V1, V2, V3 … — where V1 is the oldest revision visible under the caller's plan. The number is what the single-version read takes.", - "example": "V2" - }, - "source": { - "type": "string", - "description": "What produced the revision: 'create' for the original, 'update' for an edit, 'restore' after a rollback.", - "example": "update" - }, - "modified_fields": { - "type": "array", - "nullable": true, - "items": { - "type": "string" - }, - "description": "Names of the fields this revision changed. Null on the creating revision." - }, - "modified": { - "type": "object", - "description": "Per-field before/after map, e.g. {\"title\": {\"old\": \"...\", \"new\": \"...\"}}. Only the first record on each page is enriched with this detail; the other rows come back without it. The creating revision never carries it either, having nothing to diff against. Rows whose old and new are both empty are dropped.", - "additionalProperties": true, - "example": { - "title": { - "old": "Original Title", - "new": "Updated Title" - } - } - }, - "comment": { - "type": "string", - "nullable": true, - "description": "Optional note recorded with the change." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the revision was recorded.", - "example": "2025-08-29T08:33:58.000Z" - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Email of the user who made the change. Null when the user id cannot be resolved to a user in this group.", - "example": "user@example.com" - }, - "user_id": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id of the editor — the raw id updated_by is resolved from." - }, - "testcase_id": { - "type": "string", - "description": "Identifier of the test case this revision belongs to — the third-party identifier when one is set, otherwise the TC-NNN identifier. Falls back to TC- if the case cannot be fetched.", - "example": "TC-123" - }, - "origin_channel": { - "type": "string", - "description": "How the edit that produced this revision arrived. One of ui, api, jira_app, azure, import, automation or ai. Revisions written through this API are always recorded as api.", - "enum": [ - "ui", - "api", - "jira_app", - "azure", - "import", - "automation", - "ai" - ], - "example": "api" - } - } - }, - "TestPlanV2": { - "type": "object", - "description": "A test plan or sub-plan as the v2 plan surface serializes it. There is no numeric id field - `identifier` (TP-NNN / STP-NNN) is the only id, and project_id is the PR-NNN identifier rather than the numeric project id. The numeric plan and project ids are recoverable only by reading them out of the web-app link under urls.self. This is the shape every write and every listing returns; only the single-plan read adds the embedded run preview and the relative API links.", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "description": { - "type": "string", - "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." - }, - "active_state": { - "type": "string", - "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", - "enum": [ - "active", - "completed" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." - }, - "test_runs_count": { - "type": "object", - "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "sub_plans_count": { - "type": "integer", - "nullable": true, - "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." - }, - "start_date": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-01-01T00:00:00Z" - }, - "end_date": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "string", - "description": "PR-NNN identifier of the owning project, not a number.", - "example": "PR-2" - }, - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - } - } - } - } - }, - "TestRunSummaryV2": { - "type": "object", - "description": "A test run as the v2 run listings serialize it — a summary shape shared by the project-wide listing and a plan's linked-run listing. It carries NO numeric id and no uuid: `identifier` (TR-NNN) is the only id present. It also never carries issues, issue_tracker or test_cases; those exist only on the single-run read.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-164" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Rich-text description, HTML-unescaped and with links rehydrated. Empty string rather than null for a run with no description." - }, - "run_state": { - "type": "string", - "description": "Lifecycle state; a brand-new run reports 'new_run'." - }, - "active_state": { - "type": "string", - "description": "Derived: 'closed' once the run is closed, otherwise 'active'.", - "enum": [ - "active", - "closed" - ] - }, - "assignee": { - "type": "string", - "nullable": true, - "description": "Email of the run's owner; null when it cannot be resolved.", - "example": "qa@acme.com" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "description": "DEFECT — currently always an empty array on both run listings, whatever configurations the run actually has. The key is always present; its emptiness carries no information. Read the run itself for its configurations. When populated it is configuration ids (integers), not configuration objects.", - "items": { - "type": "integer" - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "filter_test_cases": { - "type": "object", - "description": "Always present. Carries a single is_dynamic sub-field — whether the run's case list is filter-driven.", - "properties": { - "is_dynamic": { - "description": "Whether the run's case list is filter-driven. NOT consistently typed: the project-wide listing reports it as a boolean, the plan-scoped listing as the integer 0/1, for the same run. Test it truthily; an identity check against false is unsafe." - } - } - }, - "project_id": { - "type": "string", - "description": "The project the request was scoped to, in PR-NNN form.", - "example": "PR-2" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Omitted entirely, rather than sent as null, when the run has no recorded update time." - }, - "links": { - "type": "object", - "description": "API references for the run. Always present with exactly these two keys.", - "properties": { - "self": { - "type": "string", - "description": "Relative API reference for reading this run." - }, - "test_cases": { - "type": "string", - "description": "Relative API reference for this run's test cases." - } - } - }, - "urls": { - "type": "object", - "description": "Web-app deep links for the run. Always present on both run listings.", - "properties": { - "self": { - "type": "string", - "description": "Absolute link to this run in the web app." - } - } - } - } - }, - "V2ReportDetail": { - "type": "object", - "description": "A report's public v2 configuration record. Identifiers replace internal integer ids, and it carries no report data — only how the report is set up.", - "properties": { - "id": { - "type": "string", - "pattern": "^SC-\\d+$", - "example": "SC-42", - "description": "The report's identifier. A per-group sequence number, unrelated to the integer report id the v1 report capabilities take." - }, - "project_id": { - "type": "string", - "pattern": "^PR-\\d+$", - "description": "The owning project's identifier." - }, - "project_ids": { - "type": "array", - "items": { - "type": "string", - "pattern": "^PR-\\d+$" - }, - "description": "Every project the report covers. A single-project report lists just its own." - }, - "report_type": { - "type": "string", - "description": "Display name of the report type, e.g. 'Test Run Summary'." - }, - "title": { - "type": "string", - "description": "Display name. Auto-generated as '-MM/DD/YYYY' when the creator omitted it." - }, - "description": { - "type": "string", - "nullable": true - }, - "report_timeframe": { - "type": "string", - "description": "Rolling window or scope mode, e.g. last_one_week, custom, specific_test_runs." - }, - "report_creation_mode": { - "type": "string", - "values": [ - "custom_report", - "system_generated" - ] - }, - "custom_date_range": { - "type": "string", - "nullable": true, - "description": "Two space-separated YYYY-MM-DD dates; set only for the custom timeframe." - }, - "frequency": { - "type": "string", - "values": [ - "once", - "daily", - "weekly", - "monthly" - ], - "description": "'once' is substituted for an unscheduled report whose stored frequency is null." - }, - "frequency_details": { - "type": "object", - "nullable": true, - "description": "time (HH:MM) and, for weekly/monthly, day." - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When the schedule next fires. Null for a one-off report." - }, - "is_scheduled": { - "type": "boolean", - "description": "True only when a valid frequency and frequency_details pair was supplied." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "owner": { - "type": "object", - "nullable": true, - "description": "Creator, as email and full_name. No user id is exposed." - }, - "mail_to": { - "type": "object", - "description": "users (email + full_name objects) and external_mails (plain addresses). Populated only for scheduled reports." - }, - "report_filters": { - "type": "object", - "description": "Configured scope echoed back as public identifiers (TR-NNN, TP-NNN, assignee emails). Sub-plans collapse to TP-NNN; values fall back to prefix+internal id if the lookup fails." - }, - "dynamic_filters": { - "type": "object", - "nullable": true, - "description": "Always null on this surface, whatever was sent at create time." - }, - "latest_attachment": { - "type": "object", - "nullable": true, - "description": "Newest generated file in the PROJECT, not necessarily this report's: generated_at, file_type (a MIME type such as application/pdf) and ready, which is true whenever the object is present." - } - } - }, - "AttachmentListItem": { - "type": "object", - "description": "One attachment row as the v2 per-entity attachment listing serializes it - a deliberately thin projection, not the full stored attachment record.", - "properties": { - "id": { - "type": "integer", - "description": "Attachment id. This is the id the delete-attachment capabilities take." - }, - "name": { - "type": "string", - "description": "Original filename as uploaded." - }, - "size": { - "type": "integer", - "description": "File size in bytes. Renamed from the stored byte_size field, which is not present under this name here." - }, - "created_at": { - "type": "string", - "description": "When the file was attached." - }, - "url": { - "type": "string", - "description": "Location to read the file from. There is no separate download endpoint for these attachments." - }, - "data_id": { - "type": "integer", - "description": "Present ONLY on inline attachments (images embedded via the rich-text editor). Its presence is how an inline row is told apart from a real file attachment." - } - } - }, - "TestResultSearchItem": { - "type": "object", - "description": "One test result row from the account-wide result search. Leaner and differently keyed than TestResult: no custom_fields, no step result and no configuration_id, but it carries the owning project, run and case so a cross-project row can be traced back. Several fields exist on the row but are never loaded by this search — see their descriptions.", - "properties": { - "id": { - "type": "integer", - "description": "Result id. Project-scoped single-result reads need this together with project_id." - }, - "author": { - "type": "object", - "description": "Who logged the result. Six keys when the user resolves. When the result came from an import and no matching user exists, only full_name and email are present, sourced from the import; when neither applies the key is omitted entirely.", - "properties": { - "id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "updated_by": { - "type": "object", - "nullable": true, - "description": "Who last changed the result. ALWAYS NULL on this endpoint: the account-wide result search does not load the updating user's id, so no user is ever resolved and the object is never populated here - the key is always present and always null. The project-scoped single-result reads return the real object." - }, - "status": { - "type": "string", - "nullable": true, - "example": "blocked", - "description": "Lowercase internal status token, and null on most rows. Read result_status.field_value instead — it is the populated, display-case form of the same outcome." - }, - "result_status": { - "nullable": true, - "$schema": "ResultStatus__SearchTestResults" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Result notes as rich text. User mentions are rewritten to display names in this payload." - }, - "error_description": { - "type": "string", - "nullable": true - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "failure_type": { - "type": "string", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Always null on this endpoint — the account-wide search does not load it. Use a project-scoped result read if you need it." - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Always null on this endpoint, as for started_at." - }, - "time_elapsed": { - "type": "string", - "nullable": true, - "description": "Always null on this endpoint, as for started_at." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_by_imported": { - "type": "string", - "nullable": true, - "description": "Name carried over from an import when the result has no BrowserStack author. Always null on this endpoint — not loaded by the account-wide search." - }, - "attachments": { - "type": "array", - "nullable": true, - "description": "ALWAYS NULL on this endpoint - the account-wide result search does not load attachments, so this is never an array here and iterating it without a guard throws. No attachment object is ever produced on this path, so there are no entry fields to read; use the project-scoped result reads, which return real entries." - }, - "issues": { - "type": "array", - "description": "Linked tracker issues. Always an array - empty on a result with no linked defects, so the entry fields below appear only on rows that actually carry a link. This endpoint loads only the issue key and its link time, plus a duplicate `jira_id` mirroring issue_id whatever the tracker actually is. There is no issue_type here, so a row cannot tell you which tracker the link belongs to - the project-scoped reads do carry it.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "example": "BUG-123" - }, - "jira_id": { - "type": "string", - "description": "Copy of issue_id, named jira_id for every tracker. Written for every entry, so it is present whenever the row has a linked issue at all." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the issue was linked to this result." - } - }, - "description": "`jira_id` is written as a copy of `issue_id` on every entry, so the two always agree and always appear together — the name is historical and holds the id in whatever tracker the link uses. This shape carries no tracker name; read the run or case surfaces if you need to know which tracker an id belongs to." - } - }, - "configuration": { - "type": "object", - "nullable": true, - "description": "Configuration the result was logged against. Always requested, so present whatever compact is set to; an empty object when the result has none." - }, - "test_case": { - "type": "object", - "nullable": true, - "description": "Present ONLY when compact=false was sent; absent otherwise." - }, - "test_run": { - "type": "object", - "nullable": true, - "description": "Present ONLY when compact=false was sent. Its `id` is overwritten with the run's TR-NNN identifier string, not a number." - }, - "test_case_id": { - "type": "integer", - "nullable": true, - "description": "Integer case id from the result mapping; omitted when the mapping is absent." - }, - "test_run_id": { - "description": "Integer run id from the result mapping — but replaced by the run's TR-NNN identifier STRING whenever the test_run relation is present (compact=false). Type-check before using it.", - "nullable": true - }, - "test_run_results_mapping_id": { - "type": "integer", - "nullable": true, - "description": "Id of the run/case mapping the result hangs off." - }, - "project_id": { - "type": "integer", - "nullable": true, - "description": "Numeric id of the project the result lives in. This is how you tell cross-project rows apart; it is present only when the search service returned it." - }, - "links": { - "type": "object", - "description": "Present only when the row carried a project_id. Holds `self` only — no custom_fields link on this path.", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" - } - } - } - } - }, - "Comment": { - "type": "object", - "description": "One comment as the v1 serializer emits it — a flat object (the published spec wraps it under an extra `comment` key that the server does not send). The same object is returned on both comment surfaces: a test case's own thread and a test case's thread inside a test run.", - "properties": { - "id": { - "type": "integer", - "description": "Comment id; the handle for later edit/delete.", - "example": 3121 - }, - "user_id": { - "type": "object", - "description": "The AUTHOR, despite the name. `id` is the internal TM user id (not the BrowserStack user id) and is null for comments imported from another tool, whose original author name is carried in full_name.", - "properties": { - "id": { - "type": "integer", - "nullable": true - }, - "full_name": { - "type": "string" - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "group_id": { - "type": "integer", - "description": "Internal TM group id of the owning workspace." - }, - "project_id": { - "type": "integer" - }, - "parent_id": { - "type": "integer", - "nullable": true, - "description": "Always null through this path; replies are not threaded." - }, - "entity_id": { - "type": "integer", - "description": "What the comment is attached to, and it differs by surface: on a test case's own thread this is the test case id, while on a test case's thread inside a test run it is the id of the run/case mapping cell - neither the test case id nor the run id. Read it together with entity_type." - }, - "entity_type": { - "type": "string", - "description": "Which surface the comment lives on: 'TestCase' for a comment on the test case itself, 'TRTC' for a comment on that case inside a test run. The two are separate threads and never overlap.", - "example": "TestCase" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "comment": { - "type": "string", - "description": "HTML body with @mentions rendered to display names and links decorated; can differ from what was posted." - }, - "edited": { - "type": "boolean", - "description": "True once the comment has been changed after posting; always false on a comment that was just created." - }, - "is_editable": { - "type": "boolean", - "description": "False for system/imported comments that cannot be edited; always true on a comment created through the comment endpoints." - } - } - }, - "StatusProgressBreakdown": { - "type": "object", - "description": "Per-result-status progress of a run or one of its configurations. Keys are result-status ids as STRINGS ('1', '2', ...); each value is the count of run test cases currently in that status plus the status definition. Statuses with no cases may be absent. Empty object when nothing has been recorded.", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "description": "Number of test cases in the run (or configuration) whose latest result has this status.", - "example": 8 - }, - "result_status": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "field_value": { - "type": "string", - "description": "Display label, e.g. 'Passed'.", - "example": "Passed" - }, - "internal_name": { - "type": "string", - "description": "Stable machine name (passed, failed, blocked, untested, ...). Use this to compare across projects; ids differ per project.", - "example": "passed" - }, - "field_name": { - "type": "string", - "example": "Status" - }, - "colour": { - "type": "string", - "example": "#00FF00" - } - } - } - } - } - }, - "BinnedTestCase": { - "type": "object", - "description": "A test case as it appears in the recycle-bin listing: a slim row from the bin store plus resolved people, not the full TestCase shape.", - "properties": { - "id": { - "type": "integer", - "description": "Integer id — what restore/purge selections take." - }, - "identifier": { - "type": "string", - "example": "TC-3502" - }, - "name": { - "type": "string" - }, - "priority": { - "type": "object", - "description": "The priority as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", - "properties": { - "field_value": { - "type": "string", - "description": "The displayed name — the one to compare against." - }, - "colour": { - "type": "string", - "description": "Presentation only." - }, - "field_name": { - "type": "string", - "description": "Internal field name behind the value." - }, - "value_category": { - "type": "string", - "description": "Grouping the value belongs to." - }, - "internal_name": { - "type": "string", - "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming — `field_value` is what a person sees and can be edited." - } - } - }, - "status": { - "type": "object", - "description": "The status as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", - "properties": { - "field_value": { - "type": "string", - "description": "The displayed name — the one to compare against." - }, - "colour": { - "type": "string", - "description": "Presentation only." - }, - "field_name": { - "type": "string", - "description": "Internal field name behind the value." - }, - "value_category": { - "type": "string", - "description": "Grouping the value belongs to." - }, - "internal_name": { - "type": "string", - "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming — `field_value` is what a person sees and can be edited." - } - } - }, - "owner": { - "type": "integer", - "nullable": true, - "description": "Raw BrowserStack user id of the owner; see owner_details for the person." - }, - "owner_details": { - "type": "object", - "nullable": true, - "description": "Resolved owner {id, browserstack_user_id, full_name, email, group_id}; absent when owner is null or unknown." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "issue_id": { - "type": "string" - }, - "issue_type": { - "type": "string" - } - } - } - }, - "folder_name": { - "type": "string", - "nullable": true - }, - "folder_path": { - "type": "string", - "nullable": true, - "description": "Breadcrumb of the folder the case was in when binned." - }, - "updated_by": { - "type": "integer", - "nullable": true, - "description": "Raw BrowserStack user id of who moved it to the bin; see updator_details." - }, - "updator_details": { - "type": "object", - "nullable": true, - "description": "Resolved person who binned the case {id, browserstack_user_id, full_name, email, group_id}." - }, - "trashed_at": { - "type": "string", - "format": "date-time", - "description": "When the case was moved to the bin." - }, - "days_remaining": { - "type": "integer", - "description": "Days left before automatic permanent purge." - }, - "template": { - "type": "string", - "description": "The template the case was authored on, carried through from the case record." - }, - "created_by": { - "type": "integer", - "description": "Id of the user who created the case. `creator_details` carries the same person resolved." - }, - "creator_details": { - "type": "object", - "description": "The creating user resolved, or null when that user can no longer be found. Distinct from whoever binned the case.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "The account-level user id, not the id used for owner or assignee here." - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer", - "description": "Workspace the user belongs to." - } - } - } - } - }, - "V1DuplicatePair": { - "type": "object", - "description": "One AI-suggested duplicate test-case pair as this product serialises it. Used by the single-duplicate read and as the element type of the duplicates listing and search.", - "properties": { - "id": { - "type": "integer", - "description": "The duplicate id. This is the value the read, resolve and discard calls take." - }, - "ai_score": { - "type": "number", - "description": "Model confidence for the pair. A score below 100 is a semantic match; 100 is an exact one." - }, - "reason": { - "type": "string", - "description": "The model's stated reason for believing the two cases are duplicates." - }, - "duplicate_type": { - "type": "string", - "description": "exact or semantic.", - "values": [ - "exact", - "semantic" - ] - }, - "test_cases": { - "type": "array", - "description": "The two test cases in the pair. Always exactly two — except in a listing filtered by entity_id, where the filtered-on case is removed and only the other one remains.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Integer test case id — what the resolve call takes as source/target/archive id." - }, - "identifier": { - "type": "string", - "description": "Display identifier (TC-NNN), or the third-party identifier when the case came from an external tool." - }, - "folder_id": { - "type": "integer", - "description": "Id of the folder holding the case." - }, - "folder_path": { - "type": "string", - "description": "Human-readable folder path." - }, - "title": { - "type": "string", - "description": "The test case name (renamed from `name` on the way out)." - }, - "description": { - "type": "string", - "description": "Empty string rather than null when the case has no description." - }, - "owner_id": { - "type": "integer", - "description": "Raw BrowserStack user id of the owner." - }, - "owner": { - "type": "string", - "description": "Resolved owner display name; an empty string when the user cannot be resolved in this group." - } - } - } - } - } - }, - "FolderV2": { - "type": "object", - "description": "A folder in its public (v2) shape. One shape for all three folder writes - create, rename and move all return exactly this object under `folder`.", - "properties": { - "id": { - "type": "integer", - "description": "The folder's numeric id. This is the value every later folder call takes in its path - the project is addressed by its PR-NNN identifier, but the folder is addressed by this number." - }, - "name": { - "type": "string", - "description": "The folder's name. On create and rename this is the value you sent, unless the project already had a folder of that name under the same parent and auto-renaming was allowed, in which case the server-chosen name comes back here." - }, - "description": { - "type": "string", - "nullable": true, - "description": "The folder's free-text notes. Null when the folder has none - which is every newly created folder, since create does not accept notes." - }, - "parent_id": { - "type": "integer", - "nullable": true, - "description": "The folder this one now sits under. Null for a folder at the top of the project. After a move this is the destination you asked for, so it is the field to check to confirm the move landed." - }, - "cases_count": { - "type": "integer", - "nullable": true, - "description": "Test cases directly in this folder (not descendants). The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." - }, - "sub_folders_count": { - "type": "integer", - "nullable": true, - "description": "Immediate child folders. The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." - }, - "links": { - "type": "object", - "description": "Ready-made API links for this folder. Always present on a folder write.", - "properties": { - "sub_folders": { - "type": "string", - "description": "Link for listing this folder's immediate child folders. Built with the project's PR-NNN identifier and this folder's numeric id." - } - } - }, - "urls": { - "type": "object", - "description": "Links for a person, not for an API client. Always present on a folder write.", - "properties": { - "self": { - "type": "string", - "description": "Absolute web-app address at which someone can open this folder. Note it is built from the project's NUMERIC id, whereas the API links above use the PR-NNN identifier - do not read a project identifier out of this string." - } - } - } - } - }, - "ConfigurationGroup": { - "type": "object", - "description": "An account-level configuration dimension (for example Operating System, Browser or Device) whose selectable values are held as its options.", - "properties": { - "id": { - "type": "integer", - "description": "Integer id of the group; use it to read or change the group's options." - }, - "name": { - "type": "string", - "description": "Display name of the dimension." - }, - "is_system": { - "type": "boolean", - "description": "True for groups the product ships rather than ones the account created." - }, - "group_id": { - "type": "integer", - "description": "The owning account (BrowserStack group). Identical on every row of a single response." - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "LinkedIssueTestCase": { - "type": "object", - "description": "A test case as returned in a tracker ticket's linked-case list. Leaner than the full test case read, and carries issue-panel-only fields.", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "description": "The case's TC-NNN identifier, or the third-party identifier when the case was imported." - }, - "name": { - "type": "string" - }, - "folder_id": { - "type": "integer" - }, - "owner": { - "type": "object", - "description": "The resolved owner user, or null when the owner id cannot be resolved in the caller's group." - }, - "project": { - "type": "object", - "description": "Summary of the owning project, supplied so the panel can render across projects." - }, - "template": { - "type": "string" - }, - "template_step_type": { - "type": "string" - }, - "template_is_system": { - "type": "boolean" - }, - "datasets_linked": { - "type": "boolean" - }, - "custom_fields": { - "type": "array" - }, - "status": { - "type": "object" - }, - "case_type": { - "type": "object" - }, - "automation_state": { - "type": "object" - }, - "priority": { - "type": "string", - "description": "Flattened to the priority's internal name or display value, unlike the object form used elsewhere." - }, - "tags": { - "type": "array" - }, - "issues": { - "type": "array", - "description": "The tracker tickets this case is linked to." - }, - "reviewers": { - "type": "array" - }, - "review_status": { - "type": "string" - }, - "dropdown": { - "type": "object", - "description": "The project's default field option sets, repeated per case so the panel can label status and priority ids." - }, - "self_ui_link": { - "type": "string", - "description": "Web app path for this case — a link for a human, not an API path." - }, - "configuration": { - "type": "object" - }, - "last_test_info": { - "type": "object", - "description": "Present only when the case has been executed at least once. Absent — not null — for a never-run case.", - "properties": { - "last_run_result": { - "type": "string" - }, - "last_run_result_status": { - "type": "object" - }, - "last_run_name": { - "type": "string" - }, - "last_run_id": { - "type": "string", - "description": "The run's TR-NNN identifier, not its integer id, despite the name." - }, - "last_run_link": { - "type": "string" - }, - "last_run_status": { - "type": "string", - "description": "'closed' or 'active'." - } - } - } - } - }, - "SharedComponent": { - "type": "object", - "description": "A shared field: a reusable content block held under Shared Fields and referenced from many test cases. The same record backs all three kinds - step sequence, Gherkin background and precondition - distinguished by `field_type`. These are the fields every route returns, including the LISTING.\n", - "properties": { - "id": { - "type": "integer", - "description": "Shared-field id, from the same id space as the shared-steps endpoints." - }, - "title": { - "type": "string" - }, - "field_type": { - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Which kind of shared field this is. Defaults to `step` when the create omits it.", - "example": "background" - }, - "test_case_count": { - "type": "integer", - "description": "How many test cases reference this block." - }, - "step_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "created_by": { - "type": "object", - "description": "Reduced user reference - id and full_name only, not the wider User shape other TM serializers return.", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - } - } - }, - "V2TestCaseDetail": { - "type": "object", - "description": "A test case as the v2 public API serializes it (create, get, update and move). Differs from the internal `TestCase` shape: `title` instead of name, `steps` [{step, result}] instead of test_case_steps, owner/created_by/updated_by as emails, custom fields by display name, and NO integer id — `identifier` is the handle.", - "properties": { - "identifier": { - "type": "string", - "description": "TC-NNN, or the customer-defined identifier when the workspace uses its own ids. Use this in later calls.", - "example": "TC-1024" - }, - "title": { - "type": "string", - "description": "The case name (sent as `name` on write)." - }, - "description": { - "type": "string", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "template": { - "type": "string", - "description": "Step style: test_case_steps or test_case_bdd. The template id itself is not returned.", - "example": "test_case_steps" - }, - "folder_id": { - "type": "integer" - }, - "status": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. Active." - }, - "priority": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. High." - }, - "case_type": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. Functional." - }, - "automation_status": { - "type": "string", - "description": "Internal name, e.g. not_automated." - }, - "owner": { - "type": "string", - "nullable": true, - "description": "Owner's email; falls back to the imported owner label for migrated cases." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "jira_id": { - "type": "string", - "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made — azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." - } - }, - "description": "Only two keys exist on this shape and both are always present when the array is non-empty — nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." - }, - "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links." - }, - "steps": { - "type": "array", - "description": "Ordered steps for a classic case; always [] for a Gherkin case.", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" - } - } - } - }, - "feature": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "background": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "scenario": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "custom_fields": { - "type": "array", - "description": "One entry per set custom field, keyed by display name. Multi-dropdowns carry `values` (array) instead of `value`. Always present, as [] when the case has none.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "description": "Scalar value; dropdown option label, user email, boolean, etc." - }, - "values": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "File name as uploaded." - }, - "size": { - "type": "string", - "description": "Human-readable size, e.g. '1.2 MB' — not a byte count." - }, - "content_type": { - "type": "string", - "description": "MIME type recorded when the file was uploaded, e.g. image/png." - }, - "url": { - "type": "string", - "description": "Link to the stored file." - } - } - }, - "description": "Files attached to the case. On a create the key is omitted ENTIRELY rather than returned as an empty array — a case created a moment ago has nothing attached — while a read of a case with no files returns []. Test for the key before iterating. Every entry carries name, size, content_type and url together — these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached." - }, - "is_shared": { - "type": "boolean", - "nullable": true - }, - "review_status": { - "type": "string", - "nullable": true, - "description": "Aggregate review state of the case. Omitted unless the project has Review & Approve enabled on a Team-Pro plan, and unset when the case has no review state." - }, - "reviewers": { - "type": "array", - "description": "Omitted entirely unless the project has Review & Approve enabled on a Team-Pro plan; shape depends on the workspace's reviewer read-shape flag (emails with review_status, or user objects).", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "created_by": { - "type": "string", - "nullable": true, - "description": "Creator's email." - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "Note the name: last_updated_at, not updated_at." - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Last editor's email." - }, - "urls": { - "type": "object", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case in the Test Management UI." - } - } - } - } - }, - "GlobalSearchEntityResults": { - "type": "object", - "description": "One entity type's slice of an account-wide search result. Present only for the types that were asked for. Used for the entity types whose rows are handed back as the search index stored them rather than re-serialized: each row carries the record's id, name or title, the project it lives in, and a relevance score, plus the `display_meta` block described on `data` below. Because the rows are passed through, treat any key beyond id, name and project as informational.", - "properties": { - "data": { - "type": "array", - "description": "The matching records for this entity type, best match first. Every row carries a `display_meta` block enriched with the owner's name and the project's name so the hit can be labelled without another call - except on the typeahead fast path, where only owner and project are filled in and the rest of display_meta is skipped. Rows whose project has since been deleted or archived are dropped before you see them, so this list can be shorter than `info.count` implies.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "TestRunCaseBulkEditRow": { - "type": "object", - "description": "A run's case row as returned by the synchronous bulk edit. Built by a different serializer from the run's case LISTING — it carries the mapping cell's own fields (mapping id, result status object, defects, result notes) rather than the listing's projection. Kept separate so corrections to the listing shape do not silently redescribe this one.", - "properties": { - "identifier": { - "type": "string", - "example": "TC-42" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "priority": { - "type": "string", - "description": "Resolved label, not the id.", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "field_name": { - "type": "string", - "example": "priority" - }, - "id": { - "type": "integer", - "example": 318591 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status": { - "type": "string", - "description": "Test case status label." - }, - "case_type": { - "type": "string" - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." - }, - "assignee": { - "type": "string", - "description": "Assignee of this row within the run.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "project_id": { - "type": "string", - "example": "PR-1", - "description": "Project of the run being read." - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "automation_state": { - "type": "object" - }, - "automation_status": { - "type": "object" - }, - "case_type_imported": { - "type": "string" - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "configuration": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:33:18.553Z" - }, - "creator_details": { - "type": "string", - "nullable": true - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "dataset_values": { - "type": "string", - "nullable": true - }, - "defects": { - "type": "string", - "nullable": true - }, - "deleted": { - "type": "boolean", - "example": false - }, - "estimate": { - "type": "string", - "nullable": true - }, - "execution_eligible": { - "type": "boolean", - "example": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1985020 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "last_executed_at": { - "type": "string", - "nullable": true - }, - "last_executed_by": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { - "type": "object" - }, - "links": { - "type": "object" - }, - "mapping_id": { - "type": "integer", - "example": 2150428149 - }, - "metadata": { - "type": "object" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "planned_execution_date": { - "type": "string", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority_imported": { - "type": "string" - }, - "project_name": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "result_custom_fields": { - "type": "string", - "nullable": true - }, - "result_notes": { - "type": "string", - "nullable": true - }, - "result_status": { - "type": "object" - }, - "status_imported": { - "type": "string" - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "__mcp-probe-mergetag-20260922-a" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_assignee": { - "type": "string", - "nullable": true - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_folders_path": { - "type": "object" - }, - "test_case_id": { - "type": "integer", - "example": 1985020 - }, - "test_case_steps": { - "type": "string", - "nullable": true - }, - "test_run_id": { - "type": "integer", - "example": 17578918 - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:33:18.553Z" - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "TestRunV2Response": { - "description": "The test run at the top level under `test_run`, with no `data` envelope — the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_run": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "The run's linked defects. CONDITIONAL — present but EMPTY when the run has nothing linked; the key is absent entirely when the run payload carries no issues association at all. An absent key therefore means 'not reported', not 'no defects'. Per-entry fields have their own conditions below.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string", - "description": "The ticket's id in the tracker, e.g. XYZ-1 — this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` — the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL — omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" - } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" - } - } - }, - "test_cases": { - "type": "array", - "description": "The run's test cases, present only on the single-run read; absent on create and clone. The rows use the same element schema as the standalone case listing, deliberately, so a correction to one cannot drift from the other — but a few values differ by caller and say so on the field: steps are never included here, for instance.", - "items": { - "$schema": "TestRunTestCaseV2" - } - } - } - } - } - } - }, - "TestRunWriteV2Response": { - "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape — the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "The run's linked defects. CONDITIONAL, and usually absent on a write reply: this value is echoed from the update result rather than re-read, and on the newer run backend the write reply carries no linked-defect list at all. Never infer from its absence that the write dropped the links — read the run back to confirm what is linked.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string", - "description": "The ticket's id in the tracker, e.g. XYZ-1 — this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` — the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL — omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" - } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" - } - } - } - } - } - } - } - }, - "TestPlanV2Detail": { - "type": "object", - "description": "A test plan or sub-plan as the single-plan read returns it: everything the plan shape carries, plus an embedded preview of the plan's runs and the relative API links that go with it. Only the single-plan read produces these two; a create, an update or a listing never does.", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "description": { - "type": "string", - "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." - }, - "active_state": { - "type": "string", - "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", - "enum": [ - "active", - "completed" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." - }, - "test_runs_count": { - "type": "object", - "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "test_runs": { - "type": "array", - "description": "A preview of at most the first 30 runs in this plan, each with identifier and name alone. Absent - not empty - when the run fetch failed; check test_runs_count before concluding the plan has no runs. Only the single-plan read carries it.", - "items": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "example": "TR-164" - }, - "name": { - "type": "string" - } - } - } - }, - "sub_plans_count": { - "type": "integer", - "nullable": true, - "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." - }, - "parent_plan_id": { - "type": "string", - "description": "The parent's TP-NNN identifier, not its numeric id. Set only when this plan is a sub-plan; on a top-level plan the key is absent from the payload entirely and is never returned as null.", - "example": "TP-10000" - }, - "start_date": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-01-01T00:00:00Z" - }, - "end_date": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "string", - "description": "PR-NNN identifier of the owning project, not a number.", - "example": "PR-2" - }, - "links": { - "type": "object", - "description": "Relative API paths for this plan. Present only when the run preview was fetched successfully - these appear and disappear together with test_runs. Only the single-plan read carries them.", - "properties": { - "self": { - "type": "string", - "description": "Path to read this plan.", - "example": "/api/v2/projects/PR-2/test-plans/TP-12" - }, - "test_runs": { - "type": "string", - "description": "Path to page through the runs in this plan.", - "example": "/api/v2/projects/PR-2/test-plans/TP-12/test-runs" - } - } - }, - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - }, - "sub_test_plans": { - "type": "string", - "description": "Address of this plan's children listing. Added only when the plan has no parent, so a sub-plan never carries it. Unlike self this is an API address, not a web-app page." - } - } - } - } - }, - "V2TestRunStepResultDetail": { - "type": "object", - "description": "One per-step result written by a v2 result submission that carried step_results, returned as an entry of `test-run-step-results`. One entry per step result written, in the order the steps were sent.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the result row written for this step. Carry it forward for a follow-up update; it is the id domain the server expects back." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "description": { - "type": "string", - "nullable": true, - "description": "This step's own notes, not the case-level description." - }, - "status": { - "type": "string", - "example": "passed", - "description": "This step's own status, as the LOWERCASE internal token ('passed'/'failed'/'blocked') - not the display-case name a case-level result echoes back. The display-case form of the same status is inside test_run_test_case_step_details as field_value, and the status rolled up for the case as a whole is the response's overall-result-status." - }, - "issues": { - "type": "array", - "description": "Issues linked to this step result, as plain KEY STRINGS, deduplicated - the same shape they are sent in, not objects.", - "items": { - "type": "string", - "example": "JIRA-12" - } - }, - "urls": { - "type": "object", - "description": "Web link for this submission.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's ROW IN THE RUN - its final path segment is the run row's id, not the id of the step result just written." - } - } - }, - "test_run_test_case_step_details": { - "type": "object", - "description": "The step this result belongs to and how it was answered. Present on every entry here - each entry IS a step result - and never on the case-level `test-result`, which only exists when no step results were submitted. Alongside the step result's own ids and timestamps it carries the status twice (`status` as the lowercase internal token, `field_value` as the display-case name) with its `colour`, and the step itself: `step` (the instruction), `result` (the expected result) and `order` (its position in the case).", - "properties": { - "id": { - "type": "integer", - "description": "Id of the step-result record." - }, - "step_mapping_id": { - "type": "integer", - "description": "Id of the step within the case that this result answers." - }, - "status": { - "type": "string", - "example": "passed", - "description": "Lowercase internal token for this step's status." - }, - "field_value": { - "type": "string", - "example": "Passed", - "description": "Display-case name of the same status." - }, - "status_id": { - "type": "integer", - "description": "Id of the configured status value." - }, - "colour": { - "type": "string", - "nullable": true, - "description": "Colour configured for that status." - }, - "step": { - "type": "string", - "nullable": true, - "description": "The step instruction, as rich text." - }, - "result": { - "type": "string", - "nullable": true, - "description": "The expected result for the step, as rich text." - }, - "order": { - "type": "integer", - "nullable": true, - "description": "Position of the step in the case." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "TestCaseFieldValue": { - "type": "object", - "description": "One resolved field value on a test case — the shared descriptor behind case_type, priority, status and automation_state. Identical across the detail, list, folder-list, search, search-v2 and run-mapping routes.", - "properties": { - "id": { - "type": "integer" - }, - "internal_name": { - "type": "string", - "description": "Machine name, e.g. `acceptance`, `low`, `not_automated`." - }, - "colour": { - "type": "string", - "nullable": true - }, - "value_category": { - "type": "string", - "nullable": true, - "description": "Coarse grouping for the value, e.g. `manual` / `automated` on automation_state. Null on most priority, status and case_type values." - }, - "name": { - "type": "string", - "description": "Display label." - } - } - }, - "V1TestRunTestCaseRow": { - "type": "object", - "description": "One test case AS MAPPED INTO A v1 TEST RUN — a case-plus-configuration row, not the project-level record. A case mapped to several configurations appears once per configuration. The row is the shared test-case record (see TestCase) plus the execution fields below; it does NOT carry attachments, estimated_duration_seconds, template_name or test_case_dataset, and it DOES carry step_count.\n\nNOT EVERY WORKSPACE RETURNS EVERY FIELD DECLARED HERE. `execution_eligible` and the `review_status`/`reviewers` pair are gated by a per-workspace feature flag and are mutually exclusive in practice: a workspace returns one side or the other, never both. The declaration is the union, so treat each of those three as absent unless you have seen it on the workspace you are actually reading. The remaining fields are returned everywhere.", - "allOf": [ - { - "$schema": "TestCaseCore" - } - ], - "properties": { - "mapping_id": { - "type": "integer", - "description": "Id of THIS case-x-configuration mapping inside the run. NOT stable: it shifts to the latest result's id after a write, so re-read it before each bulk operation rather than caching it." - }, - "test_case_id": { - "type": "integer", - "description": "Integer id of the underlying project-level test case. `id` on this row is the same case id; `mapping_id` is what identifies the row." - }, - "test_run_id": { - "type": "integer", - "description": "Integer id of the run this mapping belongs to." - }, - "configuration": { - "type": "object", - "nullable": true, - "description": "The configuration this row is mapped to.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "browser": { - "type": "string", - "nullable": true - }, - "browser_version": { - "type": "string", - "nullable": true - }, - "os": { - "type": "string", - "nullable": true - }, - "os_version": { - "type": "string", - "nullable": true - }, - "device": { - "type": "string", - "nullable": true - } - } - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS row, e.g. `untested`.", - "example": "untested" - }, - "result_status": { - "$schema": "ResultStatusFieldValue", - "nullable": true - }, - "result_notes": { - "type": "string", - "nullable": true, - "description": "Notes recorded with the latest result." - }, - "result_custom_fields": { - "type": "array", - "description": "Custom field values recorded with the latest result; `[]` when none.", - "items": { - "type": "object" - } - }, - "defects": { - "type": "array", - "description": "Defects linked to the latest result; `[]` when none.", - "items": { - "type": "object" - } - }, - "dataset_values": { - "type": "array", - "description": "Dataset values bound to this mapping; `[]` when none.", - "items": { - "type": "object" - } - }, - "last_executed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Null until the row has been executed." - }, - "last_executed_by": { - "type": "integer", - "nullable": true, - "description": "Null until the row has been executed." - }, - "planned_execution_date": { - "type": "string", - "format": "date", - "nullable": true - }, - "test_assignee": { - "type": "integer", - "nullable": true, - "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`." - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "deleted": { - "type": "boolean", - "description": "True when the underlying case has been removed but the mapping row is still returned." - }, - "project_name": { - "type": "string", - "nullable": true, - "description": "Populated only for cases mapped in from another project; null otherwise." - }, - "test_case_folders_path": { - "type": "array", - "description": "Breadcrumb from root to the case's folder.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "notes": { - "type": "string", - "nullable": true - } - } - } - }, - "execution_eligible": { - "type": "boolean", - "description": "Whether this mapping may be executed. Gated by a per-workspace feature flag: a workspace returning this field does not return `review_status`/`reviewers` on these rows, and vice versa. Treat as absent unless seen." - }, - "step_count": { - "type": "integer", - "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) — count `test_case_steps` there instead." - }, - "comments_count": { - "type": "integer" - } - } - }, - "TestRunSelectionCaseRow": { - "type": "object", - "description": "The raw test-case record as returned by the run-selection endpoint. This is NOT the shared TestCase serializer: `automation_status` is a plain string here rather than the {name,value} object, the folder key is `folder_id` rather than `test_case_folder_id`, `type` replaces the `case_type` descriptor, and assignee / priority / status / tags / custom_fields / steps are not returned at all. Do not plan against TestCase fields on this route.\n", - "properties": { - "id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "example": "TC-838569" - }, - "third_party_identifier": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string", - "description": "Case type as a bare string, e.g. `manual`. The descriptor object is not returned here.", - "example": "manual" - }, - "folder_structure": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "nullable": true - }, - "source_entity_id": { - "type": "integer", - "nullable": true - }, - "owner": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id, not the TM user id used by `assignee.id` elsewhere." - }, - "folder_id": { - "type": "integer", - "description": "The case's folder. Called `test_case_folder_id` on every other test-case route." - }, - "group_id": { - "type": "integer" - }, - "template": { - "type": "string", - "example": "test_case_text" - }, - "template_id": { - "type": "integer", - "nullable": true - }, - "import_id": { - "type": "integer", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "updated_by": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id." - }, - "created_by": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id." - }, - "created_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "automation_status": { - "type": "string", - "description": "A PLAIN STRING on this route, e.g. `not_automated` — elsewhere `automation_status` is a {name, value} object.", - "example": "not_automated" - }, - "automation_id": { - "type": "integer", - "nullable": true - }, - "archived_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Non-null when the case is archived." - }, - "trashed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Non-null when the case is in the bin." - }, - "is_shared": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "nullable": true - }, - "is_project_level": { - "type": "boolean" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "review_sent_date": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "integer", - "nullable": true - } - } - }, - "TestCaseCore": { - "type": "object", - "description": "Fields returned by EVERY test-case row on every route, including the run-mapping rows. Routes add to this; nothing here is route-conditional.\n", - "properties": { - "assignee": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "creator_details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "updator_details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "case_type": { - "$schema": "TestCaseFieldValue", - "nullable": true - }, - "priority": { - "$schema": "TestCaseFieldValue" - }, - "status": { - "$schema": "TestCaseFieldValue", - "nullable": true - }, - "case_type_imported": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "estimate": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "folder": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "history_count": { - "type": "integer", - "nullable": true - }, - "owner": { - "type": "integer", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "owner_imported": { - "type": "string" - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" - } - } - } - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "template": { - "type": "string" - }, - "test_case_folder_id": { - "type": "integer" - }, - "test_case_steps": { - "$schema": "TestCaseStep" - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "metadata": { - "type": "object", - "properties": { - "lcnc_build_map": { - "type": "object", - "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - }, - "profileId": { - "type": "string", - "nullable": true, - "description": "Low-code profile id. Present under `metadata.lcnc_build_map` but NOT on the top-level `lcnc_build_map`, which carries only resource_path and status." - } - } - }, - "ai_prompt": { - "type": "object", - "properties": { - "status": { - "type": "boolean" - } - } - } - } - }, - "automation_state": { - "$schema": "TestCaseFieldValue", - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement." - }, - "is_shared": { - "type": "boolean", - "description": "True when the case is a shared case rather than one owned outright by its folder." - }, - "template_id": { - "type": "integer", - "nullable": true, - "description": "Id of the template backing this case. Null for cases on the plain-text template." - }, - "template_step_type": { - "type": "string", - "description": "Step type the template implies — `test_case_steps` or `test_case_text`. Mirrors `template`.", - "example": "test_case_steps" - }, - "test_run_results_count": { - "type": "integer", - "nullable": true, - "description": "Count of results recorded for this case across runs. Null on list routes. Known to disagree with the actual number of results — do not rely on it; page the run-results endpoint instead." - }, - "test_run_results_issues_count": { - "type": "integer", - "nullable": true, - "description": "Count of issues raised against those results. Null on list routes; carries the same reliability caveat as test_run_results_count." - } - } - }, - "V1TestCaseListRow": { - "type": "object", - "description": "A test-case row as returned by the LIST routes. Adds `step_count`, which the single-case detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "step_count": { - "type": "integer", - "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) — count `test_case_steps` there instead." - } - } - }, - "V1TestCaseFolderListRow": { - "type": "object", - "description": "The folder listing's row: a list row that also carries `duplicates_tc_count`.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "duplicates_tc_count": { - "type": "integer", - "nullable": true, - "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListFolderTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__ListFolderTestCases" - } - } - }, - "V1TestCaseSearchRow": { - "type": "object", - "description": "A search result row: a list row that also carries `attachments`.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object" - } - }, - "case_type": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "priority": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "status": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "automation_state": { - "$schema": "SearchFieldValue", - "nullable": true - } - } - }, - "V1TestCaseRecordRow": { - "type": "object", - "description": "The single-case record as returned by the integer-id read. Carries `attachments` and `comments_count`, and NOT `step_count` — count `test_case_steps` instead.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object" - } - }, - "comments_count": { - "type": "integer" - } - } - }, - "V1TestCaseDetailRow": { - "type": "object", - "description": "The slide-over detail row: the single-case record plus the resolved template and dedupe extras. Does not carry `step_count`.\n", - "allOf": [ - { - "$schema": "V1TestCaseRecordRow" - } - ], - "properties": { - "folder": { - "type": "object", - "nullable": true, - "description": "DETAIL ROUTES ONLY (get_test_case_detail). Observed null in every response seen." - }, - "testcase_template": { - "type": "object", - "description": "DETAIL ROUTES ONLY. The resolved template layout — a large nested object." - }, - "duplicates_tc_count": { - "type": "integer", - "nullable": true, - "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." - }, - "folder_path": { - "type": "string", - "description": "DETAIL ROUTE ONLY, and only when `include=folder_path` is sent." - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "priority": { - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "test_case_steps": { - "$schema": "TestCaseStep__GetTestCaseDetail" - } - } - }, - "V1TestRunDetailRow": { - "type": "object", - "description": "A single v1 test run as returned by the detail route: the shared run record plus its linked issues, which the list and search rows do not carry.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "issues": { - "type": "array", - "description": "Linked issues. Returned by the single-run detail route; not present on the list or search rows.", - "items": { - "type": "object" - } - }, - "test_result_issues": { - "type": "string", - "nullable": true, - "description": "Issues raised against this run's results. Not returned by every workspace — treat as absent unless seen." - }, - "observability_id": { - "type": "integer", - "example": 17574995 - }, - "created_by": { - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", - "$schema": "TestRunUserRef__GetTestRunByIntegerId" - }, - "updated_by": { - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", - "$schema": "TestRunUserRef__GetTestRunByIntegerId" - } - } - }, - "V1TestRunListRow": { - "type": "object", - "description": "A v1 test run as returned by the LIST and SEARCH routes: the shared run record plus `build_meta`, which the single-run detail route does not carry.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_meta": { - "type": "object", - "nullable": true, - "description": "Automation build this run is linked to — {id, name, creationType, buildNormalisedName, …}. Populated for runs created from an automation build." - } - } - }, - "ExploratorySessionCore": { - "type": "object", - "description": "Fields returned for an exploratory session on BOTH the list and the single-session read.\n", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the exploratory session.", - "example": 123 - }, - "identifier": { - "type": "string", - "description": "ES-NNN form. NOTE: the single-session read takes the INTEGER `id` in its path — passing this identifier returns 404.", - "example": "ES-295" - }, - "title": { - "type": "string", - "description": "Title of the exploratory session.", - "example": "Checkout flow exploration" - }, - "session_state": { - "type": "string", - "description": "Current lifecycle state of the session.", - "enum": [ - "active", - "closed" - ], - "example": "active" - }, - "timebox_duration": { - "type": "integer", - "description": "Planned duration of the session in minutes.", - "example": 60, - "nullable": true - }, - "assignee": { - "$schema": "ExploratorySessionUserRef" - }, - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "tags": { - "type": "array", - "description": "Tags associated with the session.", - "example": [ - "regression", - "payment" - ], - "items": { - "type": "string" - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the session was created.", - "example": "2026-01-16T10:30:00Z" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the session was last updated.", - "example": "2026-01-16T11:00:00Z" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "test_plan_id": { - "type": "integer", - "nullable": true - } - } - }, - "ExploratorySessionListRow": { - "type": "object", - "description": "An exploratory session as returned by the LIST route: the shared core plus `summary` and the resolved `test_plan`. It does NOT carry charter, description, durations, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations — read a single session for those.\n", - "allOf": [ - { - "$schema": "ExploratorySessionCore" - } - ], - "properties": { - "summary": { - "type": "string", - "nullable": true, - "description": "Returned on the LIST route; absent from the single-session read." - }, - "test_plan": { - "type": "object", - "nullable": true, - "description": "Resolved plan. Returned on the LIST route; absent from the single-session read." - } - } - }, - "UserCore": { - "type": "object", - "description": "The four fields every expanded user reference carries, on every serializer.\n", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - } - } - }, - "TestRunUserRef": { - "type": "object", - "description": "The user reference embedded in a test run's created_by / updated_by. Carries group_id and browserstack_user_id but NOT reports_and_notification_enabled.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - } - } - }, - "DatasetUserRef": { - "type": "object", - "description": "The user reference embedded in a dataset's created_by. Carries `role`, which no other user serializer returns, and omits browserstack_user_id / group_id.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "role": { - "type": "string", - "description": "The user's role on the dataset's project, e.g. `owner`.", - "example": "owner" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "ProjectsPageInfo": { - "type": "object", - "description": "The projects listing's pagination envelope: PageInfo plus `starred_count`, which no other paginated read returns.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "starred_count": { - "type": "integer", - "example": 0 - } - } - }, - "FilterDetailsFolderNode": { - "type": "object", - "description": "A folder as returned inside `filter_details.folders` — the shared folder record plus the tree/selection fields that only this projection carries.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "folder_tree": { - "type": "object" - }, - "only_child_selected_parent_ids": { - "type": "string", - "nullable": true - }, - "only_selected_parent_ids": { - "type": "integer", - "example": 764138 - }, - "partial_selected_parent_ids": { - "type": "string", - "nullable": true - }, - "select_all_parent_ids": { - "type": "integer", - "example": 764093 - }, - "folders": { - "type": "object", - "properties": { - "ancestors": { - "type": "string", - "nullable": true - }, - "folder_path": { - "type": "string", - "example": "/764093/" - }, - "id": { - "type": "integer", - "example": 764093 - }, - "name": { - "type": "string", - "example": "__readonly__" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "selected_all": { - "type": "boolean", - "example": true - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - } - } - } - } - }, - "V1TestRunCasesRunRow": { - "type": "object", - "description": "The run header returned alongside a run's test cases. Adds the build and sharing fields that the run list and single-run detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - } - } - }, - "SearchFieldValue": { - "type": "object", - "description": "The field-value descriptor as returned by the two SEARCH routes, which additionally embed the per-project binding rows.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "array", - "description": "Per-project binding rows for this value. Returned ONLY by the two search routes (search_project_entities, search_project_entities_by_filter); absent from the detail, list, folder-list and run-mapping routes.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "custom_values_id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "dataset_id": { - "type": "integer", - "nullable": true - } - } - } - } - } - }, - "ResultStatusFieldValue": { - "type": "object", - "description": "The field-value descriptor as returned for a RESULT status. This serializer names the label `field_value`; every other consumer names it `name`. Both are present and they agree.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_value": { - "type": "string", - "example": "Passed" - } - } - }, - "PathFolder": { - "type": "object", - "description": "A folder as it appears in a breadcrumb list (`path_folders`, `destination_path_folders`). The shared folder record plus the row's own audit and lineage fields, which the folder LISTING routes do not return.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - } - } - }, - "V1TestRunWriteRow": { - "type": "object", - "description": "The run as echoed by a run WRITE and by the run-cases read: the shared run record plus the build and sharing fields the run list and single-run detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - } - } - }, - "V1TestCaseWriteRow": { - "type": "object", - "description": "The test-case row as echoed by a WRITE and by global search: the project-level record plus the extras those responses attach.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - } - } - }, - "FilterWriteRow": { - "type": "object", - "description": "A filter as echoed by create/update: the shared filter record plus the ownership and lifecycle fields the filter LISTING does not return.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "TestRunWriteResponse__EditTestRun": { - "type": "object", - "description": "TestRunWriteResponse as returned by `edit_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { - "type": "object", - "properties": { - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17577900 - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 13450 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "issues": { - "type": "string", - "nullable": true - } - } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - } - } - } - } - }, - "TestRunWriteResponse__AssignTestRunOwner": { - "type": "object", - "description": "TestRunWriteResponse as returned by `assign_test_run_owner`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { - "type": "object", - "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 13450 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17577900 - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "run_state": { - "type": "string", - "example": "new_run" - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "example": 4 - }, - "test_plans": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:33.000Z" - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "uuid": { - "type": "integer", - "example": 17577900 - } - } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "success": { - "type": "boolean", - "example": true - } - } - } - } - }, - "Folder__BulkCopyTestCases": { - "type": "object", - "description": "Folder as returned by `bulk_copy_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 6 - }, - "total_cases_count": { - "type": "integer", - "example": 35 - } - } - }, - "TestCase__BulkCopyTestCases": { - "type": "object", - "description": "TestCase as returned by `bulk_copy_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkCopyTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_copy_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCase__BulkReplaceTestCaseFields": { - "type": "object", - "description": "TestCase as returned by `bulk_replace_test_case_fields`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkReplaceTestCaseFields": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_replace_test_case_fields`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "Folder__BulkMoveTestCases": { - "type": "object", - "description": "Folder as returned by `bulk_move_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 6 - }, - "total_cases_count": { - "type": "integer", - "example": 35 - } - } - }, - "TestCase__BulkMoveTestCases": { - "type": "object", - "description": "TestCase as returned by `bulk_move_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkMoveTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_move_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "ReportDetail__CreateReportByIntegerId": { - "type": "object", - "description": "ReportDetail as returned by `create_report_by_integer_id`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:49:30.804Z" - }, - "custom_date_range": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "MCP live probe of create_report_by_integer_id capability" - }, - "frequency": { - "type": "string", - "nullable": true - }, - "frequency_details": { - "type": "string", - "nullable": true - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 10411 - }, - "identifier": { - "type": "string", - "example": "SC-510" - }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - }, - "is_dynamic": { - "type": "boolean", - "example": false - }, - "next_run_at": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "report_creation_mode": { - "type": "string", - "example": "custom_report" - }, - "report_timeframe": { - "type": "string", - "example": "last_one_week" - }, - "report_type": { - "type": "string", - "example": "Test Run Summary" - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "slack_notification_supported": { - "type": "boolean", - "example": true - }, - "title": { - "type": "string", - "example": "__mcp-probe-report-20260921" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:49:30.804Z" - } - } - }, - "Project__CreateReportByIntegerId": { - "type": "object", - "description": "Project as returned by `create_report_by_integer_id`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Project" - } - ], - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-17T08:01:58.000Z" - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "probe edit_project 2026-09-17T10:47:41Z" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "V1TestCaseCreateResponse__CreateTestCaseByIntegerId": { - "type": "object", - "description": "V1TestCaseCreateResponse as returned by `create_test_case_by_integer_id`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "V1TestCaseCreateResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "cases_count": { - "type": "integer", - "example": 47 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } - } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 48 - } - } - }, - "path_folders": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13472" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "cases_count": { - "type": "integer", - "example": 47 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 48 - } - } - }, - "test_case": { - "type": "object", - "properties": { - "comments_count": { - "type": "integer", - "example": 0 - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "automation_state": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 2509329 - }, - "internal_name": { - "type": "string", - "example": "not_automated" - }, - "name": { - "type": "string", - "example": "Not Automated" - }, - "value_category": { - "type": "string", - "example": "manual" - } - } - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "case_type": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318626 - }, - "internal_name": { - "type": "string", - "example": "other" - }, - "name": { - "type": "string", - "example": "Other" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "case_type_imported": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:42.487Z" - }, - "creator_details": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "estimate": { - "type": "string", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1986203 - }, - "identifier": { - "type": "string", - "example": "TC-54559" - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - }, - "links": { - "type": "object", - "properties": { - "detail": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203/detail" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203" - } - } - }, - "metadata": { - "type": "object", - "properties": { - "ai_prompt": { - "type": "object", - "properties": { - "status": { - "type": "boolean", - "example": false - } - } - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "profileId": { - "type": "string", - "nullable": true - }, - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - } - } - }, - "name": { - "type": "string", - "example": "__mcp-probe-createcase-20260921" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318618 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "status": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "id": { - "type": "integer", - "example": 318632 - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "name": { - "type": "string", - "example": "Active" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "mcp-probe" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_name": { - "type": "string", - "example": "Test Case Steps" - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_case_dataset": { - "type": "object" - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_steps": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:42.508Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "id": { - "type": "integer", - "example": 12373418 - }, - "order": { - "type": "integer", - "example": 1 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_case_id": { - "type": "integer", - "example": 1986203 - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:42.508Z" - } - } - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "nullable": true - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - } - } - }, - "V1TestCaseCreateResponse__CreateTestCases": { - "type": "object", - "description": "V1TestCaseCreateResponse as returned by `create_test_cases`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "V1TestCaseCreateResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "cases_count": { - "type": "integer", - "example": 48 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } - } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 49 - } - } - }, - "path_folders": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13472" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "cases_count": { - "type": "integer", - "example": 48 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 49 - } - } - }, - "test_case": { - "type": "object", - "properties": { - "comments_count": { - "type": "integer", - "example": 0 - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "automation_state": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 2509329 - }, - "internal_name": { - "type": "string", - "example": "not_automated" - }, - "name": { - "type": "string", - "example": "Not Automated" - }, - "value_category": { - "type": "string", - "example": "manual" - } - } - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "case_type": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318626 - }, - "internal_name": { - "type": "string", - "example": "other" - }, - "name": { - "type": "string", - "example": "Other" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "case_type_imported": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:43.059Z" - }, - "creator_details": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "estimate": { - "type": "string", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1986204 - }, - "identifier": { - "type": "string", - "example": "TC-54560" - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - }, - "links": { - "type": "object", - "properties": { - "detail": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204/detail" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204" - } - } - }, - "metadata": { - "type": "object", - "properties": { - "ai_prompt": { - "type": "object", - "properties": { - "status": { - "type": "boolean", - "example": false - } - } - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "profileId": { - "type": "string", - "nullable": true - }, - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - } - } - }, - "name": { - "type": "string", - "example": "__mcp-probe-createcases-20260921-1" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318618 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "status": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "id": { - "type": "integer", - "example": 318632 - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "name": { - "type": "string", - "example": "Active" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "mcp-probe" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_name": { - "type": "string", - "example": "Test Case Steps" - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_case_dataset": { - "type": "object" - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_steps": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:43.080Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "id": { - "type": "integer", - "example": 12373419 - }, - "order": { - "type": "integer", - "example": 1 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_case_id": { - "type": "integer", - "example": 1986204 - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:43.080Z" - } - } - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "nullable": true - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - } - } - }, - "TestRunWriteResponse__CreateTestRunByIntegerId": { - "type": "object", - "description": "TestRunWriteResponse as returned by `create_test_run_by_integer_id`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { - "type": "object", - "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17579088 - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "run_state": { - "type": "string", - "example": "new_run" - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "example": 3 - }, - "test_plans": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:43.000Z" - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "uuid": { - "type": "integer", - "example": 17579088 - } - } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "success": { - "type": "boolean", - "example": true - } - } - } - } - }, - "TestCase__EditTestCase": { - "type": "object", - "description": "TestCase as returned by `edit_test_case`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "test_case_steps": { - "$schema": "TestCaseStep__EditTestCase" - } - } - }, - "UsersV2Response__ListGroupUsers": { - "type": "object", - "description": "UsersV2Response as returned by `list_group_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "UsersV2Response" - } - ], - "properties": { - "info": { - "$schema": "PageInfo__ListGroupUsers" - } - } - }, - "PageInfo__ListGroupUsers": { - "type": "object", - "description": "PageInfo as returned by `list_group_users`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "count": { - "type": "integer", - "example": 10 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "V1TestPlanDetail__GetTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `get_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "active_state": { - "type": "string", - "example": "active" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-18T10:40:45.000Z" - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "example": "2026-10-18T00:00:00.000Z" - }, - "id": { - "type": "integer", - "example": 48300 - }, - "issues": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "example": "2026-09-18T00:00:00.000Z" - }, - "sub_plans_count": { - "type": "integer", - "example": 0 - }, - "tags": { - "type": "string", - "example": "disposable" - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T10:40:45.000Z" - } - } - } - } - }, - "PageInfo__GetTestResultsCustomFields": { - "type": "object", - "description": "PageInfo as returned by `get_test_results_custom_fields`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "count": { - "type": "integer", - "example": 0 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "TestRunResponse__ListTestRunCases": { - "type": "object", - "description": "TestRunResponse as returned by `get_test_run_detail`. Composes TestRunResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "test_run": { - "type": "object", - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "dynamic_filter": { - "type": "string", - "nullable": true - }, - "filters": { - "type": "string", - "nullable": true - }, - "issues": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17575382 - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "trtc_folder_progress_enabled": { - "type": "boolean", - "example": false - } - } - } - } - } - } - }, - "Filter__UpdateFilter": { - "type": "object", - "description": "Filter as returned by `update_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions", - "properties": { - "customFields": { - "type": "object" - }, - "priority": { - "type": "string", - "example": "P1" - } - } - } - } - }, - "V1TestPlanDetail__UpdateTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `update_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "active_state": { - "type": "string", - "example": "active" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-21T13:44:07.000Z" - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 48319 - }, - "issues": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer", - "example": 0 - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T05:11:26.000Z" - } - } - } + "schema_version": 1, + "version": "1.46", + "build_id": "cb63a4a5_2026-09-16T18:02:57Z", + "tm": { + "title": "Test Management", + "routing_terms": [ + "team", + "setting", + "workspace", + "suite" + ], + "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. A run is executed against a named configuration and environment, and records the app version and build its results came from. Destructive capabilities exist and always need explicit human approval.", + "capabilities": [ + { + "name": "assign_test_run_owner", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "owner", + "type": "integer", + "description": "The ID of the user to assign as the owner of the test run.", + "json_path": "/test_run/owner" + } + ], + "intent": "Assign a owner to the test run", + "returns": [ + "id", + "identifier", + "name", + "active_state", + "assignee_imported", + "created_at", + "description", + "environment", + "is_automation", + "is_dynamic", + "observability_url", + "owner" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__AssignTestRunOwner" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send `owner` flat. The `test_run` wrapper is added for you from the field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "The reply's `tags` can be [] even though the run's stored tags are untouched \u2014 the owner assign does not clear them. Re-read the run before reporting its tags." + ] + }, + { + "name": "assign_test_run_test_cases", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "assign_to", + "type": "array", + "required": true, + "description": "One entry per case (or execution) to reassign. Top level \u2014 NOT wrapped in a test_run object, unlike the update calls on this run.", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "assignee", + "type": "string" + }, + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "execution_id", + "type": "integer" + } + ] + } + ], + "intent": "Hand specific test cases inside a run to the people who should execute them (or clear their assignee)", + "guidance": [ + "Queued, not applied: the 200 {success: true} only means the job was accepted. Re-read the run's test cases to confirm the assignees actually changed.", + "`assignee` is an email (or TM user id) resolved inside the run's group. An address that does not resolve is skipped silently \u2014 that case keeps its old assignee and nothing is reported.", + "Send `assignee: null` on an entry to unassign that case.", + "Each entry must carry `test_case_id` (TC-NNN) or `execution_id`; an entry with neither is dropped. Add `configuration_id` to target one configuration row when the case is mapped to several \u2014 omit it and every row for that case is reassigned.", + "The body is not validated before the 200: a missing or empty `assign_to` still answers 200 {success: true} and changes nothing.", + "Assignment is reversible \u2014 assign again (or to null) to undo." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "description": "Assignment job queued. Body is {success: true}; the assignments are applied asynchronously.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "description": "The job could not be enqueued; nothing was assigned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } + } + }, + { + "name": "bulk_archive_test_cases_by_project", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-archive", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all` \u2014 the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder to scope the selection to, at top level. Merged into the search scope and it OVERWRITES `q.folder_id` when both are present. Do not confuse it with the DESTINATION, which for move lives at `test_case.destination_folder_id` and for copy at top-level `destination_folder_id`." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of `folder_id`. Compared as the string `true`. The UI sets it for consolidated (non-folder) views." + }, + { + "name": "ids", + "type": "array", + "required": true, + "description": "Integer ids of the cases to archive / retrieve.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "required": true, + "description": "Ids to exclude when `select_all` is true.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "required": true, + "description": "Apply to every case in scope, minus `de_selected_ids`.", + "json_path": "/test_case/select_all" + } + ], + "intent": "Bulk Archive Test Cases", + "guidance": [ + "Archiving hides cases from the working set without deleting them; they can be retrieved again. Prefer it to deletion when the user says 'remove' or 'clean up' without being explicit." + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkArchiveResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "bulk_archive_test_plans", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/bulk-archive", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_plan_ids", + "type": "array", + "required": true, + "minItems": 1, + "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids \u2014 the first one present wins, so do not send two. Every entry is run through to_i and anything that yields 0 is silently dropped, so a TP-NNN identifier is discarded without an error." + } + ], + "intent": "Archive test plans in bulk \u2014 the reversible way to get plans out of the active listing; use this instead of delete whenever the user might want them back", + "returns": [ + "success", + "async", + "unique_id", + "plans", + "count" + ], + "responses": { + "200": { + "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` \u2014 the plans are not archived yet.\n", + "schema": { + "$schema": "V1TestPlanBulkResponse__BulkArchiveTestPlans" + } + }, + "400": { + "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "TestPlanV2Response__CreateTestPlan": { - "type": "object", - "description": "TestPlanV2Response as returned by `create_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestPlanV2Response" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "urls": { - "type": "object", - "properties": { - "sub_test_plans": { - "type": "string" - }, - "self": { - "type": "string", - "example": "https://test-management.browserstack.com/projects/1234/test-plans/49442" - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "V1TestCaseListRow__ListTestCases": { - "type": "object", - "description": "V1TestCaseListRow as returned by `list_test_cases`. Composes V1TestCaseListRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "automation_status": { + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Archiving is gated on a group feature flag. When the flag is off the call returns 404 with {success: false, errors: 'Feature not available'} \u2014 a 404 here means the feature is unavailable, NOT that the plans are missing. The same gate covers bulk-retrieve and both archived reads.", + "Permission is the test-plan DELETE right, not write. A user who can create and edit plans can still be 403'd here, and on restore and on the archived listing/count.", + "Ids are coerced with to_i: 'TP-12' and any non-numeric entry become 0 and are dropped silently. If every id drops the call is 400 {success: false, errors: 'Please provide test plan ids'} \u2014 the same error you get for an empty list.", + "More than 30 ids is handed to a background job and returns {success: true, async: true, unique_id} \u2014 NOTHING is archived yet at that moment. Re-read the plans (or the archived listing) to confirm; there is no job-status endpoint for this in the profile.", + "The 30 counts the ids YOU send. Sub-plan expansion happens upstream, so archiving a parent can touch far more plans than the count you get back.", + "Failures use the plural key `errors`, not `error` \u2014 {success: false, errors: '...'}. Branch on `success`, not on the key name.", + "Unlike the public/v2 archive route, this path is not JSON-schema validated, so the 1000-id cap that route enforces is not applied here. Batch large sets yourself.", + "To undo, call the bulk-retrieve capability with the same ids." + ] + }, + { + "name": "bulk_set_test_case_assignee_in_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/bulk_assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "When a folder_id is given, also include cases in its sub-folders when resolving a select_all." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the test-case listing returned AFTER the assignment. It does not affect which rows are assigned." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Page size of the listing returned after the assignment. Anything outside 1..100, or non-numeric, silently falls back to 30 rather than erroring." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort of the listing returned after the assignment." + } + ], + "body": [ + { + "name": "assignee_id", + "type": "integer", + "example": 2, + "description": "TM user id (not a BrowserStack user id, not an email) of the person to assign. The id must belong to the caller's group or the request 404s. Omit it entirely to clear the assignee on the selected rows \u2014 there is no separate 'unassign' call." + }, + { + "name": "mapping_ids", + "type": "array", + "example": [ + 999, + 991, + 1024, + 1019 + ], + "description": "TRTC MAPPING ids \u2014 the `mapping_id` field on the rows a test-run test-case listing returns \u2014 NOT test case ids. A case that runs against N configurations has N mapping ids, and passing the case's `id` here assigns nothing and still returns 200." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Assign every case the current filter resolves to, instead of an explicit mapping_ids list. The filter is `q` (plus folder_id / include_subfolders_tc); with no `q` this means the whole run." + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Mapping ids to exclude. Subtracted from mapping_ids, and from the resolved set when select_all is true." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Scope the selection \u2014 and the listing returned afterwards \u2014 to one folder of the run. Its presence also changes the response: the post-assign listing comes back from the folder view and carries an extra `folder` key." + }, + { + "name": "q", + "type": "object", + "description": "The same filter object the run's test-case listing takes. Only read when select_all is true; it is what select_all resolves against." + }, + { + "name": "automation", + "type": "boolean", + "default": false, + "description": "Automation-run path. When true the selection resolver is bypassed entirely: `test_run_ids` is taken as the id list and the operation is ALWAYS queued, whatever its size. Leave it off for manual runs." + }, + { + "name": "test_run_ids", + "type": "array", + "description": "Only read when automation is true; ignored otherwise." + } + ], + "intent": "Set the assignee on many test cases inside a test run in one call \u2014 pick the rows explicitly, or select every row matching the current filter and deselect a few", + "returns": [ + "success", + "info", + "test_cases", + "folder", + "async", + "unique_id", + "id", + "identifier", + "name", + "mapping_id", + "test_case_id", + "latest_status", + "result_status", + "test_assignee", + "last_executed_by", + "last_executed_at", + "defects", + "configuration", + "result_custom_fields", + "execution_eligible", + "owner", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "BulkAssignTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The ids here are mapping ids, not test case ids. Read the run's test cases first and take `mapping_id` off each row \u2014 `id` is the test case and assigning by it silently does nothing.", + "Above 300 selected rows the call switches to a queued write and returns {success:true, async:true, unique_id} with NO test_cases \u2014 the assignment happens later. Below that it applies inline and returns the re-listed page. Branch on `async` rather than assuming the list is there, and never retry on the async acknowledgement.", + "The selected mapping ids are locked for the duration. A concurrent bulk operation on any of the same rows comes back 400 {success:false, message:'TestCases in progress with another operation'} \u2014 that is contention, not bad input; wait and retry.", + "An empty resolution is refused, not silently accepted: 400 'No test cases to update.' when nothing is selected, and 422 'Could not resolve the selected test cases. Please retry.' when select_all counted rows it could not turn into ids.", + "assignee_id must be a TM user id inside the caller's group; an id from another group raises a 404 rather than being ignored. Omitting it clears the assignee.", + "The 200 body is the run's test-case listing re-read after the write, not a report of what changed \u2014 it is paged, so it can show fewer rows than you assigned. Re-read and check `test_assignee` if you need confirmation.", + "Needs the test-run write permission; on an automation run the automation variant of that permission is demanded instead and a role that only holds the manual one gets 403." + ], + "paginated": true, + "max_page_size": 100 + }, + { + "name": "bulk_copy_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-copy", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "description": "Copy every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default." + }, + { + "name": "folder_id", + "type": "string", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "destination_folder_id", + "type": "integer", + "required": true, + "description": "Target folder for the copies, at TOP level (bulk-move puts its destination inside `test_case` instead). Required in practice: the request is pre-checked by looking the folder up inside `destination_project_id`, and a folder that does not exist there is a bare 400 with no message. The only way to omit it is the `copy_folder` flow below, which mints the destination first." + }, + { + "name": "destination_project_id", + "type": "string", + "description": "Target project id. Only needed for a copy into ANOTHER project \u2014 it defaults to the source project when omitted, so a same-project copy can leave it out. Integer and string are both accepted. Ignored when `clone_in_place` is true, which always stays in the source project." + }, + { + "name": "clone_in_place", + "type": "boolean", + "description": "Duplicate each selected case into its OWN source folder (the product's Clone action) instead of into one shared destination. Never crosses projects. `destination_folder_id` is still pre-validated even though the copies do not land there, so it must still name a real folder." + }, + { + "name": "copy_folder", + "type": "boolean", + "description": "Compared as the string `true`. Creates the destination folder from `test_case_folder` before copying, and that new folder then wins over `destination_folder_id`. Leave it unset and pass an existing `destination_folder_id` for the normal case." + }, + { + "name": "test_case_folder", + "type": "object", + "description": "Only read when `copy_folder` is true: the destination folder to create. `test_case_folder_id` is the PARENT folder's integer id (0 with `root: true` creates a root folder); `prev`/`next` position it among its siblings.", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "notes", + "type": "string" + }, + { + "name": "test_case_folder_id", + "type": "integer" + }, + { + "name": "root", + "type": "boolean" + }, + { + "name": "prev", + "type": "integer" + }, + { + "name": "next", + "type": "integer" + } + ] + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Copy test cases into another folder or another project \u2014 by listing their ids, or by copying everything a filter matches \u2014 and clone cases in place.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "destination_path_folders", + "source_path_folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkCopyTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Selection is one of two modes and they do not mix: either `test_case.ids` (explicit integer ids), or `test_case.select_all: true`, which resolves the set from top-level `q` + `folder_id` and ignores `ids` entirely.", + "The destination is TOP-LEVEL `destination_folder_id` here. Bulk-move puts its destination inside `test_case` \u2014 copying the move body onto this route silently sends no destination and 400s.", + "The destination folder is looked up before anything is copied, inside `destination_project_id` (which defaults to the source project). A folder id that does not exist there comes back as a bare 400 with no message \u2014 that is 'no such folder', not 'bad request'.", + "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 30 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed SOURCE view (or the search results) plus folder counts \u2014 it does NOT contain the new copies or their ids. To get them, list the destination folder afterwards; `cases_count[]` only tells you the new total.", + "Copies are new test cases with new integer ids and new TC-NNN identifiers; the originals are untouched. There is no undo \u2014 the way back is to delete the copies." + ] + }, + { + "name": "bulk_delete_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-delete", + "mode": "destructive", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Delete every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Delete many test cases at once from the project-wide search/filter view \u2014 by id, or everything a filter matches.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "path_folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkDeleteTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Selection is either `test_case.ids` or `test_case.select_all: true` scoped by top-level `q` + `folder_id`. With select_all the `ids` you send are ignored, so a mis-typed filter key (which is dropped, not rejected) deletes the whole scope.", + "Whether this is recoverable depends on the workspace: with the move-to-bin feature enabled the cases go to the Bin and can be restored, otherwise the delete is permanent. Assume permanent unless you have confirmed the Bin is on for this group.", + "Deleting also drops the cases' results and unlinks them from schedules \u2014 those do not come back even when the case is restored from the Bin.", + "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed listing after the delete (`test_cases` + `info` + `cases_count`), not a report of what was deleted. The one exception: with `q.is_archived` set, the response is just `{success:true}`." + ], + "disabled": true + }, + { + "name": "bulk_delete_test_results", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-results/bulk-delete", + "mode": "destructive", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "result_ids", + "type": "array", + "required": true, + "description": "Result INTEGER ids. Non-empty, max 300.", + "minItems": 1, + "maxItems": 300 + } + ], + "intent": "Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`", + "returns": [ + "success", + "result", + "deleted_ids", + "skipped", + "id", + "reason" + ], + "responses": { + "200": { + "description": "Partial success is normal \u2014 `deleted_ids` is what actually went, `skipped` is what\ndid not (usually authorisation).\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "result": { + "type": "object", + "description": "Both lists are nested HERE, not at the top level. Reading\nresponse[\"deleted_ids\"] directly finds nothing and makes a successful\ndelete look like a no-op.\n", + "properties": { + "deleted_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "skipped": { + "type": "array", + "description": "Entries are OBJECTS, not bare ids \u2014 {\"id\": 13, \"reason\": \"not_found\"}.\nCommon reasons: not_found, or the caller is neither author nor project admin.\n", + "items": { "type": "object", "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + } } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__ListTestCases" + } } + } } - }, - "TestCaseFieldValue__ListTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `list_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } + } + } + }, + "400": { + "description": "`result_ids` missing or empty." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "description": "More than 300 ids in one request." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Partial success is the normal outcome and it still answers 200 with success:true. Both lists sit under a top-level `result` key: read result.deleted_ids and result.skipped. Never infer 'all deleted' from the status code \u2014 diff deleted_ids against the ids you sent and report the rest.", + "`skipped` entries are OBJECTS, not bare ids: {id, reason}. The only two reasons the handler produces are `not_found` and `not_authorised`.", + "Deletion is authorised per id: a project admin's whole batch is authorised wholesale, everyone else may only delete results they personally logged. A non-admin batch is resolved id by id against the recorded author.", + "The authorship lookup fails CLOSED. If that upstream call errors, you still get 200 / success:true with deleted_ids empty and EVERY id back in skipped with reason `not_found`. An all-not_found batch is as likely an upstream failure as genuinely missing ids \u2014 re-read the results before telling the user they do not exist.", + "`result_ids` is read from the top level of the body \u2014 it is not wrapped in an object. Ids are coerced with to_i and anything coercing to 0 (a non-numeric string, a null, 0 itself) is dropped silently; if that empties the list you get 400 'result_ids must be a non-empty array' even though you sent a non-empty array.", + "More than 300 ids is refused with 422 before anything is deleted \u2014 nothing partial happens, so just re-batch. Sending exactly the cap is fine.", + "There is no recycle bin for results. Deleting a case's latest result rolls that case's outcome in the run back to its previous attempt (or back to unexecuted if it was the only one); the deleted result itself is unrecoverable." + ], + "disabled": true + }, + { + "name": "bulk_replace_test_case_fields", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Edit every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "automation_status", + "type": "string", + "json_path": "/test_case/automation_status", + "description": "Automation state as an internal_name string (e.g. `not_automated`). Forwarded verbatim on this path \u2014 no name-to-id resolution happens. Prefer `automation_state` with its numeric option id." + }, + { + "name": "case_type", + "type": "integer", + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id \u2014 NOT the display name. Unlike single-case edit, this path always sends it as an id, so a name here is stored as a broken id. Resolve the id from the project's system field values first." + }, + { + "name": "custom_fields", + "type": "object", + "required": true, + "description": "Map of custom-field id to value. ALWAYS SEND THIS KEY \u2014 pass `{}` when changing no custom fields. Omitting it returns 500 (the server dereferences a nil hash), the same defect as on PATCH .../test-cases.", + "json_path": "/test_case/custom_fields" + }, + { + "name": "issues", + "type": "array", + "json_path": "/test_case/issues", + "description": "Linked issues, as `[{issue_id, issue_type}]` (bare strings are coerced to ``issue_id``). Read via a separate permit than the other fields, so it survives even though it is not in the edit allow-list. It is sent on EVERY bulk edit \u2014 as `[]` when you omit it \u2014 so treat it as a full replacement of the issue list, not an append." + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id (not a BrowserStack user id, not an email). An id that does not resolve to a user is not a validation error \u2014 it dereferences nil and returns 500.", + "json_path": "/test_case/owner" + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "json_path": "/test_case/priority", + "description": "Numeric priority option id \u2014 NOT the display name. Resolve it from the project's system field values first." + }, + { + "name": "status", + "type": "integer", + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "tags", + "type": "array", + "description": "REPLACES the entire tag list on every selected case. Read the existing tags and send the union if you mean to add.", + "json_path": "/test_case/tags" + }, + { + "name": "review_status", + "type": "string", + "json_path": "/test_case/review_status", + "description": "Review state applied to every selected case (e.g. `pending`, `in_review`, `approved`). Only meaningful on a Team-Pro workspace with review-approve enabled on the project." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_case/reviewers", + "description": "Reviewer TM user ids. Honoured only on a Team-Pro workspace with review-approve enabled on the project; otherwise dropped without any error." + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state", + "description": "Numeric automation-state option id. Takes precedence over `automation_status`." + }, + { + "name": "template", + "type": "string", + "json_path": "/test_case/template", + "description": "Step-shape template applied to every selected case. Validated against a closed set (`test_case_text`, `test_case_steps`, `bdd`, `test_case_bdd`) and DROPPED WITHOUT ERROR if the value is not one of them \u2014 a template id or a custom template name here is a silent no-op.", + "values": [ + "test_case_text", + "test_case_steps", + "bdd", + "test_case_bdd" + ] + }, + { + "name": "estimate", + "type": "string", + "json_path": "/test_case/estimate", + "description": "Forwarded to the backend on this path (unlike single-case edit, which drops it), but every read serialises `estimate` as null, so you cannot confirm it and should not report it as saved. There is no bulk equivalent of `estimated_duration_seconds`." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Free-text note carried with the bulk edit." + }, + { + "name": "folder_id", + "type": "integer", + "json_path": "/test_case/folder_id", + "description": "DANGER \u2014 inside `test_case` this MOVES every selected case into that folder. The top-level `folder_id` of the same name only scopes the selection. Omit this unless you mean to move them." + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Set the same field values \u2014 owner, priority, status, case type, tags, custom fields, review state \u2014 on many test cases at once from the project-wide search/filter view.", + "returns": [ + "success", + "test_cases", + "info", + "filter_details" + ], + "paginated": true, + "responses": { + "200": { + "description": "Applied synchronously: the refreshed search listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no test_cases.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkReplaceTestCaseFields" + } + }, + "filter_details": { + "type": "object", + "description": "Echo of the resolved filter; {} when no q was sent." + }, + "folders": { + "type": "object" + }, + "workflow": { + "type": "object" } - }, - "TestCase__SearchAllProjects": { - "type": "object", - "description": "TestCase as returned by `search_all_projects`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "display_meta": { - "type": "object", - "properties": { - "created_by": { - "type": "string", - "example": "6013" - }, - "description": { - "type": "string", - "example": "-" - }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 2522 - }, - "name": { - "type": "string", - "example": "ing" - } - } - }, - "template": { - "type": "string", - "example": "test_case_text" - }, - "template_id": { - "type": "string", - "example": "1164" - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 415798 - }, - "name": { - "type": "string", - "example": "Reports Sanity Test Temp" - } - } - } - } - }, - "user_id": { - "type": "integer", - "example": 6013 - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "ALWAYS send `test_case.custom_fields`, `{}` when you are changing none. Omitting the key dereferences a nil hash and returns 500 \u2014 this is the single most common failure on this route.", + "priority, status, case_type and automation_state must be NUMERIC option ids here. The single-case edit accepts a display name for some of them; this path does not, and a name is written through as a broken id rather than rejected.", + "`tags` REPLACES the whole tag list on every selected case. Read the existing tags and send the union if you mean to add. The same is true of `issues`, which is sent as `[]` when you omit it.", + "`test_case.folder_id` moves the cases; the top-level `folder_id` only scopes the selection. Two different meanings for one name in one body.", + "The editable set is narrow and everything outside it is dropped without error: owner, priority, status, case_type, automation_state/status, preconditions, template, review_status, reviewers, comment, custom_fields, tags, issues. `name`, `description`, `expected_result` and `test_case_steps` are NOT bulk-editable on this route.", + "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "Every successful edit writes a new version of each case, so an unwanted bulk edit can be undone case by case by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "bulk_edit_test_cases_in_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false + }, + { + "name": "status_id", + "type": "integer" + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "example": [], + "description": "Attachment blob ids to carry with the result. Declared as a scalar array \u2014 an array of objects is dropped by the permit filter and only reaches the re-architected mark path, so keep it to plain ids." + }, + { + "name": "de_selected_ids", + "type": "array", + "example": [], + "description": "Mapping ids to exclude when `select_all` is true. Optional." + }, + { + "name": "description", + "type": "string", + "example": "

something

", + "description": "HTML comment/note recorded against each result. @-mentions in it are resolved and notify those users." + }, + { + "name": "folder_id", + "type": "integer", + "example": 21, + "description": "Restrict the edit to the run's cases in this folder, and make the response the folder view instead of the whole run listing." + }, + { + "name": "issues", + "type": "array", + "example": [], + "description": "Defect ids to link to each result. Declared as a scalar array, so an array of objects is dropped by the permit filter \u2014 send plain ids. Issue display metadata is enriched asynchronously after the response." + }, + { + "name": "mapping_ids", + "type": "array", + "example": [ + 999, + 991, + 1024, + 1019 + ], + "description": "The run's per-case mapping ids (NOT test case ids and NOT TC-NNN) \u2014 the ids the run's test-case listing returns for each row. Ignored when `select_all` is true." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Apply to every case in the run (or in `folder_id`) that `q` matches, minus `de_selected_ids`. Compared as the string `true`." + }, + { + "name": "status_id", + "type": "integer", + "example": 21, + "description": "Numeric result-status id to mark each selected case with. Take it from the project's result statuses; a status NAME goes in `status` instead." + }, + { + "name": "test_run_ids", + "type": "array", + "example": [ + 1001, + 1002, + 1003 + ], + "description": "Automation BTCER ids the bulk edit applies to. Only read when `automation` is `true`." + }, + { + "name": "test_case_counts", + "type": "array", + "example": [ + 1, + 3, + 2 + ], + "description": "Per-entry case counts aligned positionally with `test_run_ids`. Only read when `automation` is `true`." + }, + { + "name": "status", + "type": "string", + "description": "Result status by DISPLAY NAME. Resolved to a status id server-side when `status_id` is absent; if it cannot be resolved the mark silently keeps the existing status. Prefer `status_id`." + }, + { + "name": "assignee_id", + "type": "integer", + "description": "TM user id to assign every selected execution to. Sending the key with `null` UNASSIGNS \u2014 omitting the key leaves the assignee alone, so null and absent are not the same thing. An id outside the group is rejected." + }, + { + "name": "planned_execution_date", + "type": "string", + "description": "Planned execution date for each selected execution." + }, + { + "name": "custom_fields", + "type": "object", + "description": "Result-level custom field values, keyed by custom field id." + }, + { + "name": "automation", + "type": "boolean", + "description": "Compared as the string `true`. Routes the edit down the automation path, which reads `test_run_ids`/`test_case_counts` instead of the manual selection." + }, + { + "name": "q", + "type": "object", + "description": "Filter that scopes `select_all` within the run (status, assignee, search, \u2026). Read from the request body on this route; array values are flattened to comma-separated strings before the search." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a `folder_id`-scoped selection into its subfolders. Compared as the string `true`." + }, + { + "name": "shared", + "type": "boolean", + "description": "Scope the selection to cases shared in from other projects. Compared as the string `true`." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the listing returned after the edit. Read from the body as well as the query string." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing returned after the edit. Read from the body as well as the query string." + } + ], + "intent": "Mark many of a test run's cases at once \u2014 set their result status, assign them, add a comment, link defects or set result custom fields.", + "returns": [ + "success", + "info", + "test_cases", + "folder", + "ancestors" + ], + "responses": { + "200": { + "$response": "TestRunCasesBulkEditResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "The selection is `mapping_ids` \u2014 the run's per-case mapping ids from the run's test-case listing. These are NOT test case ids and NOT TC-NNN identifiers; passing case ids here silently matches nothing.", + "The whole body is FLAT \u2014 there is no `test_case` wrapper on this route, unlike the project-wide bulk test-case capabilities, which all nest under a `test_case` wrapper.", + "Two ways to name a status: `status_id` (numeric, authoritative) or `status` (display name, resolved server-side). When the name cannot be resolved the mark proceeds and keeps the existing status, so prefer the id.", + "`assignee_id` distinguishes absent from null: send the key with `null` to unassign, omit it to leave the assignee unchanged.", + "`issues` and `attachments` are permitted as arrays of SCALARS. Arrays of objects are dropped before the write on the main path, so send plain ids.", + "Above the async threshold you get 200 `{success:true, async:true, unique_id}` and no listing; below it the 200 is the refreshed run listing (`test_cases` + `info`, plus `folder` and `ancestors` when `folder_id` was sent).", + "This writes execution results, not the test cases themselves \u2014 the cases' own fields are untouched. A result can be overwritten by marking again, but the previous result is not restorable.", + "The run id accepts both `TR-14` and the bare integer on this route, unlike the v2 run endpoints which take TR-NNN only." + ] + }, + { + "name": "bulk_edit_test_cases", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all` \u2014 the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Optional scope hint when editing within one folder." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "With `folder_id`, also include cases in its subfolders." + }, + { + "name": "ids", + "type": "array", + "required": true, + "description": "Integer ids of the cases to edit. One id is fine \u2014 this endpoint is also the cleanest way to add/remove a tag on a single case.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Ids to exclude when `select_all` is true.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Apply to every case in scope, minus `de_selected_ids`.", + "json_path": "/test_case/select_all" + }, + { + "name": "tags", + "type": "string", + "description": "Tag names. Supports `add` / `remove` \u2014 use those rather than `replace` unless the intent really is to discard the existing tags.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/tags" + }, + { + "name": "issues", + "type": "string", + "description": "Linked issues; each value item is `{issue_id, issue_type}`. Supports `add` / `remove`.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "custom_fields", + "type": "object", + "required": true, + "description": "Keyed by custom-field id (numeric string), each entry `{\"value\": \u2026, \"operation\": \u2026}`. Collection-valued custom fields support `add` / `remove`; scalar ones take `replace` / `empty`.\nALWAYS SEND THIS KEY, even when changing no custom fields \u2014 pass `{}`. Omitting it makes the server dereference a nil hash and return 500 (`undefined method 'each' for nil`). The server's own request validator wrongly ", + "json_path": "/test_case/custom_fields" + }, + { + "name": "priority", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/priority" + }, + { + "name": "status", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/status" + }, + { + "name": "case_type", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/case_type" + }, + { + "name": "automation_state", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy internal_name string alias for `automation_state`.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/automation_status" + }, + { + "name": "owner", + "type": "string", + "description": "TM user id (`users[].id` from GET .../users-v2), not browserstack_user_id.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/owner" + }, + { + "name": "preconditions", + "type": "string", + "description": "Text value.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/preconditions" + }, + { + "name": "review_status", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "string", + "description": "Reviewer TM user ids. Only honoured on a Team-Pro workspace with review-approve enabled.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/reviewers" + } + ], + "intent": "Bulk edit test cases with per-field add / remove / replace operations (PREFERRED bulk write).", + "guidance": [ + "Applies one change to many cases at once. Confirm the selection resolves to what the user meant before sending \u2014 there is no per-case confirmation and no undo." + ], + "returns": [ + "async", + "unique_id" + ], + "paginated": true, + "responses": { + "200": { + "description": "Cases updated, or accepted asynchronously above the threshold.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean", + "description": "Present and true when the selection exceeded the sync threshold." + }, + "unique_id": { + "type": "string", + "description": "Async job id \u2014 poll/re-read to confirm (see `async-jobs`)." + }, + "info": { + "$schema": "PageInfo" + }, + "filter_details": { + "type": "object" + }, + "folders": { + "type": "object" + }, + "test_cases": { + "type": "object" + }, + "workflow": { + "type": "object" } - }, - "TestPlanV2__ListTestPlans": { - "type": "object", - "description": "TestPlanV2 as returned by `list_test_plans`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestPlanV2" - } - ], - "properties": { - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - }, - "sub_test_plans": { - "type": "string" - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "bulk_move_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-move", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Move every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "destination_folder_id", + "type": "integer", + "description": "Target folder's integer id, INSIDE `test_case` (bulk-copy puts its destination at top level instead). Falls back to `test_case.folder_id` when omitted; if neither is sent the move is issued with no destination.", + "json_path": "/test_case/destination_folder_id" + }, + { + "name": "folder_id", + "type": "integer", + "description": "Legacy alias for `test_case.destination_folder_id`, used only when that is absent. Note this is the folder the cases move TO \u2014 the identically named TOP-LEVEL `folder_id` is the source scope.", + "json_path": "/test_case/folder_id" + }, + { + "name": "destination_project_id", + "type": "integer", + "description": "Set only for a cross-project move. Must be a JSON INTEGER \u2014 a numeric string is rejected up front with a missing-parameter 400. Sending it also requires a non-empty `ids` or `select_all: true`. Cases shared in from other projects cannot be moved across projects and come back as 422.", + "json_path": "/test_case/destination_project_id" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Move test cases into another folder, or into another project, by id or by filter.", + "paginated": true, + "responses": { + "200": { + "$response": "BulkMoveTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The destination lives INSIDE `test_case` on this route (`test_case.destination_folder_id`) \u2014 bulk-copy puts it at top level. Swapping the two bodies is the usual cause of a move that reports success and changes nothing.", + "The top-level `folder_id` scopes the SOURCE selection; `test_case.folder_id` is a legacy alias for the DESTINATION. Same name, opposite meaning, decided by nesting.", + "`destination_project_id` must be a real JSON integer. A string like \"42\" is rejected before anything runs, as a missing-parameter 400.", + "A cross-project move of shared cases is blocked by the backend: the response is 422 with `type: \"shared_tc_error\"` and a `shared_count`, and nothing moved.", + "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed listing plus `cases_count`, `destination_path_folders` and `source_path_folders`; `moved_cases_count` only appears on a cross-project move. The cases keep their ids and TC-NNN identifiers, so a move can be undone by moving them back." + ], + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "destination_path_folders", + "source_path_folders", + "moved_cases_count" + ] + }, + { + "name": "bulk_restore_archived_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-retrieve", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "Accepted but IGNORED on this route: the archive scope is fixed to `is_archived: true` before the selection is resolved, so a filter here cannot narrow what is un-archived." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Accepted but ignored on this route \u2014 the selection scope is replaced with the archived scope before it is used." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer ids of the ARCHIVED cases to restore. Not required in itself \u2014 send either `ids` or `select_all: true` \u2014 but a selection that resolves to zero cases is a bare 400.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Restore EVERY archived case in the project, minus `de_selected_ids`. The scope is the whole archive \u2014 `q` and `folder_id` do not narrow it here. Compared as the string `true`.", + "json_path": "/test_case/select_all" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + } + ], + "intent": "Restore archived test cases back into their folders \u2014 the undo for a bulk archive.", + "returns": [ + "success", + "folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkRetrieveTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the un-archive, not a read. It takes ARCHIVED case ids: list them with the archived test cases call first, because a live case id here simply resolves to nothing.", + "The scope is forced to the archive before your selection is resolved, so `q` and `folder_id` are accepted and then ignored. `select_all: true` therefore means the WHOLE archive, not the filtered subset you may think you asked for.", + "Plan-gated: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and does nothing. Check `success`, not the status code.", + "The 200 body is `{success:true, folders:{...}}` \u2014 folder case counts, NOT the restored test cases. Nothing in the response names what was restored; re-read the folders to confirm.", + "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input." + ], + "disabled": true + }, + { + "name": "bulk_restore_archived_test_plans", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/bulk-retrieve", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_plan_ids", + "type": "array", + "required": true, + "minItems": 1, + "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids \u2014 the first one present wins. Entries are coerced with to_i and anything yielding 0 is silently dropped, so a TP-NNN identifier is discarded without an error." + } + ], + "intent": "Restore archived test plans in bulk \u2014 puts them back into the normal plan listing; this is the undo for a bulk archive", + "returns": [ + "success", + "async", + "unique_id", + "plans", + "count" + ], + "responses": { + "200": { + "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` \u2014 the plans are not restored yet.\n", + "schema": { + "$schema": "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans" + } + }, + "400": { + "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "ResultStatus__SearchTestResults": { - "type": "object", - "description": "ResultStatus as returned by `search_test_results`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "valueDetails": { - "type": "string", - "nullable": true - }, - "value_category": { - "type": "string", - "example": "completed" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "TestPlanV2Response__UpdateTestPlan": { - "type": "object", - "description": "TestPlanV2Response as returned by `update_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestPlanV2Response" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "urls": { - "type": "object", - "properties": { - "sub_test_plans": { - "type": "string" - }, - "self": { - "type": "string", - "example": "https://test-management.browserstack.com/projects/1234/test-plans/48283" - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 1 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - } - } - } + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Find the ids to restore with the archived-plans listing \u2014 archived plans do not appear in the normal plan list at all.", + "Same group feature flag as archive: when it is off this returns 404 with {success: false, errors: 'Feature not available'}, which is a feature gap, not a missing plan.", + "Permission is the test-plan DELETE right, not write.", + "More than 30 ids goes async and returns {success: true, async: true, unique_id} \u2014 nothing is restored yet. Re-read the archived listing to confirm.", + "Ids are coerced with to_i and zeroes dropped; if every id drops you get 400 {success: false, errors: 'Please provide test plan ids'}.", + "Failures use the plural key `errors`.", + "Restoring does not undo a delete. Deleted plans are gone; only archived ones can come back this way." + ] + }, + { + "name": "bulk_set_test_result_status", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "minLength": 1, + "example": "Untested", + "description": "The single status every targeted case is moved to, by NAME as configured for the project. Matched case-insensitively against the configured internal name or display value. \"Reset\" is not a special mode \u2014 it is just naming an untested-style status." + }, + { + "name": "test_case_ids", + "type": "array", + "description": "Test case identifiers to target, e.g. [\"TC-101\",\"TC-102\"]. OMITTING this (or sending an empty array) targets EVERY test case in the run. Identifiers not in the run are skipped silently." + } + ], + "intent": "Move many test cases in a test run to one status in a single call \u2014 the bulk 'mark these passed' or 'reset the run to untested' action, which replaces each case's current outcome instead of adding a new one", + "guidance": [ + "NO UNDO. Each targeted case's current outcome is superseded in place by the status you name; the status that was there is not kept as a separate history entry and cannot be restored. Recovering means knowing the old status per case and setting it back by hand.", + "Omitting `test_case_ids` (or sending []) applies the status to EVERY case in the run. There is no cap, no dry run and no confirmation step on the server \u2014 send an explicit list unless you really mean the whole run.", + "`status` must be a name already configured for the project; an unconfigured name is rejected 400 before anything is queued. It is matched case-insensitively against either the configured internal name or the display value, so 'passed' and 'Passed' both resolve. Resolve the project's configured result statuses first.", + "Targets are resolved to test cases, not to cells. A case that appears in the run more than once \u2014 several configurations, or a duplicated mapping \u2014 is targeted once, and only one of its cells is moved; there is no configuration or mapping parameter to pick the other. On a multi-configuration run, read the run's cases back and expect to fix the stragglers individually.", + "Fire-and-forget: HTTP 200 {success: true} only means the job was queued. Identifiers that are not in the run are skipped with no error, and any failure inside the worker never reaches the caller \u2014 read results back to confirm.", + "No unrecognised keys are allowed in the body \u2014 `status` and `test_case_ids` only.", + "This only moves status. It records no description, defects, step results or attachments, and it deletes nothing \u2014 to log a real execution with detail, use create_test_results_for_test_run.", + "Requires the execute permission on the run; a caller without it gets 403 before anything is queued." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "V2BulkResultStatusAcceptedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "description": "The job could not be queued; nothing was applied.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + } + } + } + } + } + }, + { + "name": "bulk_update_test_run_test_cases", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "add_test_cases", + "type": "array", + "json_path": "/test_run/add_test_cases", + "fields": [ + { + "name": "test_case_ids", + "type": "array", + "description": "Non-empty list of TC-NNN strings. Must be strings \u2014 integers are rejected." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." + }, + { + "name": "source_project", + "type": "string", + "description": "Origin project identifier (PR-NNN) for shared cases \u2014 the readable alternative to source_project_id." + }, + { + "name": "configuration_ids", + "type": "array", + "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." + } + ], + "minItems": 1, + "description": "Case+configuration entries to add to the run. Send this OR remove_test_cases, never both." + }, + { + "name": "remove_test_cases", + "type": "array", + "json_path": "/test_run/remove_test_cases", + "fields": [ + { + "name": "test_case_ids", + "type": "array", + "description": "Non-empty list of TC-NNN strings. Must be strings \u2014 integers are rejected." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." + }, + { + "name": "source_project", + "type": "string", + "description": "Origin project identifier (PR-NNN) for shared cases \u2014 the readable alternative to source_project_id." + }, + { + "name": "configuration_ids", + "type": "array", + "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." + } + ], + "minItems": 1, + "description": "Case+configuration entries to take out of the run. Send this OR add_test_cases, never both. Removal deletes the recorded results for those mappings unless preserve_existing_results is true." + }, + { + "name": "preserve_existing_results", + "type": "boolean", + "default": false, + "json_path": "/test_run/preserve_existing_results", + "description": "Keep the results already recorded against the cases being removed. Defaults to FALSE, i.e. the results are deleted with the mapping. Must be a real boolean (or \"true\"/\"false\"); anything else is a 400." + } + ], + "intent": "Change which test cases a run covers \u2014 bulk-add cases to it, or bulk-remove them, optionally per configuration", + "guidance": [ + "Everything goes inside a `test_run` object, and exactly one of add_test_cases / remove_test_cases may be present and non-empty \u2014 both, or neither, is a 400.", + "Always asynchronous. A 202 {success, async, unique_id} means only that the job was queued; nothing has changed yet. There is no status endpoint for unique_id over HTTP (completion is announced on a websocket channel), so confirm by re-reading the run's test cases.", + "Removing a case DISCARDS its recorded results unless you send preserve_existing_results: true. Runs keep no version history, so those results cannot be restored \u2014 ask before removing cases from a run that has been executed.", + "At most 300 distinct test_case_ids per request, counted across all entries; a duplicate id within or across entries is a 400. Split larger changes into batches.", + "Only the shape is validated before the 202 \u2014 an id that does not exist, or is not shared with this project, fails inside the worker and is not reported back on this response.", + "Cross-project (shared) cases need source_project or source_project_id on the entry; supplying both is a 400.", + "This is the only call that changes a run's cases INCREMENTALLY, and the one to use to add cases: add_test_cases adds only the listed cases and leaves every existing case, its run row id and its assignee untouched. The run-update calls (edit_test_run, update_test_run, replace_test_run) do NOT union \u2014 the case list they are sent replaces the run's membership, silently dropping any case left out along with its results." + ], + "returns": [ + "success", + "async", + "unique_id" + ], + "responses": { + "202": { + "description": "Bulk change accepted and queued. Body is {success: true, async: true, unique_id} \u2014 the run is unchanged at this point.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" + } + } + } + }, + "400": { + "description": "Synchronous validation failure. Body is {success: false, error, message}, where error is one of operation_required, only_one_operation_allowed, invalid_preserve_flag, invalid_entry_shape, invalid_test_case_id, invalid_configuration_ids, duplicate_test_case_id, max_test_cases_per_request_exceeded, validation_failed.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "clone_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/clone", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true + } + ], + "intent": "Copy an existing exploratory session \u2014 its description, timebox, configurations and custom fields, plus its log entries, and optionally its tags, linked issues and owner \u2014 to re-run the same exploration", + "responses": { + "201": { + "$response": "ExploratorySessionCloneSyncResponse" + }, + "202": { + "$response": "ExploratorySessionCloneAsyncResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The clone body is FLAT \u2014 it is not wrapped in an `exploratory_session` object, unlike create and update on this entity.", + "There are two completely different success shapes and you must branch on the source session's size. Fewer than 50 logs: 201 with {success, async:false, unique_id:null, exploratory_session:{...}} and the copy fully built. 50 logs or more: 202 with {success, async:true, unique_id} and NO session object \u2014 the copy exists but you are not told its id.", + "Read session_log_count on the source session first to know which branch you will get. If it is 50 or more, plan to find the new session by listing sessions afterwards (its default title is ' - Copy').", + "unique_id is a realtime broadcast channel name, not a job id \u2014 there is no endpoint that reports the clone's progress or result. The only way to confirm an async clone is to re-read the sessions list.", + "The charter and the folder are NOT copied. The clone lands at the project root with an empty charter even though description, timebox, configurations and custom field values are copied. Set them with an update afterwards if they matter.", + "copy_tags, copy_issues and copy_session_owner all default to FALSE, so a plain clone arrives with no tags, no linked issues and nobody assigned. copy_issues also controls whether each copied log keeps its own linked defects.", + "The clone is always created in the active state, even when the source session is closed.", + "The three copy_* flags must be real JSON booleans \u2014 the request schema rejects the strings \"true\"/\"false\" with 400.", + "If the asynchronous log copy fails, the newly created session is deleted again, so a failed clone leaves nothing behind \u2014 and nothing over this API tells you it happened.", + "The source session is read before anything is created, so an unknown session_id fails as a bare {success:false} 404 with no session created." + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Checkout flow exploration - rerun", + "description": "Title for the new session. Sent at the top level of the body, not inside an exploratory_session wrapper. Defaults to ' - Copy' when omitted \u2014 which is also how you find the copy after an async clone.", + "json_path": "/name" + }, + { + "name": "copy_tags", + "type": "boolean", + "default": false, + "description": "Copy the source session's tags. Must be a real JSON boolean; the string \"true\" is rejected with 400. Defaults to false, so the clone is untagged unless you ask.", + "json_path": "/copy_tags" + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false, + "description": "Copy the linked issues \u2014 both the session's own issue links AND the defects on every copied log entry. Must be a real JSON boolean. Defaults to false.", + "json_path": "/copy_issues" + }, + { + "name": "copy_session_owner", + "type": "boolean", + "default": false, + "description": "Keep the source session's assignee. Must be a real JSON boolean. Defaults to false, which leaves the clone unassigned.", + "json_path": "/copy_session_owner" + } + ], + "returns": [ + "success", + "async", + "unique_id", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "session_state", + "timebox_duration", + "session_log_count", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "created_at", + "updated_at" + ] + }, + { + "name": "clone_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/clone", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/test_plan/name", + "description": "Name of the NEW plan. Required \u2014 there is no server-generated copy name. Omit it (or send blank) and the call is 400 {success: false, message: 'Name field cannot be blank'}." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Description of the new plan. Sanitised against the rich-text allowlist server-side, so the stored value can differ from what you sent. Not copied from the source plan \u2014 omit it and the clone has an empty description." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "json_path": "/test_plan/start_date", + "description": "ISO-8601 date for the new plan. Not inherited from the source." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "json_path": "/test_plan/end_date", + "description": "ISO-8601 date for the new plan. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and clones nothing." + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_plan/owner", + "description": "Owner as the internal TM user id (the `id` from the project users read, NOT a BrowserStack user id or an email). Omitted means the caller. An id that does not resolve inside the caller's group is swallowed rather than rejected and the clone ends up with no real owner." + }, + { + "name": "tags", + "type": "array", + "json_path": "/test_plan/tags", + "description": "Plain tag strings for the new plan. If ANY entry is not a string the whole list is silently replaced with [] \u2014 no error." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_plan/reviewers", + "description": "Reviewer user ids for the new plan. OMIT the key to copy the source plan's reviewers; send [] to clone with no reviewers. Validated against the project's allowed-reviewer list." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_plan/attachments", + "description": "Attachment ids already uploaded via the attachments surface." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_plan/custom_fields", + "description": "Custom-field values for the new plan, keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: } before any cloning starts." + }, + { + "name": "parent_plan_id", + "type": "integer", + "json_path": "/test_plan/parent_plan_id", + "description": "Set it to make the clone a SUB-plan of that parent (integer id). Omitted/null gives a top-level plan. This is fixed at clone time \u2014 a plan cannot be promoted or demoted later." + }, + { + "name": "copy_all_runs", + "type": "boolean", + "default": false, + "json_path": "/test_plan/copy_all_runs", + "description": "Copy every test run linked to the source plan. When true, test_runs is not consulted." + }, + { + "name": "test_runs", + "type": "array", + "json_path": "/test_plan/test_runs", + "description": "Clone only these runs of the source plan, as run ids. Ignored when copy_all_runs is true; send neither and the clone is created with no runs." + }, + { + "name": "copy_all_sub_plans", + "type": "boolean", + "default": false, + "json_path": "/test_plan/copy_all_sub_plans", + "description": "Clone every sub-plan of the source plan under the new plan." + }, + { + "name": "sub_plan_ids", + "type": "array", + "json_path": "/test_plan/sub_plan_ids", + "description": "Clone only these sub-plans (integer ids). Ignored when copy_all_sub_plans is true." + }, + { + "name": "status_ids", + "type": "array", + "json_path": "/test_plan/status_ids", + "description": "Run-level filter: keep only source runs that contain at least one test case in one of these result-status ids. Entries must be integers \u2014 anything else is 400 {success: false, error: 'status_ids must be a list of integers'}. It filters RUNS, not cases: every case inside a kept run is copied." + } + ], + "intent": "Copy a test plan into a brand-new plan, bringing its test runs (and optionally its sub-plans) with it \u2014 the copy is built in the background, so this hands back a job id rather than the new plan", + "responses": { + "200": { + "$response": "TestPlanCloneAcceptedResponse" + }, + "400": { + "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`), non-integer status_ids, or a rejected custom field (`{success: false, error: ...}`). Also used for a body with no `test_plan` wrapper.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is ASYNC. A 200 means the clone job was queued, not that the plan exists \u2014 the body is {success: true, unique_id} and carries no plan id, no identifier and no name. The source plan is untouched either way.", + "unique_id is only a correlation id for logs; there is no job-status capability for it in this profile. To find the new plan, poll the project's plan listing for the name you sent.", + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own `test_plan` object is rejected before the call as an unknown body key.", + "name is REQUIRED even though this is a clone. There is no automatic 'Copy of ...' name.", + "Nothing except the runs/sub-plans is inherited: description, dates, tags, owner and custom fields all come from THIS request, so a clone sent with only a name produces an otherwise empty plan.", + "Choose the run scope explicitly \u2014 copy_all_runs: true, or an explicit test_runs list. With neither, the job creates the plan with no runs at all and still returns success.", + "Copied runs come over whole: the job forces copy_all_cases, and keeps each case's assignee, issues and tags. status_ids narrows which RUNS are copied, never which cases inside them.", + "Date validation is a silent trap: end_date before start_date returns HTTP 200 with {success: false, error: 'Please check date params'}. Check the `success` field, not the status code.", + "Needs the test-plan write permission." + ], + "returns": [ + "success", + "unique_id" + ] + }, + { + "name": "clone_test_run_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/clone", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Cloned Test Run - 2025", + "description": "Name for the new run. Leave it out and the run is auto-named, which is how projects end up with several identically named clones." + }, + { + "name": "copy_all_cases", + "type": "boolean", + "default": false, + "description": "Copy every case from the source run. WITHOUT this (and without status_id) the clone is created EMPTY \u2014 the default is a bare run, not a copy of the case list." + }, + { + "name": "status_id", + "type": "array", + "example": [ + 1, + 2 + ], + "description": "Copy only the cases whose latest result is one of these numeric status ids (resolve them via the project's result-status field values). Numeric ids only \u2014 this is the filter that is honoured on every backend flow." + }, + { + "name": "status", + "type": "array", + "example": [ + "passed", + "failed" + ], + "description": "Status NAMES to filter the copied cases by. Honoured only on the legacy backend flow; on the current (build-backed) flow it is dropped and a clone whose only case selector was `status` copies nothing and still returns 200. Send status_id instead." + }, + { + "name": "copy_tc_assignee", + "type": "boolean", + "default": false, + "description": "Carry each copied case's assignee across. Only meaningful together with copy_all_cases / status_id \u2014 it applies to the copied rows, not to the run." + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false, + "description": "Carry the linked defects across onto the copied case rows." + }, + { + "name": "copy_tags", + "type": "boolean", + "default": false, + "description": "Copy the source run's tags onto the new run." + }, + { + "name": "source_build_id", + "type": "integer", + "description": "Optional explicit source build to copy cases from, for callers that already resolved it. Normally omitted \u2014 it is looked up from the source run." + } + ], + "intent": "Copy an existing test run into a new one \u2014 optionally carrying over its cases, their assignees, tags and linked defects \u2014 to re-execute the same scope without rebuilding the selection", + "returns": [ + "data", + "success", + "test_run", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "attachments", + "metadata", + "project_id", + "created_at", + "updated_at", + "created_by", + "observability_url", + "links" + ], + "responses": { + "200": { + "$response": "TestRunResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A clone with no case selector produces an EMPTY run. Send copy_all_cases:true, or status_id, or you get a 200 and a run with zero cases \u2014 this is the single most common surprise on this endpoint.", + "Filter the copied cases by status_id (numeric), not by `status` (names). The name form is only read on the legacy backend flow; on the current one it is ignored and the clone silently comes back empty.", + "The clone always starts in run_state `new_run` regardless of the source run's state, and its description is always copied. Tags, defects and per-case assignees are copied only when you ask. Configurations, the owner and the dynamic-filter rule are NOT carried over \u2014 set them afterwards with the edit call.", + "Cloning a run created before the current ingest flow, while asking for its cases, is refused with 422 'The source run has no test cases available to clone. Retry without case selection.' Retrying without copy_all_cases/status_id gives you an empty clone of the header.", + "Population is not synchronous on every path: the run exists as soon as the call returns, but re-read its cases rather than trusting an immediate empty list, and never retry the clone \u2014 that makes a second run.", + "Take `uuid` (the integer) from the response, not `id` \u2014 `id` is the TR-NNN string here as everywhere else on runs.", + "The new run comes back under `data.test_run` (with an underscore). The write endpoints on this entity \u2014 create, edit, assign, close, delete \u2014 use `data.testrun` instead. Do not reuse one reader for both.", + "Needs the test-run write permission; cloning an automation run demands the automation variant of it and 403s for a role that only holds the manual one." + ] + }, + { + "name": "clone_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/clone", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "test_case_selection", + "type": "string", + "values": [ + "all", + "by_results" + ], + "default": "all", + "description": "Which cases of the source run the copy gets: every case, or only those whose latest result matches `statuses`. Any other value is a 400." + }, + { + "name": "statuses", + "type": "array", + "fields": [ + { + "name": "status_name", + "type": "string" + } + ], + "example": [ + "failed", + "blocked" + ], + "description": "Result status NAMES (case-insensitive) \u2014 not ids. Required and non-empty when test_case_selection is \"by_results\"; an unknown name is a 400 naming it. Defaults plus any custom status configured for the group are accepted." + }, + { + "name": "copy_tc_assignee", + "type": "boolean", + "default": true, + "description": "Carry per-case assignees over. Defaults to TRUE here (the v1 clone defaults it to false)." + }, + { + "name": "copy_tags", + "type": "boolean", + "default": true, + "description": "Carry the source run's tags over. Defaults to TRUE here (the v1 clone defaults it to false)." + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false + }, + { + "name": "name", + "type": "string", + "example": "Sprint 24 - Regression Re-Run", + "description": "Name for the new run. Omitted or blank gives \" - Copy\"." + } + ], + "intent": "Copy an existing run into a brand-new run \u2014 the usual way to re-execute just the failed or blocked cases from the last cycle", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Body is flat \u2014 these fields sit at the top level, not inside a `test_run` object like the update calls.", + "Identity is synchronous, data is not: the response carries the new run as soon as it exists, but its case mappings are filled in by a background worker. Reading the new run's test cases immediately can legitimately return zero rows \u2014 retry before concluding the clone was empty.", + "A 202 (instead of 200) means the run was cloned and one bookkeeping leg is still settling: do NOT retry, or you get a second clone.", + "Test plan / sub-plan links are NOT carried over; link the copy afterwards if you need it under a plan.", + "A dynamic source run passes its is_dynamic flag on, but the filter is not re-evaluated, so the copy starts from the source's case list.", + "Cloning an automation run is blocked by the write-permission gate (403) \u2014 this path is for manual runs.", + "The copy is a new run, so nothing about the source changes; discard it by deleting the copy.", + "The clone comes back at the top level as `test_run`, not under `data`. A 202 means the copy is still populating.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunV2Response" + }, + "202": { + "description": "Run cloned, background bookkeeping still in progress. Body is {success: true, build_pending: true, message, test_run}. Treat as success; retrying would create a duplicate run.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "build_pending": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "test_run": { + "$schema": "TestRun" + } + } + } + }, + "400": { + "description": "Clone options failed validation \u2014 unknown test_case_selection, missing statuses for \"by_results\", or an unknown status name. Body is {success: false, message}.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" } - }, - "PathFolder__CreateBulkTestCases": { - "type": "object", - "description": "PathFolder as returned by `create_bulk_test_cases`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PathFolder" - } - ], - "properties": { - "cases_count": { - "type": "integer", - "example": 51 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 13 - }, - "total_cases_count": { - "type": "integer", - "example": 52 - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "close_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/close", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Mark an exploratory testing session finished so it leaves the active listing and is archived as completed work", + "responses": { + "200": { + "description": "Exploratory session closed successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This call takes no body at all. The handler's actual_duration pass-through is commented out, so posting a duration here returns 200 and changes nothing. Write actual_duration with the session update call BEFORE closing.", + "Closing is one-way through the API: nothing accepts session_state, and there is no reopen endpoint. Treat it as irreversible even though it is not classed destructive.", + "After closing, the session disappears from the sessions listing unless you pass q[session_state]=closed \u2014 the listing defaults to active.", + "Closing also changes who may delete the session afterwards: deletion of a closed session is gated by the closed-test-run delete permission rather than the ordinary one, so a caller who could have deleted it a moment earlier may now get 403.", + "The response is only {success:true}. Re-read the session to see session_state, closed_at and closed_by.", + "Closing an already-closed session is not rejected here; whatever the store does with it surfaces, if at all, as the generic 400 {success:false, message:'The API request is invalid or improperly formed'}.", + "A session id that does not exist returns a bare {success:false} with 404 and no message." + ], + "returns": [ + "success" + ] + }, + { + "name": "close_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/close", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "intent": "Close a test run \u2014 freeze its results, mark the cycle finished and drop it out of the default run listing", + "returns": [ + "data", + "success", + "testrun", + "project", + "skipped_test_case_ids", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__CloseTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No body at all. This is a fixed transition to run_state `closed`; anything you send is ignored.", + "Closing is final. A snapshot of every result is taken and the run's linked build in Test Observability is closed too, and nothing sets the run back to an active state afterwards \u2014 confirm with the user before closing a run that may need more testing.", + "A closed run disappears from the default project run listing \u2014 it is only visible through the closed-runs listing or a listing that explicitly asks for it. Deleting it afterwards also demands a different permission from deleting an open run.", + "A closed run cannot be reopened. The partial run update answers 404 'Test Run ID is invalid' for a closed run, and the full edit returns success while leaving it closed \u2014 do not report either as a reopen. To keep testing, clone the run (clone_test_run with copy_all_cases) and add cases to the copy; re-link it to its plan afterwards, since the clone does not carry the plan over.", + "The run comes back under `data.testrun` \u2014 one word, no underscore \u2014 alongside `data.project`. The read endpoints use `data.test_run`. The 200 also carries a TOP-LEVEL `skipped_test_case_ids` array (a sibling of `data`, not inside it).", + "Client errors from the backing service come back as `{data: {success: false, error: }}` with the 4xx status, not as the flat error envelope other endpoints use \u2014 read `data.success` before trusting `data.testrun`.", + "Needs the test-run write permission; an automation run demands the automation variant and 403s otherwise." + ] + }, + { + "name": "copy_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folders/copy", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "source_folder_id", + "type": "integer", + "required": true, + "example": 2, + "description": "Integer id of the folder to copy. Must exist in the project named in the path. Blank/absent returns 400 {error:'source_folder_id is required'}." + }, + { + "name": "destination_folder_id", + "type": "integer", + "example": 3, + "description": "Parent folder the copy is placed under, in destination_project_id. NOT required: omitting it (or sending null) is what switches the call into copy-to-root mode, exactly as copy_to_root:true does." + }, + { + "name": "destination_project_id", + "type": "integer", + "example": 10000, + "description": "Destination project. NOT required \u2014 defaults to the project in the path. Cross-project copies are allowed here (unlike cross-project moves, which are blocked when the folder has shared test cases)." + }, + { + "name": "copy_to_root", + "type": "boolean", + "default": false, + "description": "Place the copy at the destination project's root instead of under a parent folder. Accepts the boolean true or the literal string \"true\". It is also forced on whenever destination_folder_id is absent." + } + ], + "intent": "Duplicate a folder \u2014 its whole subtree of subfolders and every test case in them \u2014 into another folder, or to the root of any project you can reach; always runs as a background job", + "returns": [ + "success", + "async", + "unique_id", + "message" + ], + "responses": { + "202": { + "$response": "FolderCopyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + } + }, + "guidance": [ + "This endpoint only queues work. It answers 202 Accepted (not 200) with {success:true, async:true, unique_id, message} the moment validation passes \u2014 nothing has been copied yet when you get the response.", + "There is no way to poll the job over HTTP. `unique_id` is just the X-Request-ID header echoed back (a fresh UUID if you sent none), and progress is pushed over a websocket channel the API does not expose. Confirm the copy by re-reading the destination folder's contents after a delay.", + "`async` and `copy_test_cases` are not read anywhere in the handler or the worker. The copy is always asynchronous, and it always copies the full subtree \u2014 every nested subfolder and every test case. You cannot ask for a folders-only copy.", + "Name clashes are resolved silently, not rejected: an existing folder of the same name in the destination makes the copy land as `copy-of-`, then `copy-of-2-`, `copy-of-3-` and so on. Match on the returned tree, not on the name you sent.", + "Copying a folder into itself or into one of its own descendants is refused with 400 {error:'Cannot copy folder into itself or its subfolder'} \u2014 but only for same-project copies that name a destination folder. Copy-to-root and cross-project copies skip that check entirely.", + "Missing source or destination is 404 with a bare {error:'...'} and no `success` key \u2014 this endpoint does not use the usual {success:false} envelope for that case.", + "If the worker fails partway it rolls the whole copy back, so a failed run should leave no half-copied tree \u2014 but you only learn that by re-reading, not from this response.", + "Requires the folder-create permission on the project in the path." + ] + }, + { + "name": "count_run_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/selection-count", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "select_all", + "type": "boolean", + "required": true, + "example": false, + "description": "Select every case in the project. Marked required because the request body must contain a `selection` object at all \u2014 sending only `configurations` is rejected 400 before anything is counted, and this is the cheapest field that materialises `selection`. Pass false alongside `folders` for a folder-scoped count.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 2, + "description": "The caller's own count of distinct cases in the selection. Echoed through; it does not constrain the answer.", + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. Case ids must be integers \u2014 strings are rejected by the schema.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Map of project ids to their shared test-case selection, same inner shape as `selection`. Top level, NOT inside `selection`." + }, + { + "name": "configurations", + "type": "array", + "required": true, + "example": [], + "description": "Configuration ids (integers). Required by the schema \u2014 send an empty array when there are none; omitting the key is a 400." + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "description": "Per-configuration case mappings. An ARRAY of objects \u2014 an object here is rejected. Unknown keys inside an entry are rejected too.", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Ask how many test cases a folder/case selection will actually resolve to \u2014 including the multiplication by configurations \u2014 before committing it to a run create or edit", + "shape": "discovered", + "responses": { + "200": { + "description": "The number of test-case x configuration rows the selection resolves to.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "count": { + "type": "integer", + "example": 166 } - }, - "TestCase__CreateBulkTestCases": { + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is schema-validated with unknown top-level keys REJECTED. Only `selection`, `shared_selection`, `configurations` and `trtc_configuration_mappings` are accepted; anything else \u2014 including run fields like `name` or `filters` \u2014 returns 400 {error: 'Invalid JSON data', details: [...]} before any counting happens.", + "`selection` and `configurations` are both mandatory. A body with a selection but no `configurations` key is refused; pass `configurations: []` when the run has none.", + "The answer is the number of case-times-configuration rows the run would get, not the number of distinct test cases \u2014 with two configurations selected the count is roughly double `unique_test_case_count`. That is the point of the call.", + "Read-only and side-effect free: it resolves the selection but creates nothing. Use it before a create or edit when the user needs to see the size of what they are about to commit.", + "The body must be sent as application/json; a form-encoded body skips the schema check entirely and then fails further in with a less useful error.", + "The response is flat \u2014 {success, count} with no `data` envelope." + ], + "returns": [ + "success", + "count" + ] + }, + { + "name": "create_bulk_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_cases", + "type": "array", + "required": true, + "description": "One UNWRAPPED object per case (no per-item `test_case` key), max 10 on the synchronous path. Several fields are read from the FIRST item only and applied to all \u2014 see guidance. Send `template` and `test_case_steps` on every item.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body as well as in the path; the path folder is not substituted in." + }, + { + "name": "attachments", + "type": "array", + "description": "Blob ids. Taken from the FIRST item only and copied onto EVERY case in the batch. A blob belonging to another group fails the whole request with 400." + }, + { + "name": "automation_state", + "type": "integer", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Use `automation_status` here, or edit the cases afterwards." + }, + { + "name": "automation_status", + "type": "string" + }, + { + "name": "case_type", + "type": "integer" + }, + { + "name": "custom_fields", "type": "object", - "description": "TestCase as returned by `create_bulk_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } + "description": "Taken from the FIRST item only and applied to EVERY case in the batch. Per-item custom field values are silently discarded \u2014 create cases with differing custom fields one at a time, or edit them afterwards." + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted by the allow-list and then never written; every read serialises `estimate` as null. Do not report an estimate as saved." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Set it with an edit after creation." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean" + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer" + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer" + }, + { + "name": "review_status", + "type": "string", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. The created cases take the project's default review state." + }, + { + "name": "reviewers", + "type": "array", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Set reviewers with an edit after creation." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list." + }, + { + "name": "status", + "type": "integer" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Also decides, from the FIRST item only, which system template id every case in the batch falls back to (`test_case_steps` when the first item omits it) \u2014 so send it on every item, and keep the batch homogeneous." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string", + "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Use `template`." + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is dropped and the case is created with no steps, still 200." + }, + { + "name": "metadata", + "type": "string", + "description": "JSON-encoded string of opaque metadata (this is where an AI prompt reference rides). Unparseable JSON degrades to `{}` rather than erroring." + } + ] + }, + { + "name": "unique_id", + "type": "string", + "description": "Client-supplied idempotency key. When present AND the group has the async bulk-create flag on, the request is queued instead: 202 with the echoed `unique_id`, the \u226410 item limit is replaced by a 500-item cap, and completion arrives over the WebSocket channel. Without the flag it is inert." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Create (or reuse) a subfolder named `scenario_name` under the path folder and put every case in the batch there instead. Requires `scenario_name`. If the batch then fails, a subfolder created for it is deleted again." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is set." + } + ], + "intent": "Create several test cases in one folder in a single call (up to 10) \u2014 the call to use whenever you have more than one case to add.", + "returns": [ + "success", + "data", + "test_cases", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "description": "Test cases created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__CreateBulkTestCases" + } }, - "folder": { - "type": "object", - "properties": { - "cases_count": { - "type": "integer", - "example": 51 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } - } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 13 - }, - "total_cases_count": { - "type": "integer", - "example": 52 - } - } + "path_folders": { + "type": "array", + "items": { + "$schema": "PathFolder__CreateBulkTestCases" + } }, - "test_case_steps": { - "$schema": "TestCaseStep__CreateBulkTestCases" + "request_trace_id": { + "type": "string" } + } } - }, - "TestCaseStep__CreateBulkTestCases": { - "type": "object", - "description": "TestCaseStep as returned by `create_bulk_test_cases`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "hashed_id": { - "type": "string", - "example": "dcf9955bb4c3de3da47a04212b67ee4281dbcfa5" - }, - "record_status": { - "type": "string", - "example": "active" - } + } + } + }, + "202": { + "description": "Accepted for asynchronous processing (feature-flag gated; requires unique_id). Completion is broadcast on common_channel_ with operation bulk_create and the echoed unique_id.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" + }, + "scenario_name": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "message": { + "type": "string" } - }, - "TestResult__CreateStepResult": { - "type": "object", - "description": "TestResult as returned by `create_step_result`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestResult" - } - ], - "properties": { - "author": { - "$schema": "User__CreateStepResult" - }, - "result_status": { - "$schema": "ResultStatus__CreateStepResult" - }, - "test_run_step_result": { - "type": "object", - "nullable": true, - "properties": { - "btcer_id": { - "type": "integer", - "example": 2150428297 - }, - "colour": { - "type": "string", - "example": "#059669" - }, - "field_value": { - "type": "string", - "example": "Passed" - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "App opens successfully" - }, - "status_id": { - "type": "integer", - "example": 318610 - }, - "step": { - "type": "string", - "example": "Open the app" - }, - "step_mapping_id": { - "type": "integer", - "example": 12372581 - }, - "test_data": { - "type": "string", - "nullable": true - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Items are UNWRAPPED \u2014 `test_cases` is a plain array of case objects with no per-item `test_case` key. A non-array, or more than 10 items, is a 400 naming the problem.", + "Not everything single-create accepts survives here: `automation_state`, `review_status`, `reviewers`, `send_for_review`, `shared_precondition_id`, `template_step_type` and `estimated_duration_seconds` are all dropped by this route's allow-list, without error. Create then edit if you need them.", + "`custom_fields` and `attachments` are read from the FIRST item and applied to all of them. A batch where cases differ in custom field values will silently come out wrong \u2014 create those one at a time.", + "`test_case_folder_id` is required inside each item even though the folder is already in the path; omitting it is the usual cause of a 422 wrapping an upstream 404.", + "Steps only come from `test_case_steps`. A `steps` key is dropped, and the cases are still created \u2014 with no steps and a 200 \u2014 so verify against the response.", + "The created cases are at `data.test_cases`, with `data.path_folders` and a `data.request_trace_id` for the AI enrichment that runs afterwards. Take each new case's integer `id` and TC-NNN `identifier` from there.", + "With `unique_id` and the async flag enabled, you instead get 202 `{success, async, unique_id, count}` and NO cases \u2014 the work finishes on a WebSocket channel you cannot read through this API, so poll the folder listing instead.", + "Any unexpected failure inside this route is flattened to 500 `{success:false, message:\"Failed to create test cases.\"}` \u2014 that message covers backend rejections too, so do not read it as an outage." + ] + }, + { + "name": "create_configuration", + "method": "POST", + "path": "/api/v1/projects/{project_id}/configurations", + "mode": "write", + "entity": "configuration", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Create a named configuration \u2014 an OS / browser / device combination that test runs track their per-case results against", + "returns": [ + "success", + "configuration", + "id", + "name", + "group_id", + "record_status", + "is_suggested", + "created_at" + ], + "responses": { + "200": { + "$response": "ConfigurationCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Display name of the configuration, e.g. \"Chrome 120 on Windows 11\". This is the ONLY field the handler reads \u2014 os, os_version, browser, device and every other attribute the read returns cannot be set here and are silently ignored if sent." + } + ], + "guidance": [ + "`name` is the only input. The structured attributes (os, browser, device, versions) that come back on the listing cannot be set through this call; anything else you put in the body is dropped without an error.", + "Group-scoped, not project-scoped: `project_id` matches the route but is never sent onward, so the configuration is created at workspace level and appears in EVERY project in the group. There is no such thing as a project-private configuration here.", + "No request-body schema is registered for this action, so an omitted or empty `name` is not caught locally \u2014 whatever the backing service does with it is what you get, and the failure arrives without a field name.", + "The created record comes back under `configuration` (not `data`) beside `success:true`. Carry `configuration.id` \u2014 that integer is what a test run's configuration list takes; the run APIs want configuration ids, not configuration objects or names.", + "Check the configuration listing first. Configurations accumulate at workspace level and duplicates by name are not prevented here, so search before creating.", + "There is no delete-configuration or rename-configuration capability in this profile \u2014 a configuration created by mistake has to be cleaned up in the Test Management UI.", + "This action is missing from the RBAC permission map, so it is not permission-gated even though the rest of the configurations controller is (the listing needs test_run_view and the group/option writes need test_run_write)." + ] + }, + { + "name": "create_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces from storage." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id the option is added to. Not validated by this handler." + } + ], + "body": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label. Must be present \u2014 a null or missing value is a 400 'Invalid Params'. Must be unique within the dataset; a duplicate is rejected with the upstream message relayed." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "REQUIRED \u2014 a missing or null value is a 400. Use false for every option of a multi_dropdown, and true for at most one option of a dropdown; this layer does not enforce either rule." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "nested_dropdown children only \u2014 the option id this one hangs under, from the dataset options listing. Forwarded only when non-empty; meaningless on a flat dropdown." + } + ], + "intent": "Add one more choice to a dropdown-style custom field \u2014 a new value in an existing dataset, for every project that dataset covers", + "responses": { + "200": { + "$response": "CustomFieldAdminOptionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`is_default` is required and must be present, not null: omitting it (or option_value) is a 400 {success:false, error:'Invalid Params'}, whatever else is wrong.", + "The new option appears in every project the dataset is linked to, not just one. Check the dataset's project list before adding.", + "Only set is_default true for a single-select `dropdown`, and only for one option; for a multi_dropdown every option must be is_default false. Nothing in this layer enforces it \u2014 a second default is accepted here and the behaviour downstream is undefined.", + "`parent_option_id` is forwarded only when non-empty, and only means anything for a nested_dropdown. Get the parent id from the dataset options listing.", + "The created option comes back under `data` with its `id` \u2014 capture it, that is what the update and delete calls take.", + "A duplicate option label is rejected upstream and relayed verbatim ('Value with same name already exists. Use a unique name.'); other upstream failures are masked as 400 'The API request is invalid or improperly formed'." + ], + "returns": [ + "success", + "data", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id" + ] + }, + { + "name": "create_custom_field_dataset", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id to hang the dataset off. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "body": [ + { + "name": "linked_projects", + "type": "array", + "description": "Integer project ids this dataset applies to \u2014 this is what makes the field visible in a project. Must be an array (or omitted) or the call is a 400. Note the spelling differs from the project-mapping call, which uses link_projects / unlink_projects." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "default": false, + "description": "Auto-link projects created after this dataset. Must be a literal true/false; any other value is a 400." + }, + { + "name": "options", + "type": "array", + "description": "The allowed values, for dropdown-style fields. Each entry needs option_value and a non-null is_default or the call is a 400.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "Must be present (not null). False for every option of a multi_dropdown; at most one true for a dropdown." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "For nested_dropdown children only \u2014 the option this one hangs under." + } + ] + } + ], + "intent": "Attach another option set and project scope to an existing custom field \u2014 the way to give one field different dropdown values in different projects", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A dataset is the pairing of an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, is invisible in every project even though the definition exists.", + "`linked_projects` takes INTEGER project ids, not PR-NNN identifiers. A non-array value is a 400 'Invalid Params'; a valid array of ids that are not real projects fails upstream and comes back masked.", + "`linked_to_all_projects` is NOT read by this endpoint \u2014 it is silently ignored. It works only inside the `datasets` entries of the create-a-field call. To cover every project from here, enumerate the ids, or use the dataset project-mapping call with select_all.", + "A project can belong to only one dataset per field. Linking a project that is already on another dataset of the same field fails upstream, and this endpoint does NOT relay the reason \u2014 you get 400 {success:false, message:'The API request is invalid or improperly formed'}. Read the field's existing datasets first.", + "Options are only meaningful for dropdown / multi_dropdown / nested_dropdown fields, but nothing rejects them on other types. Each option needs option_value and a non-null is_default; for a multi_dropdown every is_default must be false, and for a dropdown at most one may be true.", + "The response is the created dataset under `data`; take `data.id` \u2014 it is the dataset_id every option and project-mapping call needs.", + "This is additive and reversible by deleting the dataset, but deleting it later destroys the values stored against its options." + ], + "returns": [ + "success", + "data", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects" + ] + }, + { + "name": "create_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields", + "mode": "write", + "entity": "custom_field", + "body": [ + { + "name": "field_name", + "type": "string", + "required": true, + "maxLength": 256, + "example": "Test Environment", + "description": "Display label. Stripped of surrounding whitespace before the reserved-name check and before storage. Blank or longer than 256 characters \u2192 400 'Invalid Params'." + }, + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Data type. Use THIS vocabulary \u2014 the legacy `field_*` spellings that the project form-fields read returns (field_dropdown, field_string, \u2026) are rejected here. `url` and `nested_dropdown` are not valid when entity_type is TestCaseStep." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Which entity the field hangs off; a field belongs to exactly one and cannot be moved later. Any other value \u2192 400 'Invalid Params'. TestCaseStep additionally requires the group's step-custom-fields flag." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "description": "Whether the field must be filled. Must be a literal JSON boolean \u2014 omitting it, or sending \"true\" as a string, is a 400." + }, + { + "name": "datasets", + "type": "array", + "required": true, + "description": "REQUIRED for every field type \u2014 omitting it (or sending a non-array) is a 400. Carries the option values and the project scope. An empty array passes validation but leaves the field linked to nothing.", + "fields": [ + { + "name": "linked_projects", + "type": "array", + "description": "Integer project ids to link. Every entry must be a positive integer; PR-NNN identifiers are rejected." + }, + { + "name": "unlinked_projects", + "type": "array", + "description": "Integer project ids to exclude; only read when linked_to_all_projects is true." + }, + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "true makes the handler resolve every project in the workspace (max 3000) into linked_projects before sending upstream." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Auto-link projects created later." + }, + { + "name": "options", + "type": "array", + "description": "Option values for dropdown-style types. Each needs option_value (non-blank) and a non-null is_default; nested_dropdown entries may carry a `children` array of the same shape." + } + ] + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "Hint text shown in the form. Stripped before storage; longer than 256 characters \u2192 400." + }, + { + "name": "default_value", + "type": "boolean", + "description": "Honoured ONLY when field_type is `boolean`, and then it must be a literal true/false. For every other field type the handler overwrites it with null, so a default cannot be set at create time." + }, + { + "name": "levels", + "type": "array", + "description": "REQUIRED when field_type is `nested_dropdown`: at least 2 entries, each with a non-blank `name` and `level_type`. Ignored for every other type.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true + }, + { + "name": "level_type", + "type": "string", + "required": true + } + ] + }, + { + "name": "parent_custom_field_id", + "type": "integer", + "description": "Adds a child level under an existing nested_dropdown field. When present the handler validates ONLY field_name and forwards the rest untouched \u2014 field_type, is_required and datasets are not checked at all." + } + ], + "intent": "Define a new custom field for the workspace \u2014 pick its data type, whether it is mandatory, and (via datasets) which projects get it and what option values it offers", + "responses": { + "200": { + "$response": "CustomFieldAdminWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`datasets` is required for EVERY field type, dropdown or not. The validator only checks it is an array, so `datasets: []` passes \u2014 and creates a field linked to no project, invisible everywhere. Send one entry carrying `linked_projects` if you want the field to appear.", + "`is_required` must be a literal JSON boolean. The string \"true\" fails the check and the whole call comes back 400 {success:false, error:'Invalid Params'} with no indication of which field was wrong \u2014 that one error covers field_type, field_name, is_required, place_holder_text, default_value, levels and datasets alike.", + "`default_value` is read ONLY when field_type is `boolean`; for every other type the handler forces it to null before sending upstream. Setting a default on a string/int/date field through this call is impossible.", + "field_name is stripped before both the duplicate check and storage, so \" Priority \" is stored as \"Priority\" and collides with the reserved name. Reserved names (Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status) return 400 'Field with same name already exists. Use a unique name.'; TestPlan and ExploratorySession fields may reuse 'Automation Status' and 'Type of Test Case'.", + "A duplicate-name rejection from storage is relayed verbatim (400 with the upstream message). Every OTHER upstream failure on this call is masked to {success:false, message:'The API request is invalid or improperly formed'} \u2014 do not try to parse a reason out of it.", + "Inside a `datasets` entry, `linked_to_all_projects: true` makes the handler expand the link list to every project in the workspace (capped at 3000) before sending. That flag works HERE but is ignored by the standalone add-a-dataset call.", + "entity_type `TestCaseStep` is accepted only when the group has step custom fields enabled; otherwise 400 'Step custom fields are not enabled for this group.'. Step fields also cannot be `url` or `nested_dropdown`.", + "The response echoes only the stored field attributes \u2014 no options, no project links. Read the field back, or read the project's form fields, to confirm what the datasets actually produced.", + "Nothing here is project-scoped: the definition is created for the whole workspace and the datasets decide where it shows up. Name the projects you are about to affect before creating." + ], + "returns": [ + "success", + "data", + "id", + "field_name", + "field_type", + "entity_type", + "is_required", + "default_value", + "place_holder_text" + ] + }, + { + "name": "create_dataset", + "method": "POST", + "path": "/api/v1/projects/{project_id}/datasets", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "json_path": "/dataset/name", + "description": "Dataset name. Must be unique within the project: a collision comes back as 400 {success:false, message:'Dataset name already exists.'}, which is the one 400 here that is NOT a schema rejection." + }, + { + "name": "variables", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 40, + "json_path": "/dataset/variables", + "description": "The columns, in order \u2014 row_data positions map to this array by index. Duplicate names (compared case-insensitively) fail the call. A column with a blank name, or one empty in every row, is SILENTLY DROPPED with its slice of every row, so the stored column list can be shorter than what you sent. 40 is the default cap; groups on the raised-limit flag get 100.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", + "description": "Must start with a letter and contain only letters, digits and underscores \u2014 no spaces, hyphens or leading digits." + }, + { + "name": "uuid", + "type": "string", + "description": "Omit on create; the server mints it. Present only so the value read back can be sent to update unchanged." + } + ] + }, + { + "name": "rows", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 100, + "json_path": "/dataset/rows", + "description": "The data rows. Heavily normalised before the write: all-blank rows are dropped, cell values are whitespace-trimmed, rows whose trimmed values are identical are de-duplicated to the first occurrence, and row_number is then REASSIGNED 1..N in the order you sent them \u2014 the numbers you supply are ignored. 100 rows is the default cap; groups with the raised-limit flag get 200.", + "fields": [ + { + "name": "row_number", + "type": "integer", + "required": true, + "description": "Required by the schema but overwritten server-side with the row's final 1-based position. Send anything valid; do not rely on it round-tripping." + }, + { + "name": "row_data", + "type": "array", + "required": true, + "minItems": 1, + "description": "Cell values, positional against `variables`. Every element must be a STRING \u2014 a number or null is a schema rejection. After empty columns are pruned the remaining length must equal the remaining column count, else 422 'Row data size mismatch with column count.'" + }, + { + "name": "uuid", + "type": "string", + "description": "Omit on create; the server mints it." + } + ] + }, + { + "name": "tags", + "type": "array", + "maxItems": 20, + "json_path": "/dataset/tags", + "description": "Free-text labels on the dataset. Plain strings, each at most 50 characters, at most 20. Accepted but undocumented in the spec.", + "fields": [ + { + "name": "tag", + "type": "string", + "maxLength": 50 + } + ] + }, + { + "name": "metadata", + "type": "object", + "json_path": "/dataset/metadata", + "description": "Free-form key/value bag stored with the dataset, at most 20 top-level keys. Only settable here \u2014 the update endpoint accepts it through validation and then drops it, so metadata cannot be changed after creation through this API." + } + ], + "intent": "Create a dataset \u2014 a named table of columns (variables) and rows that drives data-driven execution, so one test case runs once per row with the row's values substituted in", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "tags", + "metadata", + "created_at", + "created_by" + ], + "responses": { + "201": { + "description": "Dataset created. The handler renders 201 Created, not 200.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__CreateDataset" } - }, - "User__CreateStepResult": { + } + } + }, + "400": { + "description": "Either a request-schema rejection ({error:'Invalid JSON data', details:[...]}) or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:...}. 'Feature not available' = the group lacks Team Pro (entitlement, not payload). Any other message is a dataset-shape validation failure." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key.", + "Nothing outside the declared keys is allowed: the request schema sets additionalProperties:false at the top level, inside `dataset`, and inside each variable and row object. A misspelled or extra key fails the whole call with 400 {error:'Invalid JSON data', details:[...]} rather than being ignored.", + "Send Content-Type: application/json. The schema check is skipped for any other content type, and the handler then crashes on a missing `variables` instead of returning a clean 400.", + "What you get back is not what you sent: blank rows and all-blank columns are stripped, duplicate rows collapsed, cells trimmed, and row_number renumbered 1..N. Read `dataset.variables` and `dataset.rows` from the response rather than assuming your payload was stored verbatim.", + "422 is overloaded on every dataset endpoint. {message:'Feature not available'} means the group lacks the Team Pro plan \u2014 stop, do not retry with a different body. Any other 422 message ('Maximum 40 columns are allowed', 'Duplicate variable names found: ...', 'Row data size mismatch with column count.', 'Please add non null/ valid data.') is a payload problem you can fix.", + "Success is 201, not 200, and the dataset is at the top level as `dataset` \u2014 there is no `data` envelope.", + "Carry `dataset.uuid` forward: it is the id every other dataset call takes, and the integer id used internally is never exposed.", + "Needs the test-case write permission (tc_write), not a dataset-specific one." + ] + }, + { + "name": "create_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "example": "Checkout flow exploration", + "description": "Title of the exploratory session. The only field the store requires. Send it as `title` \u2014 `name` is accepted by the request schema and then silently dropped.", + "json_path": "/exploratory_session/title" + }, + { + "name": "description", + "type": "string", + "example": "Explore edge cases in the checkout flow.", + "description": "Optional free-text description.", + "json_path": "/exploratory_session/description" + }, + { + "name": "charter", + "type": "string", + "example": "Focus on payment error handling", + "description": "Testing charter describing the scope and goals. Note that cloning a session does NOT copy the charter.", + "json_path": "/exploratory_session/charter" + }, + { + "name": "timebox_duration", + "type": "integer", + "example": 60, + "description": "Planned duration of the session. TM's own exported reports render this value as minutes, while the upstream store documents the field as seconds \u2014 it is passed through unconverted, so agree the unit with the product before relying on it.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/timebox_duration" + }, + { + "name": "owner", + "type": "integer", + "example": 42, + "description": "TCM user id of the session owner. This is the field to use: it is translated to the upstream user id here. An id matching no user in this group is accepted and stored as no assignee.", + "json_path": "/exploratory_session/owner" + }, + { + "name": "assignee", + "type": "integer", + "example": 42, + "description": "NOT a safe alias on create \u2014 unlike `owner` it is forwarded without translation to an upstream user id, so it sets the wrong user. Use `owner` instead. Ignored entirely when `owner` is also present.", + "json_path": "/exploratory_session/assignee" + }, + { + "name": "folder_id", + "type": "integer", + "example": 456, + "description": "Folder to place this session in; a numeric string is also accepted. Omit to create it at the project root.", + "json_path": "/exploratory_session/folder_id" + }, + { + "name": "test_plan_id", + "type": "integer", + "example": 77, + "description": "Integer id of the test plan to link this session to (not a TP-NNN identifier). Only reachable through the wrapped body \u2014 the unwrapped fallback does not permit it.", + "json_path": "/exploratory_session/test_plan_id" + }, + { + "name": "duration", + "type": "integer", + "example": 45, + "description": "Tracked duration. Accepted on create but normally left to be set later by the update call.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/duration" + }, + { + "name": "actual_duration", + "type": "integer", + "example": 50, + "description": "Final recorded duration. Accepted on create but normally set by the update call before closing \u2014 the close call does not accept it.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/actual_duration" + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "mobile" + ], + "description": "Plain tag strings. The request schema rejects any non-string entry.", + "json_path": "/exploratory_session/tags" + }, + { + "name": "configurations", + "type": "array", + "example": [ + 1, + 3 + ], + "description": "Configuration ids (integers, or numeric strings) \u2014 not the configuration objects returned on read. An id that does not exist in this project surfaces only as the generic upstream 400.", + "json_path": "/exploratory_session/configurations" + }, + { + "name": "attachments", + "type": "array", + "example": [ + 101, + 102 + ], + "description": "Integer blob ids from a prior generic attachment upload \u2014 never file content. A blob already used by another entity is refused with 422. Unknown ids error only when none of them resolve; otherwise they are silently skipped.", + "json_path": "/exploratory_session/attachments" + }, + { + "name": "issues", + "type": "array", + "description": "Issues to link. Each entry is {issue_id, issue_type} and may also carry metadata {url, ticket_id, title, display_category, issue_category}. A list of bare id strings is coerced to jira issues. A list of anything else is silently reduced to no issues. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. Linking is applied asynchronously after the 201.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/exploratory_session/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "101": "Regression", + "102": 42 + }, + "description": "Custom field values as a map of {custom_field_id: value}, converted server-side into the upstream write shape. This is NOT the array shape the read endpoints return. Resolve the ids for entity type ExploratorySession first; a misspelled key is dropped rather than rejected.", + "json_path": "/exploratory_session/custom_fields" + } + ], + "intent": "Start a new exploratory testing session in a project \u2014 give it a charter, a timebox, an owner, tags, configurations and a folder \u2014 so that observations can be logged against it", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "created_at", + "updated_at" + ], + "responses": { + "201": { + "description": "Exploratory session created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 naming a missing `exploratory_session` means the body reached the server empty, not that you must nest it.", + "`title` is the only field the store requires. Everything else is optional.", + "Set the assignee with `owner` (a TCM user id) \u2014 NOT with `assignee`. On this create path only `owner` is translated to the upstream user id; `assignee` is forwarded raw and lands as a wrong or invalid user. On update both are translated, so the two endpoints disagree.", + "An `owner` that resolves to no user in this group does not fail: the session is created 201 with no assignee at all. Re-read the session if the assignment matters.", + "`issues` is forgiving in dangerous ways. A list of bare strings is accepted and coerced to {issue_id, issue_type:'jira'}. Jira ids are validated against ^[A-Z][A-Z0-9]*-[0-9]+$ and a bad one fails the whole call. But a list whose entries are neither strings nor objects carrying both issue_id and issue_type is silently replaced with an empty list \u2014 the session is created with no issues and no error. Each entry may also carry a `metadata` object ({url, ticket_id, title, display_category, issue_category}) even though only issue_id/issue_type are declared.", + "Issue linking happens asynchronously after the 201, so the session in the response does not yet show the links. Re-read it, or read the session's defects, to confirm.", + "`attachments` are integer ActiveStorage blob ids from a prior generic attachment upload \u2014 not file content, and not URLs. A blob already attached to another entity is refused with 422 'the attachment has been referenced elsewhere'. Unknown ids only error when NONE of them exist; a mixed list quietly attaches the ids that resolve and drops the rest.", + "`session_state` and `name` pass the request schema and are then dropped by the parameter filter \u2014 a session cannot be created closed, and the title must be sent as `title`.", + "`custom_fields` goes in as a map of {custom_field_id: value} and comes back as the normalised array shape; the two are not symmetric.", + "Take `exploratory_session.id` (integer) from the response and carry it as session_id into every log, defect, close, clone, update and delete call for this session.", + "Any upstream validation failure \u2014 an unknown configuration id, a folder in another project, a malformed value \u2014 arrives as 400 {success:false, message:'The API request is invalid or improperly formed'}. The message is the same for every cause, so it never tells you which field was wrong." + ] + }, + { + "name": "create_exploratory_session_log", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "body": [ + { + "name": "content", + "type": "string", + "example": "

Tapped checkout \u2014 spinner appeared but no order was created.

", + "description": "Rich-text HTML for the entry. Sanitised on write: anchors and images pointing outside this project/group are stripped. Reference already-uploaded images by blob id in tags here \u2014 this is the only way to attach media to a log.", + "json_path": "/session_log/content" + }, + { + "name": "status", + "type": "string", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "example": "fail", + "description": "Outcome recorded for this step. `skipped` is valid. Any other value is rejected by the request schema with 400 before the handler runs.", + "json_path": "/session_log/status" + }, + { + "name": "elapsed_time", + "type": "integer", + "example": 120, + "description": "Time spent on this step, in seconds. Must be an integer \u2014 a numeric string is rejected by the request schema.", + "json_path": "/session_log/elapsed_time" + }, + { + "name": "defects", + "type": "array", + "description": "Issues raised by this observation. Each entry is {issue_id, issue_type} and may carry a metadata object. Bare id strings are coerced to jira issues; a list whose entries lack issue_id or issue_type is silently reduced to no defects. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. Linked asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/session_log/defects" + } + ], + "intent": "Record one observation during an exploratory session \u2014 a note about what was tried and what happened, its pass/fail/bug outcome, the time it took and any defects it raised", + "returns": [ + "success", + "session_log", + "id", + "session_id", + "hashed_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "attachment_relations", + "created_at", + "updated_at" + ], + "responses": { + "201": { + "description": "Session log created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_log": { + "$schema": "ExploratorySessionLog" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 saying 'session_log is required' means the body was empty, not that you must nest it.", + "This is a child of a session: the session_id in the path is the parent, and the `id` you get back is the log_id you must carry \u2014 together with the same session_id \u2014 into the update and delete calls. A log id from one session is not addressable under another.", + "`status` accepts pass, fail, bug, retest, block, note and skipped. `skipped` is real and is missing from older declarations of this field; anything outside the seven is rejected by the request schema with 400.", + "Every field is optional \u2014 a log with no content, status or elapsed_time is created successfully. Nothing here rejects an empty observation.", + "`content` is rich-text HTML and is sanitised on write: anchors and images pointing outside this project or group are stripped, so the stored content can differ from what you sent.", + "Screenshots are not uploaded through this call. Upload the image first through the generic attachment upload, then reference the returned blob id in an tag inside `content`; the handler derives the attachment links from the blob ids it finds in the HTML. If ANY referenced blob id is not visible to your group or project, EVERY attachment link for the call is dropped and the log is still created 201 with no images attached.", + "`defects` behaves like the session's issues: bare id strings are coerced to jira issues, entries missing issue_id or issue_type silently reduce the list to empty, and a jira id must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. A per-entry metadata object is accepted even though only issue_id and issue_type are declared.", + "Defect linking is applied asynchronously after the 201, so the log echoed back may not show the links yet \u2014 re-read the logs, or the session's defects, to confirm.", + "`elapsed_time` is seconds.", + "There is no bulk log-create on this path; one call per observation." + ] + }, + { + "name": "create_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/filter", + "mode": "write", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "description": "Display name of the saved view. Must be unique for this project and entity \u2014 a duplicate is rejected by the backing service as a 400 'Filter name already exists'. There is also a server-side maximum length, also reported as a 400." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Which list this view filters. Validated by a request-body schema before the handler runs, so an unlisted value returns 400 'Invalid JSON data' with the accepted set echoed back. Cannot be inferred \u2014 it is required even though the conditions live in `filters`." + }, + { + "name": "is_project_level", + "type": "boolean", + "default": false, + "description": "true publishes the view to everyone on the project; false/omitted keeps it to you. Must be a real JSON boolean \u2014 the string \"true\" fails schema validation with 400." + }, + { + "name": "filters", + "type": "object", + "required": true, + "description": "The condition set the view applies, in the product UI's filter-state shape \u2014 NOT the flat id lists the entity search takes. The UI only applies a view whose conditions are in this shape: {systemFields: {priority: [{labelInnerText: 'High', label: 'High', value: 9262}]}, extraSystemFields: {issue_type: {label: 'Jira', value: 'jira'}}, matchAllMatchAny: {global: {value: 'all'}, priority: {value: 'any'}}}. Each selected option carries the project's numeric option id as `value` (resolve it with get_project_form_fields or get_system_field_values \u2014 the ids differ per project) and its display name as `label` and `labelInnerText`. `extraSystemFields.issue_type` is REQUIRED even when the view does not filter on linked issues: without it the view saves but the UI does not apply it. matchAllMatchAny carries `global` ('all' = match every filter, 'any' = match any one) plus one entry per filtered field ('any' = contains any one value, 'all' = contains all values). Nothing inside is validated, so a wrong shape still saves with a success response. Sent only when non-empty: `{}` is treated as absent and the backing service then rejects the create with 400 'Filters cannot be empty'." + } + ], + "intent": "Save a reusable named filter view on a project \u2014 the condition set behind a test case, test run, test plan or exploratory-session list \u2014 so the same selection can be reapplied later or shared with everyone on the project", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is flat \u2014 there is no wrapper object. It is also closed: any key other than name, entity, is_project_level and filters is rejected with 400 'Invalid JSON data' by the request-body schema before the handler sees it.", + "name AND entity are both required on create even though the conditions live in `filters`. entity is what binds the view to a list type; it is not derived from the contents of `filters`.", + "`filters: {}` is dropped as blank before the request is built, so an empty object behaves exactly like omitting the key and the create then fails downstream. Send at least one real condition.", + "Conditions are not checked against the project's custom fields at write time. A condition on a custom field that does not exist is accepted here and is silently pruned \u2014 permanently \u2014 the first time anyone reads the filter back.", + "Carry `data.id` from the response: that integer is the filter_id every later read, update and delete takes.", + "`extraSystemFields.issue_type` names the issue tracker the view's linked-issues condition searches, and must always be present. Send {label: 'Jira', value: 'jira'} unless the view filters on linked issues from another tracker. The allowed values are the product's own tracker names \u2014 jira, azure, asana, linear, clickup, devrev, github, gitlab, zoho, trello \u2014 not the integration route names (`jira-app` and the rest), which are a different vocabulary. Nothing checks that the project has that tracker connected; get_project_settings shows which trackers a project uses. To filter on another tracker's linked issues, take the value from the `issue_type` those issues carry on the test case.", + "Only the backing service's 400 is translated into `{success:false, message:...}`; any other failure from it is re-raised and surfaces as a 500, so a 500 here is not necessarily a server bug.", + "Not permission-gated \u2014 the filter controller has no entry in the RBAC map, so anyone with product access to the project can create a project-level view." + ] + }, + { + "name": "create_project", + "method": "POST", + "path": "/api/v1/projects", + "mode": "write", + "entity": "project", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Checkout Regression", + "json_path": "/project/name", + "description": "Project name. Leading/trailing whitespace is stripped. Not checked for uniqueness \u2014 a duplicate name is accepted." + }, + { + "name": "description", + "type": "string", + "json_path": "/project/description", + "description": "Free text. HTML is escaped server-side, so what comes back can differ from what you sent." + }, + { + "name": "access_type", + "type": "string", + "values": [ + "private", + "restricted", + "org_wide" + ], + "json_path": "/project/access_type", + "description": "Visibility of the new project. Only honoured when project-level RBAC is enabled for the group \u2014 otherwise accepted and ignored. When it IS enabled, a value outside this set fails the whole request with 400 'Invalid access type'." + }, + { + "name": "users", + "type": "array", + "fields": [ + { + "name": "user", + "type": "string" + }, + { + "name": "role", + "type": "string" + } + ], + "json_path": "/project/users", + "description": "Per-user grants applied at creation, as [{user, role}]. Only read when access_type is also sent AND project-level RBAC is on; ignored entirely otherwise." + } + ], + "intent": "Create a new project \u2014 the top-level container every folder, test case, run, plan and report lives under; returns both the integer id the v1 routes take and the PR-NNN identifier the v2 routes take", + "returns": [ + "data", + "success", + "project", + "id", + "identifier", + "name", + "description", + "created_at", + "thProjectId", + "normalisedName", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "starred", + "jira_mapped", + "user_role", + "permissions", + "links", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project created. The project is at data.project \u2014 same envelope and same serializer as the v1 project-detail read.", + "schema": { + "$schema": "V1ProjectDetail" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `project` wrapper is added for you from each field's json_path, and sending your own `project` object is rejected before the call as an unknown body key.", + "`name` is the only field that must be present. It is stripped of surrounding whitespace, and name uniqueness is not enforced here \u2014 creating a second project with an existing name succeeds and gives you two projects that are indistinguishable by name.", + "`description` is HTML-escaped server-side, so the stored value can differ from what you sent.", + "`access_type` and `users` are read only when project-level RBAC is enabled for the group; with that off both keys are accepted and silently ignored, and the project is created with the group default. With it on, an access_type outside private/restricted/org_wide fails the whole call with 400 'Invalid access type'.", + "The new project is at `data.project` \u2014 this v1 route wraps its payload in a `data` envelope. Carry `data.project.id` (integer) for every other the v1 surface project route and `data.project.identifier` (PR-NNN) for anything under the v2 surface; the two id forms are not interchangeable.", + "Requires the project_create permission under RBAC; without it the call is 403 'This action is not permitted for the user'.", + "There is no delete-project capability in this index, so a project created by mistake cannot be removed through the registry \u2014 only through the UI." + ] + }, + { + "name": "create_report_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "projectId", + "type": "integer", + "example": 10000, + "description": "Integer project id. Alias for `project_id`; either satisfies the project requirement on create, and the project in the path is used when both are absent." + }, + { + "name": "project_id", + "type": "integer", + "example": 10000, + "description": "Integer project id the report is primarily filed under. Must be one of `project_ids` when that is also sent." + }, + { + "name": "project_ids", + "type": "array", + "example": [ + 10000, + 10001 + ], + "description": "Integer project ids for a cross-project report. Max 60 (max 5 for User Workload Report), and only accepted for the seven multi-project types \u2014 any other type with more than one id is 400. The first entry becomes the primary project when `project_id` is omitted." + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary", + "description": "Report type as its DISPLAY NAME, not a slug. `Defects Summary` and `Defects Detailed Report` are accepted but have no server-side data branch, so such a report always reads back `report_data: null` \u2014 for defect figures use `Test Run Summary` and its `issues_by_priority` / `issues_by_status` sections. `Test Plan Detailed Report` is feature-flagged: an unflagged group gets 403." + }, + { + "name": "title", + "type": "string", + "example": "Weekly Test Case Activity" + }, + { + "name": "description", + "type": "string", + "example": "Testing" + }, + { + "name": "report_timeframe", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "The window the report covers, and the value of `reportTimeRange` on the summary-by-type read. The four relative windows and `custom` compute dates; `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500, not a 422). The remaining five carry no dates \u2014 each selects entities through one matching `report_filters` key." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "example": "custom_report", + "description": "`system_generated` is the one-click report the product creates for you; `custom_report` is user-configured. For a Requirement Traceability Report, `system_generated` makes the server auto-populate `report_filters.requirements` with every requirement in the project \u2014 do not also send that filter." + }, + { + "name": "grain", + "type": "string", + "values": [ + "test_case", + "test_plan" + ], + "default": "test_case", + "description": "Requirement Traceability Report only: `test_plan` renders Requirement to Test Plan to Sub-Test Plan rows. Any other report type is 400, and it is single-project only. Immutable \u2014 a later edit that changes it is refused 400 (resending the same value is fine)." + }, + { + "name": "is_dynamic", + "type": "boolean", + "description": "Whether the report re-resolves its selection each run. Omit and the server infers it from whether any `dynamic_filters` value is non-blank." + }, + { + "name": "test_run", + "type": "object", + "fields": [ + { + "name": "created_at", + "type": "string", + "required": true + }, + { + "name": "status", + "type": "array", + "required": true + }, + { + "name": "owner", + "type": "array", + "required": true + }, + { + "name": "automation_status", + "type": "array", + "required": true + }, + { + "name": "type", + "type": "array", + "required": true + } + ], + "json_path": "/dynamic_filters/test_run", + "description": "Dynamic (re-evaluated each run) run selection. Accepted under either `dynamic_filters` or `filters`; presence of any non-blank value here is what flips the report to dynamic when `is_dynamic` is omitted." + }, + { + "name": "status", + "type": "array", + "json_path": "/report_filters/status" + }, + { + "name": "priority", + "type": "array", + "json_path": "/report_filters/priority" + }, + { + "name": "case_type", + "type": "array", + "json_path": "/report_filters/case_type" + }, + { + "name": "assignee", + "type": "array", + "json_path": "/report_filters/assignee" + }, + { + "name": "automation_status", + "type": "array", + "json_path": "/report_filters/automation_status" + }, + { + "name": "automation_state", + "type": "array", + "json_path": "/report_filters/automation_state" + }, + { + "name": "test_runs", + "type": "array", + "description": "Run ids, and they must be INTEGERS \u2014 a quoted id or a TR-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_runs.", + "json_path": "/report_filters/test_runs" + }, + { + "name": "test_plans", + "type": "array", + "description": "Plan ids, and they must be INTEGERS \u2014 a quoted id or a TP-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_plans.", + "json_path": "/report_filters/test_plans" + }, + { + "name": "test_cases", + "type": "object", + "description": "Case selection \u2014 pairs with report_timeframe=specific_test_cases. Must be an OBJECT (an array is rejected 400 'Invalid report filters'), folder-keyed like SelectionData. Folder ids are STRING keys; case ids are INTEGERS (the numeric `id`, not TC-NNN). A wrong-but-object shape saves an UNSCOPED project-wide report at 200.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "required": true + }, + { + "name": "folders", "type": "object", - "description": "User as returned by `create_step_result`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + "required": true + }, + { + "name": "unique_test_case_count", + "type": "integer", + "required": true + } + ], + "json_path": "/report_filters/test_cases" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cross-project case selection: { \"\": } for the report's non-primary projects. A project id outside `project_ids` is dropped silently.", + "json_path": "/report_filters/shared_selection" + }, + { + "name": "session_ids", + "type": "array", + "description": "Exploratory session ids \u2014 pairs with report_timeframe=specific_sessions.", + "json_path": "/report_filters/session_ids" + }, + { + "name": "requirements", + "type": "object", + "description": "{ issue_type: [issue_id, ...] } \u2014 pairs with report_timeframe=requirements. Must be an object; an array is rejected 400. Auto-populated by the server when report_creation_mode is system_generated on a traceability report.", + "json_path": "/report_filters/requirements" + }, + { + "name": "test_plan_selection", + "type": "object", + "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. `select_all` is required on each entry and must be a real boolean; a malformed entry is silently discarded rather than rejected.", + "json_path": "/report_filters/test_plan_selection" + }, + { + "name": "test_runs_by_project", + "type": "object", + "description": "Cross-project run selection: { \"\": [run_id, ...] }. Keys must be numeric strings and values arrays of numeric ids or the request is 400. When present it REPLACES the flat `test_runs` key, which is then ignored.", + "json_path": "/report_filters/test_runs_by_project" + }, + { + "name": "test_plans_by_project", + "type": "object", + "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Same validation as test_runs_by_project, and it replaces the flat `test_plans` and `test_plan_selection` keys.", + "json_path": "/report_filters/test_plans_by_project" + }, + { + "name": "builds", + "type": "array", + "json_path": "/report_filters/builds", + "description": "Internal build ids paired 1:1 with `test_runs`. Sent back by the read path for round-tripping an edit; do not invent values." + }, + { + "name": "projects", + "type": "array", + "json_path": "/report_filters/projects" + }, + { + "name": "folder", + "type": "array", + "json_path": "/report_filters/folder" + }, + { + "name": "test_case_tags", + "type": "array", + "json_path": "/report_filters/test_case_tags" + }, + { + "name": "tc_custom_fields", + "type": "object", + "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on the other types the server never reads it and drops it silently. Persisted, and read back, under the name `custom_fields`.", + "json_path": "/report_filters/tc_custom_fields" + }, + { + "name": "custom_date_range", + "type": "string", + "example": "2025-04-16 2025-04-24", + "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED whenever report_timeframe is `custom` \u2014 omitting it is a 500, not a validation error. Ignored (and nulled on edit) for every other timeframe." + }, + { + "name": "users", + "type": "array", + "description": "TM user ids to mail the report to. Every id must belong to your group or the whole request is 400 'Invalid mail to users'. Only persisted when the report is scheduled.", + "json_path": "/mail_to/users" + }, + { + "name": "external_mails", + "type": "array", + "description": "Email addresses outside the group. Only persisted when the report is scheduled.", + "json_path": "/mail_to/external_mails" + }, + { + "name": "frequency", + "type": "string", + "values": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly", + "description": "Scheduling cadence. Send `frequency` and `frequency_details` together or omit BOTH \u2014 omitting both creates a valid unscheduled, on-demand report (and `mail_to` is then dropped silently). The enum is daily/weekly/monthly only: `once` passes body validation and then raises 'Unsupported frequency type' as a 500." + }, + { + "name": "time", + "type": "string", + "example": "08:00", + "description": "24-hour HH:MM, UTC. Required for every cadence; missing or malformed is 400 'Invalid report frequency'.", + "json_path": "/frequency_details/time" + }, + { + "name": "day", + "type": "string", + "example": "monday", + "description": "Required for weekly \u2014 a lowercase English day name, nothing else \u2014 and for monthly, where it is an integer 1-28 (29-31 are rejected). Omit for daily.", + "json_path": "/frequency_details/day" + } + ], + "intent": "Save a report definition over a project's runs, plans, cases, sessions or requirements \u2014 scheduled to email on a cadence, or unscheduled and read on demand", + "returns": [], + "responses": { + "200": { + "$response": "CreateReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Four body fields are required by the validator, not just the three the spec marks: `report_type`, `report_timeframe`, `report_creation_mode`, and a project (either `project_id`/`projectId` in the body or the project in the path). Any one missing comes back 400 `Validation error. Invalid report type|timeframe|creation mode.` \u2014 the message names which.", + "This returns HTTP 200, not 201, despite creating a record. The new report is at `data`; carry `data.id` (an integer) forward as the `report_id` for every other report call. `data.identifier` is the SC-NNN display string and is NOT accepted anywhere as an id.", + "Creating the report does NOT compute it. The response `data` is the definition only; there is no `report_data` here. Read the report afterwards to get figures, and name the sections you want.", + "`report_timeframe` and `report_filters` must agree. The five entity timeframes each read exactly one filter key \u2014 specific_test_runs reads `test_runs`, specific_test_plans reads `test_plans`, specific_test_cases reads `test_cases`, specific_sessions reads `session_ids`, requirements reads `requirements`. Naming the timeframe and leaving its filter empty saves a report that resolves to nothing, at 200.", + "`report_filters.test_runs` and `.test_plans` must be arrays of INTEGERS. A string id (\"123\") or a TR-NNN/TP-NNN identifier fails the whole request with 400 `Validation error. Invalid report filters.`, and `test_cases` / `requirements` must be objects, not arrays.", + "Scheduling is inferred, never declared: the report is scheduled iff BOTH `frequency` and `frequency_details` are present. `mail_to` is only persisted for a scheduled report \u2014 recipients sent on an unscheduled one are dropped silently and `next_run_at` stays null. Use the send-email-now capability to mail an unscheduled report.", + "`report_timeframe: custom` without `custom_date_range` is an unhandled 500, not a 422 \u2014 the server splits the value unconditionally. Send \"YYYY-MM-DD YYYY-MM-DD\", space separated.", + "`frequency: once` is accepted by body validation and then raises 'Unsupported frequency type' as a 500. Omit both scheduling fields for a one-off report instead.", + "Cross-project: send `project_ids` (integers). At most 60, and only for Test Run Summary, Test Plan Summary, Test Run Detailed Report, Test Plan Detailed Report, Test Case Activity, Requirement Traceability Report and User Workload Report \u2014 anything else is 400. User Workload Report caps at 5 projects. When both are sent, `project_id` must be one of them; when only `project_ids` is sent the first entry becomes the primary project.", + "`report_creation_mode: system_generated` on a Requirement Traceability Report makes the server fetch every linked requirement in the project and populate `report_filters.requirements` for you \u2014 do not also send that filter.", + "`grain: test_plan` is Requirement Traceability Report only, single-project only, and immutable after creation (a later edit that changes it is refused 400). Omit it for the normal test-case grain.", + "Requires the report_create permission on the project; 403 otherwise. `Test Plan Detailed Report` additionally needs a per-group feature flag and returns 403 `Operation not permitted` without it.", + "The write also syncs the report to Test Observability. If that sync fails the created row is deleted again and the call errors \u2014 a 5xx here means nothing was kept." + ] + }, + { + "name": "create_root_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folders", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Root Folder A", + "json_path": "/folder/name", + "description": "Folder name. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." + }, + { + "name": "notes", + "type": "string", + "example": "This folder contains all regression test cases", + "json_path": "/folder/notes", + "description": "Free-text description, HTML allowed. It is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. Note the key is `notes`: `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Send the literal string \"true\" to have a clashing name auto-suffixed instead of rejected. Any other value, and any boolean, is treated as false \u2014 the comparison is against the string \"true\"." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Integer ids of already-uploaded attachment blobs to bind to the folder. Each must belong to this group/project and must not already be attached to anything else \u2014 a reused blob fails the whole call with 422 and the bare string \"the attachment has been referenced elsewhere\"." + } + ], + "intent": "Create a top-level folder in a project \u2014 the first thing you need before test cases can be filed anywhere", + "returns": [ + "data", + "success", + "folder", + "project", + "id", + "name", + "notes", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__CreateRootFolder" + }, + "400": { + "$response": "BadRequest" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send `name` and `notes` flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", + "Uniqueness is always enforced against sibling root folders. A duplicate name comes back with the entity store's own message and status relayed verbatim \u2014 this is one of the few folder endpoints that does NOT mask the upstream text. Send auto_rename:\"true\" if you want it renamed instead.", + "The created folder is at `data.folder`, not at the top level. `data.project` carries the project it landed in. The id you carry forward is `data.folder.id`, a plain integer.", + "Passing the literal path segment `new` as project_id CREATES a default project and puts the folder in it. Only do that deliberately.", + "Requires the folder-create permission; 403 otherwise.", + "Folders keep no version history, so a folder created here can only be undone by deleting it \u2014 and folder deletion is itself not reversible through this API." + ] + }, + { + "name": "create_shared_step", + "method": "POST", + "path": "/api/v1/projects/{project_id}/shared-steps", + "mode": "write", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the shared step block. Only presence of the key is checked here \u2014 an empty string passes this layer and is rejected by the backing service, so a blank title fails late with a different error than a missing one." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Shared-field folder to file the block under. The key is forwarded only when present; omit it (or send null) to leave the block at the root (Unassigned)." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "description": "Plain tag name strings on the shared step itself (separate from the tags on the test cases that embed it). Accepted here but previously undeclared." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The ordered step rows. Required \u2014 omitting the key entirely fails local validation with a flat 400 'Validation Error' that names no field. An empty array IS accepted and creates a titled block with no steps.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "minimum": 1, + "description": "1-based position. Every row needs order >= 1; a missing or 0 order fails the whole request, not just that row." + }, + { + "name": "step", + "type": "string", + "description": "Step body, HTML. Each row must carry at least one of step or result or the whole request is rejected." + }, + { + "name": "result", + "type": "string", + "description": "Expected result, HTML. Each row must carry at least one of step or result." + }, + { + "name": "test_data", + "type": "string" + } + ] + } + ], + "intent": "Create a reusable shared step block in a project \u2014 a titled set of step/result rows that many test cases can then embed by reference instead of copying", + "returns": [ + "success", + "data", + "id", + "title", + "step_count", + "test_case_count", + "tags", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedStepsPostResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "render_image_urls", + "type": "boolean", + "default": false, + "description": "When true, inline image attachments referenced in the step/result HTML are returned as resolved URLs rather than raw references." + } + ], + "guidance": [ + "The body is flat \u2014 title, folder_id, tags and shared_step_details sit at the top level with no wrapper.", + "shared_step_details is required even when empty. Every row must have order >= 1 and at least one of step/result; one bad row fails the entire call with a bare 400 `{data:null, success:false, message:\"Validation Error\"}` that does not say which row or which field.", + "Success is 200 with `{data, success:true}` \u2014 not 201, and the body is not empty.", + "Carry `data.id`. That integer is the shared_step_id you put on a test case's `test_case_steps[].shared_step_id`, and the id for every later read, update and delete of this block.", + "Creating a shared step attaches it to nothing. Embedding is done from the test case side; this call only makes the block available.", + "step and result are HTML. Inline attachment references are validated server-side and an unresolvable one fails the call with 400 'attachment in text is invalid'.", + "Requires tc_write \u2014 the same permission as editing a test case." + ] + }, + { + "name": "create_step_result", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/step/{step_id}", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (this route only matches digits; a PR-NNN identifier never reaches the API)." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run id. This v1 route accepts both the bare integer and the TR-NNN identifier." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1 + }, + { + "name": "step_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric id of the step on the test case (from the case detail), not its position in the step list. The route only matches digits." + } + ], + "body": [ + { + "name": "status", + "type": "string", + "values": [ + "untested", + "passed", + "failed", + "retest", + "blocked", + "skipped", + "in_progress" + ], + "example": "passed", + "json_path": "/test_run_step_result/status", + "description": "Step outcome as a NAME. This is the value that is actually written. A same-named key at the top level of the body is validated but never written." + }, + { + "name": "status_id", + "type": "integer", + "json_path": "/test_run_step_result/status_id", + "description": "Numeric status id. Accepted and forwarded, but the step surface is name-based \u2014 prefer `status` unless you know the backing service expects an id here." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run_step_result/description", + "description": "Note for this step. Rich text; links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent. Also copied onto the case-level result." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_run_step_result/attachments", + "example": [ + 10288829 + ], + "description": "Integer blob ids from a prior generic attachment upload (or {id: } objects) \u2014 NOT filenames and NOT file bytes. An unresolvable or already-used blob fails the whole call with 422." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_run_step_result/custom_fields", + "description": "Custom field values keyed by custom field id. Copied onto the case-level result as well as the step." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run_step_result/issues", + "description": "Linked defects. Each entry must be an OBJECT; a bare string is dropped by the parameter filter with no error, so the link is silently never created." + } + ], + "intent": "Log the outcome of ONE step inside a test case's execution in a run \u2014 the per-step pass/fail you record while working through a manual test case, as opposed to the case's overall result", + "responses": { + "200": { + "description": "The full test result for the case, at the top level of the body (no envelope). The step's own outcome is nested under `test_run_step_result`.", + "schema": { + "$schema": "TestResult__CreateStepResult" + } + }, + "400": { + "description": "Either a duplicate request inside the one-second lock ({success:false, message:'Request is already being processed'}) or a top-level `status` outside the supported step statuses ({error:'Validation error'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Caller lacks execute permission on this test run ({success:false, message:\"You don't have permission to execute test cases in this test run\"})." + }, + "422": { + "description": "An attachment blob id could not be resolved or is already referenced elsewhere ({base: [reason]})." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `test_run_step_result` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. That object is forwarded to the backing service essentially verbatim, so only the keys below are acted on locally and unrecognised keys are passed through rather than rejected.", + "The step's status travels as a NAME inside the wrapper (`status`), not as the numeric status id the case-level result endpoints take. The supported names are untested, passed, failed, retest, blocked, skipped, in_progress.", + "There is a decoy: a TOP-LEVEL `status` key is validated against that same list and 400s {error:'Validation error'} when it is not in it \u2014 but it is not the value that gets written. Sending only a top-level status passes validation and logs nothing.", + "Repeat calls for the same project/run/case/step within one second are refused with 400 {success:false, message:'Request is already being processed'} by a one-second lock. That is a de-duplication guard, not a validation failure \u2014 pause and retry.", + "Logging a step result also rewrites the CASE's result in that run: if the wrapper carries any of description, attachments, custom_fields or issues, those are copied onto the case-level result and its status is taken from the step write. A status-only step write leaves the case result's fields alone.", + "`attachments` here are integer blob ids from a prior generic attachment upload \u2014 not filenames and not file content. An id that cannot be resolved, or a blob already attached elsewhere, fails the whole call with 422.", + "Requires execute permission on the run (403 otherwise). `step_id` is the numeric id of the step on the test case \u2014 read it from the case detail; it is not the step's 1-based position.", + "The 200 body is the full CASE result object at the top level \u2014 no envelope, no `success` key, and no separate step-result record. The step's own outcome is nested under `test_run_step_result` on it." + ], + "query": [ + { + "name": "configuration_id", + "type": "integer", + "description": "Target the execution for one configuration when the case runs against a configuration matrix. Forwarded as configId." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target one specific run/case row. Also selects the newer write path, on which the response is an optimistic payload built from the submitted body rather than a re-read of the stored result." + } + ], + "returns": [ + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "attachments", + "issues", + "test_run_step_result", + "links" + ] + }, + { + "name": "create_sub_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mkdir", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/folder/name", + "description": "Name of the new subfolder. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." + }, + { + "name": "notes", + "type": "string", + "json_path": "/folder/notes", + "description": "Free-text description, HTML allowed; sanitised server-side so the stored value can differ from what you sent. The key is `notes` \u2014 `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Integer ids of already-uploaded attachment blobs. Each must belong to this group/project and must be unattached; a reused blob fails the call with 422 and the bare string \"the attachment has been referenced elsewhere\"." + } + ], + "intent": "Create a folder inside an existing folder \u2014 use this to build out a nested test-case tree one level at a time", + "returns": [ + "data", + "success", + "folder", + "id", + "name", + "notes", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__CreateSubFolder" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", + "`folder_id` in the path is the PARENT. It is fetched first, so a folder id from another project comes back as a bare 404 {success:false} with no message telling you which id was wrong.", + "folder_id must be a positive integer. Literal 0 raises an unhandled ArgumentError and surfaces as a 500, not a 400.", + "Sibling-name uniqueness is always checked and the upstream message is relayed verbatim on failure (unusually \u2014 most folder endpoints mask it). auto_rename:\"true\" turns the rejection into a silent rename.", + "The new folder is at `data.folder`; there is no `folder` at the top level and no `project` key on this action (unlike root creation). Carry `data.folder.id` forward.", + "Requires the folder-create permission." + ] + }, + { + "name": "create_template", + "method": "POST", + "path": "/api/v2/templates", + "mode": "write", + "entity": "template", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Template name. Must be unique within the workspace for the entity type; a duplicate is rejected upstream with a 400 carrying the reason.", + "minLength": 1, + "example": "Regression Template" + }, + { + "name": "entity", + "type": "string", + "description": "What the template applies to. Only test-case templates are supported today; leave this out and it defaults to TestCase.", + "values": [ + "TestCase" + ], + "default": "TestCase" + }, + { + "name": "description_fields", + "type": "array", + "description": "Ordered fields placed in the template's description section. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean", + "description": "True for a built-in field, false or absent for a custom field." + }, + { + "name": "is_mandatory", + "type": "boolean", + "description": "Whether a test case using this template must fill the field." + } + ] + }, + { + "name": "properties_fields", + "type": "array", + "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields \u2014 the template gets exactly the ids you send. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + }, + { + "name": "linked_projects", + "type": "object", + "description": "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", + "fields": [ + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Automatically link projects created later.", + "default": false + }, + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "Link every existing project.", + "default": false + }, + { + "name": "link_projects", + "type": "array", + "description": "Integer project ids to link. These are the numeric project ids, not the PR- identifiers." + }, + { + "name": "unlink_projects", + "type": "array", + "description": "Integer project ids to unlink. Meaningless on a create." + } + ] + } + ], + "intent": "Create a new test-case template \u2014 define which description and property fields a test case gets and, optionally, which projects it applies to.", + "guidance": [ + "NON-REMOVABLE SYSTEM FIELDS 17 AND 18 MUST BE INCLUDED or the create fails with 400 `templateFieldValidationError: Non-removable System fields not present in template.` These are the Review & Approve pair. You cannot discover them by reading an existing template: get_template DROPS them from its response (`field_count` 16 vs 14 entries returned, on every template including one just created with all 16), so a read-then-clone round trip always fails. Add {id:17,...} and {id:18,...} to properties_fields explicitly.", + "Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", + "Paid plans only. On a free plan the gate answers with HTTP 200 and a body of success:false / 'This Feature is only for paid customers'. Check the success flag, not the status code, before reporting the template as created.", + "Nothing is validated locally \u2014 name, field ids and project ids all go straight upstream, so a malformed body surfaces as a 400 with a single message rather than a per-field error list.", + "A create does not auto-inject the Review & Approve system fields, but a later update of properties_fields will. The two calls therefore do not behave the same way on the same field list.", + "The 201 body carries the full template including its fields \u2014 read it rather than re-fetching, and keep the returned id.", + "Success is 201 with the full template echoed, linked to the named project. A 403 'This action is not permitted for the user' is a permission gap on the calling credential, not the paid-plan gate." + ], + "returns": [ + "success", + "template", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at", + "description_fields", + "properties_fields", + "step_fields" + ], + "responses": { + "201": { + "$response": "TemplateDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_case_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Create one test case at the project level \u2014 the call to use to seed the FIRST case in a project that has no folders yet, via create_at_root.", + "returns": [ + "success", + "data", + "test_case", + "folder", + "project", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "$schema": "V1TestCaseCreateResponse__CreateTestCaseByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This route has no folder in its path, so you must supply one: either `test_case.test_case_folder_id` (an existing folder) plus a top-level `folder_id` naming the same folder, or `create_at_root: true` to have the folder minted.", + "Without a resolvable folder the create still reaches the backend and then fails while building the response, surfacing as a 422 rather than a clear validation error. Resolve the folder id first.", + "`create_at_root: true` only works on a project with ZERO folders \u2014 it creates a folder called 'New Folder' and puts the case there. On a project that already has folders it is 400 'Root Folder Already Exists'.", + "For any project that already has folders, prefer the folder-scoped create instead; this route exists for the empty-project case.", + "Steps only come from `test_case_steps`, and only when `template`/`template_step_type` selects a step-shaped template \u2014 the default is `test_case_text`, which stores no steps and still returns 200.", + "The new case comes back at `data.test_case`; take its integer `id` and its TC-NNN `identifier` from there. `data.folder`, `data.project` and `data.path_folders` describe where it landed.", + "If the project has e-signature enabled and you set `review_status: in_review` (or `send_for_review`), the call needs a `test_case.reauth_token`; without it the response is 401 `{success:false, error_code:\"reauth_required\"}`.", + "A test case keeps version history, so an edit is recoverable \u2014 but nothing undoes a create except deleting the case." + ], + "body": [ + { + "name": "test_case", + "type": "object", + "required": true, + "description": "The case to create. Everything about the case goes inside this wrapper \u2014 the handler requires it, so a flat body is a 400 before any field is read.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body \u2014 the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." + }, + { + "name": "automation_state", + "type": "integer" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." + }, + { + "name": "case_type", + "type": "integer", + "description": "Numeric case-type option id, or the project's display name for it." + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Ignored on create \u2014 dataset mappings are always regenerated from `test_case_dataset`." + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer", + "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Dropped on create \u2014 the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." + }, + { + "name": "status", + "type": "integer", + "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here \u2014 it is written through as the id and breaks the field." + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string" + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." + } + ] + }, + { + "name": "create_at_root", + "type": "boolean", + "description": "Create the project's first folder ('New Folder') and put this case in it. Only valid when the project has NO folders \u2014 otherwise 400 'Root Folder Already Exists'." + }, + { + "name": "folder_id", + "type": "integer", + "description": "TOP-LEVEL folder id, outside `test_case`. This is what the ownership check reads and what the success response's folder block is built from; without it (and without `create_at_root`) the response build fails and you get a 422." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there. The subfolder is deleted again if the create then fails." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." + } + ] + }, + { + "name": "create_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_case", + "type": "object", + "required": true, + "description": "The case to create. Everything about the case goes inside this wrapper \u2014 the handler requires it, so a flat body is a 400 before any field is read.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body \u2014 the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." + }, + { + "name": "automation_state", + "type": "integer" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." + }, + { + "name": "case_type", + "type": "integer", + "description": "Numeric case-type option id, or the project's display name for it." + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Ignored on create \u2014 dataset mappings are always regenerated from `test_case_dataset`." + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer", + "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Dropped on create \u2014 the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." + }, + { + "name": "status", + "type": "integer", + "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here \u2014 it is written through as the id and breaks the field." + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string" + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." + } + ] + }, + { + "name": "create_at_root", + "type": "boolean", + "description": "Not useful on this route \u2014 the folder is already in the path. It only has an effect on a project with zero folders, where it mints 'New Folder'; otherwise it turns the call into a 400 'Root Folder Already Exists'." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there instead of in the path folder." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." + } + ], + "intent": "Create one test case in a folder \u2014 the normal single-case create. For more than one case, use the bulk create instead.", + "returns": [ + "success", + "data", + "test_case", + "folder", + "project", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "$schema": "V1TestCaseCreateResponse__CreateTestCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the plural name this creates exactly ONE case: the body is a single `test_case` object, not a list. The bulk route is the one that takes an array.", + "`test_case.test_case_folder_id` is required in the BODY as well as in the path \u2014 the path folder is not substituted in, and omitting it is the most common create failure (a 422 wrapping an upstream 404).", + "Steps only come from `test_case_steps`, and only when the template is step-shaped. Neither `template` nor `template_step_type` sent means `test_case_text`, which stores no steps and still returns 200 \u2014 verify `test_case_steps` in the response.", + "priority and case_type accept either the numeric option id or the project's display name; `status` does NOT \u2014 a status name is written through as an id. Resolve the ids from the project's system field values first.", + "`review_status` is never left alone on create: omitted, it is set from the project's review-approve setting, falling back to `pending`. `reviewers` is only honoured on a Team-Pro workspace with review-approve enabled, and including the owner is a 422.", + "The new case is at `data.test_case` \u2014 its integer `id` and TC-NNN `identifier` are both there. `data.request_trace_id` refers to the AI enrichment that continues after the response.", + "Linked `issues` are processed asynchronously after the response, so a 200 does not mean the links exist yet." + ] + }, + { + "name": "create_test_plan_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/test_plan/name", + "description": "Plan name. REQUIRED \u2014 a missing or blank name is 400 {success: false, message: 'Name field cannot be blank'}, and nothing else in the body is looked at." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text. Sanitised against the RTE allowlist and rewritten server-side, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "description": "ISO-8601 date. Stored as midnight UTC. An unparseable value is silently dropped rather than rejected.", + "format": "date", + "json_path": "/test_plan/start_date" + }, + { + "name": "end_date", + "type": "string", + "description": "ISO-8601 date. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and creates nothing.", + "format": "date", + "json_path": "/test_plan/end_date" + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "ACCEPTED BUT NEVER APPLIED ON CREATE \u2014 it is not forwarded upstream, so every new plan starts as `new`. Move it on with a follow-up plan update.", + "json_path": "/test_plan/plan_status" + }, + { + "name": "owner", + "type": "integer", + "description": "Owner as the internal TM user id (the `id` from the project users read \u2014 NOT a BrowserStack user id and not an email). Omit the key to own it yourself. An id that does not resolve inside the caller's group is swallowed, not rejected, and the plan is created with no real owner.", + "json_path": "/test_plan/owner" + }, + { + "name": "parent_plan_id", + "type": "integer", + "description": "Parent plan's INTEGER id. Set it to create a SUB-plan \u2014 the result carries an STP-NNN identifier. Null/omitted means a top-level plan. This choice is effectively permanent: a plan can never later be demoted into a sub-plan or promoted out of one.", + "json_path": "/test_plan/parent_plan_id" + }, + { + "name": "tags", + "type": "array", + "description": "Plain tag strings. If ANY entry is not a string the whole list is silently replaced with [] \u2014 the plan is created with no tags and no error.", + "json_path": "/test_plan/tags" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer user ids. Attach-only \u2014 adding reviewers does NOT send the plan for review, and reviewers are only emailed once the plan is actually in review. Rejected if the project restricts reviewers and one is not on the allowed list.", + "json_path": "/test_plan/reviewers" + }, + { + "name": "attachments", + "type": "array", + "description": "Ids of attachments already uploaded via the attachments surface. An id that does not verify against this group returns 422 with the verifier's own body, not the usual {success, error} envelope.", + "json_path": "/test_plan/attachments" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom-field values keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: }.", + "json_path": "/test_plan/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked tracker issues. Structured \u2014 NOT a flat array of keys. A bare string entry is coerced to {issue_id: }, losing the tracker type. Only url and ticket_id are kept inside metadata; other metadata keys are dropped.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_plan/issues" + }, + { + "name": "test_runs", + "type": "array", + "description": "Test runs to link, as run INTEGER ids (a run's `uuid`, not its TR-NNN `id`). Linking MOVES a run: it leaves whatever plan held it, with nothing in the 200 to say so.", + "json_path": "/test_plan/test_runs" + } + ], + "intent": "Create a test plan to group test runs for a release or milestone \u2014 or create a SUB-plan under an existing plan by setting parent_plan_id", + "responses": { + "200": { + "description": "The created plan under a top-level `test_plan` key (no `data` wrapper). `identifier` is the `TP-NNN` (or `STP-NNN` for a sub-plan) and `id` is the integer the other v1 routes take.\n", + "schema": { + "$schema": "V1TestPlanDetail__CreateTestPlanByIntegerId" + } + }, + "400": { + "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`) or a rejected custom field (`{success: false, error: ...}`). A body with no `test_plan` wrapper lands here too.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" } - }, - "ResultStatus__CreateStepResult": { - "type": "object", - "description": "ResultStatus as returned by `create_step_result`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Also used for an upstream payload rejection: `{success: false, error: \"Invalid Params\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" } - }, - "User__CreateTestResultForTestCase": { + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Every field goes inside a `test_plan` object. A flat body is rejected before any field is read.", + "name is the only required field, and it is checked first \u2014 a 400 {success: false, message: 'Name field cannot be blank'} means exactly that and nothing about the rest of the body.", + "plan_status is accepted and then ignored. A new plan is always `new`; drive it to started/completed with the plan update capability, which needs the matching date in the same request.", + "Bad dates are a 200 trap: end_date < start_date returns HTTP 200 carrying {success: false, error: 'Please check date params'}. Branch on `success`, never on the status code.", + "An upstream rejection of the payload is reported as HTTP 404 with {success: false, error: 'Invalid Params'} \u2014 a 404 from this endpoint usually means a bad body, not a missing project.", + "Linked issues are processed on a background job, so they are usually absent from the plan you get back in this response even though the call succeeded. Re-read the plan to see them.", + "Carry `test_plan.id` (the integer) forward for every other v1 plan route; `test_plan.identifier` is the TP-NNN (STP-NNN for a sub-plan) used for display and by the newer API surface.", + "The reply is the re-read plan under a top-level `test_plan` key \u2014 there is no `data` wrapper." + ], + "returns": [ + "success", + "test_plan", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "start_date", + "end_date", + "owner", + "assignee", + "created_by", + "tags", + "issues", + "attachments", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "total_test_runs_count", + "test_plan_progress_v2", + "record_status", + "project_id", + "created_at", + "updated_at", + "links" + ] + }, + { + "name": "create_test_result_for_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "status_id", + "type": "integer", + "description": "Result status id \u2014 resolve the name (\"Passed\", \"Failed\") to an id first; this field takes the id. Effectively required, though a missing value is accepted and leaves the result unstatused.", + "json_path": "/test_result/status_id" + }, + { + "name": "description", + "type": "string", + "json_path": "/test_result/description" + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_result/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects. Each entry is an OBJECT \u2014 a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_result/issues" + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_result/attachments" + }, + { + "name": "elapsed_time", + "type": "integer", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration this result is for. TOP level \u2014 the server does not read it from inside `test_result`, so nesting it silently logs the result against the default configuration." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target a specific run/case mapping. Top level." + }, + { + "name": "test_case_count", + "type": "integer", + "description": "Total number of test cases linked to the automation execution" + } + ], + "intent": "Create a new test result for a test case in a test run", + "guidance": [ + "This is how a pass or fail is recorded against a case in a run. Editing the run or the case does not record an outcome." + ], + "returns": [ + "id", + "status", + "backtrace", + "configuration_id", + "created_at", + "created_by_imported", + "custom_fields", + "description", + "error_description", + "expanded", + "failure_type", + "finished_at" + ], + "responses": { + "201": { + "$response": "TestResultCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_results_for_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + } + ], + "body": [ + { + "name": "results", + "type": "array", + "minItems": 1, + "description": "Bulk form: one entry per test case. Mutually exclusive with the single form below \u2014 send `results` OR `test_result`, never both. Each entry needs `test_result` plus `test_case_id` or `execution_id`. No other keys are allowed on an entry.", + "fields": [ + { + "name": "test_case_id", + "type": "string", + "description": "Test case identifier, e.g. TC-101. Required unless execution_id is given." + }, + { + "name": "execution_id", + "type": "string", + "description": "Automation execution id, as an alternative target to test_case_id." + }, + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "test_result", "type": "object", - "description": "User as returned by `create_test_result_for_test_case`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + "description": "The outcome itself: status (name), description, issues, step_results. Same shape as the single-result `test_result` below." + }, + { + "name": "attachments", + "type": "array", + "maxItems": 10, + "description": "Multipart file part filenames for this entry. Ignored on a plain JSON request." + } + ] + }, + { + "name": "status", + "type": "string", + "description": "Result status by NAME as configured for the project (\"Passed\", \"Failed\", \"Untested\"). v1's numeric status_id is NOT accepted here. Required unless step_results is sent instead.", + "json_path": "/test_result/status" + }, + { + "name": "description", + "type": "string", + "description": "Notes on the outcome. Rich text is accepted. Silently truncated at 16 KB rather than rejected.", + "json_path": "/test_result/description" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects as plain KEY STRINGS (e.g. [\"JIRA-12\"]) \u2014 unlike v1, which takes objects. Pair with issue_tracker to type them.", + "json_path": "/test_result/issues" + }, + { + "name": "issue_tracker", + "type": "object", + "description": "Types the linked issues, e.g. {\"name\": \"jira\"}. Only read from inside test_result.", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "json_path": "/test_result/issue_tracker" + }, + { + "name": "elapsed_time", + "type": "integer", + "description": "Execution time in seconds. Stored as time_elapsed.", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Result custom fields keyed by field name; resolved against the project's TestResult custom fields.", + "json_path": "/test_result/custom_fields" + }, + { + "name": "step_results", + "type": "array", + "minItems": 1, + "description": "Per-step outcomes. Send these INSTEAD of a top-level status; each entry needs `status` plus `step_id` or `step_index`. The parent case status is rolled up from the steps.", + "fields": [ + { + "name": "step_id", + "type": "number" + }, + { + "name": "step_index", + "type": "number" + }, + { + "name": "status", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "created_at", + "type": "string" + }, + { + "name": "updated_at", + "type": "string" + } + ], + "json_path": "/test_result/step_results" + }, + { + "name": "test_case_id", + "type": "string", + "example": "TC-101", + "description": "Single form: which case in the run this result is for. TOP level \u2014 not inside test_result. One of test_case_id, execution_id or shared_test_case_ids is required." + }, + { + "name": "execution_id", + "type": "string", + "description": "Single form: target an automation execution instead of a case identifier. Top level." + }, + { + "name": "shared_test_case_ids", + "type": "object", + "description": "Single form: target a case shared from another project, as {project identifier: [test case identifiers]}. Must resolve to EXACTLY ONE case \u2014 zero or several gives 400 \"Test Case ID is invalid\"." + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration this result is for. TOP level. Omitting it logs against the default configuration." + }, + { + "name": "attachments", + "type": "array", + "maxItems": 10, + "description": "Filenames of multipart file parts to attach to this result. Only binds on a multipart/form-data request whose JSON body rides in a `payload` part; on a plain JSON request it is accepted and silently does nothing. Each entry is a bare filename string of 1-255 characters." + }, + { + "name": "validate_tc", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Bulk only. \"true\" pre-checks every test_case_id exists in the project and 400s listing the invalid ones. Rejected outright when the request carries 300 or more unique test case ids." + } + ], + "intent": "Record the outcome of running test cases in a test run \u2014 pass/fail/blocked with notes, per-step results, linked defects and attachments \u2014 for one case or for many in a single call", + "guidance": [ + "Two shapes on one endpoint: `results: [...]` for many cases, or `test_result` plus one target (`test_case_id`, `execution_id` or `shared_test_case_ids`) for one. Sending both, neither, or any unrecognised top-level key is rejected 400 as invalid params.", + "The single form answers with `test-result` for an ordinary submission, but a submission carrying `step_results` answers with `test-run-step-results` and `overall-result-status` INSTEAD \u2014 there is no `test-result` key on that response at all. Branch on which key is present rather than assuming.", + "Status is a NAME configured for the project, not an id. An unconfigured name fails that entry with INVALID_STATUS_VALUE.", + "Each submission ADDS a result to the case's history in this run; it never replaces the previous one. To move many cases to one status instead, use bulk_set_test_result_status.", + "The bulk form is queued, not applied inline: 200 means accepted. Read `results[]` \u2014 every entry is ACCEPTED or FAILED. A mix of good and bad entries still returns HTTP 200 but with `success: false` and `status: \"PARTIAL\"`; only an all-bad request returns 400. Treating 200 as 'all logged' will misreport.", + "`test-result.issues` comes back as an array of plain issue KEY STRINGS, matching the way issues are sent in, not as objects.", + "`test-result.urls.self` points at the case's ROW IN THE RUN, so its last path segment is the run row's id, not the id of the result just created. Use `test-result.id` for anything that addresses the result itself.", + "A project can restrict which review statuses may be executed. Blocked cases come back with a REVIEW_STATUS_GATE error and are never logged, whatever the status you sent.", + "Attachments require a multipart/form-data request: JSON body in a `payload` part, each file in its own part, and every name in `attachments` must match a part's filename or the request is rejected. Limits are 10 files per result, 100 per request, 50 MB per file, and filenames must be unique across the request.", + "The `attachments` array in the response is the bind result, not the stored attachment records \u2014 each entry carries the attachment id, the filename, a time-limited url and the expiry of that url. `attachment_errors` appears only when a bind failed, and `dropped_attachments` only on a bulk request where a rejected entry or an unresolvable inline reference left files behind.", + "Attachments cannot be combined with step_results on the same bulk entry \u2014 that request is rejected 400.", + "Success is HTTP 200 here, not the 201 the v1 create returns.", + "Requires the execute permission on the run; a caller without it gets 403 before anything is written." + ], + "returns": [ + "success", + "test-result", + "test-run-step-results", + "overall-result-status", + "results", + "status", + "message", + "attachments", + "attachment_errors", + "dropped_attachments" + ], + "responses": { + "200": { + "$response": "V2TestRunResultSubmitResponse" + }, + "400": { + "description": "Invalid params, unconfigured status for every entry, every case blocked by the review-status gate, or a multipart/attachment rejection. Bulk bodies carry per-case `results` entries with an `error.code` such as INVALID_STATUS_VALUE or REVIEW_STATUS_GATE.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "$schema": "V2BulkResultOutcome" + } + }, + "dropped_attachments": { + "type": "array", + "items": { + "type": "object" + } } - }, - "ResultStatus__CreateTestResultForTestCase": { - "type": "object", - "description": "ResultStatus as returned by `create_test_result_for_test_case`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Rich-text description referenced an attachment that could not be resolved.", + "schema": { + "type": "string", + "example": "attachment in rich text editor is invalid" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "filters", + "type": "object", + "example": { + "priority": [ + "High" + ], + "folder_ids": [ + 105 + ] + }, + "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body \u2014 not inside `test_run`. This does NOT select cases: it never back-fills existing ones, so a create whose only case-bearing key is `filters` returns 200 with an EMPTY run. Use `test_case_ids` or `selection` to put cases in the run, and send `filters` only in addition, when the user asked the run to stay in sync.\nSending a non-empty " + }, + { + "name": "dynamic_filter", + "type": "object", + "example": { + "owner": [], + "status": [], + "priority": [] + }, + "description": "Filter criteria (backward compatible format - use `filters` instead). Top level, same as `filters`, including that it selects no cases and flips the run dynamic." + }, + { + "name": "test_case_ids", + "type": "array", + "example": [ + 2336 + ] + }, + { + "name": "auto_assign", + "type": "boolean" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Selection of cases shared in from other projects. Top level, same as `selection`." + }, + { + "name": "run_state", + "type": "string", + "required": true, + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "new_run", + "description": "MANDATORY. The v1 endpoint validates this against the enum and hard-rejects anything else (including a missing key) with `400 \"invalid data\"`. Use `new_run` for a freshly created run. Note: v2 defaulted this to `new_run` server-side; v1 does NOT.", + "json_path": "/test_run/run_state" + }, + { + "name": "name", + "type": "string", + "example": "Regression \u2013 Login", + "json_path": "/test_run/name" + }, + { + "name": "description", + "type": "string", + "example": "", + "json_path": "/test_run/description" + }, + { + "name": "owner", + "type": "integer", + "example": 2, + "description": "TM user id of the run owner. Unresolvable ids are silently treated as unassigned rather than erroring.", + "json_path": "/test_run/owner" + }, + { + "name": "is_dynamic", + "type": "boolean", + "example": false, + "description": "Keep the run's membership live against `filters`. Must be sent INSIDE `test_run` \u2014 v1 does not read a top-level `is_dynamic` and will silently create a static run if you put it there.", + "json_path": "/test_run/is_dynamic" + }, + { + "name": "configurations", + "type": "array", + "example": [], + "description": "Configuration ids to run against \u2014 ids, not the configuration objects returned on read.", + "json_path": "/test_run/configurations" + }, + { + "name": "test_plans", + "type": "array", + "example": [], + "description": "Test plan ids. Only the first entry is linked; a run belongs to at most one plan.", + "json_path": "/test_run/test_plans" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login" + ], + "json_path": "/test_run/tags" + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues" + }, + { + "name": "environment", + "type": "string", + "json_path": "/test_run/environment" + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_run/attachments" + }, + { + "name": "metadata", + "type": "object", + "json_path": "/test_run/metadata" + }, + { + "name": "build_group_id", + "type": "integer", + "json_path": "/test_run/build_group_id" + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 83, + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "json_path": "/selection/folders" + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Create a test run for a project", + "guidance": [ + "Discovering cases does not put them in a run. Carry the case ids you found into this call \u2014 a create whose only case-bearing field is a filter rule succeeds and creates an EMPTY run.", + "run_state is required and is validated before the selection, so a bare 'invalid data' error means a bad run field, not a bad selection.", + "A filter rule also makes the run keep syncing as cases change, even if you did not ask for that. Create runs static unless the user explicitly wants a run that keeps updating.", + "Dynamic runs match on priority, status, category, owner, automation status, automation state, folder, tags and issues only. Creation and update dates are echoed back but never matched, so a 'last 7 days' dynamic run silently drops the date window and keeps collecting.", + "Preview the selection first if the count matters \u2014 it reports what the selection resolves to before anything is created." + ], + "returns": [ + "id", + "identifier", + "name", + "active_state", + "assignee_imported", + "created_at", + "description", + "environment", + "is_automation", + "is_dynamic", + "observability_url", + "owner" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__CreateTestRunByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "delete_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id holding the option. Not validated by this handler." + }, + { + "name": "option_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric option id to delete, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." + } + ], + "intent": "Permanently remove one choice from a dropdown-style custom field, dropping that value from every case that had it selected", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible, and it takes data with it: every case, result or plan holding this option loses that value, across every project the dataset covers. There is no bin.", + "If the value is simply wrong, rename it through the option update instead \u2014 a rename keeps the option id, so nothing stored is lost.", + "On a nested_dropdown, deleting a parent option orphans the children that pointed at it; list the options first and delete the subtree deliberately.", + "None of the three path ids is validated in this layer, so a wrong or mismatched triple reaches storage and returns a bare 404 {success:false} with no message \u2014 indistinguishable from a permissions failure.", + "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change after the call returns.", + "Success is only `{success: true}`; capture the option's label and id beforehand if you need to report what went." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_custom_field_dataset", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler \u2014 a wrong value surfaces as a bodyless 404 from storage." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Not validated by this handler; a dataset that belongs to a different field returns the same bodyless 404." + } + ], + "intent": "Remove one option set and its project scope from a custom field \u2014 the field survives, but every value its projects stored through this dataset is gone", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. The dataset, all of its options, and the values recorded against those options in every project the dataset linked are removed together. There is no bin.", + "Count and name the affected projects first (read the dataset, or the field with its datasets). Callers routinely think a dataset belongs to one project.", + "Neither path id is validated before the upstream call, so a wrong or mismatched pair reaches storage and returns a bare 404 {success:false} with no message. Confirm the pair with a dataset read first.", + "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape after the call returns.", + "If you only want to stop one project seeing the field, unlink that project through the dataset project-mapping call instead \u2014 narrower, though it also drops that project's stored values.", + "Success is only `{success: true}`; nothing about what was removed is echoed back." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id, as returned by the workspace field list. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Permanently remove a custom field from the workspace, together with every value stored against it in every project that used it", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible and workspace-wide. The field, its datasets, its options and every value stored on every test case / result / plan in every linked project go at once. There is no bin and no restore.", + "Read the field first and report how many projects its datasets link to before asking to proceed \u2014 the caller usually thinks of a field as belonging to one project.", + "A side effect reaches outside the field: saved report filters that referenced this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape minutes after the call returns.", + "The path id is the numeric custom field id. The route only matches digits, and an id of 0 returns 400 'Invalid Params'. A well-formed id that does not exist comes back as a bare 404 {success:false} with no message \u2014 the same body a permissions failure would give.", + "Success is only `{success: true}`; nothing is echoed back, so capture the field's name and id before deleting if you need them for the report." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_dataset", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "destructive", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string. There is no integer-id form of this route." + } + ], + "intent": "Permanently delete a dataset and all of its rows", + "responses": { + "200": { + "description": "Dataset deleted.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "DatasetV2 deleted successfully" } - }, - "TestCaseStep__EditTestCase": { - "type": "object", - "description": "TestCaseStep as returned by `edit_test_case`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project; nothing was deleted." + }, + "422": { + "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible and unguarded: there is no soft delete, no recycle bin and no restore endpoint for datasets, and the handler performs no check for test cases still bound to this dataset \u2014 it deletes and reports success either way.", + "Check what depends on it first with the dataset's linked-test-cases listing, and read the dataset itself if the contents matter, because neither is recoverable afterwards.", + "An unknown uuid is 404 {success:false, message:'Dataset not found.'} \u2014 the lookup happens before the delete, so a 404 means nothing was removed.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad request.", + "The success body is {success:true, message:'DatasetV2 deleted successfully'} \u2014 the internal 'DatasetV2' name is not a typo you can rely on staying.", + "Needs the test-case write permission (tc_write)." + ], + "returns": [ + "success", + "message" + ], + "disabled": true + }, + { + "name": "delete_exploratory_session", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "destructive", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Permanently remove an exploratory session and everything recorded inside it \u2014 every log entry and every defect link goes with it", + "responses": { + "200": { + "description": "Exploratory session deleted successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Forbidden. Insufficient permissions to delete this session.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You don't have permission to delete this exploratory session" } - }, - "TestRunsListResponse__ListClosedTestRuns": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_closed_test_runs`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17578623" - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "updated_by": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - } - } + } + } + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "There is no recycle bin for exploratory sessions. Unlike test cases, a deleted session has no restore path: it and all of its log entries are gone.", + "Permission depends on the session's state, and the handler reads the session before deciding: a closed session needs the closed-test-run delete permission, an active one the ordinary test-run delete permission. The refusal is 403 {success:false, message:\"You don't have permission to delete this exploratory session\"} and nothing is deleted.", + "Because the session is fetched first, an unknown session_id fails as a bare {success:false} 404 with no message.", + "Delete the logs individually first only if you want them removed selectively \u2014 deleting the session needs no cleanup of its children.", + "The response is only {success:true}; there is no echo of what was removed, so capture anything you need from the session before calling this." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_exploratory_session_log", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", + "mode": "destructive", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + }, + { + "name": "log_id", + "type": "integer", + "required": true, + "example": 789 + } + ], + "intent": "Permanently remove one observation from an exploratory session, discarding its note, outcome, elapsed time and defect links", + "responses": { + "200": { + "description": "Session log deleted successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Both ids are integers and both must match: log_id is only addressable under the session_id that owns it.", + "There is no bin or restore for session logs \u2014 the entry, its embedded screenshots' links and its defect links are gone.", + "This removes one log only. It does not touch the issues linked at session level, so a defect raised here can still appear in the session's defects listing through the session-level link.", + "The session's session_log_count drops, which can move a session back below the 50-log threshold that pushes a clone onto the asynchronous path.", + "The response is only {success:true}. An unknown log id surfaces as a bare {success:false} 404 with no message.", + "Deleting a log needs the test-run delete permission \u2014 the same permission as deleting a whole session, with no separate closed-session variant at log level." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_filter", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "destructive", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete a saved filter view from a project permanently", + "responses": { + "200": { + "$response": "FilterDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Removes the saved view only. The test cases, runs or plans it selected are untouched \u2014 this deletes a query, not data.", + "There is no undo and no bin for filter views, and no version history to restore from. Recreating one means re-authoring the whole condition set, so read the filter first if you might want it back.", + "A project-level view is deleted for everyone on the project, not just for you, and the filter controller is not RBAC-gated \u2014 any user with product access to the project can delete a view someone else created.", + "The success body is `{success:true}` and nothing else: no id echo, no copy of what was deleted, nothing to carry forward.", + "Deleting an id that does not exist comes back as 400 `{success:false, message:...}` rather than 404 \u2014 only the backing service's 400 is translated here, and its other failures surface as a 500." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_folder_and_contents", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm", + "mode": "destructive", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Permanently delete a folder together with everything beneath it \u2014 every nested subfolder and every test case in the whole subtree", + "returns": [ + "data", + "success", + "folder", + "path_folders", + "id", + "name", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "description": "Folder and its contents deleted successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder" }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 60 - }, - "closed": { - "type": "integer", - "example": 1 - } - } + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } + "success": { + "type": "boolean" } + } } - }, - "Dataset__GetDataset": { - "type": "object", - "description": "Dataset as returned by `get_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__GetDataset" - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is recursive and total. It removes the folder, all of its descendant folders, and every test case in that entire subtree \u2014 not just the cases filed directly in it. There is no option to keep the contents or to delete the folder only.", + "Run the folder remove-summary dry run first. It is the only way to see, before committing, how many active test cases, archived test cases, subfolders and low-code test recordings this call will destroy.", + "Recoverability depends on a group feature flag you cannot read from this API. When Move to Bin is enabled for the group, the folder's TEST CASES land in the project recycle bin and can be listed and restored from there until they auto-purge. When it is not enabled, the test cases are deleted outright.", + "The FOLDERS themselves are never recoverable, under either setting. Nothing in this API restores a deleted folder \u2014 the bin holds test cases only, and restored cases do not bring their folder tree back with them. Treat the folder structure as gone and be ready to recreate it.", + "Do not plan on the undo-remove endpoint as your way back. It is a dead alias that re-issues this same delete; it does not restore anything (see the flag on delete_folder_and_subtree).", + "Linked low-code test recordings belong to a different product and are destroyed too. The summary's has_other_product_entity flag is true exactly when the subtree contains such recordings \u2014 check it before deleting if anyone else depends on them.", + "Requires the folder-delete permission (403 otherwise), which is a higher bar than the edit permission most other folder writes need.", + "On success the deleted folder is echoed at `data.folder` with cases_count and total_cases_count forced to 0 \u2014 those zeros are cosmetic, not a report of what was removed. `data.path_folders` is the surviving breadcrumb of the deleted folder's parent, useful for re-pointing a UI.", + "Failure does not use the usual envelope: an upstream rejection is relayed as the raw upstream body with the upstream status code, so do not expect {success:false, message} to be there to parse.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." + ], + "disabled": true + }, + { + "name": "delete_report_schedule", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/reports/schedules/{schedule_id}", + "mode": "destructive", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "schedule_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[query]", + "type": "string", + "description": "Only filters the listing returned AFTER the delete (matches report type, title, or owner name) \u2014 it does not scope what is deleted." + }, + { + "name": "q[scheduled]", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Only the exact string `true` does anything (restricts the returned listing to scheduled reports). `false` or any other value is ignored and returns everything." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the listing returned after the delete. Fixed 30 per page; past the last page returns 400." + } + ], + "intent": "Permanently delete a report \u2014 the whole saved report and its filters, not just its email schedule", + "returns": [ + "success", + "data", + "scheduled_report_count", + "total_report_count", + "info" + ], + "responses": { + "200": { + "$response": "DeleteScheduleResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This deletes the report record itself. Despite the path, there is no way to remove only the schedule: to stop the emails but keep the report, edit it and omit `frequency` / `frequency_details` instead.", + "The delete is unconditional and silent. A `schedule_id` that does not exist, or belongs to another group, still returns 200 with `success: true` \u2014 there is no 404 and no count of what was removed. Confirm by checking that the returned `data` list no longer contains the id.", + "The report is matched on group + id only; the project in the path does not scope it, so an id belonging to a sibling project in the same group IS deleted.", + "The response body is not the deleted report \u2014 it is the CURRENT page of the caller's remaining reports, plus `scheduled_report_count` and `total_report_count`. Treat it as a fresh listing.", + "That embedded listing is paged at a fixed 30 per page, honours `p`, and `p` past the last page returns 400 `page_out_of_bounds`. Do not page it by re-issuing the delete \u2014 call list_schedules instead.", + "Deleting also removes the report's filter rows and mail recipients and propagates a delete to Test Observability; there is no undo and no restore endpoint. Requires the report_delete permission (403 otherwise)." + ], + "disabled": true + }, + { + "name": "delete_shared_step", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "destructive", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete a shared step from a project permanently \u2014 every test case that embeds the block loses it, in one operation", + "responses": { + "200": { + "$response": "SharedStepDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the widest single delete in the shared-step surface. A shared step is embedded by reference by many test cases at once, so removing it takes that block out of all of them together. The blast radius is not visible from the id \u2014 read the shared step, or find its row in the shared-step listing, and look at `test_case_count`. That number is exactly how many test cases this affects; anything above zero means you are editing that many cases, not deleting an unused fragment.", + "The delete is not blocked by references. A block used by hundreds of cases deletes as readily as an unused one, and nothing warns you.", + "There is no undo: shared steps have no bin, no archive and no version history. Recreating a block with the same title and steps does NOT re-link the test cases that referenced the old one \u2014 their references are gone and each case has to be re-edited by hand. Capture the block's steps (and, if it matters, which cases used it) before deleting.", + "Deleting the block is not the way to stop one test case using it \u2014 edit that test case's steps instead. This call is for retiring a fragment project-wide.", + "Success is 200 with `{success:true}`, not 204 with an empty body. Do not treat a body-less 204 as the success signal.", + "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}` before any call is made \u2014 and the route segment only matches digits, so a non-numeric id never reaches the handler at all.", + "Requires tc_write, the same permission as editing a test case; there is no separate shared-step delete permission, so anyone who can edit cases can do this." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_template", + "method": "DELETE", + "path": "/api/v2/templates/{id}", + "mode": "destructive", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id \u2014 a plain integer with no prefix, discovered from the template list.", + "pattern": "^[0-9]+$" + } + ], + "intent": "Permanently delete a template together with its field and project links \u2014 there is no bin and no undo; disable it instead if you might want it back.", + "guidance": [ + "This removes the template record itself, along with its field mappings and its project mappings. Test Management exposes no restore for a deleted template.", + "If the goal is only to stop people picking the template for new test cases, DISABLE it instead \u2014 disabling is reversible and keeps the fields and project links intact.", + "System templates and the workspace default template cannot be deleted; the attempt comes back as a 400 with the reason.", + "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is deleted \u2014 check the flag, not the status code.", + "Success returns only success:true, with no record of what was removed. Read the template first if you need its name or field list for the record." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TemplateWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "delete_test_case_attachment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments/{attachment_id}/delete", + "mode": "destructive", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "attachment_id", + "type": "string", + "required": true + } + ], + "intent": "Remove one file from a test case and purge the stored blob \u2014 takes a screenshot, log or document off the case for good", + "returns": [ + "data", + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "$response": "TestCaseAttachmentDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "LIKELY BROKEN, and dangerous to retry. It matches the attachment by comparing a stored integer id against the id from the path, which arrives as text, so the match fails and the next line dereferences nothing \u2014 a bare 500. Because the purge is queued before the response, treat a 500 here as an UNKNOWN outcome rather than a failure, and re-read the case before assuming nothing was removed.", + "The response is wrapped in `data`: read data.attachments for the removed record and data.success. This action wraps; the attachment list and upload on the same path do not. Reading a top-level `attachments` finds nothing.", + "Irreversible. The link is removed AND the underlying stored file is scheduled for purge, so the file itself goes, not just the association. There is no undo and no recycle bin for attachments.", + "`attachment_id` must be an id from this case's own attachment listing. The handler looks it up inside the case's in-memory attachment list and does not return a clean 404 when it is absent, so verify the id with a list read first rather than probing.", + "`folder_id` is in the route but is NOT used to find the case \u2014 the lookup is by project and test case id, so a stale folder id after a move still resolves.", + "Note the shape: a POST to .../delete, not a DELETE.", + "The v2 attachment surface is the maintained one for listing and reading attachments; use this only when you specifically need to remove a v1 test-case attachment." + ], + "disabled": true + }, + { + "name": "delete_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", + "mode": "destructive", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Permanently delete a test plan or sub-plan \u2014 there is no recycle bin and no undo; archive it instead if there is any chance it is wanted back", + "responses": { + "200": { + "description": "Plan deleted: `{success: true}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No such plan: `{success: false, error: \"Test Plan not found\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" } - }, - "DatasetUserRef__GetDataset": { - "type": "object", - "description": "DatasetUserRef as returned by `get_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. Plans have no bin and no version history, so a deleted plan cannot be restored by any capability here \u2014 the reversible option is bulk-archive, which keeps the plan and can be undone with bulk-retrieve.", + "The path segment is the plan's INTEGER id. A TP-NNN / STP-NNN identifier does not match the route and never reaches the handler.", + "The only miss signal is 404 {success: false, error: 'Test Plan not found'}; a success is a bare {success: true} with no echo of what was removed. Read the plan first if you need to tell the user what is about to go.", + "Needs the test-plan DELETE permission, which is separate from the write permission used by create/update.", + "Deleting emits an activity-feed event and a personal alert to the plan's owner, so the side effect is visible outside this API.", + "What happens to the plan's sub-plans and to the test runs it held is decided upstream and is not echoed in the response \u2014 read the sub-plans and linked runs first if that matters." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_test_result", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-results/{test_result_id}/delete", + "mode": "destructive", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_result_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete one logged test result by its id \u2014 removes that single execution record while leaving the case's other attempts in the run intact", + "responses": { + "200": { + "description": "Result deleted.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Caller is neither the result's author nor a project admin." + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Authorship-gated: only the user who logged the result may delete it, unless the caller is a project admin. Anyone else gets 403 {success:false, message:\"You can delete only results logged by you, unless you're a project admin\"} and nothing is removed.", + "Irreversible \u2014 there is no recycle bin for results. After the delete the case's outcome in that run rolls back to its previous attempt, or to unexecuted if this was the only result.", + "Note the shape: a POST to .../delete, not a DELETE, and `test_result_id` must be numeric \u2014 the route only matches digits, so a TR-/TC-style identifier 404s at routing.", + "To remove several results use the bulk delete instead: it authorises per id and reports what it skipped, whereas this one refuses the entire call on the first authorisation failure.", + "Success is only {success: true} \u2014 the deleted record is not echoed back, so capture anything you need from it first." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/delete", + "mode": "destructive", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "intent": "Permanently delete a test run and every result recorded in it \u2014 there is no bin and no restore", + "returns": [ + "data", + "success", + "testrun", + "project", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "project_id", + "created_at", + "updated_at", + "links" + ], + "responses": { + "200": { + "$response": "TestRunWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. A test run has no version history and no recycle bin: the run, its case mappings and every result recorded against them go, and nothing in this API brings them back. Confirm with the user before calling.", + "Deleting a CLOSED run needs a different permission (closed-run delete) from deleting an open one \u2014 a role that can delete working runs can be 403'd on a finished one. Automation runs demand the automation variant again.", + "The run's build in Test Observability is archived FIRST, and a rejection there aborts the delete, so a failure can leave the run intact. Re-read before assuming it is gone.", + "The run is also stripped out of every saved report filter that referenced it; those reports silently narrow rather than erroring.", + "The deleted run is echoed back under `data.testrun` \u2014 one word, no underscore \u2014 with `data.project`. Do NOT read `data.test_run` here; that key does not exist on this response.", + "The path accepts either the TR-NNN identifier or the integer id, but the body key and every downstream reference wants the integer from `uuid`." + ], + "disabled": true + }, + { + "name": "discard_duplicate", + "method": "PUT", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", + "mode": "write", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id, from the `id` of a listing row. Here it is a path segment \u2014 unlike the resolve call, which takes it in the body." + } + ], + "body": [ + { + "name": "discard_reason", + "type": "string", + "description": "Short reason code or label for why this is not a duplicate. Flat in the body (no wrapper object). Optional and unvalidated \u2014 no closed set is enforced, and omitting it stores null." + }, + { + "name": "user_feedback", + "type": "string", + "description": "Free-text feedback. Flat in the body. Stored under a different name (`discard_feedback`) than the one you send, so do not expect it echoed back under this key." + } + ], + "intent": "Dismiss a duplicate suggestion as wrong \u2014 removes it from the review queue and feeds the reason back to the model, without touching either test case", + "responses": { + "200": { + "description": "Duplicate discarded. Body is {success:true} only.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "The caller lacks the test-case write permission (tc_write)." + }, + "404": { + "description": "{success:false, error:'Duplicate not found.'} \u2014 no such pair for this project, or it is no longer in `suggested` status." + }, + "409": { + "description": "{success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} \u2014 a dedupe data migration is in progress. Nothing was discarded; retry later." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the 'not a duplicate' verdict. Neither test case is touched \u2014 nothing is archived, merged, commented on or tagged. Use the resolve call when a case should actually go away.", + "The body is flat: `discard_reason` and `user_feedback` sit at the top level, with no wrapper object and no request-schema check, so a misspelled key is silently dropped and you get 200 with the feedback lost.", + "One-way. The pair moves to `discarded` and leaves every listing; there is no un-discard endpoint and the pair can no longer be read, so confirm the verdict before calling.", + "Suggested-only: a pair already merged, archived or discarded is 404 {success:false, error:'Duplicate not found.'} \u2014 usually 'already handled' rather than 'never existed'.", + "Undeclared until now, and retryable: while a dedupe data migration is in progress this is refused with 409 {success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. Nothing was discarded; retry later.", + "Note the envelope differs from the resolve call, which uses `errors` for its failures where this one uses `error`.", + "Needs the test-case write permission (tc_write) \u2014 lighter than the delete permission the resolve call demands." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "download_report", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/download", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv", + "pdf", + "xlsx" + ], + "example": "csv", + "description": "Export format, as a single STRING. Anything outside the three values is 422 `Invalid file_type.`. Note the same-named field on send-email-now is an ARRAY." + }, + { + "name": "required", + "type": "object", + "description": "Optional column selection, honoured only for `Test Run Detailed Report` and `Test Plan Detailed Report`; ignored elsewhere. Shape `{ \"\": { \"fields\": [\"id\",\"title\",...] } }`; `fields` may also be a comma-separated string, and `[\"all\"]` expands to every supported column. Invalid names are NOT rejected here \u2014 the export silently reverts to defaults." + }, + { + "name": "columns", + "type": "object", + "description": "Alias for `required`, and it WINS when both are sent. Same shape and same silent-fallback behaviour." + } + ], + "intent": "Kick off an async export of a report to CSV, PDF or XLSX \u2014 returns a job channel, not the file", + "returns": [ + "channel", + "success" + ], + "responses": { + "200": { + "$response": "DownloadReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This returns immediately with `{ channel: , success: true }` and nothing else. The export runs in a background worker and the finished file's URL is pushed over a realtime channel named from that uuid \u2014 there is NO endpoint to poll it and no way to fetch the artefact through the API. If you need the file itself, use the send-email-now capability and have it mailed, or hand the channel to a UI client.", + "No bytes come back from this call and no download URL appears anywhere in the response \u2014 a `channel` that you cannot resolve is the whole result, so do not report the export as delivered.", + "`file_type` is a single STRING here (`csv` / `pdf` / `xlsx`). The same-named field on send-email-now is an ARRAY \u2014 they are not interchangeable. An unsupported value is 422 `Invalid file_type.`, not 400.", + "Column selection (`required`, or its alias `columns`, which wins when both are sent) is only honoured for `Test Run Detailed Report` and `Test Plan Detailed Report`. On every other report type it is ignored. Shape is `{ \"\": { \"fields\": [...] } }` with entities test_run_test_case / test_run / test_result (and test_plan for the plan report).", + "Unlike the on-screen read, this path does NOT validate your column list: a misspelled field name is not an error here \u2014 the worker silently falls back to the report's default columns, so the export arrives missing what you asked for. Verify field names against the report read first.", + "`fields: [\"all\"]` for an entity expands to every supported column for that entity.", + "The report is matched on group + id, so the project in the path does not scope the lookup; an unknown id is 404 `Report not found.`. A `Test Plan Detailed Report` on a group without the feature flag is 403 `Operation not permitted`. Requires the report_view permission." + ], + "disabled": true + }, + { + "name": "edit_test_case_partial", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit-v2", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append \u2014 any currently-attached id missing from the array is detached. Read the case's existing attachments first and send them back alongside the new ids.", + "json_path": "/test_case/attachments" + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, given as an internal_name string (e.g. `not_automated`, `automated`). Applied only when `automation_state` is omitted.", + "json_path": "/test_case/automation_status" + }, + { + "name": "case_type", + "type": "integer", + "example": 490, + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "123": "option_value", + "456": [ + "multi", + "select" + ] + }, + "json_path": "/test_case/custom_fields", + "description": "Map of custom-field id to value, applied only when the key is present AND non-empty \u2014 `{}` is a no-op here (it is the BULK edit that 500s when the key is missing). Values replace the field's current value." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description" + }, + { + "name": "estimate", + "type": "string", + "description": "ACCEPTED BUT NOT PERSISTED \u2014 passes validation and is then dropped before the write, on this path as well as create. Use `estimated_duration_seconds`; never report an estimate as saved.", + "json_path": "/test_case/estimate" + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS; `null` clears it. This is the field that actually stores an estimate.", + "json_path": "/test_case/estimated_duration_seconds" + }, + { + "name": "expected_result", + "type": "string", + "description": "Overall expected outcome, distinct from a step's own `result`.", + "json_path": "/test_case/expected_result" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Must be `true` for a `test_case_dataset` change to be applied; with any other value the dataset payload is ignored.", + "json_path": "/test_case/is_dataset_modified" + }, + { + "name": "issues", + "type": "array", + "example": [ + { + "issue_id": "TM-1234", + "issue_type": "jira" + } + ], + "description": "Linked issues/defects. Processed ASYNCHRONOUSLY after the response returns, so a 200 does not mean the links exist yet \u2014 re-read the case to confirm.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "name", + "type": "string", + "example": "Verify login functionality", + "description": "The name/title of the test case.", + "json_path": "/test_case/name" + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_case/owner", + "description": "TM user id. Unlike the POST edit path, an id that does not resolve is silently skipped rather than rejected, so the owner is left unchanged with a 200." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "example": 483, + "json_path": "/test_case/priority", + "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state, e.g. `pending` / `in_review` / `approved`. Unlike POST .../edit, omitting it here leaves the stored value untouched.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled on the project \u2014 otherwise silently ignored. Including the owner is rejected with 422 'Owner cannot be a reviewer'. This path does NOT run the send-for-review checks or send review emails.", + "json_path": "/test_case/reviewers" + }, + { + "name": "status", + "type": "integer", + "example": 496, + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "steps", + "type": "string", + "description": "LEGACY \u2014 use `test_case_steps` instead. This param skips the request allowlist and is read with JSON.parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200.", + "json_path": "/test_case/steps" + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "smoke" + ], + "description": "Tag names. `[]` removes all tags; omit the field to keep the existing ones.", + "json_path": "/test_case/tags" + }, + { + "name": "template", + "type": "string", + "description": "Template NAME (the same values the create paths accept) \u2014 not an id. Sending an integer is rejected with 422 \"Invalid template value \"; use `template_id` for the numeric custom-template reference.", + "json_path": "/test_case/template" + }, + { + "name": "template_id", + "type": "integer", + "description": "Custom-template id.", + "json_path": "/test_case/template_id" + }, + { + "name": "template_step_type", + "type": "string", + "description": "Takes precedence over `template` when both are sent.", + "json_path": "/test_case/template_step_type" + }, + { + "name": "test_case_dataset", + "type": "object", + "description": "Dataset binding for a data-driven case, as an OBJECT \u2014 the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, read it and send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as returned. It is a full replace of the case's binding, and on this path it is applied only when `is_dataset_modified` is true. The edit answers only {success:true}, so re-read the case (or the dataset's linked-test-cases listing) to confirm the binding landed.", + "fields": [ + { + "name": "variables", + "type": "array" + }, + { + "name": "rows", + "type": "array" + } + ], + "json_path": "/test_case/test_case_dataset" + }, + { + "name": "test_case_folder_id", + "type": "string", + "description": "Move the case to a different folder (integer id). Dropped server-side when it matches the current folder, so passing the existing folder is a safe no-op.", + "json_path": "/test_case/test_case_folder_id" + }, + { + "name": "test_case_steps", + "type": "array", + "example": [ + { + "order": 1, + "step": "Navigate to the login page", + "result": "The login page is displayed" + }, + { + "order": 2, + "step": "Submit valid credentials", + "result": "The user lands on the dashboard" + } + ], + "description": "The structured step list \u2014 same shape as the create body. `order` is filled in by position when omitted. `[]` removes all steps; omit the field to keep them.", + "fields": [ + { + "name": "order", + "type": "integer" + }, + { + "name": "step", + "type": "string" + }, + { + "name": "result", + "type": "string" + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps" + }, + { + "name": "third_party_identifier", + "type": "string", + "json_path": "/test_case/third_party_identifier", + "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." + }, + { + "name": "isMergedDuplicate", + "type": "boolean", + "json_path": "/test_case/isMergedDuplicate", + "description": "Compared to the literal boolean `true`. Tells the backend to keep the case's existing attachments rather than reconciling them against `attachments`. Used by the duplicate-merge flow." + } + ], + "intent": "Change just the fields you name on one test case, leaving everything you omit untouched \u2014 the safe path for an inline single-field edit.", + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TestCaseWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "The response is `{success: true}` and NOTHING ELSE. It does not return the updated case, so re-read the case if you need to confirm or show the new values. (The POST .../edit sibling does return `data.test_case`.)", + "Only the keys present in `test_case` are applied \u2014 this is the partial-update path. The POST .../edit on the same case rebuilds more of the record, including resetting `review_status`.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "`steps` is legacy and is read with a JSON parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200. Use `test_case_steps`.", + "`attachments` is the complete set the case should end up with, not an append \u2014 anything currently attached and missing from the array is detached. `test_case_steps: []` and `tags: []` likewise clear; omit the key to keep what is there.", + "A workflow that forbids the status transition comes back as 422 with `code: \"transition_not_allowed\"` and a `violations` list \u2014 that is a rule rejection, not bad input.", + "This path does not accept `send_for_review` or `shared_precondition_id`, and does not run the review-payload checks; use the POST .../edit path for a send-for-review flow.", + "Every successful edit writes a new version, so an edit is recoverable by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "edit_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "json_path": "/test_case/attachments", + "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append \u2014 anything currently attached and missing from the array is detached. Read the case's existing attachments first." + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, taken as an internal_name string (e.g. `not_automated`, `automated`). Only applied when `automation_state` is omitted. Prefer `automation_state`.", + "json_path": "/test_case/automation_status" + }, + { + "name": "case_type", + "type": "integer", + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_case/custom_fields", + "description": "Map of custom-field id to value. Values replace the field's current value; a field id the project does not define is rejected by the backend with a field-named 400 message." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description" + }, + { + "name": "estimate", + "type": "string", + "description": "ACCEPTED BUT NOT PERSISTED \u2014 the field passes request validation and is then dropped before the write on both the create and the edit paths. Use `estimated_duration_seconds` instead; do not report an estimate as saved.", + "json_path": "/test_case/estimate" + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. `null` clears it. This is the field that actually persists an estimate.", + "json_path": "/test_case/estimated_duration_seconds" + }, + { + "name": "expected_result", + "type": "string", + "description": "Overall expected outcome (distinct from a step's own `result`). Rich-text field \u2014 send plain text/HTML, not a JSON document.", + "json_path": "/test_case/expected_result" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Set with `test_case_dataset` on an edit to signal the dataset changed; on create the mappings are always regenerated and this is ignored.", + "json_path": "/test_case/is_dataset_modified" + }, + { + "name": "issues", + "type": "array", + "json_path": "/test_case/issues", + "description": "Linked issues as `[{issue_id, issue_type, metadata?}]` (bare strings are coerced to ``issue_id``). Processed ASYNCHRONOUSLY after the response, so a 200 does not mean the links exist yet." + }, + { + "name": "name", + "type": "string", + "json_path": "/test_case/name" + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_case/owner", + "description": "TM user id. An id that is not a member of the group is silently replaced with the caller rather than rejected." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "json_path": "/test_case/priority", + "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state, e.g. `pending` / `in_review` / `approved`. When omitted it is set from the project's review-approve setting, falling back to `pending` \u2014 it is never left untouched on create or on POST .../edit.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Only honoured when the workspace is on a Team-Pro plan AND the project has review-approve enabled; otherwise silently ignored. Including the `owner` is rejected with 422 \"Owner cannot be a reviewer\".", + "json_path": "/test_case/reviewers" + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "EDIT PATHS ONLY \u2014 drives the per-reviewer review-request email. Dropped without error on create (the create flow already notifies from `reviewers` + `review_status`) and not accepted by PATCH .../edit-v2.", + "json_path": "/test_case/send_for_review" + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body.", + "json_path": "/test_case/shared_precondition_id" + }, + { + "name": "status", + "type": "integer", + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "tags", + "type": "array", + "json_path": "/test_case/tags" + }, + { + "name": "template", + "type": "string", + "json_path": "/test_case/template", + "description": "Step-shape template NAME, not an id. When neither this nor `template_step_type` is sent it DEFAULTS to `test_case_text` on this path \u2014 which silently discards `test_case_steps`. Always send it on an edit that touches steps." + }, + { + "name": "template_id", + "type": "integer", + "json_path": "/test_case/template_id" + }, + { + "name": "template_step_type", + "type": "string", + "description": "Step shape \u2014 `test_case_steps` | `test_case_text` | `test_case_bdd`. OPTIONAL; when sending `template_id`, use that template's own `step_type` from the templates listing rather than assuming.", + "json_path": "/test_case/template_step_type" + }, + { + "name": "test_case_dataset", + "type": "object", + "description": "Dataset binding for a data-driven case, as an OBJECT \u2014 the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as the dataset read returns them. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here; on an edit, send `is_dataset_modified: true` alongside it.", + "fields": [ + { + "name": "variables", + "type": "array" + }, + { + "name": "rows", + "type": "array" + } + ], + "json_path": "/test_case/test_case_dataset" + }, + { + "name": "test_case_folder_id", + "type": "string", + "description": "Target folder's integer id. On create this is REQUIRED IN THE BODY as well as in the path \u2014 omitting it is the most common create failure (422 wrapping an upstream 404). Integer or string are equivalent; the server coerces with .to_i, so the distinction does not matter.", + "json_path": "/test_case/test_case_folder_id" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The ONLY key the server reads for steps \u2014 on single create, bulk create and the update paths alike. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is silently ignored and the create still returns 200 for a case with NO steps, so verify against `test_case_steps` in the response.", + "json_path": "/test_case/test_case_steps" + }, + { + "name": "steps", + "type": "string", + "json_path": "/test_case/steps", + "description": "LEGACY \u2014 use `test_case_steps`. Read with a JSON parse, so it must be a JSON-encoded STRING. A real JSON array parses to `[]` and WIPES the case's steps while still returning 200." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "description": "One-shot re-authentication token, required only when the project has e-signature on send-for-review enabled AND this edit sets `send_for_review` or `review_status: in_review`. Missing or expired is 401 with `error_code: \"reauth_required\"`." + }, + { + "name": "third_party_identifier", + "type": "string", + "json_path": "/test_case/third_party_identifier", + "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." + }, + { + "name": "isMergedDuplicate", + "type": "boolean", + "json_path": "/test_case/isMergedDuplicate", + "description": "Compared to the literal boolean `true`. Keeps the case's existing attachments instead of reconciling them against `attachments`. Used by the duplicate-merge flow." + } + ], + "intent": "Edit one test case through the full edit form \u2014 rename it, rewrite its steps, preconditions and expected result, retag it, or send it for review.", + "returns": [ + "success", + "data", + "test_case" + ], + "responses": { + "200": { + "$response": "V1TestCaseWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "This is the FULL edit path, not a patch: `review_status` is recomputed on every call (omitted, it falls back to the project's review-approve default), and `template` defaults to `test_case_text` when you omit it, which discards any `test_case_steps` you sent. Send the case's current template unless you mean to change it.", + "For a targeted single-field change prefer the PATCH .../edit-v2 sibling, which applies only the keys you send.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "Array fields REPLACE rather than append: `attachments`, `tags` and `test_case_steps` are each the complete intended set, and `[]` clears. Read the case first and send the union.", + "Reviewers and the owner cannot overlap: this path rejects it up front with 400 `code: \"OWNER_AS_REVIEWER\"`, and sending `send_for_review` with no reviewers is 400 `code: \"NO_REVIEWERS\"`. (Create, which skips that check, surfaces the same conflict as a 422.)", + "If the project has e-signature enabled, an edit that sets `send_for_review` or `review_status: in_review` needs `test_case.reauth_token`; without it the answer is 401 `{success:false, error_code:\"reauth_required\"}`.", + "The updated case comes back at `data.test_case` \u2014 note the `data` envelope, and note that `estimate` always serialises as null regardless of what you sent.", + "Every successful edit writes a new version, so this is recoverable by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "edit_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "run_state", + "type": "string", + "required": true, + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "description": "MANDATORY on every edit, even one that is not changing the state \u2014 resend the run's current value. It is validated against the enum before anything else, so a missing or misspelled value returns 400 {success:false, message:'invalid data'} with no hint about which field was wrong. `closed` is one-way: a closed run cannot be reopened, and this call does not do it. The full edit: on a closed run it returns success and leaves the run closed.", + "json_path": "/test_run/run_state" + }, + { + "name": "name", + "type": "string", + "example": "Regression - Login", + "description": "Only applied when non-empty. Sending \"\" leaves the existing name rather than clearing it.", + "json_path": "/test_run/name" + }, + { + "name": "description", + "type": "string", + "example": "", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent. Unlike name, an empty string here DOES clear the description.", + "json_path": "/test_run/description" + }, + { + "name": "owner", + "type": "integer", + "example": 2, + "description": "Run owner as a TM user id (a BrowserStack user id is also accepted and resolved). An id that matches nobody in the group is silently treated as unassigned \u2014 the run's owner is cleared rather than the call failing.", + "json_path": "/test_run/owner" + }, + { + "name": "is_dynamic", + "type": "boolean", + "example": false, + "description": "Keep the run's membership live against `filters`. On EDIT a TOP-LEVEL `is_dynamic` is also read and OVERRIDES this one; on create only the nested form exists. Send it in one place only. Omit the key entirely to leave the run's current dynamic flag alone \u2014 sending false on a full resend flips a dynamic run back to static.", + "json_path": "/test_run/is_dynamic" + }, + { + "name": "configurations", + "type": "array", + "example": [], + "description": "Configuration ids (integers) the run executes against \u2014 not the configuration objects returned on read.", + "json_path": "/test_run/configurations" + }, + { + "name": "test_plans", + "type": "array", + "example": [], + "description": "Test plan ids. Only the FIRST entry is used; a run belongs to at most one plan. Send the plan's integer id.", + "json_path": "/test_run/test_plans" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login" + ], + "description": "Plain tag strings. The list you send REPLACES the run's tags \u2014 omit the key to leave them alone, since there is no history to restore a cleared list from.", + "json_path": "/test_run/tags" + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "description": "Linked defects. Each entry is an object; a bare string is coerced to {issue_id: ...} and loses the tracker type. Linking kicks off an asynchronous fetch of the ticket metadata, so titles appear on a later read.", + "json_path": "/test_run/issues" + }, + { + "name": "environment", + "type": "string", + "json_path": "/test_run/environment" + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the run. Sending [] to remove every attachment only works where the clear-all behaviour is enabled for the group; elsewhere an empty array is a no-op. An id that does not verify fails the whole edit with 422 'attachment is invalid'.", + "json_path": "/test_run/attachments" + }, + { + "name": "metadata", + "type": "object", + "description": "Free-form metadata. Only forwarded when non-empty; sending {} does not clear it.", + "json_path": "/test_run/metadata" + }, + { + "name": "build_group_id", + "type": "integer", + "description": "Only forwarded when present; there is no way to unset it through this call.", + "json_path": "/test_run/build_group_id" + }, + { + "name": "filters", + "type": "object", + "example": { + "priority": [ + "High" + ], + "folder_ids": [ + 105 + ] + }, + "fields": [ + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "automation_status", + "type": "array" + }, + { + "name": "automation_state", + "type": "array" + }, + { + "name": "folder_ids", + "type": "array" + }, + { + "name": "issue_ids", + "type": "array" + }, + { + "name": "reviewers", + "type": "array" + }, + { + "name": "review_status", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body \u2014 NOT inside `test_run`. It selects nothing and never back-fills, so an edit whose only case-bearing key is `filters` returns 200 having added none. To add cases use bulk_update_test_run_test_cases with add_test_cases \u2014 test_case_ids and selection here replace the run's membership rather than adding to it. Every `owner`/`reviewers` entry must resolve to a user in this group or the edit 400s. Labels are resolved to ids for you." + }, + { + "name": "dynamic_filter", + "type": "object", + "example": { + "owner": [], + "status": [], + "priority": [] + }, + "description": "Backward-compatible alias of `filters`, same top-level position and same semantics. `filters` wins when both are sent." + }, + { + "name": "test_case_ids", + "type": "array", + "example": [ + 2336 + ], + "description": "Test case ids \u2014 but NOT added to the run: together with `selection` this becomes the run's COMPLETE case list, so any case already in the run and missing from what you send is REMOVED, along with its results and assignee, and the call still succeeds. To add cases, use bulk_update_test_run_test_cases with add_test_cases, which leaves existing cases untouched. Top level, not inside `test_run`. Must be an array \u2014 a scalar is rejected." + }, + { + "name": "auto_assign", + "type": "boolean", + "description": "Top level. Forwarded to the backing service to auto-assign the newly added cases." + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cases shared in from other projects, as a map of project id to a selection object with the same inner shape as `selection`. Top level." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Make every case in the project the run's case list. Like the rest of the selection this is not additive \u2014 it replaces the run's membership, so it only adds cases in the sense that the whole project ends up in the run. To add specific cases, use bulk_update_test_run_test_cases with add_test_cases.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 83, + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "description": "Per-configuration case mappings, at the TOP level of the body. An ARRAY of objects.", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Rewrite a test run through the full edit form \u2014 rename it, move its state on, change owner, tags, configurations or linked plan. NOT the way to add cases: the case selection you send becomes the run's entire membership, so add cases with bulk_update_test_run_test_cases (add_test_cases) instead", + "returns": [ + "data", + "success", + "testrun", + "project", + "skipped_test_case_ids", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "attachments", + "metadata", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "project_id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__EditTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send every field flat, including the case-selection keys. Each field's json_path decides where it lands \u2014 the run's own fields go inside a `test_run` object and the selection keys (test_case_ids, selection, shared_selection, filters, dynamic_filter, trtc_configuration_mappings, auto_assign, is_dynamic) stay beside it at the top level. Building either level yourself is rejected before the call as an unknown body key.", + "run_state is mandatory on every call, including edits that only rename the run. It is validated first, so a bare 400 'invalid data' almost always means a missing or misspelled run_state \u2014 not a bad selection.", + "NOT ADDITIVE: the case selection you send (test_case_ids + selection) REPLACES the run's membership, and run fields you omit \u2014 the owner among them \u2014 are cleared. The call still returns success. ALWAYS call get_test_run_edit_selection first and resend what it returns \u2014 `selection`, `shared_selection`, `trtc_configuration_mappings` and every run field \u2014 changing only what you mean to change, after checking the stored selection still matches the run's current cases (it is not updated by the add-only and remove calls, so a stale one re-adds removed cases); that read-then-resend is how the product UI edits a run without losing anything. To only add cases, use bulk_update_test_run_test_cases with add_test_cases instead, which adds without disturbing existing cases, their row ids or assignees. Results on removed cases cannot be restored.", + "There is no undo. A test run keeps no version history, so an overwritten name, a replaced tag list or a cleared description cannot be restored \u2014 confirm destructive-looking edits with the user first.", + "Changing which cases the run covers is queued: the call returns before the new cases are attached, and an immediate re-read of the run's cases can be short. Re-read rather than retrying, which can double-add.", + "`filters` is a sync rule, not a selection. An edit carrying only `filters` returns 200 and adds nothing, while quietly marking the run dynamic if you also sent is_dynamic.", + "is_dynamic is read from BOTH the top level and inside `test_run`, and the top-level one wins on this endpoint. Pick one place. Omitting it entirely is the only way to leave the run's current mode untouched.", + "An owner id that matches nobody in the group unassigns the run instead of erroring \u2014 but an unresolvable user inside `filters.owner` or `filters.reviewers` hard-fails the whole edit with 400 'invalid data'. The two behave oppositely.", + "The updated run comes back under `data.testrun` \u2014 one word, no underscore \u2014 with `data.project`, plus a TOP-LEVEL `skipped_test_case_ids` array listing cases the review gate refused to add. Check it: those cases are silently missing from the run.", + "Backing-service client errors are returned as `{data: {success: false, error: }}` with the 4xx status, so read `data.success` before reading `data.testrun`.", + "Needs the test-run write permission, with the automation variant demanded on automation runs." + ] + }, + { + "name": "get_test_run_edit_selection", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit/selection", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Either the TR-NNN identifier or the bare integer \u2014 the v1 run routes accept both forms in the path." + } + ], + "intent": "Read a test run's stored case selection and editable fields \u2014 the exact state to resend when editing the run through the full edit form, so the edit does not drop cases, configurations or the owner", + "returns": [ + "data", + "success", + "selection", + "shared_selection", + "test_run", + "dynamic_filter", + "filters", + "trtc_configuration_mappings" + ], + "responses": { + "200": { + "$response": "TestRunEditSelectionResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read this before every edit_test_run call. The full edit replaces whatever it is not sent, so resend `data.selection` as `selection`, `data.shared_selection` as `shared_selection` and `data.trtc_configuration_mappings` as `trtc_configuration_mappings` unchanged, together with every field of `data.test_run` \u2014 name, description, run_state, owner, is_dynamic, configurations, tags, issues and test_plans.", + "`configurations` and `test_plans` come back on `data.test_run` as objects; the edit takes plain integer ids, so map each to its `id` before resending. Tags are sent as their plain strings.", + "`selection` carries a top-level `select_all`, a `unique_test_case_count` and `folders`. `selection.folders` is keyed by folder id AS A STRING. In each entry `test_case_select_all: true` means every case in that folder \u2014 including cases added to it later \u2014 with `deselected_test_cases` as the exceptions; `false` means only the ids in `selected_test_cases`. Do not rebuild this from the run's case listing: whether a folder was stored as all-or-some exists only here, and getting it wrong adds or drops cases.", + "To add cases through the edit, merge the new case ids into `selection.folders[\"0\"].selected_test_cases` (create that entry with test_case_select_all: false and empty lists if it is absent) \u2014 the product resolves each case's real folder. When the goal is only to add cases, bulk_update_test_run_test_cases with add_test_cases is simpler and cannot remove anything.", + "`selection` can be `{}`, and a run with no configurations still returns one default entry in `trtc_configuration_mappings` \u2014 {configuration_id: null, select_all: true, linked_test_cases: [], unlinked_test_cases: []}. Resend both as returned rather than omitting them.", + "`dynamic_filter` and `filters` carry the same rule twice. For a dynamic run, resend it under both keys plus a top-level `is_dynamic`; for a static run they are null and should be left out.", + "The stored selection is NOT kept in step with bulk_update_test_run_test_cases or update_test_run: cases removed or added that way can still be listed here, or be missing, and a folder can still be stored as select-all. Before resending it, compare `selection.unique_test_case_count` with `test_run.test_cases_count` and the run's case listing; when they disagree, resending as-is re-adds removed cases and pulls in whole folders \u2014 rebuild the folders entries from the run's current cases, or avoid the full edit.", + "Everything is under `data`, including `success` \u2014 there is no top-level `success` on this response. Closed runs can be read.", + "A read only: it changes nothing and needs no permission beyond access to the project." + ] + }, + { + "name": "export_dashboard_analytics", + "method": "POST", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/download", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "csv" + ], + "example": "csv", + "json_path": "/q/type", + "description": "Export format. Only the literal 'csv' is accepted \u2014 any other value is dropped and the export record is created with no file type, while the worker still writes CSV. Must be nested under `q`; a top-level `type` is never read." + }, + { + "name": "created_at", + "type": "string", + "example": "3_month", + "json_path": "/q/created_at", + "description": "Relative window on creation date, _[_gte]. 'all_time' is honoured here and applies no bound. Send this explicitly whenever you send any other filter." + }, + { + "name": "closed_at", + "type": "string", + "example": "3_month", + "json_path": "/q/closed_at", + "description": "Relative window on close date, same token format. Only read when created_at is absent or 'all_time'. 'all_time' is NOT honoured on this key \u2014 it becomes 1_year." + }, + { + "name": "owner", + "type": "string", + "example": "3700,3701", + "json_path": "/q/owner", + "description": "Comma-separated TM user ids. Unresolvable ids are dropped silently; if none resolve the filter is omitted rather than erroring." + } + ], + "intent": "Queue a CSV export of the whole project dashboard (active + closed runs, issue counts, case-type split, automation stats) and get back an export id \u2014 the file itself is delivered out of band", + "returns": [ + "success", + "export_id" + ], + "responses": { + "200": { + "$response": "DashboardExportInitiatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The inputs are the same q[...] filter object the dashboard reads take, NOT flat body fields. The handler reads params[:q]; a top-level {\"type\":\"csv\"} is ignored and the export runs unfiltered. On this POST the object may be sent in the JSON body as {\"q\": {...}} or as a query string \u2014 both land in the same place.", + "CSV is the only format. Anything other than the literal 'csv' is discarded by the handler, and the worker writes a CSV regardless \u2014 there is no PDF or Excel export behind this route despite what the format name suggests.", + "You cannot collect the file through this API. The worker uploads the CSV to S3 and pushes the URL over a websocket channel addressed to the requesting user; it never attaches the file to the export record and never advances the export's status past 'pending'. The generic export status/download endpoints therefore report pending forever and 404 on download for this export type, and neither is exposed as a capability here. Treat the returned export_id as a correlation token, not as something you can redeem.", + "Date filtering follows the dashboard rules: q[created_at] wins over q[closed_at], and sending any q key without an explicit q[created_at] defaults both to 'all_time', which on the closed_at path becomes the last 1 year \u2014 so an owner-only filter silently produces a 12-month export.", + "The response is immediate and says nothing about whether the export succeeded; a failure happens later in the background and is only signalled on the websocket.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API." + ] + }, + { + "name": "get_active_test_runs_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/active-test-runs-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart the result-status breakdown across the project's currently active test runs \u2014 how many results are passed / failed / blocked / untested right now", + "shape": "discovered", + "responses": { + "200": { + "$response": "ActiveTestRunsInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The payload is an OBJECT with a `data` array, not a bare array: read `data`, not the response root.", + "When more than six result statuses exist, the response is collapsed: only the top six by count survive and everything else is merged into a single row named 'Other'. That 'Other' row has NO `result_status` object \u2014 it carries a `links.filter` URL instead \u2014 so code that reads row.result_status.id must tolerate its absence.", + "Counts are results across active runs, not runs and not test cases. There is no drill-down here; to see the runs themselves use the test-run listings.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ], + "returns": [ + "data", + "name", + "y", + "result_status", + "links" + ] + }, + { + "name": "list_archived_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/archived_test_cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List the test cases that have been archived in a project \u2014 the source of the ids the bulk-retrieve (un-archive) call needs.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count" + ], + "responses": { + "200": { + "$response": "ArchivedTestCasesListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-gated, and it fails OPEN-LOOKING: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and no test cases. Check `success`, not the status code, before concluding the archive is empty.", + "Project-wide only \u2014 there is no folder scoping on this route. Each row carries its original folder path so you can tell where a case will be restored to.", + "Take the integer `id` of each row: that is what bulk-retrieve takes to un-archive them.", + "Paged with `p`; there is no per_page on this route, so walk pages until `info.next` is null.", + "`cases_count` in the response is keyed by folder id, and on this route that key is null because no folder was scoped \u2014 read `info.count` for the total instead." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no per_page here; the page size is the server default." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token (`-ASC|DESC`). `case_type` is translated to the backend's `category` for you." + } + ], + "paginated": true + }, + { + "name": "get_archived_test_plan_count", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/archive_count", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "How many test plans in this project are archived \u2014 the badge number for the archived view; use the archived-plans listing when you need the plans themselves", + "shape": "discovered", + "responses": { + "200": { + "description": "Archived plan count: `{success, count}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "count": { + "type": "integer" } - }, - "ResultStatus__ListEntityFilterDetails": { - "type": "object", - "description": "ResultStatus as returned by `list_entity_filter_details`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "entity_type": { - "type": "string", - "example": "TestCase" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "User__ListEntityFilterDetails": { - "type": "object", - "description": "User as returned by `list_entity_filter_details`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a fixed literal route on the project's plan collection, not a plan-scoped read \u2014 it takes no plan id and no filters. Counting archived plans is all it does.", + "Counts top-level archived plans only, matching the archived listing; archived sub-plans are not included, so the number can look low against what a user expects.", + "Gated on the same group archive flag as archive/restore: when it is off the call is 404 {success: false, errors: 'Feature not available'} \u2014 a feature gap, not an empty project.", + "Needs the test-plan DELETE permission, not view. Someone who can read plans normally may still be 403'd here.", + "The read is forced onto the primary store, so the number is correct immediately after an archive or restore rather than lagging behind it.", + "A count the upstream page info does not carry comes back as 0 rather than an error, so 0 can mean 'none archived' or 'upstream returned no paging info'." + ], + "returns": [ + "success", + "count" + ] + }, + { + "name": "get_automation_stats", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/automation-stats", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Get the project's automation coverage \u2014 what percentage of its test cases are automated, with the automated / manual / total counts behind it", + "returns": [ + "success", + "automated_coverage", + "automated_test_cases", + "manual_test_cases", + "total_test_cases", + "empty_data" + ], + "responses": { + "200": { + "$response": "AutomationStats" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the one dashboard chart assembled in this service rather than passed through, so the field names below are exact: `automated_coverage` is a percentage (0\u2013100, a float), and the three counts are test cases.", + "`manual_test_cases` is derived as total minus automated, so every automation state that is not 'automated' \u2014 including 'in progress' or unset \u2014 is counted as manual.", + "When the project has no test cases, `empty_data` is true and `automated_coverage` is computed from a zero denominator; treat empty_data:true as 'no data' and ignore the coverage number entirely rather than rendering it.", + "Returns counts only \u2014 there is no list of which cases are automated. Use the test case search with an automation_state filter for that.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_closed_test_runs", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/closed", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List a project's finished (closed) test runs \u2014 the ones the default run listing leaves out \u2014 to report on completed cycles", + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "build_meta", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListClosedTestRuns" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no page-size parameter here: the page is fixed at 30 rows, so walk `info` rather than asking for more." + } + ], + "guidance": [ + "This is the counterpart to the main run listing, which hides closed runs. Neither one alone is 'all our runs' \u2014 answering that question means reading both.", + "Page size is fixed at 30 and cannot be raised; only `p` moves. Stop when `info.next` is null.", + "Each run's `id` is the TR-NNN STRING and the integer lives in `uuid`. Carry `uuid` anywhere a numeric run id is needed \u2014 passing the string through reaches the backing service uncoerced and fails there.", + "The response is flat: {success, test_runs, info, count} with no `data` envelope, unlike the single-run write endpoints on this entity.", + "`count` is an object of per-state totals for the whole project, not the size of this page \u2014 the page size is in `info`.", + "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent. Do not read a zeroed `overall_progress` as 'nothing was executed'." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_closed_test_runs_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how many test runs were closed per month over the selected window \u2014 the project's throughput trend", + "returns": [ + "data", + "empty_data" + ], + "responses": { + "200": { + "$response": "ClosedTestRunsInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Reads the CLOSE date, so q[closed_at] is the filter that matters here, not q[created_at]. Sending q[created_at] shadows q[closed_at] entirely (created_at wins) and you get a creation-window chart instead.", + "Each entry in `data` is a positional pair \u2014 period label first, count second \u2014 not a {month, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", + "`empty_data` is the reliable 'nothing to plot' signal \u2014 the series is padded, so a non-empty `data` array does not mean there were any closed runs.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_closed_test_runs_split", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-split", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart closed test runs per month split by result status \u2014 a stacked view of how each month's closed runs finished", + "returns": [ + "data", + "date_list", + "empty_data", + "name", + "result_status" + ], + "responses": { + "200": { + "$response": "ClosedTestRunsSplit" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each entry in `data` is one status series: `name` plus a `data` array of per-period counts aligned positionally with the top-level `date_list`. The Nth number in a series belongs to the Nth label in date_list; there is no month key on the numbers themselves.", + "Filters on close date \u2014 q[closed_at] is the meaningful key, and q[created_at] shadows it when both are sent.", + "This route caches for six hours for one specific large project, so a freshly closed run may not appear immediately there.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_configurations", + "method": "GET", + "path": "/api/v1/projects/{project_id}/configurations", + "mode": "read", + "entity": "configuration", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List the configurations a project's test runs can execute against \u2014 search by name or fetch specific ids, to resolve the configuration_id a run needs", + "returns": [ + "success", + "configurations", + "info", + "id", + "name", + "record_status", + "is_suggested", + "group_id", + "created_at" + ], + "responses": { + "200": { + "description": "Successfully retrieved configurations", + "schema": { + "$schema": "ConfigurationListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search. `name` is an accepted alias and `q` WINS when both are sent." + }, + { + "name": "name", + "type": "string", + "description": "Alias for `q`. Only used when `q` is absent." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `page` is an accepted alias and `p` WINS. Only `p` is validated \u2014 a non-positive or non-numeric `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`, while a bad `page` is forwarded as-is." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page. `count` is an accepted alias and `count` WINS when both are sent. Forwarded unchanged to the backing service, which owns the real ceiling \u2014 nothing bounds it here." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "description": "Alias for `per_page`, and the one that wins when both are sent." + }, + { + "name": "ids", + "type": "string", + "example": "101,102,103", + "description": "Comma-separated configuration ids to fetch, as a STRING \u2014 not a JSON array. Split on commas before being forwarded." + }, + { + "name": "type", + "type": "string", + "values": [ + "system", + "custom", + "all" + ], + "description": "Configuration source filter, validated locally: anything else returns 400 `{success:false, error:\"Invalid type parameter. Must be one of: system, custom, all\"}`." + }, + { + "name": "custom", + "type": "boolean", + "default": false, + "description": "CHANGES THE RESPONSE KEY. When 'true' the rows come back under `custom_configurations` instead of `configurations`; everything else about the response is identical. Code that always reads `configurations` gets nothing back." + }, + { + "name": "is_suggested", + "type": "boolean", + "description": "Forwarded unchanged to the backing service to filter on whether the configuration is a suggested/default one. Ignored on the `custom=true` branch." + } + ], + "guidance": [ + "The `custom=true` branch renames the array: rows arrive under `custom_configurations`, not `configurations`. Read the key that matches the request you made \u2014 this is the easiest way to get an apparently empty result from a call that worked.", + "Group-scoped, not project-scoped: the list is the workspace's configurations and `project_id` only matches the route. The same set comes back for every project in the group.", + "`ids` is a comma-separated string, not an array; sending `ids[]=101&ids[]=102` does not filter.", + "Alias pairs and which one wins: q beats name, p beats page, count beats per_page. Only `p` is validated, so a typo'd `page` is forwarded and ignored rather than rejected.", + "Rows carry os, os_version, device, browser, browser_version and is_valid_on_bstack in addition to id/name/group_id/record_status/is_suggested \u2014 more than the Configuration schema in this index describes. `is_valid_on_bstack` is what tells you the combination is still runnable on BrowserStack.", + "Carry `id` from the row: the run APIs take configuration ids, not names or objects.", + "Paged via `info` {page, page_size, count, prev, next}; stop when `next` is null.", + "Requires test_run_view \u2014 configurations are gated on the test-run permissions, not the test-case ones." + ], + "paginated": true + }, + { + "name": "get_custom_field_dataset", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id, from the field read or from the create-dataset response. 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Read one dataset of a custom field \u2014 which projects it covers and, on request, the option values it holds", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Options are NOT returned by default. Pass fetch_options=true or you get project links only and will wrongly conclude the dataset has no values.", + "There is no `info` in this response even though the linked-projects list is paged: `p` is honoured, the page size is fixed at 30, and you get no total and no next-page marker. Use `linked_project_count` on the dataset to tell whether more pages exist.", + "`linked_to_all_projects` is computed here by comparing linked_project_count against the workspace project count \u2014 it is derived per request, not stored.", + "Both path ids must be positive integers; 0 in either position is a 400 'Invalid Params'. A well-formed id that does not exist is a bare 404 {success:false} with no message.", + "Nothing checks that the dataset actually belongs to the field in the path before the upstream call \u2014 a mismatched pair surfaces as that same bodyless 404." + ], + "query": [ + { + "name": "fetch_options", + "type": "boolean", + "default": false, + "description": "Include the dataset's option values in the response. Omit it and `options` is absent entirely \u2014 which reads identically to a dataset that has none." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the dataset's linked projects. Page size is fixed at 30 and `per_page` is ignored; no page metadata is returned." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects", + "options" + ] + }, + { + "name": "get_custom_field_definition", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Read one custom field's stored definition together with its datasets and the projects each dataset is linked to \u2014 the call to make before any edit, since edits here are full replaces", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this to build an update body: the edit call requires field_name, field_type and is_required together and clears place_holder_text and default_value when they are omitted.", + "`entity_type` is NOT in this response. If you need to know whether the field belongs to TestCase, TestResult, TestPlan or ExploratorySession, take it from the workspace field list instead.", + "The response carries datasets with their project links but NOT their option values. Read a dataset's options through the dataset options list.", + "Paging is per dataset row and is controlled only by `p`; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata.", + "`linked_to_all_projects` on a dataset is computed by comparing its linked project count against the workspace project count \u2014 it is derived, not a stored flag.", + "A field id that does not exist comes back as a bare 404 {success:false} with no message; id 0 is a 400 'Invalid Params'." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the field's datasets. The only pagination control \u2014 `per_page` is read but discarded, the page size is always 30." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "info", + "id", + "field_name", + "field_type", + "system_name", + "is_required", + "place_holder_text", + "is_bulk_editable", + "is_filterable", + "default_value", + "datasets", + "levels", + "type" + ] + }, + { + "name": "get_custom_field_values", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route also matches the literal 'new', which returns an empty 200 rather than an error. PR-NNN identifiers do not match the route." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-case", + "test-result", + "test-plan", + "exploratory-session" + ], + "description": "Hyphenated, lower-case entity segment. Anything outside this set fails the route constraint and 404s. It must match the field's own entity type or the lookup returns nothing." + }, + { + "name": "field_id", + "type": "string", + "required": true, + "example": "4821", + "description": "Numeric custom field id, from the workspace field list or the project's form fields. Whatever you send here becomes the top-level key of the response, verbatim." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Substring search over the option labels (or user names for a `user` field). Forwarded upstream as the value filter." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, forwarded unchanged. There is no per-page control on this endpoint \u2014 the backing service owns the page size; read it back from `info`." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "nested_dropdown only: return the child options of this parent instead of the level's own values. Get the parent id from a previous call on the parent level." + } + ], + "intent": "List the values one custom field offers inside a given project \u2014 the dropdown choices, or the users for a user field \u2014 so you can pick a valid value before writing it onto a test case or result", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "CustomFieldValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + } + }, + "guidance": [ + "This is the project-scoped value lookup, not a definition read. Use it to turn a value a human named into something you can send; the field's definition, type and project scope live in the workspace field list.", + "The response has no fixed top-level key: the payload sits under the field id exactly as you spelled it in the path (`{\"4821\": {field_type, values}, info, success}`). Read it back by that same string, not by a constant name.", + "`field_type` comes back in the LEGACY spelling \u2014 field_dropdown, field_multi_dropdown, field_string, field_user, field_boolean \u2026 \u2014 not the canonical vocabulary the admin write calls take. Translate before feeding it anywhere else.", + "A nested_dropdown field has no legacy spelling, so its `field_type` comes back null. Do not treat that as 'field not found'.", + "For a nested_dropdown, pass `parent_option_id` to get the children of one parent; without it you get the level's own values.", + "A field that does not exist, or any upstream failure, returns 400 {success:false, error:'Something went wrong'} \u2014 there is no 404 here and no way to tell a bad field id from a backend outage.", + "A project_id the route accepts but which is not numeric (the literal 'new') returns 200 with field_type null, values null and info null \u2014 an empty success, not an error.", + "The entity_type segment is hyphenated and lower-case (test-case / test-result / test-plan / exploratory-session); anything else does not match the route and 404s before reaching the API.", + "Boolean fields have their values converted to real true/false before rendering; every other type returns them as stored." + ], + "returns": [ + "success", + "info", + "field_type", + "values" + ] + }, + { + "name": "get_dataset", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits, so a PR-NNN identifier never reaches the API." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string, as returned by the list or create calls. There is no integer-id form of this route." + } + ], + "intent": "Read one dataset in full \u2014 its columns, every row of data, tags and metadata \u2014 by dataset uuid", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "created_at", + "created_by" + ], + "responses": { + "200": { + "description": "The dataset with its full column and row contents.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__GetDataset" } - }, - "ReportData__GetReportDetail": { - "type": "object", - "description": "ReportData as returned by `get_report_detail`. Composes ReportData and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportData" - } - ], - "properties": { - "time_tracking_enabled": { - "type": "boolean", - "example": false - }, - "test_cases_status": { - "type": "array", - "items": { - "type": "object", - "required": [ - "count", - "result_status" - ], - "properties": { - "count": { - "type": "integer" - }, - "result_status": { - "$schema": "ResultStatus__GetReportDetail" - } - } - } - }, - "test_run_status": { - "type": "object", - "properties": { - "Closed": { - "type": "integer", - "example": 1 - }, - "Done": { - "type": "integer", - "example": 0 - }, - "In_Progress": { - "type": "integer", - "example": 0 - }, - "New": { - "type": "integer", - "example": 59 - }, - "Rejected": { - "type": "integer", - "example": 0 - }, - "Under_Review": { - "type": "integer", - "example": 1 - } - } - }, - "time_tracking": { - "type": "object", - "properties": { - "cases_in_scope": { - "type": "integer", - "example": 166 - }, - "cases_with_elapsed": { - "type": "integer", - "example": 0 - }, - "total_elapsed_seconds": { - "type": "integer", - "example": 0 - } - } - }, - "user_result_stats": { - "type": "array", - "items": { - "type": "object", - "required": [ - "user_id", - "test_results", - "user_name" - ], - "properties": { - "user_id": { - "type": "integer" - }, - "test_results": { - "type": "integer" - }, - "user_name": { - "type": "string" - }, - "local_user_id": { - "type": "integer", - "example": 3741 - }, - "tc_count": { - "type": "integer", - "example": 2 - } - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" + }, + "422": { + "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan. Entitlement, not payload." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the only dataset call that returns the actual data: the list endpoint returns counts and metadata but no `variables` or `rows`.", + "Read this before any update \u2014 update is a full replace with no history, so the response here is the only copy of the prior contents you will get.", + "`rows_count` is computed from the rows in this payload, so it always agrees with `rows.length`.", + "`created_by` is resolved against users in the caller's group; a creator who has left resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", + "An unknown uuid is 404 {success:false, message:'Dataset not found.'} \u2014 note the doubled space in the message if you are matching on it.", + "422 {message:'Feature not available'} means the group lacks the Team Pro plan. That is an entitlement answer, not a bad request; do not retry.", + "The dataset is at the top level as `dataset`; there is no `data` envelope." + ] + }, + { + "name": "list_dataset_linked_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}/test-cases", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string, from the dataset list or a create response." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, forwarded as-is to the backing store. A non-numeric or non-positive value is rejected globally with 400 {success:false, error:'Invalid Params'}. No page-size parameter is exposed." + } + ], + "intent": "List the test cases bound to a dataset \u2014 check what a dataset drives before you edit or delete it", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "A page of linked test cases. Note there is no `success` key in this envelope.", + "schema": { + "type": "object", + "required": [ + "test_cases", + "info" + ], + "properties": { + "test_cases": { + "type": "array", + "description": "Test cases linked to the dataset, forwarded from the backing store; the per-row field set is not shaped by this service.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" } - }, - "ResultStatus__GetReportDetail": { - "type": "object", - "description": "ResultStatus as returned by `get_report_detail`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - } + } + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Either 'Dataset not found.' (no such dataset) or 'No test cases found for dataset .' (the backing store returned nothing)." + }, + "422": { + "description": "`Feature not available` \u2014 the group lacks the Team Pro plan. Not a payload error; do not retry with a different body." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Run this before deleting or rewriting a dataset \u2014 nothing else warns you that test cases depend on it, and the delete gives no preview.", + "This response has NO `success` key, unlike every other dataset endpoint: it is exactly {test_cases, info}. Do not branch on `success` here.", + "Two different 404s: an unknown dataset uuid fails in the lookup with 'Dataset not found.', while a dataset the backing store returns nothing at all for fails with 'No test cases found for dataset .'. Neither distinguishes 'exists but has no linked cases' reliably \u2014 treat a 404 as 'nothing to read', not as proof the dataset is gone.", + "The test-case rows are passed through from the backing store unchanged; their field set is not shaped by this service and is not declared here.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint." + ], + "returns": [ + "test_cases", + "info" + ] + }, + { + "name": "get_dataset_variables", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/variables", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[name]", + "type": "string", + "description": "Partial, case-insensitive match on the column name. This is the search this endpoint exists for." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}. There is no page-size parameter; read `info.page_size` for what you got." + } + ], + "intent": "List every dataset column defined anywhere in the project, each with the dataset it belongs to \u2014 use this to find which dataset holds a variable name you saw in a test case", + "returns": [ + "success", + "variables", + "info", + "uuid", + "name", + "dataset" + ], + "paginated": true, + "responses": { + "200": { + "description": "A page of dataset columns, each with its owning dataset, plus paging metadata under `info`.", + "schema": { + "$schema": "DatasetVariablesResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is project-wide, not per dataset. `variables` on this path is a fixed route segment, not a dataset uuid \u2014 it lists columns across every dataset in the project. To see one dataset's columns, read that dataset instead.", + "Each row carries its owning dataset inline as `dataset` {uuid, name, count_rows}, so this call resolves a bare variable name to the dataset uuid you need for every other dataset operation.", + "Column names are not unique across datasets \u2014 the same name in two datasets is two rows here. Disambiguate on `dataset.uuid`.", + "The page size is set by the backing store and cannot be requested from here; page with `p` and read `info` for page/page_size/next.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint.", + "`success`, `variables` and `info` are all top level \u2014 `variables` is the array itself, not a wrapper object." + ] + }, + { + "name": "get_dedupe_status", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/status", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + } + ], + "intent": "Check whether duplicate detection has ever run for a project and where the latest scan got to \u2014 the only way to tell 'dedupe is off or has never run' from 'it ran and found nothing'", + "returns": [ + "status", + "job_id", + "scheduled_run_time", + "actual_end_time", + "updated_at", + "message" + ], + "responses": { + "200": { + "description": "The latest dedupe job for the project. When no job has ever run, only {status:'NOT_ENABLED', message:'No dedupe job found for this project'} is returned and the job fields are absent.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Latest job status, or the synthesised 'NOT_ENABLED' when no job exists.", + "values": [ + "NOT_ENABLED", + "QUEUED", + "PENDING", + "IN_PROGRESS", + "CLUSTER", + "CLUSTERING", + "COMPLETED", + "FAILED", + "RETRY", + "RETRY_FAILED", + "STATISTICAL_FAILED", + "STATISTICAL_RETRY", + "CLUSTERING_FAILED", + "CLUSTERING_RETRY", + "CANCELLED", + "BACKFILLING", + "LLM_ANALYSIS_SKIPPED", + "CLUSTERING_LIMIT" + ] + }, + "job_id": { + "type": "integer", + "description": "Pipeline job id. Absent when status is NOT_ENABLED." + }, + "scheduled_run_time": { + "type": "string", + "format": "date-time", + "description": "When the scan was scheduled to run. Nullable; absent when status is NOT_ENABLED." + }, + "actual_end_time": { + "type": "string", + "format": "date-time", + "description": "When the scan finished. Null while the job is still in flight; absent when status is NOT_ENABLED." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Last status change. Absent when status is NOT_ENABLED." + }, + "message": { + "type": "string", + "description": "Present only with NOT_ENABLED: 'No dedupe job found for this project'." } - }, - "Folder__ListRootFolders": { - "type": "object", - "description": "Folder as returned by `list_root_folders`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764093" - } - } + } + } + }, + "400": { + "description": "{error:'Missing project_id'} \u2014 unreachable through this route, where project_id is a required path segment." + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Call this first whenever a duplicates listing comes back empty. An empty listing is returned both when the feature is off and when a completed scan found nothing; only this endpoint separates them.", + "`NOT_ENABLED` is synthesised, not stored: it means no scan job row exists for this project at all, and it arrives with an explanatory `message` and none of the job fields. Every other value is a real job status and comes with job_id and timestamps.", + "Only the MOST RECENT job is reported, by last update. There is no history and no way to page back to earlier runs.", + "Statuses are not just running/done. COMPLETED, FAILED, CANCELLED, RETRY_FAILED, STATISTICAL_FAILED, CLUSTERING_FAILED, CLUSTERING_LIMIT and LLM_ANALYSIS_SKIPPED are terminal; QUEUED, PENDING, IN_PROGRESS, CLUSTER, CLUSTERING, BACKFILLING, RETRY, STATISTICAL_RETRY and CLUSTERING_RETRY mean a scan is still in flight and the duplicates listing may be incomplete.", + "This is a plain read of the last job record \u2014 it does not start a scan, and nothing in this API can. Scans are scheduled by the dedupe pipeline.", + "Unlike the rest of this family, no role permission is checked here; project access is enough.", + "The documented 400 for a missing project id is unreachable through this route, since the id is a required path segment." + ] + }, + { + "name": "get_duplicate_source_test_case", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}/source_tc", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id of an ALREADY-MERGED pair. The route only matches digits." + } + ], + "intent": "Read the pre-merge snapshot of the test case that was folded away when a duplicate was merged \u2014 the only record of what the archived case contained at merge time", + "shape": "discovered", + "responses": { + "200": { + "description": "The pre-merge snapshot, wrapped in a `data` envelope: {data:{success:true, test_case:{...}}}. `test_case` is the frozen full test-case serialisation stored at merge time (steps, attachments with signed URLs, template); its field set is whatever was captured then and is not shaped by this endpoint.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_case": { + "type": "object", + "description": "Frozen pre-merge serialisation of the source test case." } + } } - }, - "SharedStepsIDResponse__GetSharedStep": { - "type": "object", - "description": "SharedStepsIDResponse as returned by `get_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "SharedStepsIDResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "page_info": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "example": 1 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 150 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "created_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } - } - }, - "shared_step_details": { - "type": "object", - "properties": { - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "probe result" - }, - "step": { - "type": "string", - "example": "probe step" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } - } - }, - "tags": { - "type": "string", - "nullable": true - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "description": "Two distinct bodies: {data:{success:false, error:'Source test case snapshot not available'}} when the pair merged but retained no snapshot, and {success:false, error:'Duplicate not found.'} (no `data` key) when the pair is unknown or was never merged." + }, + "422": { + "description": "{success:false, message:'Invalid source_tc data'} \u2014 the stored snapshot could not be parsed." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This works only for a pair whose status is `merged` \u2014 the exact opposite of the read/resolve/discard calls, which work only while a pair is `suggested`. Calling it on a live suggestion is a 404.", + "The snapshot is written only by the merge path. A pair resolved by archiving instead of merging is also marked merged but stores no snapshot, so it answers 404 with the message 'Source test case snapshot not available'. Treat that as 'not retained', not as a retryable error.", + "The two 404s have DIFFERENT envelopes. Missing snapshot: {data:{success:false, error:'Source test case snapshot not available'}}. Unknown or unmerged pair: {success:false, error:'Duplicate not found.'} with no `data`. Check for `data` before reading `error`.", + "Success is wrapped: the test case is at `data.test_case`, not at the top level. This is the only duplicate endpoint with a `data` envelope.", + "The snapshot is a frozen serialisation taken at merge time, including steps, attachments (with freshly signed, short-lived URLs) and the template \u2014 it is not re-read from the live test case, so it will not reflect anything that happened afterwards.", + "A stored snapshot that cannot be parsed comes back 422 {success:false, message:'Invalid source_tc data'} \u2014 a data fault, not something a different request fixes.", + "To find the merged pair id in the first place: it is no longer in the duplicates listing, so you need the id you saw before resolving, or the id embedded in the merge comment left on the surviving test case." + ], + "returns": [ + "data", + "success", + "test_case" + ], + "disabled": true + }, + { + "name": "get_duplicate", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id, taken from the `id` of a row in the duplicates listing or search. The route only matches digits." + } + ], + "intent": "Read one suggested duplicate pair \u2014 the two test cases side by side with their titles, descriptions, folders and owners, plus the confidence score and the model's reason \u2014 so a human can decide how to resolve it", + "shape": "discovered", + "responses": { + "200": { + "description": "The duplicate pair with both test cases resolved, at the top level as `duplicate`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "duplicate": { + "$schema": "V1DuplicatePair" } - }, - "V1TestCaseRecordRow__GetTestCaseByIntegerId": { - "type": "object", - "description": "V1TestCaseRecordRow as returned by `get_test_case_by_integer_id`. Composes V1TestCaseRecordRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseRecordRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "{errors:'Duplicate not found.'} \u2014 no such pair in this project, or its status is no longer `suggested`. Or {errors:'Test case not found.'} \u2014 the pair exists but a member test case could not be resolved." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Suggested-only. A pair that has already been merged, archived or discarded answers 404 {errors:'Duplicate not found.'} \u2014 here a 404 usually means 'already handled', not 'never existed'. Check the listing before concluding the id is wrong.", + "The error key is `errors` (plural), not `error` or `message`, on both 404 paths from this endpoint.", + "A second 404, {errors:'Test case not found.'}, means the pair exists but one of its two test cases could not be resolved (deleted or moved out of reach). The pair is unusable \u2014 it is also silently dropped from the listings for the same reason.", + "Unlike the listings, this has NO feature-flag gate: a single pair stays readable by id even for a group whose dedupe listing returns empty.", + "The pair is at the top level as `duplicate`; there is no `data` envelope. Inside it, `test_cases` always holds exactly two entries and each entry's `id` is the integer test-case id you pass to the resolve call as source or target.", + "`owner` is a resolved display name and is an empty string when the owner cannot be resolved; `owner_id` is the raw BrowserStack user id. `description` is '' rather than null when absent.", + "`ai_score` is the model's confidence and `duplicate_type` is exact vs semantic \u2014 read both before merging, since an exact pair and a low-confidence semantic pair warrant different treatment." + ], + "returns": [ + "success", + "duplicate", + "id", + "ai_score", + "reason", + "duplicate_type", + "test_cases", + "identifier", + "folder_id", + "folder_path", + "title", + "description", + "owner_id", + "owner" + ] + }, + { + "name": "list_entity_filter_details", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity}/filter-details", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "trtc", + "test-runs" + ], + "example": "test-cases", + "description": "Which entity's filter vocabulary to resolve against \u2014 this also decides which user fields are resolved. 'test-plans' is allowed by the route but rejected by the handler with 400." + } + ], + "query": [ + { + "name": "q[status]", + "type": "string", + "example": "9319,9321", + "description": "Comma-separated numeric status IDs to resolve. Returned under filter_details.status as objects with id, field_value, internal_name and colour." + }, + { + "name": "q[priority]", + "type": "string", + "example": "550376,9261", + "description": "Comma-separated numeric priority IDs to resolve; returned under filter_details.priority." + }, + { + "name": "q[case_type]", + "type": "string", + "example": "9379,9375", + "description": "Comma-separated numeric case-type IDs to resolve; returned under filter_details.case_type." + }, + { + "name": "q[automation_state]", + "type": "string", + "example": "automated", + "description": "Numeric ids, or the literal word 'automated' / 'manual' which is expanded to this project's ids first; returned under filter_details.automation_state." + }, + { + "name": "q[automation_status]", + "type": "string", + "example": "passed,failed", + "description": "Comma-separated values; split on commas and echoed back as an array, not resolved against anything." + }, + { + "name": "q[folder_ids]", + "type": "string", + "example": "3306878,3669741", + "description": "Comma-separated folder ids; returned under filter_details.folders as a folder tree with paths and ancestors. At most 600 folders are resolved." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids. Resolved for entity=test-cases and entity=test-runs only; ignored for trtc." + }, + { + "name": "q[reviewers]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=test-cases only." + }, + { + "name": "q[assignee]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=trtc only." + }, + { + "name": "q[last_executed_by]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=trtc only." + }, + { + "name": "q[test_case_tags]", + "type": "string", + "example": "[\"regression\",\"smoke\"]", + "description": "Must be a JSON-encoded string containing an array of tag names \u2014 it is JSON.parse'd and echoed back. A bare comma-separated list raises rather than filtering. `q[tags]` is not a recognised key." + }, + { + "name": "q[custom_fields]", + "type": "string", + "example": "{\"45\":[\"abc\"]}", + "description": "Custom-field selections keyed by field id; returned resolved under filter_details.custom_fields. Date custom fields echo back the raw value you sent rather than the normalised one." + }, + { + "name": "q[result_custom_fields]", + "type": "string", + "example": "{\"88\":[\"a\",\"b\"]}", + "description": "Test-result custom-field selections; resolved separately and returned under filter_details.result_custom_fields." + }, + { + "name": "q[configuration_id]", + "type": "string", + "example": "101,102", + "description": "Comma-separated configuration ids; resolved to configuration objects under filter_details.configurations." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Echoed back verbatim under filter_details.created_at \u2014 not parsed, not validated." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "3_month", + "description": "Echoed back verbatim under filter_details.updated_at." + }, + { + "name": "q[latest_status]", + "type": "string", + "example": "passed,failed", + "description": "Comma-separated values, split and echoed back as an array." + } + ], + "intent": "Resolve a set of filter IDs you already hold into their display objects \u2014 turn priority/status/case-type/custom-field ids, folder ids and user ids into names, colours and folder paths so a saved filter can be rendered; it does NOT list the filter options available in a project", + "returns": [ + "success", + "filter_details", + "priority", + "status", + "case_type", + "automation_state", + "automation_status", + "custom_fields", + "result_custom_fields", + "folders", + "configurations", + "created_at", + "updated_at", + "test_case_tags", + "latest_status", + "owner", + "reviewers", + "assignee", + "last_executed_by" + ], + "responses": { + "200": { + "description": "Everything is nested under `filter_details`; only the keys you supplied under q are present, and an empty q yields filter_details:{}.", + "schema": { + "type": "object", + "required": [ + "success", + "filter_details" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "filter_details": { + "type": "object", + "description": "Resolved form of the filters that were sent. Keys absent from the request are absent here.", + "properties": { + "priority": { + "type": "array", + "description": "Resolved priority options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + "status": { + "type": "array", + "description": "Resolved status options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } }, "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + "type": "array", + "description": "Resolved case-type options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } }, - "priority": { - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + "automation_state": { + "type": "array", + "description": "Resolved automation-state options.", + "items": { + "$schema": "ResultStatus" + } }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + "automation_status": { + "type": "array", + "description": "The q[automation_status] value split on commas and echoed back unresolved.", + "items": { + "type": "string" + } }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "download_url": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "TestCaseFieldValue__GetTestCaseByIntegerId": { - "type": "object", - "description": "TestCaseFieldValue as returned by `get_test_case_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCaseFieldValue__GetTestCaseDetail": { - "type": "object", - "description": "TestCaseFieldValue as returned by `get_test_case_detail`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCaseStep__GetTestCaseDetail": { - "type": "object", - "description": "TestCaseStep as returned by `get_test_case_detail`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "background": { - "type": "string", - "nullable": true + "test_case_tags": { + "type": "array", + "description": "The JSON-decoded q[test_case_tags] value, echoed back.", + "items": { + "type": "string" + } + }, + "latest_status": { + "type": "array", + "description": "The q[latest_status] value split on commas and echoed back.", + "items": { + "type": "string" + } }, - "feature": { - "type": "string", - "nullable": true + "created_at": { + "type": "string", + "nullable": true, + "description": "Echo of q[created_at], unparsed." }, - "hashed_id": { - "type": "string", - "nullable": true + "updated_at": { + "type": "string", + "nullable": true, + "description": "Echo of q[updated_at], unparsed." }, - "scenario": { - "type": "string", - "nullable": true + "custom_fields": { + "type": "object", + "description": "Resolved custom-field values keyed by field id. Date fields carry the raw value that was sent, not the normalised one." }, - "sharedStep": { - "type": "string", - "nullable": true + "result_custom_fields": { + "type": "object", + "description": "Resolved test-result custom-field values keyed by field id." }, - "shared_step_detail_id": { - "type": "string", - "nullable": true + "configurations": { + "type": "array", + "description": "Resolved configuration objects for q[configuration_id].", + "items": { + "$schema": "Configuration" + } }, - "shared_step_id": { - "type": "string", - "nullable": true + "owner": { + "type": "array", + "description": "Resolved users for q[owner] \u2014 test-cases and test-runs only.", + "items": { + "$schema": "User__ListEntityFilterDetails" + } }, - "test_data": { - "type": "string", - "nullable": true + "reviewers": { + "type": "array", + "description": "Resolved users for q[reviewers] \u2014 test-cases only.", + "items": { + "$schema": "User" + } }, - "test_recording_id": { - "type": "string", - "nullable": true + "assignee": { + "type": "array", + "description": "Resolved users for q[assignee] \u2014 trtc only.", + "items": { + "$schema": "User" + } }, - "title": { - "type": "string", - "nullable": true + "last_executed_by": { + "type": "array", + "description": "Resolved users for q[last_executed_by] \u2014 trtc only.", + "items": { + "$schema": "User" + } + }, + "folders": { + "$schema": "FilterDetailsFolderNode" } + } } - }, - "History__ListTestCaseHistories": { - "type": "object", - "description": "History as returned by `list_test_case_histories`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "History" - } - ], - "properties": { - "user": { - "$schema": "User__ListTestCaseHistories" + } + } + }, + "400": { + "description": "Unsupported entity (including test-plans, which the route allows but the handler rejects), or malformed filter values." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is an echo/hydration endpoint, not a discovery one. It returns only the keys you actually passed under `q`: send q[status]=9319 and you get back `status` resolved; send nothing and you get {success:true, filter_details:{}} with a 200. To enumerate the options a project offers, use the system/custom field value capabilities instead.", + "The result is nested one level down under `filter_details` \u2014 read filter_details.status, filter_details.priority, filter_details.folders. The keys are not at the top level.", + "Only three entity values work here: test-cases, trtc and test-runs. 'test-plans' passes the route constraint but is not in the handler's map and returns 400 \u2014 the route and the handler disagree.", + "Which user fields get resolved depends on the entity: owner and reviewers for test-cases, assignee and last_executed_by for trtc, owner for test-runs. A user id sent under the wrong key for that entity is ignored without comment.", + "Filter keys are renamed internally (q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id) but you send the un-suffixed names. q[automation_state] additionally accepts the literal words 'automated' / 'manual'.", + "q[test_case_tags] must be a JSON-ENCODED STRING of an array; it is JSON.parse'd and echoed back verbatim. `q[tags]` is not a key this endpoint knows and is dropped silently.", + "q[created_at] and q[updated_at] are echoed back exactly as sent \u2014 they are not parsed or normalised here.", + "Folder resolution caps out at 600 folders per call, so a very wide folder selection comes back truncated rather than paged.", + "q[query] has no effect on this endpoint \u2014 it is sanitised and then ignored." + ] + }, + { + "name": "get_exploratory_session", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Read one exploratory session in full \u2014 its charter, state, timings, assignee, tags, configurations, linked test plan, linked issues, custom fields and how many logs it holds \u2014 before reading its logs or changing it", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "closed_by", + "closed_at", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "timeline", + "created_at", + "updated_at", + "project_id", + "group_id", + "owner", + "record_status", + "attachment_relations" + ], + "responses": { + "200": { + "description": "Successfully retrieved the exploratory session.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "session_id is a plain integer. The route segment only matches digits, so the display identifier (ES-NNN) never reaches the API \u2014 it fails as a routing 404, not as an API error.", + "Read `session_log_count` first: it tells you how many log pages to walk, and it is the same number that decides whether a clone of this session runs synchronously or asynchronously (50 or more logs switches clone to the async path).", + "custom_fields is always present on this read \u2014 normalised into the flat TCM shape, and an empty array when the session has none. That normalisation is applied here, on create, update and clone, but NOT on the sessions listing.", + "assignee, created_by and closed_by are expanded into {id, email, full_name, group_id} objects by looking the user up locally. If the stored user is not a TM user in this group the field comes back null even though a user is set upstream.", + "A session id that does not exist comes back as a bare {success:false} with the 404 status and no message \u2014 the upstream error text is deliberately not relayed.", + "The session is at the top level under `exploratory_session`. There is no `data` envelope on any endpoint of this entity.", + "Requires the test-run view permission: exploratory sessions are gated by the test run permission set, not by a permission of their own.", + "The path takes the INTEGER session id, not the ES-NNN identifier \u2014 ES-NNN returns 404. The session's display field is `title`; there is no `name`. `summary` and `test_plan` come back from the LIST route only, not from this one." + ] + }, + { + "name": "get_exploratory_session_summary", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/exploratory-session-summary", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "mode", + "type": "string", + "values": [ + "time_period", + "session_selection" + ], + "default": "time_period", + "description": "`time_period` scopes by dates and ignores session_ids; `session_selection` scopes by session_ids and ignores the dates. Mismatched parameters are dropped silently, not rejected." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2025-05-01", + "description": "Inclusive start, YYYY-MM-DD. Only read in time_period mode; omitted entirely from the upstream call when absent." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2025-05-31", + "description": "Inclusive end, YYYY-MM-DD. Only read in time_period mode." + }, + { + "name": "session_ids", + "type": "string", + "example": "101,102,103", + "description": "Comma-separated session ids as a STRING, not an array. Only read in session_selection mode." + } + ], + "intent": "Get exploratory-session testing figures for a project directly \u2014 sessions, outcomes and top testers over a date range or a chosen set of sessions, with no saved report needed", + "shape": "discovered", + "responses": { + "200": { + "description": "Summary figures under `data`.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "description": "Exploratory-session summary payload \u2014 session counts, outcome breakdown, `sessions` (with `assignee` expanded to a user object) and `top_testers` (each `{ user, bug_count }`, `user` null when the tester is no longer in the group)." + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this for an ad-hoc question about exploratory testing. It computes live from the project and needs no report to exist; the saved equivalent is an `Exploratory Session Summary` report read through the report capabilities.", + "The figures are under `data`, not at the top level. `data.top_testers[].user` and `data.sessions[].assignee` are expanded from ids into user objects server-side, and come back `null` for a tester who is no longer a TM user.", + "`mode` decides which of the other parameters matter: `time_period` (the default) uses `start_date` / `end_date` and ignores `session_ids`; `session_selection` uses `session_ids` and ignores the dates. Sending the wrong pair for the mode is not an error \u2014 it is silently ignored and you get an unintended scope.", + "`session_ids` is a comma-separated STRING here, not an array. Dates are plain YYYY-MM-DD.", + "Omitted parameters are dropped rather than defaulted locally, so the upstream service picks the window \u2014 always send explicit dates when the range matters.", + "This route only exists under a project; it reads the project from the path and requires the report_view permission (403 otherwise)." + ], + "returns": [ + "success", + "data" + ] + }, + { + "name": "get_filter", + "method": "GET", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "read", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one saved filter view \u2014 its name, scope and the exact condition set it applies \u2014 before reusing, editing or deleting it", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This read is not side-effect free. The handler validates every custom-field condition in the saved view against the project's current custom fields and, when any no longer exist, it rewrites the stored filter without them. Reading a filter can permanently change it.", + "So `data.filters.customFields` can legitimately be a subset of what was saved, and a second read will not put the pruned conditions back. Treat what you get as the new truth.", + "The entity_type to custom-field-entity map has no entry for `testrun`, so a run filter is validated against TEST CASE custom fields and conditions that were valid for runs can be pruned. Be cautious about reading run filters you do not want altered.", + "Known defect: if the custom-field lookup or the write-back fails with a 400, the action returns without rendering anything at all \u2014 you get an empty/500 response rather than the filter. Fall back to the filter listing, which does no validation.", + "The filter is under `data` with a sibling `success`; the conditions are at `data.filters` and the list type at `data.entity_type` (not `entity`, which is the request-side spelling).", + "Only a 400 from the backing service is translated to `{success:false, message:...}`; anything else from it surfaces as a 500, so an unknown filter_id does not reliably produce a 404." + ], + "disabled": true + }, + { + "name": "get_folder_remove_summary", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm-summary", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Dry-run a folder deletion \u2014 count the test cases, subfolders and cross-product recordings that would be destroyed \u2014 before committing to it", + "returns": [ + "success", + "has_other_product_entity", + "deleted_entity_count", + "active_test_case_count", + "archived_test_case_count", + "sub_folders", + "test_recordings_count" + ], + "responses": { + "200": { + "$response": "RemoveFolderSummaryResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read-only despite the delete machinery behind it: the handler calls the delete path with dry_run set, so nothing is removed. This is the safety check to run before deleting a folder.", + "It is nonetheless gated on the folder-DELETE permission, not the view permission. A caller who can read folders but not delete them gets 403 here.", + "The counts are nested under `deleted_entity_count` \u2014 active_test_case_count, archived_test_case_count, sub_folders and test_recordings_count all live inside that object, not at the top level.", + "`has_other_product_entity` is derived from one thing only: it is true exactly when test_recordings_count is greater than zero, meaning the subtree holds low-code test recordings owned by another product that the delete would take with it.", + "The folder is not fetched before the dry run, so a folder id that does not exist in this project surfaces as a bare 404 {success:false} with no message identifying the id.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." + ] + }, + { + "name": "get_folder_tree", + "method": "GET", + "path": "/api/v1/projects/{project_id}/repository/mapping", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Fetch a project's entire folder tree in one call \u2014 every root folder with its children nested under `contents` \u2014 to resolve a folder name to the integer id every other folder and test-case call needs", + "shape": "discovered", + "responses": { + "200": { + "description": "Folder tree.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "structure": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "description": "Unauthorized." + } + }, + "query": [ + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Sibling ordering. Comma-separated combinations are accepted and each element is validated; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." + }, + { + "name": "shared", + "type": "boolean", + "description": "Any truthy presence switches the tree to the shared-folder view. Presence alone is what counts \u2014 sending shared=false still enables it, because only presence is tested." + } + ], + "guidance": [ + "This returns the WHOLE tree in one response and is not paginated \u2014 no page or per-page parameter is read. On a large project it is a big payload; use the per-folder listing instead if you only need one level.", + "The tree is at `structure`, a top-level array. There is no `data` envelope on this endpoint. Children hang off each node's `contents` array, recursively.", + "Folder ids in the tree are plain integers, and they are what every other folder capability expects in its path.", + "An unresolvable project (including the literal path segment `new`) returns 200 with {structure: [], success: true}, not a 404. An empty structure is therefore ambiguous between 'no folders' and 'no such project' \u2014 confirm the project separately.", + "When Review & Approve is switched on for the project, the per-folder test-case counts in the tree are silently narrowed to review-eligible statuses. If you also pass a review_status filter, it is intersected with the project's eligible set \u2014 and when the intersection is empty your filter is discarded and the project's set is used instead, so you can get counts you did not ask for rather than an error.", + "Counts here are aggregates over the tree; they are not a list of test cases. Read test cases through the test-case listings." + ], + "returns": [ + "success", + "structure" + ] + }, + { + "name": "list_group_users", + "method": "GET", + "path": "/api/v1/group/users-v2", + "mode": "read", + "entity": "user", + "query": [ + { + "name": "q", + "type": "string", + "description": "As a bare string: filters on FULL NAME only, as a SQL LIKE '%value%'. Email is not matched by this form and there is no partial-email search. Whitespace is not trimmed." + }, + { + "name": "q[email]", + "type": "string", + "example": "qa@acme.com", + "description": "Exact email lookup \u2014 no partial matching. Returns at most one user, and this branch OMITS the `info` pagination block entirely, so a reader that always dereferences info.next breaks on it." + }, + { + "name": "q[id]", + "type": "string", + "example": "14,27", + "description": "Comma-separated Test Management user ids. This does NOT filter \u2014 it only re-orders, putting the named ids first and returning the rest of the workspace behind them. It also raises the page size to the number of ids when that exceeds 30." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Previously undeclared. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Has no effect on the q[email] branch, which is unpaginated." + } + ], + "intent": "List or look up the people in this workspace \u2014 search by name, resolve an exact email, or read your own identity \u2014 to get the user id that owner and assignee fields take", + "returns": [ + "success", + "users", + "myself", + "info", + "id", + "browserstack_user_id", + "email", + "full_name", + "group_id", + "onboarded", + "reports_and_notification_enabled" + ], + "responses": { + "200": { + "$schema": "UsersV2Response__ListGroupUsers" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Scope is the whole WORKSPACE (group), not a project \u2014 every active user in the group is returned regardless of which projects they can see, and no project id can be supplied. This is not a way to answer 'who is on this project'.", + "`q[id]` is a sort, not a filter. Passing ids re-orders the full user list so those ids come first; you still get everyone. Filtering to specific people is not supported here \u2014 match on the ids yourself after reading.", + "The response shape varies by branch: the default, name-search and id branches return `{users, myself, success, info}`, but the `q[email]` branch returns `{users, myself, success}` with NO `info` at all.", + "`id` is the Test Management user id; `browserstack_user_id` is the separate BrowserStack account id for the same person, and the two are different numbers. Owner and assignee fields on cases and runs take the TM `id`; some listing filters take browserstack_user_id instead. Sending the wrong one usually resolves to nobody rather than erroring, so check which the target capability wants.", + "Only active users are returned. A deactivated person cannot be resolved here, which means an assignee who has left the workspace cannot be looked up to explain an existing assignment.", + "`myself` is the calling user as {id, full_name, email, reports_and_notification_enabled} \u2014 the cheapest way to get your own TM user id, with no search needed.", + "Page size is fixed at 30 (the q[id] branch excepted, where it becomes the number of ids); there is no per_page parameter.", + "No project is involved, so this works without a project_id and is not project-permission gated." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_issues_count_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/issues-count-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how many defects/issues were linked from this project per period \u2014 the defect-discovery trend", + "returns": [ + "data", + "empty_data" + ], + "responses": { + "200": { + "$response": "IssuesCountInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each entry in `data` is a positional pair \u2014 period label first, count second \u2014 not a {label, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", + "This counts issues per period; it does not list them. There is no way to reach the individual defects from this response.", + "`empty_data` is the 'nothing to plot' flag; the series is padded with zeroes otherwise.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_project_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Get a project by INTEGER id (v1) \u2014 full detail + its PR-NNN identifier", + "returns": [ + "data", + "success", + "project", + "id", + "identifier", + "name", + "description", + "created_at", + "thProjectId", + "normalisedName", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "starred", + "jira_mapped", + "links", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project detail; data.project.identifier is the PR-NNN form.", + "schema": { + "$schema": "V1ProjectDetail__GetProjectByIntegerId" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No project with that integer id, OR the project exists but is not visible to this user \u2014 both render as 404 {success:false}." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The path segment only matches digits (or the literal string 'new'), so a PR-NNN identifier never reaches this action \u2014 it is a routing 404, not an API error.", + "This is the integer-id -> PR-NNN translation step: read `data.project.identifier` and carry it into any the v2 surface route, which will not accept the integer.", + "The project sits under a `data` envelope: `data.project`. The paginated/minified project listings do NOT use `data` \u2014 they return `projects` at the top level. Do not reuse a path across the two.", + "A project that exists but is not visible to this caller comes back as 404 {success:false} \u2014 the underlying 403 is deliberately rewritten. You cannot distinguish 'no such project' from 'no access' on this route.", + "`test_cases_count`, `test_runs_count` and `test_plans_count` are fetched with the detail here (they are absent from the lean listings), which is the reason to use this call rather than filtering a listing.", + "`permissions` is the caller's own permission list for this project and `user_role` their role name \u2014 both reflect the requesting user, not the project.", + "Requires the project_view permission under RBAC." + ] + }, + { + "name": "get_project_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/form-fields-v2", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. PR-NNN identifiers do not match the route; the literal 'new' matches the route but is rejected with 400 'Invalid Params: project_id must be a valid integer'." + } + ], + "intent": "Get the test-case form for one project \u2014 the custom fields it shows, the built-in system fields, and the pick lists behind the standard priority / state / type / automation-status dropdowns", + "returns": [ + "success", + "custom_fields", + "default_fields", + "system_fields", + "id", + "field_name", + "field_type", + "entity_type", + "field_user_name", + "optional", + "is_required", + "is_bulk_editable", + "is_filterable", + "links", + "placeholder", + "default_value", + "option_values", + "field_values", + "sort_options" + ], + "responses": { + "200": { + "$response": "ProjectFormFieldsV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the read to make before creating or editing a test case: it tells you which fields that project actually shows, which are mandatory, and what the standard dropdowns accept.", + "The response is three lists at the top level \u2014 `custom_fields`, `default_fields`, `system_fields` \u2014 with NO `data` envelope and no pagination. Every custom field the project has comes back in one call.", + "`field_type` inside `custom_fields` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean \u2026), not the canonical vocabulary the admin create/update calls take. Translate in both directions.", + "TestCase only. Result, plan, exploratory-session and step custom fields are served by different endpoints and never appear here.", + "`optional` and `is_required` are both present and are inverses of one another; prefer is_required.", + "Each row carries `links.self` \u2014 the ready-made path to that field's values in this project (or to the project user list for a `user` field). Follow it rather than building the path.", + "`option_values` / `field_values` appear only when the upstream row carried them, so an absent key does not mean the field has no options \u2014 follow links.self to be sure.", + "project_id must be numeric. The route also matches the literal 'new', but this handler rejects a non-numeric value with 400 'Invalid Params: project_id must be a valid integer'.", + "`automation_status` under default_fields is assembled in this layer, not read from storage, and the automation-status custom field is omitted for groups without that feature." + ] + }, + { + "name": "get_project_settings", + "method": "GET", + "path": "/api/v1/projects/{project_id}/settings", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "in_review_count", + "type": "string", + "values": [ + "true" + ], + "description": "Compute meta_data.in_review_count and meta_data.test_plan_in_review_count for the CALLING user. Without it both are returned as 0 rather than omitted, so an unrequested 0 is not evidence of an empty queue." + }, + { + "name": "fetch_dedupe_button", + "type": "string", + "values": [ + "true" + ], + "description": "Append a duplicate-review marker string to the project_settings array ('show_review_duplicates', 'disable_review_duplicates' or 'hide_review_duplicates'). Any failure computing it is swallowed and no marker is appended." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the underlying settings rows (30 per page) before they are flattened into project_settings[0]." + } + ], + "intent": "Read a project's configuration flags \u2014 review/approval gating, the custom test-case id prefix, time-tracking, Part 11 re-auth, Jira/Azure linking \u2014 and optionally the caller's own pending-review counts", + "returns": [ + "success", + "project_settings", + "info", + "meta_data", + "in_review_count", + "test_plan_in_review_count" + ], + "responses": { + "200": { + "description": "project_settings[0] is a flat attribute->value object (all values are strings); later elements, when present, are bare marker strings. info and meta_data are omitted entirely when the project has no stored settings.", + "schema": { + "type": "object", + "required": [ + "success", + "project_settings" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "project_settings": { + "type": "array", + "description": "Element 0 is a single object mapping each stored setting name to its string value. Any further elements are duplicate-review marker strings appended when fetch_dedupe_button=true. Empty array when nothing is stored.", + "items": { + "description": "Either the flattened settings object or a marker string." + } + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the underlying settings rows. Absent when the project has no stored settings." + }, + "meta_data": { + "type": "object", + "description": "Per-caller review counts. Present whenever settings exist; both fields are 0 unless in_review_count=true was passed.", + "properties": { + "in_review_count": { + "type": "integer", + "description": "Test cases in review where the calling user is a reviewer.", + "example": 4 + }, + "test_plan_in_review_count": { + "type": "integer", + "description": "Test plans in review for the calling user. Best-effort \u2014 a backend error yields 0.", + "example": 2 + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`project_settings` is an ARRAY, but not a list of settings. Element 0 is a single flat object holding every stored setting as attribute -> value; any further elements are bare marker STRINGS ('show_review_duplicates' / 'disable_review_duplicates' / 'hide_review_duplicates') appended only when fetch_dedupe_button=true. Read project_settings[0] for the settings and expect mixed types in the rest of the array.", + "A project with no stored settings returns {success:true, project_settings:[]} with NO `info` and NO `meta_data` at all \u2014 not an object of defaults. Absence of a key means 'never set', and the effective behaviour is the group default, which is not visible here.", + "Every value is stored as a string (the settings are an EAV store), so booleans come back as \"true\"/\"false\" and numbers as strings. Compare against strings, not native types.", + "`meta_data` is always present when any setting exists, but both counts are hard-zero unless you pass in_review_count=true \u2014 a 0 you did not ask for means 'not computed', not 'nothing in review'.", + "in_review_count counts test cases where the CALLER is a reviewer and the case is in review; it is per-user, not per-project. test_plan_in_review_count is computed on a separate path and is best-effort \u2014 a backend hiccup silently yields 0.", + "The underlying rows are paged at 30 per page (`p` selects the page) before being flattened, so a project with an unusually large settings set can produce an incomplete first-page object.", + "This read is not feature-gated, but the matching write is: update_project_settings returns 422 unless the new-project-settings flag is on for the group or user." + ] + }, + { + "name": "list_project_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users-v2", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "example": "jane", + "description": "Flat string: case-sensitive LIKE match on full_name (substring). Paginated, 30 per page." + }, + { + "name": "q[email]", + "type": "string", + "example": "jane@acme.com", + "description": "Exact email lookup, at most one row. This branch returns NO `info` block \u2014 do not read pagination from it. An unknown address returns an empty `users` array, not a 404." + }, + { + "name": "q[id]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids. This does NOT filter \u2014 it moves those users to the front of the full group list. Page size grows to the id count when more than 30 ids are given." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number (the param is `p`, not `page`). Overflow returns the last page rather than an empty one." + } + ], + "intent": "Find the users in this group \u2014 by name fragment, by exact email, or by id \u2014 and get the caller's own identity back alongside them; use this to resolve a person's name or email to the numeric user id that owner/assignee/reviewer filters and writes require", + "returns": [], + "responses": { + "200": { + "$schema": "UsersV2Response__ListProjectUsers" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the path, this is GROUP-scoped, not project-scoped: it lists every active user in the caller's group and never filters by the project in the URL. The project_id only has to be a real project the caller can see.", + "`q` is polymorphic and the three forms behave differently. `q=jane` (a flat string) is a name LIKE match and is paginated. `q[email]=jane@acme.com` is an exact lookup that returns at most one user AND OMITS the `info` block entirely. `q[id]=12,34` reorders those ids to the front of the full list rather than filtering to them \u2014 it is a sort, not a filter.", + "Page size is fixed at 30 and there is no size parameter. The one exception is `q[id]` with more than 30 ids, where the page grows to fit the id list.", + "Use `p` for the page number (not `page`). An out-of-range page returns the LAST page rather than an empty list, so never treat a non-empty response as proof the page number was valid.", + "`myself` is the caller's own record and is always present, but it is a reduced shape \u2014 id, full_name, email, reports_and_notification_enabled only, with no browserstack_user_id.", + "The `id` on each user is the TM user id. That is what the owner/assignee/reviewer search filters and test-run writes expect; `browserstack_user_id` is a different number and is only correct where a capability explicitly says BrowserStack id.", + "Only active users are returned \u2014 a deactivated user who still owns test cases will not appear here, so an unresolvable owner id is not necessarily invalid." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_projects_basic", + "method": "GET", + "path": "/api/v1/projects/basic", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Validated as an integer; a non-integer returns 400." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page. Validated as an integer here but NOT bounded in this service before being forwarded to the backing store; 300 is the documented maximum." + }, + { + "name": "q[query]", + "type": "string", + "example": "checkout", + "description": "Free-text match on project name." + }, + { + "name": "q[identifier]", + "type": "string", + "example": "PR-60863", + "description": "Exact PR-NNN identifier lookup. Note the matched rows do not echo `identifier` back." + } + ], + "intent": "Look up projects by name or by PR-NNN identifier, or page through them cheaply \u2014 the lean listing, and the only one with an identifier search; note the rows it returns do NOT include the identifier itself", + "returns": [], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectsPaginatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`q` must be a structured object here \u2014 q[query] for a name match, q[identifier] for an exact PR-NNN lookup. A flat `q=foo` string returns 400 'Invalid Params: q must be an object with identifier/query keys'. This is the exact opposite of list_projects, which demands a flat string.", + "Asymmetry worth planning around: you can FILTER by q[identifier], but the rows come back without `identifier` \u2014 the serializer here omits it. If you need the PR-NNN form back, use list_projects or the single-project read.", + "Only q[query] and q[identifier] are read; any other key under q is dropped silently and the search runs unfiltered, which reads as 'matched everything' rather than as an error.", + "Default page size is 30 (not 300). `count` is forwarded to the backing service and is not bounded in this codebase \u2014 300 is the documented ceiling, so treat larger values as unsupported rather than assuming they are clamped.", + "Rows carry `permissions` (the caller's own rights on that project) but no counts \u2014 test_cases_count/test_runs_count are absent from this payload. Fetch the single project if you need them.", + "The response is `projects` + `info` at the top level; there is no `data` envelope." + ] + }, + { + "name": "get_projects_minify", + "method": "GET", + "path": "/api/v1/projects/minify", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 300, + "description": "Rows per page. Clamped to 300 \u2014 a larger value is silently reduced, not rejected. Omitting it gives 300, not 30." + } + ], + "intent": "Page through every project in the group as a flat dropdown list, 300 at a time \u2014 the fastest way to enumerate all projects, but it cannot search and it returns no pagination metadata", + "returns": [], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectMinifyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "There is no search on this route at all \u2014 no `q` in any form. To find a project by name or identifier use get_projects_basic.", + "The response has NO `info` block: `{success, projects}` only. You cannot tell from one page whether more exist \u2014 keep requesting pages until one comes back with fewer than `count` rows (or empty).", + "Default page size is 300, not 30, and `count` is hard-clamped to 300: a larger value is silently reduced rather than rejected. That makes this the cheapest full enumeration in the index.", + "Rows are the same lean dropdown shape as get_projects_basic \u2014 id, name, description, starred, the caller's `permissions` \u2014 with no identifier and no test case / run counts.", + "`p` is validated globally: a non-positive or non-numeric page returns 400 'Invalid Params'." + ] + }, + { + "name": "get_report_attachment_url", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/attachments/{attachment_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. Part of the lookup key \u2014 an attachment filed under a different project is a 404 even with the correct id." + }, + { + "name": "attachment_id", + "type": "integer", + "required": true, + "description": "Integer stored-file id for the attachment. Not a report id and not an export channel id; no report capability returns one." + } + ], + "intent": "Exchange a report attachment's id for a short-lived direct download URL", + "returns": [ + "url", + "success" + ], + "responses": { + "200": { + "description": "Successfully retrieved report attachment URL", + "schema": { + "type": "object", + "required": [ + "url", + "success" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "example": "https://example.com/download/report.pdf" + }, + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns a presigned URL you can fetch yourself \u2014 the bytes never pass through this API. It expires in 100 minutes, so fetch it promptly rather than storing it.", + "`attachment_id` is a stored-file id, not a report id and not a channel id. No capability in this index hands one out: the report reads do not return attachment ids, and the export path only broadcasts its result over a realtime channel. In practice you need the id from an attachment link the product rendered elsewhere.", + "A miss returns HTTP 404 with a normal-looking body `{ url: null, success: false }` rather than an error envelope \u2014 check `success`, not just the status, and never treat `url: null` as a valid URL.", + "The lookup is scoped by BOTH the attachment id and the `project_id` in the path (plus your group), so the right id under the wrong project is a 404. Requires the report_view permission." + ], + "disabled": true + }, + { + "name": "get_report_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "sections", + "type": "string", + "example": "test_case_created_priority,test_case_top_creators", + "description": "Comma-separated widget names to compute, from the report type's allow-list. Anything else fails the whole call 400 `Invalid sections passed`. Omit for the report's default (often nearly empty) payload." + }, + { + "name": "is_print", + "type": "boolean", + "description": "Send `true` to compute EVERY section for `Test Run Summary` and `Test Plan Summary` in one call, ignoring `sections`. Ignored by the other report types. Compared as the literal string 'true'." + }, + { + "name": "report_data_only", + "type": "boolean", + "description": "Return ONLY the `report_data` object, dropping the definition and filters. Any non-empty value is truthy \u2014 `report_data_only=false` also strips the envelope. Omit it entirely for the full body." + }, + { + "name": "widget_type", + "type": "string", + "description": "Required when `sections` names a `*_drilldown` widget; the allowed set differs per drilldown (see get_report_section). Missing or unknown is 400." + }, + { + "name": "segment", + "type": "string", + "description": "Drilldown sub-slice, sent as a JSON-ENCODED STRING (e.g. `{\"outcome\":\"bugs_found\"}`), not a nested object. Unparseable values are ignored rather than rejected." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for the row-bearing sections (drilldowns, trtc_details, traceability rows). Does not page the definition." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table. Ignored by the other report types, which use their own page size." + }, + { + "name": "today", + "type": "string", + "example": "2025-05-13", + "format": "date", + "description": "Overrides the server's notion of 'today' for Test Plan Summary overdue/near-completion maths. Unparseable values fall back to the real date silently." + }, + { + "name": "issue_categories", + "type": "string", + "example": "epic,story", + "description": "Requirement Traceability Report only: restrict to these issue types. Accepts a comma-separated string or a repeated array param. The catch-all value `other` is stripped before the upstream call." + } + ], + "intent": "Read one saved report \u2014 its definition, resolved filters, and computed figures \u2014 the general-purpose report read", + "returns": [ + "data", + "report_filters", + "report_data", + "dynamic_filters", + "project" + ], + "paginated": true, + "responses": { + "200": { + "$response": "GetReportDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the read to reach for when you have a report id and want everything: the definition is at `data`, the resolved filter selection at `report_filters`, and the numbers at `report_data`. Use get_report_section instead when you want one widget and nothing else.", + "`report_data` is computed per report type and is mostly EMPTY unless you name sections. A bare read fills a Test Case Activity report's headline counts but leaves Test Run Summary's issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null. A null section means NOT COMPUTED \u2014 never read it as 'no data'. Request the section, then judge.", + "Shortcut: `is_print=true` forces ALL sections for `Test Run Summary` and `Test Plan Summary` in a single call, which is usually what you want rather than guessing section names. User Workload Report already returns every section when `sections` is omitted.", + "`report_data_only` is a trap: any non-empty value is truthy, INCLUDING the string \"false\". Sending `report_data_only=false` collapses the response to the bare `report_data` object with no `data`, no `report_filters` and no envelope. Omit the parameter entirely when you want the full body.", + "A section name not in the report type's allow-list is 400 `Invalid sections passed` for the whole call. Do not answer that by retrying without `sections` \u2014 you get a stripped body, not the data. Fix the name (see get_report_section for the per-type lists).", + "A report saved with `report_timeframe: specific_test_plans` whose plan filters have since gone returns 422 `This report is invalid. Please create a new Report`. That report cannot be read at all; recreate it.", + "`Defects Summary` and `Defects Detailed Report` have no data branch on the server \u2014 they always read back `report_data: null` no matter what you ask for. For defect figures use a Test Run Summary and its issues_by_priority / issues_by_status sections.", + "The report is resolved by group + id; the project in the path does not scope the lookup. 404 `Report Not Found` means no such report in your group. Requires the report_view permission.", + "`*_drilldown` sections (tr_drilldown, tc_activity_drilldown, es_drilldown, tp_drilldown) return PAGINATED ROWS and additionally require `widget_type` \u2014 never tally those rows into a total, read the aggregate section instead." + ] + }, + { + "name": "get_report_section", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/{section_name}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + }, + { + "name": "section_name", + "type": "string", + "required": true, + "example": "test_case_created_priority", + "description": "One widget name from the report type's allow-list (see guidance for the per-type lists). A name outside the list is 400 `Invalid sections passed`; report types with no list accept anything and return a default payload." + } + ], + "query": [ + { + "name": "widget_type", + "type": "string", + "description": "Required for the `*_drilldown` sections and validated against a set that differs per drilldown (see guidance). Missing or unknown is 400 `invalid widget_type`." + }, + { + "name": "segment", + "type": "string", + "example": "{\"outcome\":\"bugs_found\"}", + "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object. For es_drilldown + session_outcomes the `outcome` value is validated (400 otherwise); elsewhere an unparseable value is ignored." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for row-bearing sections." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table; ignored elsewhere." + } + ], + "intent": "Fetch one report widget on its own \u2014 a single chart, table or drill-down slice from a saved report, without the report definition", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "The bare section payload (the report read's `report_data` object) \u2014 no success flag and no envelope. `null` for report types with no data branch.", + "schema": { + "type": "object", + "properties": { + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "issues_by_priority": { + "type": "string", + "nullable": true + }, + "issues_by_status": { + "type": "string", + "nullable": true + }, + "issues_data": { + "type": "string", + "nullable": true + }, + "test_cases_status": { + "type": "string", + "nullable": true + }, + "test_run_data": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 44 }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - }, - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "new": { - "type": "object", - "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - }, - "old": { - "type": "object", - "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - } - } - } - } + "closed": { + "type": "integer", + "example": 1 + }, + "test_cases": { + "type": "integer", + "example": 158 + }, + "test_results": { + "type": "integer", + "example": 9 } + } + }, + "test_run_status": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "string", + "nullable": true + }, + "user_result_stats": { + "type": "string", + "nullable": true } - }, - "User__ListTestCaseHistories": { - "type": "object", - "description": "User as returned by `list_test_case_histories`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "reports_and_notification_enabled": { - "type": "boolean", - "example": true + } + } + }, + "400": { + "description": "Section name not in the report type's allow-list, or a missing/invalid widget_type or segment on a drilldown section." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "The report's saved plan selection no longer resolves \u2014 the report must be recreated." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is the BARE widget payload. This route runs the report read with `report_data_only` forced on, so there is no `success`, no `data` and no `report_filters` \u2014 just the section object keyed by the section name.", + "The section name must be in the report type's allow-list or the call is 400 `Invalid sections passed`. Test Run Summary: test_run_data, issues_data, test_run_status, test_cases_status, user_result_stats, issues_by_priority, issues_by_status, time_tracking, estimated_vs_actual, tr_drilldown. Test Run Detailed Report: trtc_details, trtc_filter_options, test_run_data, issues_data, trtc_performance, tr_drilldown.", + "Test Case Activity: test_case_created_count, test_case_updated_count, test_case_deleted_count, test_case_automation_coverage, test_case_top_creators, test_case_trend, test_case_created_priority, test_case_ai_coverage, test_case_list, tc_activity_drilldown. Test Plan Summary: test_run_data, test_run_status, test_plans_progress, test_plans_near_completion, overdue_test_plans, test_plans_execution_trend_list, test_plans_execution_trend_graph, test_result_user_stats, test_run_requirements, test_result_defects, defects_by_status, defects_by_priority, tp_drilldown. Exploratory Session Summary: es_drilldown. User Workload Report: cards, user_table, workload_distribution, execution_trend, status_distribution.", + "Requirement Traceability Report, Test Plan Detailed Report, Defects Summary and Defects Detailed Report have NO allow-list, so any section name is accepted and forwarded \u2014 a typo there returns an empty or default payload at 200 instead of a 400.", + "On `Defects Summary` / `Defects Detailed Report` the body is literally `null`: those types have no data branch at all. That is the report type, not a failure of your section name.", + "Drill-down sections need `widget_type` and reject anything outside their own set. tr_drilldown: active_runs, closed_runs, tr_performance, total_test_cases, linked_issues, requirements_linked, runs_by_state, defects_linked, assignee_breakdown, tcs_by_status. tc_activity_drilldown: created_test_cases, updated_test_cases, deleted_test_cases, automation_coverage, ai_coverage, trend, priority, top_creators. es_drilldown: total_sessions, linked_test_plans, session_outcomes. tp_drilldown has no allow-list and passes through.", + "For `es_drilldown` with widget_type `session_outcomes`, `segment` must carry an `outcome` from bugs_found, issues_found, needs_followup, skipped, no_issues, no_activity \u2014 anything else is 400.", + "Drill-downs return PAGINATED ROWS, not aggregates. Page them with `p`; never sum rows into a count \u2014 read the matching aggregate section instead.", + "Every precondition of the full report read still applies: 404 for an unknown report, 422 for a specific_test_plans report whose plan filters are gone, and the report_view permission.", + "The section name occupies a wildcard path segment. Two-segment paths with a reserved word (for example `detail`) are matched by other routes first, so only use real section names here." + ] + }, + { + "name": "get_report_summary_by_type", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "test_runs_summary", + "test_runs_details", + "test_plan_details", + "requirement_traceability_report" + ], + "description": "Which renderer to run. This is NOT checked against the report's stored report_type \u2014 a mismatch still computes and returns 200. Any other value is 400 `Report type not found`." + } + ], + "query": [ + { + "name": "reportTimeRange", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "Window to compute over. Required for test_runs_summary / test_runs_details \u2014 missing or unrecognised is an unhandled 500, not a 400. `custom` reads dates off the REPORT's stored custom_date_range. Ignored by requirement_traceability_report; optional for test_plan_details." + }, + { + "name": "sections", + "type": "string", + "example": "test_run_data,test_run_status", + "description": "Comma-separated widgets. test_runs_details defaults to `test_run_data,issues_data,trtc_performance` when omitted. Not allow-list-checked on this route." + }, + { + "name": "widget_type", + "type": "string", + "values": [ + "active_runs", + "closed_runs", + "tr_performance", + "total_test_cases", + "linked_issues", + "requirements_linked", + "runs_by_state", + "defects_linked", + "assignee_breakdown", + "tcs_by_status" + ], + "description": "Only read when `sections` includes `tr_drilldown`, and then required \u2014 an unknown value is 400 `invalid widget_type`." + }, + { + "name": "segment", + "type": "string", + "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for the row-bearing renderers (test_runs_details, test_plan_details, traceability)." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page for the test_runs_details table. The upstream ceiling is not enforced here." + }, + { + "name": "required", + "type": "object", + "description": "Column selection for test_runs_details / test_plan_details: `{ \"\": { \"fields\": [...] } }`, entities test_run_test_case / test_run / test_result (plus test_plan). `fields` may be a comma-separated string, and `[\"all\"]` expands to every supported column. Unknown names are 400 here." + }, + { + "name": "issue_categories", + "type": "string", + "example": "epic,story", + "description": "requirement_traceability_report only: restrict to these issue types. Comma-separated string or repeated array param; the catch-all `other` is stripped before the upstream call." + }, + { + "name": "today", + "type": "string", + "example": "2025-05-13", + "format": "date", + "description": "Overrides 'today' for plan overdue/near-completion maths. Unparseable values fall back silently." + } + ], + "intent": "Render a saved report through one of four fixed renderers \u2014 run summary, run detail table, test plan detail table, or requirement traceability", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "ReportSummaryResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The `report_type` path segment selects a RENDERER, not the report's own type, and the server never checks the two agree. Pointing `test_runs_summary` at a Test Case Activity report computes a run summary from that report's filters and returns 200 \u2014 so the figures may not be the report the user asked about. Prefer get_report_detail unless you specifically want one of these four renderings.", + "The path segment takes four values, one more than the spec lists: `test_runs_summary`, `test_runs_details`, `test_plan_details` and `requirement_traceability_report`. Anything else is 400 `Report type not found`, as is omitting it.", + "`reportTimeRange` is genuinely required for `test_runs_summary` and `test_runs_details`: omitting it, or sending a value outside the timeframe enum, is an unhandled 500 rather than a validation error. `test_plan_details` falls back to the report's saved timeframe, and `requirement_traceability_report` ignores it entirely.", + "`reportTimeRange=custom` reads the dates off the REPORT's stored `custom_date_range`, not from the request \u2014 on a report saved without one it is a 500. Use the report's own timeframe value unless you know it has a custom range.", + "The response is the renderer's payload merged onto `{ success: true }` \u2014 the keys differ per renderer, so read what came back rather than assuming a fixed shape.", + "`test_runs_details` defaults `sections` to `test_run_data,issues_data,trtc_performance`, pages with `p` / `per_page`, and accepts a column selection under `required` which IS validated here (an unknown field name is 400 `Invalid fields: ... for `).", + "`test_plan_details` is feature-flagged: a group without the flag gets 403 `Operation not permitted`.", + "`requirement_traceability_report` accepts `issue_categories` (comma-separated or repeated) to restrict issue types; the catch-all value `other` is stripped before the upstream call.", + "`widget_type` here is the tr_drilldown set, and only applies when `sections` includes `tr_drilldown`. 404 `Report Not Found` for an unknown id; requires the report_view permission." + ], + "returns": [ + "success" + ] + }, + { + "name": "list_root_folders", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folders", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "folder_name", + "type": "string", + "example": "Folder_123", + "description": "Name search across the project's folders, not just root ones. It is trimmed and ignored when blank, so an all-whitespace value quietly returns the unfiltered root listing instead of an empty result." + }, + { + "name": "include_folder_hierarchy", + "type": "boolean", + "default": false, + "description": "Adds a `folder_hierarchy` map of folder id to its ancestor chain \u2014 the only way to learn a matched folder's path from this endpoint. Enabled only by the literal string \"true\" (case-insensitively); any other value is false." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "description": "Page number, and the switch that turns paging on at all. With `p` absent NO page parameters are sent upstream, `per_page` and `case_count` are ignored entirely, and you get the upstream default page. Always send `p` if you intend to page." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "description": "Rows per page. Read only when `p` is also present, and forwarded upstream unchanged \u2014 this service enforces no ceiling of its own, so the real maximum is the upstream one (documented as 100; not enforceable from this codebase). Trust `info` for what you actually got." + }, + { + "name": "case_count", + "type": "boolean", + "default": true, + "description": "Send the literal string \"false\" to drop the expensive per-folder test-case counts and the total count, keeping only subfolder counts \u2014 a worthwhile speedup on wide projects. Read only when `p` is present; any value other than \"false\" leaves counts on." + }, + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Ordering. Comma-separated combinations are accepted; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." + } + ], + "intent": "List a project's root folders, or search folders anywhere in the project by name \u2014 the paginated way in when the full tree is too big to pull at once", + "returns": [ + "folders", + "info", + "folder_hierarchy", + "id", + "name", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "$response": "RootFolderDetailsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Without `folder_name` or a filter this returns ROOT folders only \u2014 it is not a flat list of every folder in the project. To reach nested folders either search by name, ask for the hierarchy, or walk the tree.", + "Paging is conditional on `p`. Sending only `per_page` does nothing at all: the handler never builds page parameters unless `p` is present, so you silently get the upstream default page size.", + "The payload is {folders: [...], info: {...}} with no `data` envelope, and `folder_hierarchy` appears only when you asked for it. Page forward using `info`, not by counting rows.", + "Folder ids are plain integers. `parent_id` is null on root folders.", + "Test-case filters passed under `q` narrow which folders come back (folders whose cases match). Using a 'contains only' match type without the feature flag returns 403 {success:false, message:'This feature is not available'} \u2014 that is a feature gap, not a permission problem.", + "Requires the folder-view permission." + ] + }, + { + "name": "get_selected_report_testcases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/selection/edit", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "intent": "Read a report's saved test-case selection in the folder-keyed shape the editor uses \u2014 the value to round-trip back when editing the report", + "returns": [ + "success", + "selection_data", + "selection", + "shared_selection" + ], + "responses": { + "200": { + "$response": "GetReportTestCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this before editing a report's case selection. The report edit treats `report_filters.test_cases` as authoritative and deletes what you omit, and a flat id list cannot express folder-level or project-level 'select all' \u2014 this returns the exact object to send back.", + "`selection` is the primary project's saved selection (`{}` when nothing is saved); `shared_selection` is `{ \"\": }` for the report's other projects on a cross-project report, and `{}` otherwise. `selection_data` is the project's full folder/case tree used to render the picker, not the saved selection \u2014 do not confuse the two.", + "Despite the name it is not traceability-specific: it works for any report type. It simply returns an unfiltered project selection tree when the report has no case filters saved.", + "Unlike the other report reads, the `project_id` in the path IS used \u2014 `selection_data` is built for that project, so pass the report's own project or the tree will not match the saved ids.", + "Case ids inside the selection are INTEGERS (the numeric id), not TC-NNN identifiers.", + "404 `Report Not Found` for an id that is not in your group. Requires the report_view permission." + ] + }, + { + "name": "get_shared_step", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "read", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the block's step ROWS (not over shared steps). A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 150, + "description": "Step rows per page. Forwarded unchanged to the backing service, which owns the ceiling \u2014 it is not bounded here. Previously undeclared, which is why long blocks silently truncated at 150." + }, + { + "name": "paginated", + "type": "boolean", + "default": false, + "description": "Does NOT control whether the rows are paginated \u2014 they always are. It only controls whether `page_info` is included in the response. Send 'true' or you get a page of rows with no way to tell that more exist." + } + ], + "intent": "Read one shared step with its full ordered step/result rows \u2014 the block that every referencing test case renders, and the row ids those references point at", + "returns": [ + "success", + "data", + "id", + "title", + "created_by", + "updated_by", + "shared_step_details", + "page_info" + ], + "paginated": true, + "responses": { + "200": { + "$schema": "SharedStepsIDResponse__GetSharedStep" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The rows are always fetched a page at a time (150 by default) regardless of `paginated`. What `paginated=true` buys you is the `data.page_info` block; without it a block with 400 steps returns its first 150 and looks complete. Always send paginated=true when you care about the whole list.", + "Each row in `data.shared_step_details` carries its own `id` \u2014 that integer is the `shared_step_detail_id` a referencing test-case step holds. Carry the row id, not its `order`, when you need to line up a test case's steps with this block.", + "Send these rows back verbatim (with their ids) when updating the block; the update replaces the whole array, so anything you did not read here is lost on the next write.", + "`test_case_count` and `step_count` appear only when the backing service returns them on this path \u2014 do not rely on them here. The shared-step listing always carries both.", + "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}`; a block that does not exist returns 404 `{data:null, success:false}`.", + "`created_by`/`updated_by` are null when the original author is no longer a user in this workspace.", + "Requires tc_view." + ] + }, + { + "name": "get_shared_steps", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-steps", + "mode": "read", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. There is no per_page here \u2014 the page size is whatever the backing service defaults to." + }, + { + "name": "q[query]", + "type": "string", + "description": "Free-text match on the shared step TITLE only \u2014 it does not search step or result text. A bare scalar ``q` foo` is ignored rather than rejected, so the nested form is required." + }, + { + "name": "q[tags]", + "type": "string", + "description": "Filter to shared steps carrying this tag. Match mode is fixed to 'any' server-side; a match_object you send is not read." + }, + { + "name": "q[empty][system_fields][]", + "type": "array", + "values": [ + "tags" + ], + "description": "Find blocks with the named field empty. Only `tags` is honoured \u2014 any other field name is filtered out silently and the condition simply disappears." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Restrict to one shared-field folder. Forwarded only when present." + }, + { + "name": "include_sub_collections", + "type": "boolean", + "default": false, + "description": "Include blocks in sub-folders of folder_id. Only the literal string 'true' turns it on." + }, + { + "name": "field_type", + "type": "string", + "description": "Shared-field type filter, shared with the shared-components listing. Leave unset for ordinary shared steps." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort column, forwarded unchanged to the backing service, which owns the accepted set and the default." + }, + { + "name": "pre_fetch", + "type": "boolean", + "default": false, + "description": "Inline the first row's step details under `pre_fetched_shared_step`. Only takes effect on page 1 and only for the first row \u2014 it is a UI optimisation, not a way to fetch details in bulk." + } + ], + "intent": "List a project's shared steps with how many steps each holds and how many test cases embed it \u2014 use this to find a shared_step_id and to size the blast radius before editing or deleting a block", + "returns": [ + "success", + "data", + "info", + "pre_fetched_shared_step", + "id", + "title", + "step_count", + "test_case_count", + "created_by", + "updated_by" + ], + "paginated": true, + "responses": { + "200": { + "$response": "SharedStepsGetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`test_case_count` on each row is the number of test cases embedding that block. It is the number to read before any edit or delete, and this listing is the cheapest place to get it.", + "`step_count` is how many step rows the block holds; the rows themselves are not in this response \u2014 read the block by id for those.", + "Search is title-only via `q[query]`. Sending `q` as a bare string is silently ignored, so a search that returns everything usually means the parameter was flat instead of nested.", + "Page with `p`; there is no page-size parameter on this listing at all, so you cannot pull it in fewer, larger pages. Walk until `info.next` is null.", + "`created_by` and `updated_by` are resolved from BrowserStack user ids to `{id, full_name}` where that person still exists in this workspace, and come back **null** when they do not. A null author means a missing or removed user, not a missing field.", + "Requires tc_view." + ] + }, + { + "name": "get_system_field_values", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/{field_name}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "field_name", + "type": "string", + "required": true, + "values": [ + "priority", + "status", + "case_type", + "automation_state", + "defects" + ] + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Filter the options by display value (substring match)." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + } + ], + "intent": "List the option ids and display names a project defines for one system field (priority, status, case type, automation state) \u2014 resolve these before writing any of them.", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "SystemFieldValuesResponse" + }, + "401": { + "$response": "Unauthorized" + } + }, + "guidance": [ + "This is the lookup that makes writes correct: priority, status, case type and automation state are per-project configurable, and the bulk-edit paths require the NUMERIC id, not the display name.", + "The list is keyed by the field name you asked for, so the options are at `.values`, not at a fixed key. Each entry is `{value: , name: }` \u2014 `value` is the id to send.", + "`field_name` is one of priority, status, case_type, automation_state, defects. Any other name is not rejected by the route; it resolves to nothing and you get an empty list back with `success: true`.", + "`q` filters the options by value text and `p` pages them \u2014 a project with many options needs paging, so check `info` rather than assuming one page is the whole vocabulary.", + "Custom fields are not here; this covers system fields only." + ], + "returns": [ + "success", + "values", + "value", + "name", + "info" + ] + }, + { + "name": "get_template", + "method": "GET", + "path": "/api/v2/templates/{id}", + "mode": "read", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id \u2014 a plain integer with no prefix. Discover it from the template list; a non-numeric value does not reach this endpoint at all.", + "pattern": "^[0-9]+$" + } + ], + "intent": "Get one template in full, including every description, property and step field it carries \u2014 read this before editing a template so you can resend the complete field set.", + "guidance": [ + "This is the only read that returns the field layout; the list returns metadata and counts only.", + "The field count is not calculated on this read and always comes back empty here, even though the same field is populated on the template list. Take the count from the list when you need it; do not read the empty value as a template with no fields.", + "The Review & Approve property fields (reviewers, review status) are always filtered out of this response, on every workspace, even when the template stores them. Do not conclude from a read that they are absent.", + "The properties list contains a synthetic extra row: whenever the automation-status field is present, a duplicate of it is emitted with its internal name rewritten to the automation-state spelling, sharing the original's mapping id, field id and position. It is one stored field presented as two rows.", + "A field update replaces the whole section, so treat this response as the baseline: drop what you want gone, keep everything else, then send the full array back. Two things make that unsafe to do literally - the duplicated automation row above, and the Review & Approve fields that were filtered out - so reconcile both before resending rather than echoing the arrays verbatim.", + "The step-field list is emptied for workspaces without step custom fields enabled, while the key itself is always present. An empty list therefore means either 'not enabled here' or 'none configured', and this response cannot tell you which.", + "For a custom field, the field definition reports its type twice under different names: one is the product-facing type name, the other is the stored type name. Both are text - neither is a number - and for a small number of workspaces the product-facing one can be replaced with a nested-dropdown type that does not appear in the ordinary set.", + "There are two independent required-ness signals and they can disagree: the mandatory flag on the placement row, and a required flag inside the field definition itself. A field can be required by its own definition while the placement row says it is not.", + "An unknown or out-of-workspace id comes back as a 404 saying the template was not found, not as an empty success." + ], + "returns": [ + "success", + "template", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at", + "description_fields", + "properties_fields", + "step_fields" + ], + "responses": { + "200": { + "$response": "TemplateDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "list_test_case_attachments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List the files attached to one test case \u2014 id, filename, MIME type and size per file, so you can see what evidence is on the case and pick an id to remove", + "returns": [ + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "description": "Attachments retrieved successfully", + "schema": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentResponse" + } + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DOES NOT WORK. Returns a bare 500 on any request. The handler calls a collection method on a value that is a plain array on this path, so it raises before serialising. Read a case's attachments through the entity-attachments listing instead.", + "Metadata only: id, filename, content_type, byte_size. There is NO url and no download endpoint on this v1 surface \u2014 if you need to read the file, use the v2 attachment listing, which returns a readable URL per file.", + "`attachments` and `success` are at the top level; there is no `data` envelope here, even though the sibling delete on the same path wraps its payload in one.", + "Not paginated and not filterable \u2014 every attachment on the case comes back in one array.", + "`folder_id` is part of the route but is not used to locate the case; the lookup is by project and test case id, so a stale folder id after a move still resolves.", + "An empty array means the case has no attachments; it is not an error." + ], + "disabled": true + }, + { + "name": "get_test_case_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one test case by its INTEGER id inside a folder \u2014 the v1 lookup, and the way to translate an integer id into the TC-NNN identifier the v2 surface needs.", + "returns": [ + "success", + "data", + "test_case", + "id", + "identifier", + "name" + ], + "responses": { + "200": { + "description": "Test case detail; data.test_case.identifier is the TC-NNN form.", + "schema": { + "$schema": "V1TestCaseDetail" + } + }, + "401": { + "description": "Unauthorized \u2014 authentication missing or invalid." + }, + "404": { + "description": "No test case with that integer id in that folder (or not visible)." + } + }, + "guidance": [ + "All three path segments are plain integers here, and the route only matches digits \u2014 a TC-NNN or PR-NNN identifier never reaches the API and 404s at the router.", + "The folder id must be the case's CURRENT folder. A correct case id under the wrong folder is a 404, not a redirect.", + "The case is at `data.test_case` \u2014 there is a `data` envelope on this v1 read. `data.test_case.identifier` is the TC-NNN form (or the workspace's third-party identifier when the workspace issues project-level ids); the field is called `name`, not `title`.", + "`estimate` always comes back null on every read path \u2014 the stored estimate, if any, is in `estimated_duration_seconds`.", + "This returns the case record only. For its linked runs, results and issue metadata use the detail read instead." + ] + }, + { + "name": "get_test_case_count_trend", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-count-trend", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how the project's test case count grew month by month, broken down by case type \u2014 use this for 'how fast is the suite growing'", + "returns": [ + "data", + "empty_data", + "order" + ], + "responses": { + "200": { + "$response": "TestCaseCountTrend" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`data` is keyed BY CASE TYPE NAME, not by month \u2014 the keys are whatever case types this group has defined (including custom ones), so treat it as an open map and iterate it rather than looking up fixed names.", + "The monthly numbers sit one level deeper, under each entry's `graphData`, keyed by three-letter month abbreviation (Jan\u2026Dec) for the trailing 12 months. `data.` itself is not the series.", + "Read `order` for the intended display sequence of those keys; object key order is not meaningful.", + "`Total` is a sibling key in the same map alongside the real case types (the CSV exporter special-cases it), so summing every key double-counts.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_test_case_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/detail", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "include", + "type": "string", + "values": [ + "folder_path" + ], + "example": "folder_path", + "description": "Comma-separated extras. `folder_path` is the only value read; anything else is ignored silently." + }, + { + "name": "exclude", + "type": "string", + "example": "results,defects", + "description": "Comma-separated relations to leave out (`results`, `defects`, `issue_metadata`). Passing it also switches off the heavier slide-over aggregation, so the response is smaller in more ways than the names suggest." + }, + { + "name": "hasDuplicates", + "type": "boolean", + "description": "When true, populates `duplicates_tc_count` from the dedupe store. Left null otherwise, and degraded to 0 rather than erroring if that store is unavailable." + } + ], + "intent": "Read one test case with its full slide-over detail \u2014 steps, custom fields, template, attachments and its linked runs, results and defects.", + "returns": [ + "data", + "success", + "test_case", + "is_archived", + "linked_issues", + "pagy" + ], + "responses": { + "200": { + "$response": "V1TestCaseDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The case is at `data.test_case`, and `success` is INSIDE `data` alongside it, not at the top level. `data.is_archived` tells you the case is archived rather than live.", + "By default this returns counts, not the run results themselves. Pass `exclude` to switch into the lighter mode, and read the paginated runs-results / runs-issues endpoints when you need the actual rows.", + "`include=folder_path` adds `data.test_case.folder_path` \u2014 the human-readable folder path. It is the only supported value.", + "All three path segments are plain integers and the route only matches digits; the folder id must be the case's current folder or it is a 404.", + "The template is resolved for you: `data.test_case.testcase_template` carries the field layout, and `using_fallback_template` is set when the case's own template could not be found and a system template was substituted.", + "`estimate` is always null here; the real value is `estimated_duration_seconds`." + ] + }, + { + "name": "list_test_case_histories", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List a test case's revision trail \u2014 who changed it, when, which fields moved, and the ids you need to view, diff or restore an earlier version", + "returns": [ + "success", + "histories", + "info", + "plan_restriction", + "id", + "entity_id", + "entity_type", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "user", + "user_id", + "comment", + "created_at", + "project_id", + "group_id", + "links" + ], + "responses": { + "200": { + "description": "Successfully retrieved test case histories", + "schema": { + "$schema": "HistoryListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only the FIRST record on each page carries the per-field `modified` map (old/new values). Every other record has `modified_fields` (the field NAMES only). To describe what actually changed in any other revision you must read that revision individually \u2014 one extra call per revision.", + "`plan_restriction: true` means the caller is not on Enterprise or Team Pro and the response was clamped to the last 30 days of revisions. Older revisions are simply absent with no other marker \u2014 never report 'no earlier changes' while that flag is true.", + "`version_name` (V1, V2, \u2026) is computed from the record's POSITION within the visible total, so labels renumber as old revisions age out of a restricted window. Carry the numeric `id` forward to the single-revision read, the diff and the restore \u2014 never the V label.", + "Page size is fixed at 30; `p` is the only paging control. Counts and page metadata are under `info`.", + "`user` is resolved from the actor's id on the way out, and `links.self` points at the single-revision read for that record.", + "Revisions track edits to the test case itself \u2014 not executions of it. Result history lives on the test run.", + "A `source` of 'create' marks the original revision; it has no diff to fetch and is skipped by the enrichment above." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Page size is fixed at 30 server-side; there is no per_page on this endpoint." + } + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_test_case_history_diff", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/diff", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "versions[]", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two NUMERIC history record ids from the revision listing, old first. MUST BE SENT AS A REPEATED PARAMETER - `versions[]=141552670&versions[]=104806106`. A comma-joined single value (`versions[]=141552670,104806106`) is NOT parsed and the route answers a bare 404 {success:false}, which reads as 'case or revision not found' when the ids are perfectly valid.", + "style": "repeated" + } + ], + "intent": "Compare two revisions of a test case field by field \u2014 what the name, description, preconditions, steps, status, priority, owner, tags, defects, attachments, reviewers and custom fields looked like in each", + "returns": [ + "success", + "diff", + "links" + ], + "responses": { + "200": { + "$response": "HistoryDiffResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type does not support comparison ({success:false, message:'Not supported for build'})." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SEND `versions[]` AS TWO REPEATED PARAMETERS, not one comma-joined value. The joined form returns a bare 404 that looks like a missing record; the repeated form works.", + "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is compared \u2014 fall back to reading the two revisions individually, which is not plan-gated.", + "`versions[]` takes exactly two NUMERIC history record ids from the revision listing, in the order old then new: the first becomes `old` in every entry. The V1/V2 labels are not accepted here.", + "Omitting `versions[]` is not validated \u2014 both ids silently become 0 and the call fails downstream instead of telling you the parameter was missing. Always send two real ids.", + "Only fields that actually differ appear under `diff`, and only from a fixed comparable set: name, description, preconditions, automation_status, category, status, priority, owner, assignee, folder, tags, issues, custom_fields, test_case_steps, attachments, reviewers, review_status, review_sent_date. Anything outside that set is invisible here regardless of whether it changed.", + "Values differing only by type (1 versus \"1\") are treated as unchanged, so the diff will not show spurious id edits.", + "Steps come back as whole step arrays under old and new rather than a line-by-line diff \u2014 turning that into a readable 'what changed' summary is the caller's job.", + "`links` maps each version id to its single-revision read, which is where the rest of that snapshot lives." + ] + }, + { + "name": "get_test_case_history", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "history_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one revision of a test case in full \u2014 the per-field before/after for that single change and who made it; the follow-up read after finding a revision in the trail", + "returns": [ + "success", + "history", + "id", + "entity_id", + "entity_type", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "user", + "user_id", + "comment", + "created_at", + "project_id", + "group_id", + "links" + ], + "responses": { + "200": { + "description": "Successfully retrieved test case history", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "history": { + "$schema": "History__GetTestCaseHistory" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`history_id` is the numeric `id` from the revision listing, not the V1/V2 position label the listing also renders.", + "This read ALWAYS carries the full `modified` map. The listing only populates it for the first record on each page, so this is the call to make whenever you need actual old/new values for a specific revision.", + "The record is at the top level as `history` alongside `success` \u2014 no `data` envelope.", + "Unlike the diff and the restore, this read is not plan-gated. The listing that discovers ids is windowed to 30 days on non-Enterprise/Team Pro plans, though, so an older revision may simply be unreachable rather than forbidden.", + "Rows with no data on either side are dropped from `modified`, and the `modified_fields` banner is reconciled against what survived \u2014 so the two always agree, and a field named in neither genuinely did not change." + ] + }, + { + "name": "get_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/tags", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List the tag names currently on one test case \u2014 to see what it is labelled with before filtering, grouping or re-tagging", + "returns": [], + "responses": { + "200": { + "$response": "TestCaseTagListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a BARE JSON ARRAY of the case's tags. There is no `data`, no `success` and no test case object in it \u2014 anything reading `data.test_case` from this call gets nothing. Read the array itself.", + "Despite the path segments, this returns tags only. For the test case's own fields (name, description, owner, status, steps) use a test-case read capability instead.", + "All three path ids are required and folder_id must be the folder the case actually lives in \u2014 the case is looked up by the triple, so a stale folder_id resolves to no test case rather than to the case in its new folder. Re-read the case's folder after any move.", + "Tags are referenced by NAME everywhere in this product; there are no tag ids in this response and none are needed to filter by tag.", + "This is read-only. To change a case's tags, write the test case's `tags` field \u2014 there is no add/remove-tag capability in this profile.", + "The `tags_show` action is absent from the RBAC permission map, so unlike the rest of the test-case surface it is not permission-gated." + ] + }, + { + "name": "get_test_case_type_split", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-type-split", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how the project's test cases divide across case types (functional, regression, smoke, custom types\u2026) \u2014 the current composition of the suite", + "returns": [ + "data", + "empty_data", + "name", + "y", + "value", + "field" + ], + "responses": { + "200": { + "$response": "TestCaseTypeSplit" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each row carries both `y` and `value`; they are the same count under two names (`y` is the chart's series value). `field` is the underlying field key and `name` its display label.", + "Case types are group-configurable, so the set of rows is whatever this group has defined \u2014 never assume a fixed list.", + "This is a snapshot of the current suite, not a trend; use get_test_case_count_trend for growth over time.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_test_run_test_cases_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Rows per page. Anything outside 1..100, or non-numeric, silently falls back to 30 instead of erroring \u2014 a request for 500 returns 30 rows and looks like a short run." + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority,tags,status", + "default": "owner,automation_status,automation_state,priority,tags,issues,reviewers", + "description": "COMMA-SEPARATED LIST OF COLUMNS to include on each row \u2014 not a mode. Omit it and the default column set above is used. `case_type` and `defects` are accepted as aliases of the stored column names." + }, + { + "name": "required[custom_fields]", + "type": "string", + "description": "Comma-separated test-case custom field ids to include on each row." + }, + { + "name": "required[result_custom_fields]", + "type": "string", + "description": "Comma-separated test-result custom field ids to include on each row." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-asc", + "description": "Column and direction, as `-`. TRAP: the column must also appear in required[fields] or the call fails with 'Sort by column is not present in required fields'. Custom fields (cf#) are exempt." + }, + { + "name": "shared", + "type": "boolean", + "default": false, + "description": "Return ONLY the cases shared into this run from other projects. Default (false) returns the run's full list including shared ones." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Restrict the list to the slice of the run contributed by one source project. Digits only; anything else is rejected." + }, + { + "name": "q", + "type": "object", + "description": "Filter object (same shape the run-detail search takes) applied to the rows. Sent as bracketed query params." + } + ], + "intent": "List the test cases inside a test run with their latest result, assignee and configuration \u2014 the execution grid for the run, and the source of the mapping ids the bulk operations take", + "shape": "discovered", + "responses": { + "200": { + "description": "The run's test cases for the requested page. Flat body: {success, info, test_cases}, plus a top-level skipped_test_case_ids array when the review gate excluded any case. Each row is one case x configuration mapping and carries both `id` (test case) and `mapping_id` (the run mapping).", + "schema": { + "$schema": "TestCaseListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are per case-times-configuration mapping, not per test case: a case running against three configurations appears three times, with the same `id` and three different `mapping_id` values. Count distinct `id` if you want a case count.", + "`mapping_id` is the id every bulk operation on this run takes \u2014 assigning, bulk-editing results, unlinking. `id` is the test case. Passing `id` where `mapping_id` is expected returns 200 and changes nothing.", + "sort_by silently depends on required[fields]: sorting by a column you did not ask for errors out. Add the column to required[fields] whenever you sort by it.", + "per_page tops out at 100 and out-of-range values fall back to 30 rather than erroring, so always read `info.page_size` for what you actually got and `info.count` for the total.", + "The 200 can carry a top-level `skipped_test_case_ids` array \u2014 cases the review gate excluded from the run. Their absence from the list is not a data error.", + "`latest_status` is 'untested' for a case that has never been executed; it is never blank.", + "The response is flat \u2014 {success, info, test_cases} with no `data` envelope.", + "Needs the test-run view permission, with the automation variant demanded on automation runs." + ], + "returns": [ + "success", + "info", + "test_cases", + "skipped_test_case_ids", + "id", + "identifier", + "name", + "mapping_id", + "test_case_id", + "test_run_id", + "latest_status", + "result_status", + "result_custom_fields", + "result_notes", + "defects", + "configuration", + "test_assignee", + "last_executed_by", + "last_executed_at", + "planned_execution_date", + "execution_eligible", + "owner", + "priority", + "tags", + "created_at", + "updated_at" + ], + "paginated": true, + "max_page_size": 100, + "disabled": true + }, + { + "name": "list_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Walk pages until `info.next` is null; `p` advances by the page size, not by how many rows came back." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." + }, + { + "name": "required[custom_fields]", + "type": "string", + "example": "121,119", + "description": "Comma-separated custom field ids to include on each row." + }, + { + "name": "all_folders", + "type": "string", + "values": [ + "true" + ], + "example": "true", + "description": "Send the string `true` to list the WHOLE project. Without it this route is scoped to no folder and comes back effectively empty \u2014 this is the single flag that makes the endpoint do what its name suggests. It also adds a `folders` block to the response." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-ASC", + "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." + } + ], + "intent": "List a project's test cases across every folder \u2014 the project-wide repository read. Requires all_folders=true.", + "returns": [ + "success", + "test_cases", + "info", + "folders" + ], + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "description": "Successfully retrieved test cases", + "schema": { + "$schema": "TestCaseConsolidatedListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`all_folders` is compared as the STRING `true` \u2014 a JSON boolean does not match. It is what makes this read project-wide; without it no folder scope is applied and the response is not the project's case list, while still reporting `success: true`.", + "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else \u2014 including a value above 100 \u2014 so `info.page_size` is what you actually got, not what you asked for.", + "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", + "Walk `p` until `info.next` is null. `p` advances by the page size rather than by the number of rows returned, so a partial read cannot be recovered by paging further.", + "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null on every row.", + "A backend 404 is swallowed into an empty list with `success: true` rather than surfacing \u2014 another reason a zero count is not proof the project is empty." + ] + }, + { + "name": "get_test_plan_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one test plan or sub-plan in full by its integer id \u2014 name, dates, status, owner, tags, custom fields, reviewers, progress rollup, and its TP-NNN / STP-NNN identifier", + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "assignee", + "created_by", + "test_runs_count", + "total_test_runs_count", + "test_results_trend_v2", + "links" + ], + "responses": { + "200": { + "description": "Test plan detail; `test_plan.identifier` (no `data` wrapper) is the TP-NNN / STP-NNN form.", + "schema": { + "$schema": "V1TestPlanDetail__GetTestPlanByIntegerId" + } + }, + "401": { + "description": "Unauthorized \u2014 authentication missing or invalid." + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No test plan with that integer id, not visible to this user, or archived and read without is_archived=true. Body is a bare `{success: false}` with no message.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "is_archived", + "type": "boolean", + "description": "Opt in to reading an ARCHIVED plan. Without it an archived plan is 404 \u2014 the read is active-only by default. Pass the literal string 'true'." + } + ], + "guidance": [ + "The path segment is the plan's INTEGER id; the route only matches digits, so a TP-NNN / STP-NNN identifier gives a routing 404 and never reaches the handler. Resolve the integer from the plan listing first.", + "This is the read for one plan. Two sibling routes on the same collection path are NOT plan reads and take no id: the archived listing and the archived count.", + "An archived plan reads as 404 unless you pass is_archived=true. If a plan a user named looks missing, retry with that flag before concluding it was deleted.", + "There is no `data` wrapper: the plan is at the top level under `test_plan`, and `test_plan.identifier` is the TP-NNN / STP-NNN form while `test_plan.id` is the integer every v1 route wants.", + "A 404 body is a bare {success: false} with no message \u2014 it carries nothing that distinguishes 'no such plan' from 'not visible to you'.", + "test_plan_progress is ALWAYS an empty object; the real rollup is test_plan_progress_v2 (counts keyed by result-status id). Same for test_results_trend vs test_results_trend_v2.", + "custom_fields comes back as [] (an empty array, not an object) when the plan has none.", + "parent_plan is present only on a sub-plan; on a top-level plan parent_plan_id is null and sub_plans_count tells you whether it has children.", + "review_status, review_sent_date, reviewers and review_settings appear only when the plan review workflow is configured for the project \u2014 do not treat their absence as 'not reviewed'.", + "Sub-plans and their runs are not embedded. Use the plan's test-runs listing (with include_sub_test_plan_runs) to see what is under it." + ] + }, + { + "name": "get_test_plan_execution_trend", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/execution-trend", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "test_plan_id", + "type": "integer", + "description": "Scope the trend to this plan (INTEGER id). Mutually exclusive with test_run_id \u2014 exactly one of the two must be present." + }, + { + "name": "test_run_id", + "type": "integer", + "description": "Scope the trend to this run instead of a plan. Mutually exclusive with test_plan_id." + }, + { + "name": "today", + "type": "string", + "format": "date", + "description": "Anchor date the trend window ends on. Defaults to the server's current date; an unparseable value silently falls back to it rather than erroring." + } + ], + "intent": "Execution-trend chart series (results over time) for exactly ONE test plan or ONE test run \u2014 the Insights trend graph; scope it with test_plan_id or test_run_id, never both", + "shape": "discovered", + "responses": { + "200": { + "description": "`{success, data}` \u2014 the requested section sits at `data.test_plans_execution_trend_graph`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "test_plans_execution_trend_graph": { + "type": "object", + "description": "The trend series for the scoped plan or run." } + } } - }, - "History__GetTestCaseHistory": { - "type": "object", - "description": "History as returned by `get_test_case_history`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "History" - } - ], - "properties": { - "user": { - "$schema": "User__GetTestCaseHistory" - }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - }, - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "new": { - "type": "object", - "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - }, - "old": { - "type": "object", - "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - } - } - } - } - } + } + } + }, + "400": { + "description": "Neither or both of test_plan_id / test_run_id supplied. `{success: false, message: ...}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" } - }, - "User__GetTestCaseHistory": { - "type": "object", - "description": "User as returned by `get_test_case_history`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a project-level chart route, not a plan read: the plan or run is chosen by QUERY parameter, and the path carries only the project. It is a different endpoint from the plan detail read even though it sits on the same collection path.", + "Exactly one scope. Both ids \u2192 400 {success: false, message: 'Provide either test_plan_id or test_run_id, not both'}; neither \u2192 400 {success: false, message: 'test_plan_id or test_run_id is required'}. Note the error key is `message`, not `error`.", + "The series is nested: read data.test_plans_execution_trend_graph. `data` is the report envelope and carries only the section that was requested.", + "Ids are forwarded to the reporting backend uncoerced \u2014 send the INTEGER plan id / run id, not TP-NNN or TR-NNN.", + "There is nothing to create, update or delete here; this returns chart data only. For the burndown view there is a separate chart route that accepts a plan and rejects a run.", + "Needs the test-plan VIEW permission (unlike the archived reads, which need delete)." + ], + "returns": [ + "success", + "data", + "test_plans_execution_trend_graph" + ] + }, + { + "name": "get_test_plan_linked_sessions_chart_data", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/linked-sessions-chart-data", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart data for the exploratory sessions linked to one test plan \u2014 the plan's sessions insight tile", + "shape": "discovered", + "responses": { + "200": { + "description": "`{success, data}` \u2014 linked-session chart data for the plan.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object" } - }, - "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "V1TestRunTestCaseRow as returned by `list_test_run_test_cases_by_integer_id`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestRunTestCaseRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes the plan's INTEGER id in the path and nothing else: no query parameters, no paging, no date window.", + "The payload is unwrapped for you \u2014 where the upstream reply nests everything under `result`, that inner object is what lands in `data`.", + "A plan with no linked exploratory sessions returns 200 with an empty/zeroed `data`, not a 404. Do not read an empty chart as a missing plan.", + "This route is served by the exploratory-sessions handler rather than the test-plan one, so it is not behind the test-plan view permission the other plan reads use.", + "Chart data only \u2014 there is no session list here. Use the exploratory-session listing capabilities to get the sessions themselves." + ], + "returns": [ + "success", + "data" + ] + }, + { + "name": "list_test_plan_test_runs_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page \u2014 the page size is fixed at 30." + }, + { + "name": "q", + "type": "string", + "description": "Free-text search over the linked runs. Supplying it also turns off the analytics rollup, so progress fields come back thinner than on an unsearched page." + }, + { + "name": "include_sub_test_plan_runs", + "type": "boolean", + "description": "Also return the runs linked to this plan's sub-plans. Off by default, so a run a user attributes to the plan can be missing simply because it hangs off a sub-plan." + }, + { + "name": "compact", + "type": "boolean", + "description": "Ask upstream for the slim linkage payload (identity fields only, no pagination) used to pre-seed an edit form. Do not combine it with paging expectations." + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Required to see the runs of an ARCHIVED plan \u2014 without it an archived plan's run list comes back empty rather than erroring." + } + ], + "intent": "List the test runs linked to a test plan, with each run's state and progress \u2014 optionally including the runs that belong to its sub-plans", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListTestPlanTestRunsByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The plan is addressed by INTEGER id; the route only matches digits, so a TP-NNN identifier 404s at routing.", + "Each row's `id` is the TR-NNN string and the integer lives in `uuid`. Take `uuid` for anything that wants a numeric run id \u2014 including the plan's own unlink call and the plan update's test_runs list.", + "Sub-plan runs are excluded by default. Pass include_sub_test_plan_runs=true before telling a user a run is not in the plan.", + "An archived plan returns an empty run list unless is_archived=true is passed \u2014 empty is not proof the plan has no runs.", + "Page size is fixed at 30 and there is no per_page parameter; page with `p` and stop using the `info` envelope. `count` is {active, closed} across the whole linkage, not the current page.", + "Rows are run summaries, not run detail: `overall_progress_by_status_id` is the useful rollup and `overall_progress` is the legacy one. To see a run's cases, read the run's test cases separately." + ], + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "run_state", + "active_state", + "owner", + "assignee", + "tags", + "is_automation", + "is_dynamic", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "test_plans", + "project_id", + "created_at", + "updated_at" + ], + "max_page_size": 30 + }, + { + "name": "get_test_results_custom_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-results/custom-fields", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. PR-NNN identifiers do not match the route." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control on this endpoint \u2014 the page size is fixed at 30." + } + ], + "intent": "List the custom fields a project shows on a test RESULT \u2014 the extra fields a tester fills when recording a pass or fail, with the ids and types needed to send values", + "returns": [], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "TestResultCustomFieldsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Result fields only. This endpoint hard-codes entity_type=TestResult, so nothing here overlaps the test-case form fields \u2014 a field defined against TestCase never appears, whatever the project.", + "`field_type` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean \u2026), not the canonical vocabulary the admin create/update calls take. A nested_dropdown field has no legacy spelling and comes back as null.", + "Page size is fixed at 30 and cannot be raised: `per_page` is accepted on the wire and discarded, and only `p` is read. Page until `info.next` is null.", + "`optional` and `is_required` are inverses of each other; prefer is_required.", + "Each row's `links.self` is the ready-made path to that field's allowed values scoped to this project \u2014 follow it instead of building the path, and note it resolves to the project user list for a `user` field.", + "`option_values` / `field_values` are present only when the upstream row carried them, so an absent key is not proof the field has no options.", + "project_id is the integer id; PR-NNN identifiers do not match the route. An unknown project is rejected by the project lookup before the fields are read." + ] + }, + { + "name": "list_test_results_for_test_case_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "read", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Read the result history for one test case inside one test run \u2014 every attempt logged against that case with its status, author, note, attachments, linked defects and custom fields; the read for 'what happened when we executed this case?'", + "returns": [ + "success", + "test-results", + "info", + "unique_issues", + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "custom_fields_count", + "test_run_step_result", + "attachments", + "issues", + "links" + ], + "responses": { + "200": { + "$response": "TestResultListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The list key is `test-results` with a HYPHEN. Reading response['test_results'] finds nothing on a perfectly populated response \u2014 this endpoint is the odd one out.", + "Newest first: the first entry is the latest attempt, and it is exactly the one the PATCH-latest capability edits.", + "Page size is FIXED at 30 and is not caller-controllable \u2014 `p` is the only paging input, and there is no per_page. Paging metadata is under `info` (count / page / page_size / next / prev).", + "With a configuration matrix you get rows for every configuration mixed together. Narrow with configuration_id, or with mapping_id for one specific run/case row, before concluding anything about 'the' result.", + "`unique_issues` at the top level is the de-duplicated set of defects linked across all the results returned \u2014 use it rather than unioning each result's own issues.", + "Custom fields arrive already grouped and typed per result, with `custom_fields_count` beside them; per-step outcomes hang off each result as `test_run_step_result`.", + "An empty list means the case has never been executed in this run \u2014 it is not an error and not a permission problem." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Page size is fixed at 30 server-side and cannot be raised." + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Restrict to results logged against one configuration." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Restrict to one specific run/case mapping row." + } + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "list_test_results_for_test_case", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/results", + "mode": "read", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-101", + "description": "Test case IDENTIFIER (TC- form), resolved by lookup \u2014 not the numeric internal id the v1 route takes. An identifier that does not resolve in this project returns 404 \"Test case not found\"." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Note the name is `p`, not `page`; anything else is ignored and page 1 comes back." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Results per page. ONLY 30 or 300 are accepted \u2014 any other value is rejected 400 \"Invalid page_size\"." + }, + { + "name": "count", + "type": "integer", + "values": [ + 30, + 300 + ], + "description": "Alias for page_size, and it WINS when both are sent. Same 30-or-300 restriction." + } + ], + "intent": "Read what was recorded for one test case in one test run \u2014 every execution logged against it, with status, notes, linked defects, custom fields and configuration", + "guidance": [ + "Returns the case's result history in this run, one entry per recorded execution \u2014 not just the latest outcome. Take the newest entry if you only want the current status.", + "Only genuinely logged executions appear here. A case that has been added to the run but never executed has no entry at all, even though the run's case grid shows it with a result id \u2014 that id is the run row, not a result.", + "`result_status` on this endpoint is deliberately cut down to `field_value` alone (display case, e.g. 'Passed'). There is no id, internal_name, field_name or colour here, unlike the v1 result reads. Match on the display-case name, or resolve the project's configured statuses separately if you need the id or colour.", + "`custom_fields` is an ARRAY of entries, each carrying a field_name and a value \u2014 not an object keyed by field name. It is empty when the result has no custom field values. field_name is the field's system name, and for dropdown / single-select fields the value is the selected option's id rather than its label, so resolve the option separately if you need text.", + "Both ids are identifiers: TR- for the run, TC- for the case. Passing internal numeric ids does not match the route (run) or does not resolve (case).", + "Page size is a closed set: 30 or 300, nothing in between. The page parameter is `p`.", + "`urls.self` is a deep link to the case's row in the run. It is omitted (the key comes back null) when the result cannot be tied to a project, run, case and run row all at once.", + "`step_result` is null for case-level results and only populated for step-level ones; `configuration_id` is null when the run row has no configuration; `dataset` is empty for non-data-driven runs.", + "Results are listed under `test_results` with an underscore here \u2014 the v1 list uses `test-results` with a hyphen, so a client written against v1 reads an empty list and reports no results." + ], + "returns": [ + "success", + "test_results", + "info", + "id", + "created_at", + "updated_at", + "created_by", + "description", + "test_case_id", + "test_run_id", + "result_status", + "issues", + "custom_fields", + "step_result", + "configuration_id", + "execution_id", + "dataset", + "urls" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "V2TestResultListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "integer", + "required": true + } + ], + "intent": "Get a test run by INTEGER id (v1) \u2014 full detail + its TR-NNN identifier", + "guidance": [ + "The run's id field is the display identifier string, while uuid is the integer. Take uuid whenever you carry this run into another call: the display string is rejected downstream." + ], + "returns": [ + "id", + "identifier", + "name", + "uuid" + ], + "responses": { + "200": { + "description": "Test run detail; data.test_run.identifier is the TR-NNN form.", + "schema": { + "$schema": "V1TestRunDetail" + } + }, + "401": { + "description": "Unauthorized \u2014 authentication missing or invalid." + }, + "404": { + "description": "No test run with that integer id (or not visible to this user)." + } + } + }, + { + "name": "get_test_run_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/detail", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Either the TR-NNN identifier or the bare integer \u2014 the v1 run routes accept both forms in the path." + } + ], + "intent": "Open a test run's detail view \u2014 its header, progress counts, linked defects and the dynamic-filter rule behind it. This does NOT return the run's test cases; list those separately", + "shape": "discovered", + "responses": { + "200": { + "$schema": "TestRunResponse__ListTestRunCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the path, this returns NO per-case rows. It is the run header enriched with linked issues and the dynamic-filter rule; the run's cases come from the run's test-cases listing.", + "This is the only read that returns the run's filter rule, and it returns it twice under two names \u2014 `dynamic_filter` and `filters` \u2014 with identical content. Read either.", + "It also returns `issues` (defects linked to the run) and `test_result_issues` (defects linked to individual results), which the plain run read omits.", + "The run's `id` is the TR-NNN STRING; the integer is in `uuid`. Take `uuid` whenever a numeric run id leaves this read.", + "The run sits under `data.test_run` \u2014 with an underscore, inside a `data` envelope. The write endpoints on this entity use `data.testrun` instead, and the v2 run read has no `data` at all.", + "`trtc_folder_progress_enabled` is a group feature flag, and a lookup failure is reported as false \u2014 treat false as 'not available', not as a confirmed setting.", + "Needs the test-run view permission, with the automation variant demanded on automation runs." + ], + "returns": [ + "data", + "success", + "test_run", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "attachments", + "metadata", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "created_by", + "updated_by", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ] + }, + { + "name": "list_test_run_test_cases", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." + }, + { + "name": "page_size", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page. Defaults to 30 when omitted. Above 300 the request errors ('Page limit exceeds the maximum limit of 300'); with fetch_steps=true the ceiling drops to 30." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "description": "Alias for page_size and read first when both are sent. Same ceilings." + }, + { + "name": "fetch_steps", + "type": "boolean", + "default": false, + "description": "Include each case's steps. Pass the literal string 'true'. Doing so lowers the maximum page size from 300 to 30." + }, + { + "name": "minify", + "type": "boolean", + "default": false, + "description": "Compact row shape. Pass the literal string 'true'." + }, + { + "name": "is_shared", + "type": "boolean", + "description": "Restrict to cases shared in from another project." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project, to narrow shared cases to one source project." + }, + { + "name": "include_subfolders", + "type": "boolean", + "default": false, + "description": "Make folder_id include all descendant folders." + }, + { + "name": "match", + "type": "object", + "description": "Per-field operator, e.g. match[tags]=all&match[assignee]=none. Multi-value fields (tags) take all|any|none|only; single-value fields take only any|none \u2014 anything else is a 400. match[global]=all (default) ANDs the fields, match[global]=any ORs them. `only` needs a group entitlement, else 403." + }, + { + "name": "latest_status", + "type": "string", + "description": "Comma-separated latest execution statuses (untested, passed, failed, retest, blocked, skipped, in_progress, plus any custom group status). Unknown values are a 400 listing what is valid." + }, + { + "name": "assignee", + "type": "string", + "description": "Comma-separated assignee emails. Use match[assignee]=none for unassigned cases. An email that does not resolve in the group is a 400, never a silently ignored filter." + }, + { + "name": "priority", + "type": "string", + "description": "Priority label, resolved to an id inside the group." + }, + { + "name": "tags", + "type": "string", + "description": "Comma-separated tags. Combine with match[tags]=all|any|none|only." + }, + { + "name": "folder_id", + "type": "string", + "description": "Folder id. Pair with include_subfolders=true to include descendants." + }, + { + "name": "configuration", + "type": "string", + "description": "Configuration label, resolved to an id inside the group." + }, + { + "name": "name", + "type": "string", + "description": "Substring match on the test case name." + }, + { + "name": "identifier", + "type": "string", + "description": "Exact test case identifier, e.g. TC-42." + }, + { + "name": "query", + "type": "string", + "description": "Free-text search across name and identifier." + }, + { + "name": "test_case_status", + "type": "string", + "description": "Test case status label, resolved to an id inside the group." + }, + { + "name": "test_case_owner", + "type": "string", + "description": "Comma-separated test case owner emails." + }, + { + "name": "case_type", + "type": "string", + "description": "Category / type label, resolved to an id inside the group." + }, + { + "name": "automation_status", + "type": "string", + "description": "Automation status label, resolved to an id inside the group." + }, + { + "name": "created_by", + "type": "string", + "description": "Comma-separated creator emails." + }, + { + "name": "updated_by", + "type": "string", + "description": "Comma-separated last-updater emails." + }, + { + "name": "template", + "type": "string", + "description": "Template label, resolved to an id inside the group." + }, + { + "name": "created_between", + "type": "string", + "description": "Two comma-separated dates (start,end) bounding creation time." + }, + { + "name": "updated_between", + "type": "string", + "description": "Two comma-separated dates (start,end) bounding last-update time." + } + ], + "intent": "List the test cases inside a run with their latest result, assignee and configuration \u2014 the read to answer \"what is still untested / failing in this run?\"", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size` (or `count`). The default page is 30 rows and the hard ceiling is 300; asking for more errors instead of clamping.", + "fetch_steps=true drops the ceiling to 30 rows per page, so a run of any size takes many more calls with steps on. It also adds a `steps` array to each row, which is otherwise absent.", + "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 'request_limit_exceeded'. Filter the run down rather than walking a huge run to the end.", + "Filters fail closed \u2014 an unknown status, an unresolvable email or an invalid match mode returns 400 listing the valid values, so a filtered read never quietly degrades into the whole run.", + "Rows are per case-and-configuration mapping: a case mapped to three configurations comes back as three rows, each with its own configuration_id and latest_status. configuration_id is null for cases with no configuration.", + "Rows carry more than the core execution fields: is_shared, source_project_name, execution_id, created_at, last_updated_at, created_by and updated_by arrive on every row, plus a urls object. The created/updated stamps belong to the row's place in the run, not to the underlying test case record.", + "Use latest_result_id from a row when you need to fetch or amend that case's recorded result. It is populated even on rows that have never been executed \u2014 the result row is minted when the case is mapped into the run \u2014 so a non-null id does NOT mean the case has run. Detect 'never executed' from latest_status being untested instead.", + "The `urls.self` deep link on these rows is currently emitted without the run segment, so it does not resolve. Build run links from the run identifier you already hold rather than following it." + ], + "returns": [ + "success", + "info", + "test_cases", + "identifier", + "name", + "description", + "latest_status", + "latest_result_id", + "assignee", + "priority", + "status", + "case_type", + "configuration_id", + "folder_path", + "dataset", + "project_id", + "source_project_id", + "source_project_name", + "is_shared", + "execution_id", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "TestRunTestCasesV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "query": [ + { + "name": "minify", + "type": "boolean", + "default": false, + "description": "Return the compact run payload (drops the heavier analytics/detail fields). Pass the literal string 'true'." + } + ], + "intent": "Look up one test run \u2014 its state, owner, tags, configurations, linked plan and per-status progress counts", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Both ids are prefixed strings here (PR-1 / TR-14). The v2 route only matches PR-\\d+ and TR-\\d+, so the integer ids the v1 run capabilities take give a 404 on this path.", + "This returns the run header only \u2014 progress is aggregated counts. To see which cases are in the run, or their latest results, read the run's test cases instead.", + "403 means the caller lacks view permission for this run type (automation runs are gated separately from manual ones).", + "The run is at the top level of the response as `test_run`; there is no `data` envelope here. The v1 run endpoints wrap their payload in `data` \u2014 these v2 ones do not.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "test_cases", + "urls" + ], + "responses": { + "200": { + "$schema": "TestRunV2Response__ListTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_runs_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/form-fields", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "all", + "type": "boolean", + "default": false, + "description": "Include the project's custom result fields as well as the defaults. PRESENCE-checked, not value-checked: `all=false` and `all=0` behave exactly like `all=true`. The only way to get the compact shape is to omit the parameter entirely." + } + ], + "intent": "Discover the fields available when recording a test RESULT in this project \u2014 the built-in status options and, on request, the project's custom result fields \u2014 so a result can be submitted with valid values", + "returns": [], + "responses": { + "200": { + "$response": "TestRunFormFieldsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These are TEST RESULT fields, not test run fields \u2014 despite the path. Use them to build a valid result submission (status id, custom field ids and their allowed option values).", + "The response SHAPE changes with `all`: omit it and you get {success, default_fields} only; send it and `custom_fields` appears alongside. Code that reads custom_fields must send the parameter.", + "`default_fields` is an object keyed by field name (today just `status`), not an array. Each entry lists its allowed values with their numeric ids \u2014 those ids, not the display labels, are what result writes take.", + "The import-only pseudo-fields (owner_imported, owner_imported_email) are stripped from custom_fields, so a field visible in an exported CSV can be missing here.", + "The response is flat \u2014 {success, default_fields, custom_fields} with no `data` envelope." + ] + }, + { + "name": "list_test_runs_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/selection", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Select every case in the project. When false, `folders` decides the set.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 2, + "description": "The caller's own count of the selection. Echoed through; it does not constrain the result.", + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. A folder with test_case_select_all takes the whole folder minus deselected_test_cases; otherwise only selected_test_cases counts.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "show", + "type": "string", + "values": [ + "all", + "filtered" + ], + "default": "filtered", + "description": "`filtered` (the default, and what ANY value other than the literal 'all' means) returns only the cases matching both the selection and `q`. `all` returns the whole selection and adds a `selected` array naming which of those rows also match `q`." + }, + { + "name": "q", + "type": "object", + "description": "Test-case filter/search object, intersected with the selection. Omit it to get the selection as-is. Free-text goes in as a search string matched against case name and identifier." + }, + { + "name": "count", + "type": "integer", + "maximum": 20000, + "default": 30, + "description": "PAGE SIZE, despite the name \u2014 not a count to return. Defaults to 30 when absent or unparseable, and a value above 20000 is refused with a page-size error." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "shared_selection", + "type": "object", + "description": "Accepted by the parameter filter but NOT read by this action \u2014 cases shared in from other projects are not resolved here. Use the count endpoint if you need shared selections accounted for." + } + ], + "intent": "Resolve a folder/case selection into the actual list of test cases it covers \u2014 use it to preview or page through what a run create or edit would pull in before committing it", + "returns": [ + "success", + "info", + "test_cases", + "selected", + "id", + "identifier", + "name", + "description", + "type", + "owner", + "folder_id", + "template", + "template_id", + "project_id", + "group_id", + "is_shared", + "is_project_level", + "review_status", + "estimated_duration_seconds", + "automation_status", + "archived_at", + "trashed_at", + "created_at", + "updated_at", + "preconditions", + "expected_result" + ], + "responses": { + "200": { + "$response": "TestRunsSelectionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This returns TEST CASES, not test runs, despite sitting under the test-runs path and despite the older wording of its name. It is the preview step for a run's case selection.", + "`selection` is effectively mandatory: the backing service reads straight into it and a body without it fails rather than defaulting to everything.", + "Page size is the `count` field, not `per_page`, and it caps at 20000; leaving it out gives 30 rows, which is how a selection preview looks far smaller than the run it will create.", + "`selected` only appears when show='all'. In the default mode the rows already ARE the filtered set, so its absence is not a failure.", + "Folder ids are STRING keys in the `folders` map, while the case ids inside are integers. Mixing those up drops the folder silently.", + "shared_selection is ignored here. A selection that pulls cases from other projects will preview short.", + "The response is flat \u2014 {success, info, test_cases, selected} with no `data` envelope.", + "Needs the test-run write permission even though it only reads \u2014 a viewer-only role is 403'd.", + "The rows here are the RAW case record, not the shared test-case serializer used by every other test-case route. `automation_status` is a plain string, the folder key is `folder_id` (not `test_case_folder_id`), case type arrives as the bare string `type`, and priority, status, case_type, tags, issues, custom_fields and steps are NOT returned at all. Read the case detail route if you need those." + ], + "paginated": true, + "max_page_size": 20000 + }, + { + "name": "list_test_runs_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no page-size parameter: the page is fixed at 30 rows (5000 when filter_mapping is set)." + }, + { + "name": "ignore_run_type", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' to stop the backing service filtering by run type, so manual and automation runs are listed together. Any other value, including boolean true in some encodings, leaves the default filtering in place." + }, + { + "name": "include_test_plan", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' to include each run's linked test plan on the row, so you can tell which runs are already attached to a plan without a second read." + }, + { + "name": "filter_mapping", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' for the lightweight picker shape: the page grows to 5000 rows, progress analytics are skipped, and every run is reduced to {id, identifier} ONLY. Every other field disappears \u2014 do not use it when you need run state, owner or counts." + } + ], + "intent": "List a project's active test runs with their progress counts, owner and linked plan \u2014 the starting point for finding a run to read, execute or report on", + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "build_meta", + "assignee_imported", + "environment", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListTestRunsByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Closed runs are NOT in this list. A run count or an 'all our runs' answer built from this endpoint alone silently omits every finished cycle \u2014 read the closed-runs listing too.", + "Page size is fixed at 30 and there is no per_page; only `p` moves. The one exception is filter_mapping=true, which raises it to 5000 and strips every field but id and identifier.", + "Each run's `id` is the TR-NNN STRING, and the integer id is in `uuid`. Take `uuid` whenever a numeric run id leaves this read \u2014 passing the string into a test plan's run list reaches the backing service uncoerced and fails there.", + "The three boolean query params are compared against the literal string 'true'; anything else, including 1 or the word yes, is treated as not set.", + "The response is flat \u2014 {success, test_runs, info, count} with no `data` envelope. `count` is the project-wide per-state totals, not this page's size; the page's size is in `info`.", + "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent \u2014 do not read a zeroed `overall_progress` as 'nothing was executed'.", + "To filter or search runs rather than page through them, use the project entity search with the test-runs entity; this endpoint takes no filter parameters.", + "Needs the test-run view permission, with the automation variant demanded for automation runs." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "import_dataset_csv", + "method": "POST", + "path": "/api/v1/projects/{project_id}/datasets/import_csv", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Parse an uploaded CSV file into column headers and rows \u2014 NOT INVOKABLE from this profile: it takes a multipart file upload, and it does not create a dataset", + "responses": { + "200": { + "description": "Parsed CSV contents, wrapped in a `data` envelope (unlike every other dataset endpoint, which returns at the top level). Nothing was written.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - "priority": { - "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + "headers": { + "type": "array", + "description": "The CSV header row, in file order \u2014 the candidate column names.", + "items": { + "type": "string" + } }, - "result_status": { - "nullable": true, - "$schema": "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId" + "rows": { + "type": "array", + "description": "Data rows as header-keyed objects, not positional arrays \u2014 they are NOT in the shape the dataset create call expects.", + "items": { + "type": "object" + } } + } } - }, - "TestCaseFieldValue__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "TestCaseFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" + } + } + }, + "400": { + "description": "{error:'No file uploaded'} \u2014 what every invocation from this profile returns, because no file part can be sent." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, error:...} \u2014 oversized file, non-.csv extension, blank headers, too many rows or columns, or malformed CSV. Also the Team Pro gate, which renders {success:false, message:'Feature not available'}." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "QUARANTINE CANDIDATE \u2014 do not attempt to call this. The only input is a multipart/form-data file part; a byte stream has to cross the boundary and there is no way to declare it here, so this record has no parameter for the one input it needs and any invocation fails with 400 {error:'No file uploaded'}.", + "The name is misleading: this does not import anything. It validates and parses the CSV and returns the parsed headers and rows to the caller. Nothing is written \u2014 no dataset is created or modified by this call.", + "To get the same result without a file upload: parse the CSV yourself and call the dataset create capability with the header row as `variables` and the data rows as `rows`. That is what the UI does with this endpoint's output.", + "If it is ever reachable, the limits enforced are: .csv extension only, 50 MB max (the published spec's 10 MB is wrong), every header non-blank, and at most 40 columns / 100 rows for a standard group (100 / 200 for groups on the raised-limit flag). Failures come back as 422 {success:false, error:...}.", + "Still behind the Team Pro plan gate and the test-case write permission like the rest of the dataset family." + ], + "disabled": true + }, + { + "name": "list_activity_events", + "method": "GET", + "path": "/api/v1/activities", + "mode": "read", + "entity": "activity", + "query": [ + { + "name": "project_id", + "type": "integer", + "description": "Narrow the group-wide feed to one project. A query filter, not a path segment." + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque cursor from a previous page's pagination.next_cursor. There is no page number." + }, + { + "name": "limit", + "type": "integer", + "description": "Page size, forwarded to the backing service which owns the default and ceiling; the empty-starred short-circuit reports 50. Trust pagination.limit for what you actually got." + }, + { + "name": "actor_id", + "type": "integer", + "description": "Filter to one actor, as a BrowserStack user id (the same id events carry in actor_id)." + }, + { + "name": "starred_only", + "type": "boolean", + "description": "Send the literal string 'true' to restrict to the caller's starred projects. With none starred the response is {events: [], no_starred_projects: true} \u2014 that is 'nothing starred', not 'no activity'." + }, + { + "name": "entity_type", + "type": "array", + "description": "Filter by subject entity type. An array parameter \u2014 repeat the key rather than sending a comma-joined string." + }, + { + "name": "origin_channel", + "type": "string", + "values": [ + "ui", + "api" + ], + "description": "Restrict to events raised from the UI or from the API. Any other value is silently DROPPED rather than rejected, so a typo quietly widens the result instead of erroring." + } + ], + "intent": "Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window", + "returns": [ + "success", + "events", + "pagination", + "projects", + "no_starred_projects", + "next_cursor", + "has_next_page", + "limit", + "actor_id", + "actor_name" + ], + "responses": { + "200": { + "description": "A cursor page of activity events.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "object" + } + }, + "pagination": { + "type": "object", + "properties": { + "next_cursor": { + "type": "string", + "nullable": true + }, + "has_next_page": { + "type": "boolean" + }, + "limit": { + "type": "integer" } + } + }, + "projects": { + "type": "object", + "description": "Project id \u2192 metadata, for resolving event project references." + }, + "no_starred_projects": { + "type": "boolean", + "description": "Present and true only when starred_only was set and the user has none starred." } - }, - "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "ResultStatusFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatusFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Cursor paging only: follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no page number and no total count, so you cannot state how many events exist or jump to a page.", + "The server clamps every query to the last 15 DAYS regardless of what you ask for. For anything older, say the history is not retrievable through the API and point at the Test Management UI \u2014 reporting 'no activity' for an older period is a false negative.", + "Group-wide, not project-scoped: `project_id` is a query filter, not a path segment. The response's `projects` map (project id to metadata) resolves the project each event refers to without a second read.", + "`starred_only` is compared against the literal string 'true'. When the caller has no starred projects the reply is {events: [], no_starred_projects: true} \u2014 that means 'nothing is starred', NOT 'no activity'. Check that flag before reporting emptiness.", + "Gated by a group feature flag. A 403 {success:false, error:'Activity feed is not enabled'} means the feed is off for the group, not that the caller lacks permission \u2014 say so rather than reporting no activity.", + "Every event is enriched with `actor_name`; an actor who cannot be resolved inside the caller's group renders as 'Unknown User' rather than being dropped, so an unfamiliar name is not necessarily a data gap.", + "`entity_type` is an array filter \u2014 repeat the key rather than sending a comma-joined string. `origin_channel` accepts only 'ui' or 'api'; any other value is dropped silently instead of erroring, which widens the result rather than narrowing it.", + "Read-only by design: activity rows are emitted as a side effect of other writes. There is no create, update or delete, so an audit entry cannot be edited or removed on request.", + "Per-project visibility is applied downstream \u2014 the feed is already scoped to projects the caller can access, so an empty page can mean 'no accessible projects had events' rather than 'nothing happened'." + ], + "disabled": true + }, + { + "name": "list_archived_test_plans", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/archived_test_plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. Page size is fixed at 30; there is no per_page." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match on the archived plan's name or identifier." + }, + { + "name": "plan_status", + "type": "string", + "description": "Status filter for the archived view, forwarded upstream as given (a comma-joined list such as 'new,started' is accepted). Unlike the active listing, an unrecognised value is not rejected here \u2014 it simply matches nothing." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Forwarded WITHOUT validation on this route (the active listing validates the same parameter), so a typo is passed upstream rather than returning a 400." + } + ], + "intent": "Page through the test plans that have been ARCHIVED in a project \u2014 the place to find a plan to restore; archived plans do not appear in the normal plan listing at all", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "V1ArchivedTestPlanListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" } - }, - "TestRunsListResponse__ListTestPlanTestRunsByIntegerId": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_test_plan_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17574995" - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "example": "MCP+capability+probe+fixture+run+%28link_entities_to_jira_issue%29" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string" - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "object", - "properties": { - "test_plan_error": { - "type": "string", - "example": "PLAN_LINKED" - } - } - }, - "observability_url": { - "type": "string" - }, - "updated_by": { - "type": "string", - "nullable": true - } - } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a fixed literal route on the plan collection \u2014 it takes no plan id. It is a different endpoint from the plan detail read that sits on the same path prefix.", + "Gated on the group archive flag: when it is off the call is 404 {success: false, errors: 'Feature not available'}, which means the feature is unavailable, not that nothing is archived.", + "Needs the test-plan DELETE permission, not view \u2014 a user who can list plans normally can still be 403'd here.", + "Archived SUB-plans are not listed; there is no include_sub_plans switch on this route. Only top-level archived plans come back.", + "Rows are serialized exactly like the active listing (same fields, plus record_status: 'archived'), so anything that consumes the normal plan list works unchanged here.", + "Page size is fixed at 30; page with `p` and stop using the `info` envelope. There is no `count` object on this response, unlike the active listing.", + "To put a plan back, pass its integer id to the bulk-retrieve capability." + ], + "returns": [ + "success", + "test_plans", + "info", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "record_status", + "start_date", + "end_date", + "owner", + "assignee", + "tags", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "test_plan_progress_v2", + "project_id", + "created_at", + "updated_at", + "links" + ], + "max_page_size": 30 + }, + { + "name": "list_custom_field_dataset_options", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces as a bodyless 404." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Not validated by this handler; a dataset belonging to another field returns the same bodyless 404." + } + ], + "intent": "List the option values a dataset offers, with the numeric option id each one carries \u2014 the ids you need to rename, re-default or delete an option", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is where option ids come from. The dataset read returns options only when asked and without a stable contract; this call always returns them with their ids.", + "Each row is {id, option_value, is_default, colour, parent_option_id}. When any row carries a parent_option_id the whole list is rebuilt as a tree and children appear under a `children` key on their parent instead of at the top level \u2014 a flat scan of `data` then misses them.", + "Exception: if every row in the page has a parent (a single level of a nested_dropdown read on its own), the tree build finds no root and falls back to returning the rows flat.", + "Paging is `p` only; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata. On a nested field the tree is built per page, so a parent and its children can land on different pages.", + "Neither path id is validated here, so a wrong field/dataset pair reaches storage and returns a bare 404 {success:false} with no message." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control \u2014 page size is fixed at 30 and `per_page` is ignored." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "info", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id", + "children" + ] + }, + { + "name": "list_datasets", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[name]", + "type": "string", + "description": "Partial, case-insensitive match on dataset name. Use this to find a dataset you only know by name." + }, + { + "name": "q[uuid]", + "type": "string", + "description": "Exact match on dataset uuid." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort order, forwarded unchanged to the backing store; the accepted values are not validated or enumerated here, and an unrecognised one is not rejected by this service. Omit it unless you know the store's vocabulary." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Validated globally: a non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}; p=0 is treated as page 1. There is no page-size parameter \u2014 see max_page_size." + } + ], + "intent": "Find datasets in a project \u2014 browse them or look one up by name or uuid to get the uuid the other dataset calls need", + "returns": [ + "success", + "datasets", + "info", + "uuid", + "name", + "rows_count", + "test_cases_count", + "created_by", + "created_at", + "updated_at" + ], + "paginated": true, + "responses": { + "200": { + "description": "A page of datasets, with paging metadata under `info`.", + "schema": { + "$schema": "DatasetsListResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are the compact form: name, uuid, counts, tags, metadata, creator and timestamps \u2014 but NOT the columns or rows. Read a single dataset by uuid to get its contents.", + "Page size is fixed at 30 and cannot be requested; ask for successive `p` values and stop when `info.next` is null.", + "`test_cases_count` here is the cheapest way to tell whether a dataset is in use before deleting it.", + "A creator no longer in the group resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", + "422 {message:'Feature not available'} means the group lacks the Team Pro plan \u2014 every dataset endpoint answers this way, and it is an entitlement answer, not a payload error.", + "`success`, `datasets` and `info` are all top level; there is no `data` envelope and no second nesting under `datasets`." + ], + "max_page_size": 30 + }, + { + "name": "list_duplicates", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `p` is accepted as an alias and `page` WINS if both are sent. Unlike `p`, `page` is NOT validated: a non-numeric value silently becomes 0 and is forwarded as page 0. Send a positive integer." + }, + { + "name": "entity_type", + "type": "string", + "description": "Accepted and IGNORED \u2014 the handler never reads it. Every pair links test cases; there is no other entity type. Sending it changes nothing." + }, + { + "name": "entity_id", + "type": "integer", + "description": "Integer test-case id. Restricts the listing to pairs involving that case AND strips that case out of each returned pair, so each row's `test_cases` holds only the OTHER case. Use it to answer 'what is this test case a duplicate of'." + }, + { + "name": "duplicates", + "type": "string", + "values": [ + "exact", + "semantic" + ], + "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim to the backing store and not validated here, so an unrecognised value narrows to nothing rather than returning an error. Omit for both types." + }, + { + "name": "owner", + "type": "string", + "description": "The 'My duplicates' tab. ANY non-empty value scopes the listing to pairs involving the CALLER's own test cases \u2014 the value itself is discarded, so this cannot select another user. Omit for all duplicates in the project." + } + ], + "intent": "List the AI-suggested duplicate test-case pairs awaiting review in a project, highest-confidence first \u2014 optionally only those involving one test case, or only the caller's own", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "Suggested duplicate pairs. This exact shape with an empty `duplicates` is also what comes back when the group lacks the dedupe feature flag, so an empty list is not evidence the project has none.", + "schema": { + "$schema": "V1DuplicatesListResponse", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "last_scan_time": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the dedupe scan that produced these groups last ran. NOTE: this can be populated while `get_dedupe_status` reports NOT_ENABLED for the same project - the two disagree." + }, + "duplicates": { + "type": "array", + "items": { + "$schema": "DuplicateGroup" + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An empty list is ambiguous. When the group lacks the dedupe feature flag this returns 200 with exactly {success:true, duplicates:[], info:{count:0,total_pages:0}} \u2014 indistinguishable from a genuinely clean project. Read the dedupe status capability to tell 'feature off / never run' from 'nothing found'.", + "Only pairs still in `suggested` status appear. Once a pair is merged, archived or discarded it leaves this listing permanently; there is no filter to bring it back.", + "Page size is fixed at 10 and cannot be requested. Pairs are ordered by confidence (ai_score descending, then id descending), NOT by age \u2014 the oldest suggestion is not on page 1 and a bigger page cannot be asked for.", + "`info.count` can exceed the rows you actually receive: a pair whose test case cannot be resolved is dropped from the page after the count is taken. Do not derive page counts from row arithmetic; use `info.total_pages` / `info.next`.", + "Carry each row's `id` forward \u2014 that integer is the duplicate id the read, resolve and discard calls take.", + "`last_scan_time` at the top level is the project's last dedupe run, not a property of this page; it is fetched separately and can be null when no scan has completed.", + "Duplicate REVIEW is all that is exposed here. There is no way to create a suggestion or trigger a scan through this API." + ], + "returns": [ + "success", + "duplicates", + "info" + ], + "max_page_size": 10 + }, + { + "name": "list_exploratory_session_defects", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. The only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 30." + } + ], + "intent": "List the defects and issues raised during an exploratory session \u2014 the external tracker tickets linked to the session and to its individual log entries \u2014 to review what the exploration found", + "returns": [ + "success", + "defects", + "info", + "issue_id", + "issue_type", + "jira_id", + "created_at" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionDefectsV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ONLY LOG-LEVEL DEFECTS APPEAR HERE. A defect linked on a session LOG shows up immediately; an issue linked on the SESSION itself (the `issues` array on create/update_exploratory_session) NEVER appears, though it persists and is readable from the session's own detail.", + "The row is FOUR fields - {issue_id, issue_type, jira_id, created_at}. It is NOT the full issue-link record: no id, entity_type, entity_id, entity_uuid, issue_status, priority, record_status, project_id, metadata or updated_at, all of which an earlier version of this contract promised.", + "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected as 400 {success:false, error:'Invalid Params'}.", + "`per_page` is accepted and ignored - the page size is fixed at 30. Walk `p` until a page comes back short.", + "No filters: no status, tracker or date narrowing. Filter client-side.", + "There is no project-wide defect listing in this index. 'What defects exist in this project' means listing sessions and issuing one request per session.", + "Everything is at the top level: defects, info. There is no `data` envelope.", + "An unknown session_id returns a bare {success:false} with 404 and no message." + ] + }, + { + "name": "list_exploratory_session_logs", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. The only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 50, + "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 50." + }, + { + "name": "q[status]", + "type": "string", + "example": "fail,bug", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "description": "Comma-separated list of statuses as a single string (e.g. 'retest,block') \u2014 not a repeated array parameter. Every value is allowlisted before the upstream call; anything else, or an empty list, returns 400 'Invalid status value.'. Does not affect status_counts." + } + ], + "intent": "Read the observations recorded during an exploratory session \u2014 the timestamped notes, their pass/fail/bug status, elapsed time, linked defects and embedded screenshots \u2014 optionally narrowed to particular statuses", + "returns": [ + "success", + "session_logs", + "info", + "status_counts", + "id", + "session_id", + "hashed_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "attachment_relations", + "created_at", + "updated_at" + ], + "max_page_size": 50, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionLogListV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Logs are children of one session: session_id in the path scopes the whole listing, and a log id is only meaningful together with the session it belongs to.", + "Paging uses `p`, NOT `page` \u2014 `page` is read by nothing here. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'}.", + "`per_page` is accepted and ignored: the handler overwrites it with the fixed page size of 50. Walk `p` until a page comes back short. Compare against session_log_count on the session to know when you have them all.", + "q[status] is a COMMA-SEPARATED STRING, not an array, and it is allowlisted before the upstream call: any value outside pass, fail, bug, retest, block, note, skipped returns 400 'Invalid status value.'. Note `skipped` \u2014 it is a real status that older documentation of this entity omits.", + "status_counts is meant to be the whole-session per-status totals, deliberately unfiltered by q[status]. Do not rely on it today: the typed upstream response object carries no such field, so this key is currently rendered as an empty object on every call.", + "There is no sort parameter on this endpoint and the upstream default is not ascending \u2014 the internal session-copy path has to ask for ascending order explicitly. Sort by created_at yourself rather than assuming chronological order.", + "`content` is sanitised HTML, not plain text, and embedded images are TM-hosted attachment URLs. What is stored can differ from what was written: links and images pointing outside the project or group are stripped on write.", + "Each log's `defects` are the issue links on that log. The session-level defects listing is a different, broader set \u2014 read that one for everything linked to the session.", + "Everything is at the top level: session_logs, info, status_counts. There is no `data` envelope." + ] + }, + { + "name": "list_exploratory_sessions", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. This is the only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 30. There is no way to widen the page from the caller side." + }, + { + "name": "q[session_state]", + "type": "string", + "values": [ + "active", + "closed" + ], + "example": "active", + "default": "active", + "description": "Lifecycle state. Defaults to 'active' when omitted, so closed sessions are invisible unless you ask for them. Not validated locally \u2014 an unrecognised value is forwarded upstream rather than rejected." + }, + { + "name": "q[assignee]", + "type": "integer", + "example": 42, + "description": "TCM user id of the session assignee; translated to a BrowserStack user id before the query is sent. Wins over q[owner] when both are given. An id that resolves to no user is sent as null instead of erroring." + }, + { + "name": "q[owner]", + "type": "integer", + "example": 42, + "description": "Alias for q[assignee], used only when q[assignee] is absent. Same TCM user id semantics." + }, + { + "name": "q[created_by]", + "type": "integer", + "example": 10, + "description": "TCM user id of the session creator; translated to a BrowserStack user id. An unresolvable id is sent as null rather than rejected." + }, + { + "name": "q[created_at_from]", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Created on or after this date. Forwarded verbatim \u2014 not parsed or validated here." + }, + { + "name": "q[created_at_to]", + "type": "string", + "format": "date", + "example": "2026-01-31", + "description": "Created on or before this date. Forwarded verbatim \u2014 not parsed or validated here." + }, + { + "name": "q[tags][]", + "type": "array", + "example": [ + "regression", + "payment" + ], + "description": "Tag names to match. Forwarded verbatim." + }, + { + "name": "q[configuration_ids][]", + "type": "array", + "example": [ + 1, + 2 + ], + "description": "Configuration ids (integers) the session runs against." + }, + { + "name": "q[search]", + "type": "string", + "example": "checkout", + "description": "Free-text match, forwarded upstream as-is." + }, + { + "name": "q[query]", + "type": "string", + "description": "Second free-text key the handler forwards alongside q[search]; both are passed through independently. Undocumented upstream \u2014 prefer q[search]." + }, + { + "name": "q[status]", + "type": "string", + "example": "fail", + "description": "Filter by the log statuses recorded inside the session. Forwarded verbatim and not allowlisted here, unlike the same-named filter on the session logs listing." + }, + { + "name": "q[test_plan_id]", + "type": "integer", + "example": 77, + "description": "Only sessions linked to this test plan. Takes the integer test plan id, not a TP-NNN identifier." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "id-asc", + "id-desc", + "title-asc", + "title-desc" + ], + "description": "Combined field+direction token. There is no separate sort_order parameter. Any other value is rejected with 400 'Invalid sort_by value.' before the upstream call." + } + ], + "intent": "Find exploratory testing sessions in a project \u2014 filter by state, assignee, creator, tags, configurations, linked test plan, date range or free text \u2014 to pick the session whose logs or defects you want to read next", + "returns": [ + "success", + "exploratory_sessions", + "info", + "count", + "id", + "identifier", + "title", + "session_state", + "timebox_duration", + "assignee", + "created_by", + "tags", + "attachments", + "created_at", + "updated_at", + "project_id", + "group_id", + "test_plan_id", + "summary", + "test_plan" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionListV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The default listing hides closed work: when q[session_state] is omitted the handler substitutes 'active'. Pass q[session_state]=closed explicitly to find a finished session, and expect a session to vanish from the default list the moment it is closed.", + "Paging is driven by `p`, NOT `page`. `page` is read by nothing on this path. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'} before the action runs.", + "`per_page` is accepted and ignored \u2014 the handler overwrites it with the fixed limit of 30 rows. There is no way to ask for a larger page; walk `p` until a page comes back short.", + "q[assignee] / q[owner] / q[created_by] take TCM user ids (integers) and are translated to BrowserStack user ids before the query is sent. q[owner] is only a fallback for q[assignee] \u2014 if both are present, q[assignee] wins. An id that matches no user in this group is not an error: the filter is sent as null and you get back a result set that silently does not mean what you asked for.", + "sort_by is a single combined token ('id-asc', 'id-desc', 'title-asc', 'title-desc') \u2014 there is no separate sort_order. Anything else is 400 'Invalid sort_by value.'.", + "`count` is an OBJECT of per-state totals ({active, closed}), not the number of rows matching your filter. The row total lives in info.count. Do not use `count` to size a pagination loop.", + "Rows are NOT put through the custom-field normaliser that the single-session read applies, so custom_fields on a list row is the raw upstream shape (or absent). Re-read the session by id if you need normalised custom fields.", + "assignee / created_by / closed_by come back as user objects resolved from the local user table, and resolve to null when the underlying user is not a TM user in this group \u2014 a null assignee here does not prove the session is unassigned.", + "Take `id` (integer) from a row and carry it as session_id into the logs, defects, close, clone, update and delete calls. `identifier` (ES-NNN) is display-only and is not accepted by any route on this entity.", + "Everything is at the top level: exploratory_sessions, info, count. There is no `data` envelope.", + "This listing is a NARROW projection, not the full session. It does not return charter, description, duration, actual_duration, session_log_count, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations \u2014 read a single session for those. It is also the one session endpoint that does NOT normalise custom_fields." + ] + }, + { + "name": "list_filters", + "method": "GET", + "path": "/api/v1/projects/{project_id}/filter", + "mode": "read", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "entity", + "required": true, + "type": "string", + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Required \u2014 which list's saved views to return, one type per call. Omitting it is a 400 'Invalid entity type'; to see every view, call once per type. An unlisted value is rejected with 422 `{success:false, message:\"Invalid payload\"}` \u2014 422 here, not 400." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. This is the only page parameter the handler reads \u2014 a `page` query parameter is ignored and you silently get page 1. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + } + ], + "intent": "List the saved filter views available in a project \u2014 yours and the project-wide ones \u2014 to find the filter_id to read, apply, update or delete", + "returns": [ + "success", + "data", + "info", + "id", + "name", + "is_project_level" + ], + "paginated": true, + "responses": { + "200": { + "$response": "FilterListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "max_page_size": 30, + "guidance": [ + "Page with `p`. The previously published `page` parameter is never read \u2014 sending it returns page 1 and looks like a successful call.", + "Page size is fixed at 30 and cannot be changed; there is no per_page or limit here. Walk pages until `info.next` is null.", + "The list mixes your private views with the project-level ones. Each row carries only `id`, `name` and `is_project_level` \u2014 no owner, entity_type or conditions \u2014 and `is_project_level` is the only thing that tells private views from project-wide ones.", + "An invalid `entity` returns 422, not the 400 the other filter endpoints use for validation failures.", + "Unlike reading a single filter, this listing does no custom-field validation and never rewrites anything \u2014 but it does not return a view's conditions either, only its id, name and scope.", + "Not permission-gated \u2014 the filter controller has no RBAC entry." + ] + }, + { + "name": "list_folder_contents", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Open one folder \u2014 its immediate subfolders, the folder's own record, and optionally its ancestor breadcrumb \u2014 to walk the tree a level at a time or work out where a folder sits", + "returns": [ + "success", + "folders", + "self", + "parent_id", + "ancestors", + "test_cases", + "info", + "id", + "name", + "notes", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "contents", + "links" + ], + "responses": { + "200": { + "$response": "FolderDetailsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the SUBFOLDERS. A non-positive or non-numeric value returns 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "detail", + "type": "boolean", + "default": false, + "description": "Send the literal string \"true\" to populate `ancestors` with the folder's breadcrumb up to the root. Without it `ancestors` comes back null. Only the exact string \"true\" enables it." + }, + { + "name": "nested_sub_folders", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" asks for the whole descendant subtree rather than one level. It is additionally gated on a group flag, so it silently does nothing when the group is not enabled. When it does take effect, `self.contents` is stripped from the response." + }, + { + "name": "case_count", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" to include per-folder test-case counts. Read only on the nested_sub_folders path; ignored on a plain one-level listing." + }, + { + "name": "total_count", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" to include the total row count. Read only on the nested_sub_folders path; ignored on a plain one-level listing." + }, + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Subfolder ordering. Comma-separated combinations accepted; anything else returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort gains a secondary created_at-asc tiebreak." + } + ], + "paginated": true, + "max_page_size": 300, + "guidance": [ + "Page size is fixed at 300 subfolders and cannot be changed: `per_page` from the caller is discarded and the ceiling is applied unconditionally. Only `p` moves you through the pages. (A group-level flag can lift the limit to unbounded, which you cannot detect from the response.)", + "`test_cases` is ALWAYS null. The key exists but this endpoint never returns test cases \u2014 fetch them from the folder's test-case listing instead.", + "`folders` holds the immediate children only. The folder you asked for is at `self`, its parent id is duplicated at the top level as `parent_id`, and `ancestors` is null unless you passed detail=true.", + "Folder notes come back HTML-escaped with signed image URLs injected, at every level of the response \u2014 do not treat them as the literal text that was stored.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500. A folder id from another project is a bare 404 {success:false} with no message.", + "This action has no RBAC entry, so it is not permission-gated the way the other folder reads are; it is also the action reachable through a public share link, scoped to the shared folder." + ] + }, + { + "name": "list_folder_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Walk pages until `info.next` is null." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." + }, + { + "name": "required[custom_fields]", + "type": "string", + "example": "121,119", + "description": "Comma-separated custom field ids to include on each row." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-ASC", + "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "Send the string `true` to include cases from this folder's subfolders, and add a `folders` block to the response." + }, + { + "name": "shared", + "type": "boolean", + "description": "Scope the listing to cases shared in from other projects." + }, + { + "name": "for_test_run", + "type": "boolean", + "description": "Send the string `true` when building an 'add cases to run' picker: on a project with review gating configured it hides cases whose review status makes them ineligible, so the same folder returns FEWER rows than a plain read." + }, + { + "name": "hasDuplicates", + "type": "boolean", + "description": "When true, fills `duplicates_tc_count` on each row from the dedupe store; otherwise it is null." + } + ], + "intent": "List the test cases in one folder \u2014 the repository browser read, with optional extra columns and custom fields.", + "shape": "discovered", + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "$response": "TestCaseFolderListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Folder-scoped and non-recursive by default: pass `include_subfolders_tc=true` to reach cases in child folders. To read the whole project use the project-level listing with all_folders=true instead.", + "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else \u2014 so trust `info.page_size`, not your request. Walk `p` until `info.next` is null.", + "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", + "`for_test_run=true` deliberately returns FEWER rows on a review-gated project \u2014 do not use it for a plain repository read or you will under-report the folder.", + "A backend 404 is swallowed into an empty list with `success: true`, so an empty result is not proof the folder is empty or that the id is right.", + "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null." + ], + "returns": [ + "success", + "test_cases", + "info", + "folders" + ] + }, + { + "name": "list_projects", + "method": "GET", + "path": "/api/v1/projects", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "q", + "type": "string", + "example": "checkout", + "description": "Free-text match on project name. Must be a flat string \u2014 a nested q[...] value returns 400 'Invalid Params: q should be a String'." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive, non-numeric or array values return 400 'Invalid Params'." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Any other value returns 400 echoing the allowed list." + }, + { + "name": "starred", + "type": "boolean", + "description": "Restrict to starred projects. Forwarded to the backing service verbatim, so sending the string 'false' is still a filter, not 'no filter' \u2014 omit the key instead." + }, + { + "name": "shared", + "type": "boolean", + "description": "Restrict to projects that have shared test cases. Any present value is treated as true; omit the key to not filter." + }, + { + "name": "is_hidden_projects", + "type": "boolean", + "default": false, + "example": true, + "description": "Include projects hidden from the dashboard. Only the exact string 'true' turns this on." + } + ], + "intent": "List the group's projects with their PR-NNN identifiers, case/run counts and duplicate badge \u2014 the listing to use when you need each project's identifier; for a plain name lookup or a count, get_projects_basic is leaner", + "returns": [ + "success", + "projects", + "info", + "id", + "identifier", + "name", + "description", + "created_at", + "import_id", + "links", + "page", + "page_size", + "count", + "prev", + "next" + ], + "paginated": true, + "responses": { + "200": { + "description": "Projects at the top level as `projects` (no `data` envelope), with pagination under `info`. Each row also carries duplicates_tc_count; project_creator is NOT emitted despite being on the shared Project schema.", + "schema": { + "$schema": "ProjectsListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Page size is fixed at 30 on this route. There is no size parameter \u2014 `count` and `page_size` are ignored here (only the basic and minify project listings honour `count`). Walk pages with `p` and stop when `info.next` is null.", + "`q` must be a flat string on this route, not a nested object: `q=checkout`. Sending q[query]=... returns 400 'Invalid Params: q should be a String'. The nested q[query]/q[identifier] form belongs to get_projects_basic.", + "`sort_by` is validated against a closed set; anything else returns 400 listing the allowed values. Omitting it leaves the backing service's default order.", + "Unlike the other project listings, each row carries `identifier` (PR-NNN) \u2014 this is the cheapest way to resolve project names to the v2 id form in bulk.", + "`duplicates_tc_count` is a decorative badge and is zero whenever the AI duplicate-detection feature is off for the group; a failure computing it is swallowed and also reads as 0, so never treat 0 as 'no duplicates'.", + "`project_creator` is documented on the shared Project schema but is NOT emitted by this endpoint \u2014 it is commented out in the serializer. Do not branch on it.", + "`starred` and `shared` are forwarded to the backing service verbatim, including the string 'false', so send them only when you mean to filter.", + "Requires the project_view permission under RBAC." + ], + "max_page_size": 30 + }, + { + "name": "list_schedules", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/schedules", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[query]", + "type": "string", + "description": "Case-insensitive substring search over report type, title and owner full name. Also enables the `owner_id` and `created_between` filters, which are ignored without it." + }, + { + "name": "q[scheduled]", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Only the exact string `true` filters (to scheduled reports). `false` and every other value are ignored and return all reports." + }, + { + "name": "owner_id", + "type": "integer", + "description": "Restrict to one owner. Only honoured alongside `q[query]`; supplying it also drops the owner name from the search match." + }, + { + "name": "created_between", + "type": "array", + "description": "Two timestamps, [from, to], bounding created_at. Only honoured alongside `q[query]`, and only when exactly two entries are given \u2014 otherwise ignored." + }, + { + "name": "per_page", + "type": "integer", + "description": "Accepted and then IGNORED \u2014 the page size is fixed at 30 on both code paths." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, 30 per page. A page past the last one returns 400 `page_out_of_bounds`, not an empty list." + } + ], + "intent": "List a project's reports \u2014 scheduled and unscheduled alike, with type, window, cadence and owner \u2014 the way to find a report_id", + "returns": [ + "data", + "scheduled_report_count", + "total_report_count", + "info" + ], + "paginated": true, + "responses": { + "200": { + "description": "A successful response containing a list of schedules.", + "schema": { + "$schema": "ReportScheduleGetResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the discovery call for the report entity: every other report capability needs an integer `report_id`, and this is where it comes from (`data[].id`; `identifier` is the SC-NNN display string and is not accepted as an id anywhere).", + "It lists ALL of the project's reports, not only scheduled ones \u2014 the route name is misleading. `scheduled_report_count` and `total_report_count` are counts over the whole project, not over the returned page.", + "`q[scheduled]` only acts on the exact string `true`. `q[scheduled]=false` is silently ignored and returns everything, so you cannot filter for unscheduled reports \u2014 filter client-side on `frequency == null` instead.", + "Page size is a hard-coded 30 and cannot be changed: `per_page` is read from the request and then never applied. Page with `p`; requesting a page past the end returns 400 `page_out_of_bounds` rather than an empty list.", + "Ordering is always newest first by id. An `order_by` parameter is parsed and then ignored.", + "`q[query]` is a case-insensitive substring match over report type, title, and (unless `owner_id` is also given) the owner's full name. Supplying it switches to a different code path that additionally honours `owner_id` and `created_between` \u2014 those two do nothing without `q[query]`.", + "Requires the report_view permission on the project (403 otherwise)." + ], + "max_page_size": 30 + }, + { + "name": "list_templates", + "method": "GET", + "path": "/api/v2/templates", + "mode": "read", + "entity": "template", + "query": [ + { + "name": "name", + "type": "string", + "description": "Narrow the list to templates whose name contains this value. It is a substring filter, not an exact lookup, so it can return several templates or none." + }, + { + "name": "p", + "type": "integer", + "description": "Page number. This is the page parameter for this endpoint \u2014 `page` is ignored. A value that is not a positive integer is rejected with a 400 before the search runs.", + "minimum": 1, + "default": 1 + }, + { + "name": "count", + "type": "integer", + "description": "Page size. This is the size parameter for this endpoint \u2014 `per_page` is ignored. Above 300 the request is REJECTED with a 400, it is not clamped. A non-numeric value silently falls back to 30.", + "maximum": 300, + "default": 30 + } + ], + "intent": "List the workspace's test-case templates \u2014 start here to find a template's id before creating a test case from it or before editing the template itself.", + "guidance": [ + "Asking for more than 300 per page fails the whole call with a 400 rather than returning a clamped page; page through at 300 or below instead.", + "List items carry only template metadata and counts \u2014 no field detail. Read a single template when you need its description or property fields.", + "Only test-case templates are returned. The entity type is fixed server-side and cannot be widened from the request, so result/plan/exploratory-session templates are not reachable here.", + "project_count and the field counts can come back null: on some workspaces the expensive per-project count is skipped server-side. Treat a null count as 'not computed', not as zero." + ], + "returns": [ + "success", + "templates", + "info", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "TemplateListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/tags-v2", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. IGNORED whenever `q` is also sent: the search branch drops the pagination options entirely and always returns the first page. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + }, + { + "name": "q", + "type": "string", + "description": "Free-text match on tag name. Trimmed; an all-whitespace value is treated as absent. Sending it silently disables paging (see `p`)." + } + ], + "intent": "List a project's test-case tag vocabulary with usage counts \u2014 the set of tag names to pick from when tagging a case or filtering a list by tag", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "TagsSearchResponse" + }, + "401": { + "description": "Unauthorized." + } + }, + "guidance": [ + "`tags` is a plain array of tag NAMES; `tag_objects` carries the same tags with `created_at` and `usage_count`. Tags are referenced by name everywhere else in this product, so `tags` is usually the field you want and `usage_count` is what tells you which are actually in use.", + "Combining `q` with `p` does not work: when `q` is present the handler forwards only the query and never the page, so you always get page 1 and a second page request returns the same rows. To walk the whole vocabulary, page with `p` and no `q`; to search, accept the first page and narrow the query.", + "Page size is fixed at 30 and there is no per_page parameter.", + "This is the TEST-CASE tag vocabulary only. Run, plan and shared-step tags are separate vocabularies with separate listings, and a name existing here says nothing about the others.", + "No tag ids are returned here. If you need an id (the tag merge takes integer ids), use the group-level tag search.", + "Not permission-gated \u2014 the tags controller has no RBAC entry." + ], + "returns": [ + "tags", + "tag_objects", + "info", + "name", + "created_at", + "usage_count" + ], + "max_page_size": 30 + }, + { + "name": "list_test_case_templates", + "method": "GET", + "path": "/api/v1/admin-v2/settings/templates", + "mode": "read", + "entity": "template", + "query": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "TestCase", + "TestRunResult" + ], + "example": "TestCase", + "description": "Which kind of template to list. Effectively required \u2014 omitting it errors rather than defaulting. Only these two values are accepted; TestPlan and ExploratorySession templates are not listable here." + }, + { + "name": "project", + "type": "integer", + "description": "Integer project id \u2014 restricts the list to templates linked to that project." + }, + { + "name": "name", + "type": "string", + "description": "Fuzzy match on template name (not an exact match)." + }, + { + "name": "paginated", + "type": "boolean", + "default": false, + "description": "Must be the literal string 'true' to turn paging on. Without it `p` is ignored, every matching template is returned in one array, and `info` is absent from the response." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Only read when paginated=true is also sent; page size is then fixed at 10." + } + ], + "intent": "List the workspace's test-case (or test-result) templates with their ids \u2014 the lookup to run before creating a case from a template or editing a template's field layout", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "`{success, templates: [{id, name, entity, enabled, step_type, is_default, is_system, future_projects_applicable, field_count, linked_to_all_projects, project_count, created_at, updated_at}]}`. `info` is present only when paginated=true was sent.", + "schema": { + "type": "object" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`entity_type` is effectively REQUIRED even though it reads as optional: it is forwarded straight to the backing client, which refuses a missing value outright rather than defaulting to TestCase. Always send it.", + "Only two values are accepted: `TestCase` and `TestRunResult`. `TestResult` is not the accepted spelling, and TestPlan / ExploratorySession templates cannot be listed from here at all \u2014 anything outside the pair errors rather than returning an empty list.", + "Paging is opt-in and easy to miss: `p` is IGNORED unless `paginated=true` is sent alongside it. With paging on, page size is fixed at 10 and `info` appears in the response; with it off you get every matching template in one array and no `info`.", + "`project` narrows to templates linked to that integer project id; `name` is a fuzzy match on the template name, not an exact one.", + "Rows are the summary shape \u2014 id, name, entity, step_type, is_system, is_default, enabled, project_count, field_count, linked_to_all_projects. The field layout itself is not here; read a single template for that.", + "Templates belong to the workspace, not to a project: the same id is valid in every linked project, so do not look for a per-project id.", + "Responses are served from a short-lived per-group cache, so a template created or edited through a different surface can take a moment to show up here.", + "A validation failure from the backing service is passed through raw at its original status rather than being reshaped into the usual {success:false} envelope." + ], + "max_page_size": 10, + "returns": [ + "success", + "templates", + "info", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at" + ] + }, + { + "name": "list_test_plans_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page \u2014 the page size is fixed at 30." + }, + { + "name": "include_sub_plans", + "type": "boolean", + "default": false, + "description": "Include sub-plans in the page. Off by default, so a sub-plan the user named looks missing until this is set. Pass the literal string 'true'." + }, + { + "name": "status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "Plan status filter. Anything outside the three values returns HTTP 200 carrying {success: false, error: 'Check query params for test plan status'} and NO plans \u2014 it does not 400." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Validated: anything else is 400 with the allowed list in the message. Must be a string \u2014 an array form is 400 'Invalid Params: sort_by should be a String'." + }, + { + "name": "minify", + "type": "boolean", + "description": "Ask for the slim plan rows. A group-level flag can force this on regardless of what you send, so rows may be slimmer than requested." + }, + { + "name": "q", + "type": "object", + "description": "Filter object (must be a nested object, not a string, or the call is 400). Recognised keys include query (free text), tags, ids, parent_plan_id, owner, team, created_at / created_between, days_left, overdue_days, tp_custom_fields, issue_type + issue_ids (both needed together), and match. Keys outside that set are ignored silently.", + "fields": [ + { + "name": "query", + "type": "string" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "ids", + "type": "array" + }, + { + "name": "parent_plan_id", + "type": "integer" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "team", + "type": "array" + }, + { + "name": "created_at", + "type": "string" + }, + { + "name": "created_between", + "type": "array" + }, + { + "name": "days_left", + "type": "string" + }, + { + "name": "overdue_days", + "type": "string" + }, + { + "name": "tp_custom_fields", + "type": "object" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "issue_ids", + "type": "array" + }, + { + "name": "match", + "type": "object" + } + ] + }, + { + "name": "ignore_type", + "type": "boolean", + "description": "Drop the implicit plan_status narrowing that the filter path otherwise applies (active plans unless status=completed). Pass the literal string 'true'." + }, + { + "name": "filter_details", + "type": "boolean", + "description": "Add a filter_details object resolving the owner and custom-field filters you sent in `q` back into displayable values. Only populated for q.owner and q.tp_custom_fields." + }, + { + "name": "filter_mapping", + "type": "boolean", + "description": "Id-picker mode: raises the page size to 5000 but returns ONLY {id, identifier} per plan \u2014 no name, no status, no dates. Do not use it to read plan detail." + } + ], + "intent": "Page through a project's test plans \u2014 filter by status, search and sort, and optionally include sub-plans; this is how you resolve a plan name a user mentioned to the integer id every other v1 plan route needs", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "V1TestPlanListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Sub-plans are hidden by default. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", + "An invalid `status` is a 200 trap: the response is HTTP 200 with {success: false, error: 'Check query params for test plan status'} and no plans. Branch on `success`, not on the status code.", + "Page size is fixed at 30 and there is no per_page parameter. Page with `p` and stop using the `info` envelope. filter_mapping=true is the only way to get more rows and it strips every row down to {id, identifier}.", + "`count` is {active, completed} for the whole project (active = every plan not completed), not for the current page \u2014 do not use it as a page total.", + "Each row carries both ids: `id` is the integer every v1 plan route takes, `identifier` is the TP-NNN / STP-NNN for display. Carry the integer forward.", + "Rows are full plan objects, same serializer as the single-plan read, unless a minify/skip-analytics path trims them \u2014 a group-level flag can turn that on without you asking, so check for the fields you need rather than assuming.", + "`q` must be sent as a nested object; a scalar q is 400 'Invalid Params: q should be a ActionController::Parameters'. Custom-field filters inside q.tp_custom_fields are validated and a bad field id fails the whole request.", + "Archived plans are NOT in this listing under any filter \u2014 they have their own archived listing.", + "Needs the test-plan VIEW permission." + ], + "returns": [ + "success", + "test_plans", + "info", + "count", + "filter_details", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "record_status", + "start_date", + "end_date", + "owner", + "assignee", + "created_by", + "tags", + "issues", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "total_test_runs_count", + "test_plan_progress_v2", + "project_id", + "created_at", + "updated_at", + "links" + ], + "max_page_size": 30 + }, + { + "name": "list_workspace_fields", + "method": "GET", + "path": "/api/v1/admin-v2/settings/fields", + "mode": "read", + "entity": "custom_field", + "query": [ + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Which entity's fields to list. Any other value is a 400 'Invalid Params'. TestCaseStep returns an empty list when the group's step-custom-fields flag is off." + }, + { + "name": "field_source", + "type": "string", + "values": [ + "system", + "custom", + "all" + ], + "default": "all", + "description": "Case-insensitive. `system` returns every system field on a single page (total_pages 1) and no custom fields; `custom` skips the system fields entirely. Anything else is a 400 naming the valid values." + }, + { + "name": "field_type", + "type": "string", + "description": "Filter custom fields by data type, using the canonical vocabulary (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown). System fields all report as `dropdown`, so any other value silently excludes every one of them." + }, + { + "name": "q", + "type": "string", + "description": "Case-insensitive substring match on the field name. Applied to system fields in this layer and forwarded as a search for custom fields." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control \u2014 page size is fixed at 10 and cannot be raised. Must be a scalar; `p[]=1` is a 400." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name", + "field_name", + "created_at", + "id" + ], + "description": "Sort column. Also accepts a combined `-` form, e.g. `name-desc`, in which case sort_order is ignored. `name` is an alias for field_name. Any unrecognised column falls back to sorting by id." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Direction, used only when sort_by does not already carry one. Compared case-insensitively; anything that is not `desc` sorts ascending." + }, + { + "name": "selected_ids", + "type": "array", + "description": "Custom field ids to EXCLUDE from the custom-field half of the result. Has no effect on the system fields injected on page 1." + } + ], + "intent": "Browse or search every field configured for the workspace \u2014 built-in system fields and user-defined custom fields together \u2014 to find a field's numeric id, type and how many projects it reaches", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "WorkspaceFieldsListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Start here. This is the only listing that returns both system and custom fields, and the numeric `id` it gives is what every other custom-field call takes in its path.", + "The page size is fixed at 10 and there is no way to raise it: only `p` is read. Expect to page. `info` carries {count, page, page_size, total_pages}.", + "System fields are injected on page 1 ONLY, ahead of the custom fields, and they eat into that page's 10 slots. Page 2 onward is custom fields only. Row order is therefore not stable across pages unless you also send sort_by.", + "Every system field is reported as field_type `dropdown`, so a field_type filter with any other value silently drops all of them. Set field_source=custom when you only want user-defined fields, or field_source=system to get them all on one page.", + "An unsupported field_source is a 400 naming the three valid values; an unsupported entity_type is a 400 'Invalid Params'. entity_type=TestCaseStep returns an empty list (not an error) when the group lacks the step-custom-fields flag.", + "sort_by also accepts a combined form like `name-desc`; `name` is an alias for field_name. Sorting is applied in this layer AFTER the system fields are merged in, so it reorders only the rows on the page you asked for.", + "`q` matches on field name only, case-insensitively, and is applied to both system and custom fields.", + "Rows carry `dataset_count` and a derived `linked_to_all_projects`, but no option values and no project list \u2014 read the field, then its datasets, for those." + ], + "max_page_size": 10, + "returns": [ + "success", + "data", + "info", + "id", + "field_name", + "field_type", + "entity_type", + "system_name", + "place_holder_text", + "linked_projects_count", + "dataset_count", + "is_system_field", + "type", + "field_values", + "linked_to_all_projects", + "count", + "page", + "page_size", + "total_pages" + ] + }, + { + "name": "merge_tags", + "method": "POST", + "path": "/api/v1/projects/{project_id}/tags/merge", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Needed to match the route, and nothing else \u2014 it is never sent to the backing service. The merge is NOT scoped to this project; it applies across the whole workspace." + } + ], + "body": [ + { + "name": "source_id", + "type": "integer", + "required": true, + "description": "The tag that LOSES. It is absorbed into the target and ceases to exist; every entity carrying it is re-pointed at the target tag. Integer id \u2014 the project tag listings return names only, so resolve the id from the group tag search first." + }, + { + "name": "target_id", + "type": "integer", + "required": true, + "description": "The tag that SURVIVES. It keeps its own name and picks up all of the source tag's usages. Nothing about the target's name or metadata changes." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run", + "test_plan", + "shared_step" + ], + "description": "Which tag vocabulary the two ids belong to. NOT validated here \u2014 only checked for presence \u2014 so an unknown or mismatched value is forwarded and comes back as an opaque 400 from the backing service." + } + ], + "intent": "Merge one tag into another \u2014 everything tagged with the source tag ends up carrying the target tag instead, and the source tag stops existing", + "responses": { + "200": { + "description": "The backing service's merge result, passed through unchanged. No `success` flag and no envelope are added by this API; the exact keys are owned by the tag service.", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Missing source_id/target_id/entity_type, or any failure reported by the backing service (including an unknown tag id), as `{error: \"\"}`." + }, + "401": { + "description": "Unauthorized." + }, + "403": { + "description": "Production only: caller is not an owner or admin \u2014 `{error: \"Only admins can manage tags\"}`." + }, + "500": { + "description": "Unexpected failure \u2014 `{error: \"Internal server error\"}`." + } + }, + "guidance": [ + "CURRENTLY IMPOSSIBLE TO COMPLETE, and not because of the field names here. The server accepts `source_id` exactly as declared, then forwards it to the backing service, which rejects the request asking for a differently-named plural field. So a correctly-formed call is refused downstream and no input from this surface succeeds. Do not work around it by renaming the field \u2014 the server's own check would then reject the call first. This needs a product fix, not a contract change.", + "Which tag loses: `source_id`. After the merge the source tag no longer exists as a tag, and every test case, run, plan or shared step that carried it carries the target's tag instead. `target_id` is unchanged apart from gaining those usages. Getting the two the wrong way round destroys the tag you meant to keep.", + "There is no unmerge. The response does not list what was re-tagged, so nothing here tells you which entities to fix afterwards \u2014 capture the source tag's usage list (search by that tag) BEFORE calling, or the mapping is unrecoverable.", + "Not project-scoped despite the path: `project_id` is used for routing only and never reaches the backing service, so the merge applies across the entire workspace and can touch projects you were not looking at.", + "Both ids are integers. Every project-level tag read in this profile returns names rather than ids \u2014 resolve the ids through the group-level tag search, and never send a tag name here.", + "`entity_type` must match the vocabulary both tags live in. It is not validated locally, so a mismatch is not caught until the backing service rejects it.", + "Tag management is restricted to account owners and admins. Check who is asking before promising a merge, and expect a refusal for anyone else.", + "Every downstream failure \u2014 unknown tag id, mismatched entity_type, permission problem \u2014 is flattened to 400 `{error: \"\"}`. There is no 404 and no `success` flag; the message text is the only signal.", + "On success the body is the backing service's raw merge result passed straight through, with no envelope added by this API." + ], + "disabled": true + }, + { + "name": "move_folder_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mv", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "new_base_folder_id", + "type": "integer", + "example": 123, + "description": "Destination parent folder. NOT required despite what the name suggests: omit it or send null to move the folder to the project ROOT. It must exist in the destination project." + }, + { + "name": "new_base_project_id", + "type": "integer", + "example": 456, + "description": "Destination project; omit for a same-project move. Send it as a JSON NUMBER \u2014 the cross-project test is a raw inequality against the integer project id, so the string \"456\" against project 456 is misread as a cross-project move and takes the slow async path with all its extra restrictions." + }, + { + "name": "auto_rename", + "type": "boolean", + "default": true, + "description": "Defaults to TRUE here, unlike create and rename. A name clash in the destination is silently renamed rather than rejected; send false explicitly if you would rather the move fail." + }, + { + "name": "prev", + "type": "integer", + "description": "Optional: after the move, place the folder immediately after this sibling id. Must be an integer; sending prev or next triggers a second reorder call whose failure is not reported in this response." + }, + { + "name": "next", + "type": "integer", + "description": "Optional: after the move, place the folder immediately before this sibling id. Must be an integer." + } + ], + "intent": "Move a folder \u2014 with everything under it \u2014 to a different parent, to the project root, or into another project; same-project moves complete immediately, cross-project ones run in the background", + "returns": [ + "data", + "success", + "async", + "unique_id", + "folder", + "source_path_folders", + "destination_path_folders", + "parent_id", + "links" + ], + "responses": { + "200": { + "description": "Folder moved successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder__MoveFolderByIntegerId" }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 1 - }, - "closed": { - "type": "integer", - "example": 0 - } - } + "source_path_folders": { + "type": "array", + "nullable": true, + "items": { + "$schema": "Folder" + } }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "PathFolder__MoveFolderByIntegerId" + } + }, + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" } + } } - }, - "TestRunUserRef__GetTestRunByIntegerId": { - "type": "object", - "description": "TestRunUserRef as returned by `get_test_run_by_integer_id`. Composes TestRunUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + } + } + }, + "400": { + "description": "Invalid input or move not allowed", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" } - }, - "TestRunsListResponse__ListTestRunsByIntegerId": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17579092" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - }, - "updated_by": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "example": "__mcp-probe-createrun-20260921-260922110512" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - } - } - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 60 - }, - "closed": { - "type": "integer", - "example": 1 - } - } + } + } + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Two completely different responses come out of this one route. A same-project move is synchronous and returns {data:{folder, source_path_folders, destination_path_folders, success:true, async:false}}. A cross-project move returns only {data:{success:true, async:true, unique_id}} and has done nothing yet \u2014 branch on `data.async` before reading `data.folder`.", + "For the async path there is no HTTP status endpoint. `unique_id` is the X-Request-ID echoed back and progress goes over a websocket the API does not expose; confirm the move by re-reading the destination folder.", + "Omit new_base_folder_id to move to the project root. That is the only way to promote a nested folder to root level.", + "Moving a folder into one of its own descendants is NOT blocked. The only self-move check compares the folder id to the destination id, so a move into a child is accepted here (the copy endpoint does check for this \u2014 the move endpoint does not).", + "Cross-project moves carry two hard limits: the subtree may contain at most 50 folders counting all nesting, and the folder must contain no shared test cases at all. Either one fails the request with 422 {success:false, error:'...'} before anything moves.", + "The error shape varies by stage: local validation failures are 422 {success:false, error}, upstream rejections are 400 {success:false, error, message} (both keys, same text), and anything unexpected is 500 {success:false, error:'An unexpected error occurred. Please try again.'}.", + "`user_action` appears nowhere in the codebase \u2014 it is accepted and ignored. Do not build anything on it.", + "This action has no RBAC entry, so it is not permission-gated the way rename and reorder are.", + "A move is reversible by moving the folder back, but the async cross-project path recreates folders rather than relocating them, so ids on the far side are new." + ] + }, + { + "name": "rename_folder_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rename", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/folder/name", + "description": "New folder name. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves. Not strictly required: a notes-only edit is accepted, but the `folder` object itself must be present and non-empty." + }, + { + "name": "notes", + "type": "string", + "json_path": "/folder/notes", + "description": "New description, HTML allowed; unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. The key is `notes` \u2014 `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Replaces the folder's attachment set with these blob ids. Sending the key at all switches the handler into attachment mode and re-reads the folder's relations; an invalid set returns 422 with the bare string \"attachment is invalid\"." + } + ], + "intent": "Rename a folder or rewrite its notes \u2014 despite the name this is the general folder-edit call, and it changes whichever of the two fields you send", + "returns": [ + "data", + "success", + "folder", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__RenameFolderByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send `name` flat. The `folder` wrapper is added for you from the field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key. Sending no fields at all fails on the server for an empty wrapper.", + "Sibling-name uniqueness is always checked. A clash relays the entity store's own message and status verbatim rather than the generic masked text most folder endpoints return \u2014 read `message` for the real reason. auto_rename:\"true\" turns the rejection into a silent rename.", + "There is no undo. Folders keep no version history, so an overwritten name or notes value is gone and the only way back is to send the old value again \u2014 capture it with a read first if it matters.", + "The updated folder is at `data.folder`. The `cases_count` and `sub_folders_count` in that echo are copied from the read taken BEFORE the update, not re-derived \u2014 do not treat them as fresh counts.", + "This endpoint does NOT return a request_trace_id; the only keys are `data.folder` and `data.success`.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500.", + "Requires the folder-edit permission." + ] + }, + { + "name": "reorder_folders", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folders/reorder", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "current", + "type": "array", + "required": true, + "example": [ + 21 + ], + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "description": "The folder ids being repositioned. Must be a JSON array of NUMBERS \u2014 the check is a strict integer type test, so [\"21\"] or a form-encoded current[]=21 fails with 422 {success:false, message:'test_case_folders must be an array of integers.'}." + }, + { + "name": "prev", + "type": "integer", + "example": 101, + "description": "Id of the sibling the moved folders should land AFTER. Integer or null only \u2014 a string is 422. Null means 'place at the top', but see next: prev and next cannot both be omitted." + }, + { + "name": "next", + "type": "integer", + "example": 103, + "description": "Id of the sibling the moved folders should land BEFORE. Integer or null only. At least one of prev/next must be a real id." + } + ], + "intent": "Change the display order of sibling folders by dropping one or more of them between two named neighbours \u2014 ordering only, it never changes nesting", + "returns": [ + "data", + "success", + "message" + ], + "responses": { + "200": { + "description": "Folders reordered successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "integer", - "example": 2 - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } + "message": { + "type": "string", + "example": "Folders reordered successfully" } + } } - }, - "DatasetUserRef__ListDatasets": { - "type": "object", - "description": "DatasetUserRef as returned by `list_datasets`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`current` alone is not enough, whatever the required flags say. Sending neither prev nor next fails with 422 {success:false, message:'Both top_folder_id and bottom_folder_id cannot be nil.'} \u2014 you must anchor the move against at least one neighbour.", + "Every id is type-checked as a Ruby Integer before anything is sent upstream. Numeric strings are rejected, so send a JSON body with real numbers rather than form-encoded parameters.", + "All the validation failures come back as 422, not 400, even though they are plainly bad-request conditions \u2014 the status the code intended is not the status that is sent.", + "The response is only {data:{success:true, message:'Folders reordered successfully'}}. It does not echo the new order and it does NOT contain a request_trace_id. Re-read the folder listing to confirm the result.", + "Nothing about the ids is validated locally \u2014 existence, project ownership and sibling-ness are all the upstream service's problem, so a wrong-but-well-typed id can return success-shaped output without moving what you meant.", + "This reorders siblings within their existing parent. To change a folder's parent, use the move call instead.", + "Requires the folder-edit permission." + ] + }, + { + "name": "reorder_test_cases_by_folder", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "top_test_case", + "type": "string", + "description": "Integer id of the case the moved block should sit BELOW. Omit it to move the block to the top of the page.", + "json_path": "/re_order/top_test_case" + }, + { + "name": "bottom_test_case", + "type": "string", + "description": "Integer id of the case the moved block should sit ABOVE. Omit it to move the block to the bottom of the page.", + "json_path": "/re_order/bottom_test_case" + }, + { + "name": "page", + "type": "integer", + "description": "Page of the folder listing the move happens on. Ordering is resolved against that page's window, so a neighbour that is not on this page cannot anchor the move.", + "json_path": "/re_order/page" + }, + { + "name": "test_cases", + "type": "array", + "required": true, + "description": "Integer ids of the cases to move, in the order they should end up in. These are moved as one block between `top_test_case` and `bottom_test_case`.", + "json_path": "/re_order/test_cases" + } + ], + "intent": "Change the manual order of test cases within a folder by dropping a block of them between two neighbours.", + "returns": [ + "success", + "test_cases", + "info" + ], + "paginated": true, + "responses": { + "200": { + "description": "Test cases reordered successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__ReorderTestCasesByFolder" + } + }, + "info": { + "type": "object", + "description": "Additional metadata about the reorder operation." } - }, - "TestCaseFieldValue__ListFolderTestCases": { + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The wrapper applied for you here is `re_order`, NOT `test_case` like every other test-case write on this surface \u2014 that difference is handled by the json_paths, so you do not need to act on it, but it is why a hand-built `test_case` wrapper fails here in a different way than elsewhere.", + "Position is expressed by NEIGHBOURS, not by index: `top_test_case` and `bottom_test_case` are the case ids the moved block lands between. Omit `top_test_case` for the top of the page, `bottom_test_case` for the bottom.", + "Everything is integer case ids \u2014 the anchors and the moved list alike. TC-NNN identifiers are not accepted here.", + "Ordering is resolved within the page named by `page`, so both anchors must be on that page of the folder listing for the move to mean what you intend.", + "A rejection comes back as 400 `{errors: \"\"}` \u2014 not the usual `{success:false}` \u2014 and covers 'ids not in this folder' as well as malformed input.", + "The 200 body is the folder's listing in its NEW order, so you can confirm the result without a second read. Ordering is not versioned: the only way back is another reorder." + ] + }, + { + "name": "replace_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' \u2014 it is not skipped." + }, + { + "name": "run_state", + "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it.", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "json_path": "/test_run/run_state" + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings; the list you send replaces the run's tags." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read. Duplicates are collapsed." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^$|^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^$|^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "The run's COMPLETE intended case list, not a list to add. The partial-update call on this same path REPLACES membership rather than unioning it, and this full-replace call is no gentler: assume a case missing from the list is removed with its results and assignee. Send the run's existing case identifiers plus the new ones \u2014 or, to add cases, use bulk_update_test_run_test_cases with add_test_cases, which is truly additive." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders" + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list \u2014 cases you list elsewhere are still included, with a null-configuration row." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "json_path": "/test_run/filter_scope", + "description": "Accepted by the parameter filter but NOT honoured on either update path \u2014 the selection is always built in global mode here. Only create reads it." + } + ], + "intent": "Rewrite a test run from a complete payload \u2014 every metadata field you leave out is reset, so the run ends up as whatever this one body says. Use the partial update instead when you only mean to change some fields.", + "guidance": [ + "Two different operations share this path. This POST rebuilds the whole run payload; the PATCH applies only the keys you send. If you are renaming a run, closing it, or linking a plan, pick the PATCH.", + "Omission is destructive here: no run_state resets the run to new_run, no test_plan_id/sub_test_plan_id unlinks the plan it was under, and no issues clears its linked defects. Read the run first and re-send its current values for anything you do not mean to change.", + "An invalid run_state is not rejected on this call \u2014 it is silently coerced to new_run. The partial-update call on the same run returns 400 instead, so use that one if you want the error.", + "shared_test_cases and filter_scope are accepted by the parameter filter and then ignored on this path. Cross-project cases only land through the PATCH.", + "Case membership is REPLACED, not unioned: whatever test_cases / folder_ids / include_all resolve to should be treated as the run's whole case list. Always send the existing cases plus the new ones; a case left out loses its results.", + "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "400 'invalid data' also covers an assignee email that cannot be resolved in this group \u2014 the run is left untouched in that case.", + "There is no undo: test runs keep no version history, so a field this call clears cannot be restored.", + "The updated run comes back at the top level as `testrun` \u2014 one word, no underscore, and no `data` envelope.", + "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", + "`urls.self` is a link to the run in the web UI, not an API path \u2014 it is built from the frontend base URL, so calling it returns HTML.", + "`assignee` comes back as a plain email string \u2014 or null when the owner cannot be resolved \u2014 and `configurations` as a list of configuration ids. Neither is a list of objects.", + "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", + "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", + "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", + "The run's case list is not on this reply \u2014 `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", + "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write. Re-read the run when the exact stored values matter.", + "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunWriteV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "resolve_duplicate", + "method": "POST", + "path": "/api/v1/projects/{project_id}/ai-duplicates", + "mode": "write", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "description": "The duplicate to resolve, from the `id` of a listing row. Passed in the BODY, not the path \u2014 this endpoint posts to the collection path, unlike discard which puts to the item path." + }, + { + "name": "merge_action", + "type": "string", + "required": true, + "values": [ + "merge", + "archive" + ], + "description": "Exactly `merge` or `archive`. There is no default and no fallback: any other value, including a missing one, is rejected with 400 {error:'Invalid action'} and nothing happens." + }, + { + "name": "source_testcase_id", + "type": "integer", + "description": "Required when merge_action is `merge`. The integer id of the case that will be archived and folded into the other. Must be one of the pair's two test cases." + }, + { + "name": "target_testcase_id", + "type": "integer", + "description": "Required when merge_action is `merge`. The integer id of the case that survives and receives the merge comment. Must be one of the pair's two test cases." + }, + { + "name": "archive_testcase_id", + "type": "integer", + "description": "Required when merge_action is `archive`, and ignored otherwise. The integer id of the single case to archive; must be one of the pair's two test cases. Without it the archive path fails with 'Test case not found in duplicates'." + } + ], + "intent": "Act on a duplicate suggestion \u2014 merge one test case into the other (archiving the source and commenting on the survivor), or archive one of the pair outright", + "responses": { + "200": { + "description": "Duplicate resolved. Body is {success:true} only \u2014 nothing about the affected test cases is returned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "description": "{error:'Invalid action'} when merge_action is neither `merge` nor `archive`, or {errors:'Invalid Input'} when the backing store rejects the merge/archive." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "The caller lacks the test-case delete permission (tc_delete)." + }, + "404": { + "description": "{errors:'Duplicate not found.'} \u2014 no such pair in this project, or it is no longer in `suggested` status." + }, + "409": { + "description": "{errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} \u2014 a dedupe data migration is in progress. Nothing was changed; retry later." + }, + "422": { + "description": "A test case id that is not part of this pair. Body is {errors:'Test case not found in duplicates'} on the merge path and {success:false, message:'Test case not found in duplicates'} on the archive path." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Two different operations behind one field. `merge` archives source_testcase_id, tags it 'Archived as Duplicate', posts a comment on target_testcase_id recording the merge, stores a snapshot of the source, and marks the pair merged. `archive` archives archive_testcase_id only \u2014 no comment, no snapshot \u2014 and still marks the pair merged.", + "Ids are not interchangeable: the SOURCE is the case that disappears. Getting source and target the wrong way round archives the case you meant to keep, and the only route back is unarchiving it manually.", + "Archived is not deleted \u2014 a merged-away or archived case is recoverable from the project's archived cases, findable by the 'Archived as Duplicate' tag on the merge path. Nothing here is a hard delete, but the merge comment, the snapshot and the pair's status change are not undone by unarchiving.", + "Resolving requires the test-case DELETE permission (tc_delete), not write. A caller who can edit test cases may still be refused here with 403.", + "The pair must still be `suggested`. An already-resolved pair is 404 {errors:'Duplicate not found.'}, which usually means someone else handled it, not that the id is wrong.", + "Undeclared until now, and retryable: while a dedupe data migration is in progress the call is refused with 409 {errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. It is deliberately not a 200 \u2014 nothing was merged. Retry later.", + "A test case id that is not part of the pair is rejected AFTER the pair lookup and reports 422, not 404, with two different bodies depending on the action: merge gives {errors:'Test case not found in duplicates'} and archive gives {success:false, message:'Test case not found in duplicates'}.", + "Success is just {success:true} \u2014 the affected test cases are not echoed back. Re-read the surviving case if you need its post-merge state, and note the pair leaves every duplicates listing at this point.", + "After a merge the source case's pre-merge contents are readable only through the merged pair's source-snapshot call. Keep the duplicate id if you might need it; it is no longer discoverable from any listing." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "restore_history", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}/restore", + "mode": "write", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "history_id", + "type": "integer", + "required": true + } + ], + "intent": "Roll a test case back to an earlier revision \u2014 reapplies that snapshot's name, description, preconditions, steps, status, priority, owner, tags, defects, custom fields, attachments and reviewers to the live case", + "responses": { + "200": { + "description": "Restore applied. Body is only {success: true} \u2014 read the test case back to see the restored content.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type is not restorable ({success:false, message:'Not supported'} / 'Not supported for build')." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is changed.", + "`history_id` is the numeric revision id from the listing, not the V1/V2 position label.", + "It is a full OVERWRITE of the snapshotted fields, not a merge. A custom field that has a value today but was empty in the snapshot is CLEARED. Steps are rewritten from the snapshot, so steps added since are removed, and steps whose ids no longer exist come back with new ids. The attachment set is rewritten too, unlinking files added after that revision.", + "There is a way back, but only one: the restore is applied as an ordinary edit of the live case, so the pre-restore state remains in the revision trail and can itself be restored. There is no undo call.", + "Say what will be lost before calling. The caller cannot see the delta from this endpoint \u2014 read the diff (or the two revisions) first and tell the user which fields will change.", + "Only test cases are restorable on this route; the same shape against another entity type answers 422 'Not supported'.", + "Success is only {success: true}. Read the case back to confirm what the restore actually produced \u2014 in particular, custom fields whose definition no longer exists in the project are not recreated and stay absent." + ], + "returns": [ + "success" + ] + }, + { + "name": "search_duplicates", + "method": "POST", + "path": "/api/v1/projects/{project_id}/ai-duplicates/search-v2", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "Test-case filter object, the same grammar the test-case search takes; a pair matches when its test cases match. Sharp edges here: `owner`/`reviewers` take TM user ids and are translated server-side (an id resolving to nobody filters to nothing, never to everything); date filters must use `date_range` or the relative `created_at`/`updated_at` tokens, and `all_time` is dropped rather than applied.", + "fields": [ + { + "name": "query", + "type": "string", + "description": "Free-text match on test case title / identifier." + }, + { + "name": "folder_ids", + "type": "string", + "description": "Folder ids, comma-separated string or array." + }, + { + "name": "owner", + "type": "string", + "description": "TM user ids, comma-separated. Translated to backing-store ids before the search." + }, + { + "name": "reviewers", + "type": "string", + "description": "TM user ids, comma-separated. Translated like owner." + }, + { + "name": "tags", + "type": "string", + "description": "Tag names, comma-separated string or array." + }, + { + "name": "priority", + "type": "string", + "description": "Priority option ids, not display labels." + }, + { + "name": "status", + "type": "string", + "description": "Status option ids, not display labels." + }, + { + "name": "case_type", + "type": "string", + "description": "Case-type option ids." + }, + { + "name": "automation_state", + "type": "string", + "description": "Option ids, or the literals `automated` / `manual` which the server resolves." + }, + { + "name": "issue_ids", + "type": "string", + "description": "Linked issue ids, comma-separated." + }, + { + "name": "custom_fields", "type": "object", - "description": "TestCaseFieldValue as returned by `list_folder_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "Folder__MoveFolderByIntegerId": { + "description": "Keyed by custom-field id, e.g. {\"179720\": [\"Yes\"]}." + }, + { + "name": "match", "type": "object", - "description": "Folder as returned by `move_folder_by_integer_id`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/765883" - } - } - }, - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765883 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "copy-of-__mcp-probe-subfolder-20260921" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - }, - "total_cases_count": { - "type": "integer", - "example": 2 - } - } - }, - "FolderWriteResponse__RenameFolderByIntegerId": { + "description": "Per-field operator map (all / any / none). Values stay beside it under their own key \u2014 a value placed inside `match` applies no filter at all." + }, + { + "name": "empty", "type": "object", - "description": "FolderWriteResponse as returned by `rename_folder_by_integer_id`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "FolderWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/765811" - } - } - }, - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 16 - }, - "total_cases_count": { - "type": "integer", - "example": 169 - } - } - } - } - } + "description": "Is-empty filter: {system_fields:[...], custom_fields:[...]}." + }, + { + "name": "date_range", + "type": "string", + "description": "Absolute created-at range; takes precedence over created_at when both are sent." + }, + { + "name": "created_at", + "type": "string", + "description": "Relative token such as `7_day`. `all_time` is dropped, applying no bound." + }, + { + "name": "updated_at", + "type": "string", + "description": "Relative token, same form as created_at. `all_time` is dropped." + } + ] + }, + { + "name": "match_scope", + "type": "string", + "values": [ + "any", + "all" + ], + "default": "any", + "description": "Whether ONE test case of a pair matching `q` is enough (`any`) or BOTH must match (`all`). Defaults to `any` \u2014 with `all` a filter that matches only one side returns nothing." + }, + { + "name": "duplicates", + "type": "string", + "values": [ + "exact", + "semantic" + ], + "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim and not validated here; an unrecognised value narrows to nothing." + }, + { + "name": "owner", + "type": "string", + "description": "The 'My duplicates' tab. ANY non-empty value scopes results to the CALLER's own test cases; the value is discarded, so it cannot select another user. This is separate from the `owner` filter inside `q`, which does take user ids." + }, + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `p` is an accepted alias and only `p` is validated (a bad `p` is 400 {success:false, error:'Invalid Params'}); `page` wins when both are sent and a non-numeric `page` silently becomes 0." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Send the literal string 'true' (with a non-empty `q`) to get a `filter_details` object back that resolves the filter's ids to display labels. Any other value, or a missing `q`, omits the key entirely." + } + ], + "intent": "Search the AI-suggested duplicate pairs with the full test-case filter grammar \u2014 narrow the review queue by folder, owner, tags, priority, status, custom fields or free text", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "Matching suggested duplicates, plus `filter_details` when it was requested. The same shape with an empty `duplicates` is returned when the dedupe feature flag is off.", + "schema": { + "$schema": "V1DuplicatesListResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric `p`." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "description": "{success:false, message:...} \u2014 the backing store refused the filter combination (usually because it resolves to too many test cases). This is NOT 'no results'." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A POST, but a read \u2014 it changes nothing. It exists because the filter object is too long for a query string.", + "Same feature gate as the plain listing: with the dedupe flag off this returns 200 {success:true, duplicates:[], info:{count:0,total_pages:0}} and never reaches the filter. An empty result is not evidence that nothing matched.", + "'Nothing matched' and 'the filter could not be evaluated' are different answers. A filter the backing store refuses (typically one resolving to too many test case ids) comes back 422 {success:false, message:}, not an empty list \u2014 do not treat that as zero results.", + "Page size is fixed at 10, ordering is by confidence (ai_score descending), and `info.count` can exceed the rows on the page because pairs with an unresolvable test case are dropped after counting.", + "Send only the filter keys you mean. `q` is forwarded to the search engine, which discards keys that would take it off the duplicate-pair scope, so an unexpected key fails silently rather than erroring.", + "Carry each row's `id` forward as the duplicate id for the read, resolve and discard calls.", + "Response adds two keys the plain listing has: `filter_details` (only when requested) and, as there, `last_scan_time` for the project's last dedupe run." + ], + "returns": [ + "success", + "duplicates", + "info" + ], + "max_page_size": 10 + }, + { + "name": "search_group_tags", + "method": "GET", + "path": "/api/v1/group/{entity_type}/tags", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run" + ], + "example": "test_case", + "description": "Which tag vocabulary to search. Hyphens are normalised to underscores, so `test-case` and `test_case` both work. Not validated against a list here \u2014 an unrecognised value is forwarded to the backing service rather than rejected." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search, trimmed. Omit it to get the first page of the whole vocabulary." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + } + ], + "intent": "Search the workspace-wide tag vocabulary for one entity type and get each tag's id \u2014 the only tag read here that returns ids, so it is where you resolve the source_id and target_id a tag merge needs", + "returns": [ + "tags", + "tag_objects", + "info", + "name" + ], + "paginated": true, + "responses": { + "200": { + "$schema": "TagsSearchResponse__SearchGroupTags" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Group-scoped: no project is involved and none can be supplied, so tags from every project in the workspace come back together. A name you find here may not be in use in the project you care about.", + "This read returns the tag `id` alongside the name \u2014 that is what makes it the resolution step for a tag merge, which takes integer ids. The project-level tag reads return names only.", + "Page size is fixed at 30 and there is no per_page parameter; page with `p`.", + "`created_at` and `usage_count`, which the project-scoped tag search returns, are not guaranteed on this group-scoped path \u2014 treat `id` and `name` as the fields you can rely on.", + "Not permission-gated \u2014 the search-filter controller has no RBAC entry, and this route deliberately skips project resolution.", + "Neither `info.count` nor `info.next` can be trusted to size a fetch. Confirmed live: a listing that returns 30 rows on page 1 and 30 more distinct rows on page 2 reports a count of 31 on both, and `next` is correct on page 1 and then null on page 2 with 60 rows already returned. Page until a page comes back empty rather than until the count is satisfied or `next` is null, and never report the count to a user as a total." + ], + "max_page_size": 30 + }, + { + "name": "search_project_entities", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity}/search", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "test-runs", + "trtc", + "test-plans" + ], + "example": "test-cases", + "description": "What to search. 'trtc' means the test cases inside one test run and needs test_run_id. 'test-plans' matches the route but has no handler branch and always returns an empty list. 'reports' is NOT a valid value \u2014 it fails the route constraint with a 404." + } + ], + "query": [ + { + "name": "q[tags][]", + "type": "array", + "example": [ + "2FA", + "delivery" + ], + "description": "Tag filter. Send it as a REPEATED BRACKETED parameter - `q[tags][]=2FA&q[tags][]=delivery` - one entry per tag name. Multiple tags are OR'd: a case matching any of them is returned. DO NOT use `q[test_case_tags]`: it is accepted, returns 200, and is SILENTLY IGNORED - the full unfiltered set comes back looking like a successful search. A bare `q[tags]` carrying a JSON string matches nothing (0 rows), and `q[tags]` with some other shapes 500s." + }, + { + "name": "q[query]", + "type": "string", + "example": "login", + "description": "Free-text match on title/identifier. Special characters are stripped before the match." + }, + { + "name": "q[folder_ids]", + "type": "string", + "example": "3306878,3669741", + "description": "Comma-separated folder ids to restrict to (test-cases). Add include_subfolders_tc=true to descend into sub-folders." + }, + { + "name": "q[folder_id]", + "type": "integer", + "example": 29529465, + "description": "Single folder id; read on the trtc path only." + }, + { + "name": "q[status]", + "type": "string", + "example": "9319,9321", + "description": "Comma-separated numeric status IDs, not display names \u2014 resolve them first. Renamed to status_id internally." + }, + { + "name": "q[priority]", + "type": "string", + "example": "550376,9261", + "description": "Comma-separated numeric priority IDs, not display names. Renamed to priority_id internally." + }, + { + "name": "q[case_type]", + "type": "string", + "example": "9379,9375", + "description": "Comma-separated numeric case-type IDs, not display names. Renamed to category_id internally." + }, + { + "name": "q[automation_state]", + "type": "string", + "example": "automated", + "description": "Either the literal word 'automated' / 'manual' (resolved to ids for this project) or comma-separated numeric ids. The only filter here that accepts words." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (test-cases and test-runs). Pass use_bstack_id=1 if you are supplying BrowserStack ids instead." + }, + { + "name": "q[assignee]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids; the trtc equivalent of owner (mapped to owner internally)." + }, + { + "name": "q[reviewers]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids; translated to BrowserStack ids server-side because reviews are stored against the BrowserStack id." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative window as _[_gte], or 'all_time' for no bound. Converted to an absolute range server-side." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, on last-updated date." + }, + { + "name": "q[review_status]", + "type": "string", + "example": "in_review", + "description": "Comma-separated review-status tokens (approved, rejected, in_review, pending, changes_requested), test-cases only." + }, + { + "name": "q[configuration_id]", + "type": "string", + "example": "101,102", + "description": "Comma-separated configuration ids; trtc path." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page \u2014 this is the size parameter, NOT page_size or count. Above 300 on test-cases the call silently switches to id-only compact rows; on test-runs it is overridden to 30 regardless." + }, + { + "name": "required[fields]", + "type": "string", + "example": "count", + "description": "Comma-separated list of extra columns to hydrate. The single value 'count' switches the endpoint into count-only mode: the response becomes {success, count} with no rows." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-asc", + "description": "Column and direction as -. For test-cases the column must also appear in required[fields] when that is used, or the request fails." + }, + { + "name": "use_bstack_id", + "type": "string", + "values": [ + "0", + "1" + ], + "description": "Set to '1' when the user ids in q[owner]/q[assignee]/q[reviewers]/q[last_executed_by] are BrowserStack ids; they are then translated to TM ids. Without it BrowserStack ids simply match nothing." + }, + { + "name": "test_run_id", + "type": "integer", + "example": 45123, + "description": "Required when entity=trtc \u2014 the run whose test cases you are searching. Omitting it yields an empty page, not an error." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "Descend into sub-folders of q[folder_ids] (test-cases). Only the exact string 'true' enables it." + }, + { + "name": "include_test_plan", + "type": "boolean", + "default": false, + "description": "Decorate each run with its linked test plans (test-runs only). Only the exact string 'true' enables it." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Also return the filters you sent, resolved into display objects, under `filter_details`. Ignored when q is empty." + }, + { + "name": "compact", + "type": "boolean", + "default": false, + "description": "Force compact mode for test-cases: id-only stub rows grouped by folder, empty filter_details. This is the same mode per_page>300 triggers implicitly." + } + ], + "intent": "Search and filter a project's test cases, test runs, or the test cases inside one run \u2014 the general-purpose finder: free text plus status / priority / type / owner / folder / tag / date filters, with an id-only count mode", + "shape": "discovered", + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "description": "Top-level result; the collection key depends on entity (test_cases for test-cases and trtc, test_runs for test-runs) and there is no `data` envelope. Compact mode and count-only mode return reduced shapes \u2014 see guidance.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "description": "Present for entity=test-cases and entity=trtc. In compact mode the rows degrade to {id, test_case_folder_id, project_id} stubs.", + "items": { + "$schema": "V1TestCaseSearchRow__SearchProjectEntities" + } + }, + "test_runs": { + "type": "array", + "description": "Present for entity=test-runs.", + "items": { + "$schema": "V1TestRunListRow" + } + }, + "folders": { + "type": "object", + "description": "KEYED BY FOLDER ID \u2014 {\"764093\": [{id, name, parent_id, ui_position}, \u2026]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." + }, + "count": { + "type": "integer", + "description": "Total matches. Always present for entity=test-runs; the sole payload (with success) in count-only mode." + }, + "filter_details": { + "type": "object", + "description": "The filters you sent, resolved to display objects. Empty {} unless filter_details=true and q was non-empty, and always empty in compact mode." + }, + "info": { + "$schema": "PageInfo" } - }, - "V1TestCaseSearchRow__SearchProjectEntities": { - "type": "object", - "description": "V1TestCaseSearchRow as returned by `search_project_entities`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseSearchRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "case_type": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "priority": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "status": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "id": { - "type": "integer", - "example": 1102619 - }, - "key": { - "type": "string", - "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" - }, - "name": { - "type": "string", - "example": "probe.txt" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" - } - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "TAG FILTERING USES `q[tags][]`, REPEATED ONCE PER TAG. The previously documented `q[test_case_tags]` does not filter at all - it returns 200 and the complete unfiltered list, which reads as 'no tag filtering available'.", + "Four entity values route here: test-cases, test-runs, trtc (the test cases inside one test run) and test-plans. Anything else \u2014 'reports' included \u2014 does not match the route constraint and is a 404 before the API is reached.", + "entity=test-plans is accepted by the route and by the controller but has no implementation behind it: it always renders {test_plans: [], info: null} with a 200. It is a dead branch, not an empty result \u2014 use the test-plan listing capabilities instead.", + "entity=trtc additionally requires a `test_run_id` query param. Without it the run filter is built from 0 and you get an empty page rather than an error.", + "The page-size parameter is `per_page`, not `page_size` or `count`; `page_size` is ignored and you silently get the 30-row default.", + "Ceiling behaviour differs per entity and this is the sharpest edge here. For test-cases, asking for more than 300 rows (or passing compact=true) flips the call into compact mode: the rows degrade to id-only stubs {id, test_case_folder_id, project_id}, filter_details comes back empty, and the page size jumps to 20000. For test-runs, per_page is overridden to 30 no matter what you ask for. Only trtc forwards per_page unchanged.", + "Count-only mode: send required[fields]=count (exactly that one field) and the response collapses to {success, count} with no rows \u2014 far cheaper than paging for a total.", + "Filter keys are renamed on the way through: q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id. They take numeric IDs, not display labels \u2014 resolve them with the system/custom field value capabilities first. q[automation_state] is the exception: it accepts the literal words 'automated' or 'manual' as well as ids.", + "Any key under q that the backing allow-list does not recognise is dropped silently. A misspelled filter therefore widens the result set to everything instead of erroring \u2014 always sanity-check the count against an unfiltered call.", + "q[owner] / q[assignee] / q[reviewers] take TM user ids by default. Pass use_bstack_id=1 if your ids are BrowserStack ids and they will be translated first; without it, BrowserStack ids match nothing and the response is simply empty.", + "Response shape depends on the entity: test-cases returns {success, test_cases, folders, info, filter_details}; trtc returns {success, info, test_cases, filter_details}; test-runs returns {success, test_runs, info, count} plus filter_details only when filter_details=true and q is non-empty. There is no `data` envelope on any of them.", + "For test-cases, `sort_by` must name a column that is also present in required[fields] when required[fields] is used, or the call fails with 'Sort by column X is not present in required fields'.", + "Binned (recycle-bin) and archived test cases are excluded from this search; they have their own listing capabilities." + ], + "returns": [ + "success", + "test_cases", + "test_runs", + "folders", + "info", + "filter_details", + "count", + "page", + "page_size", + "prev", + "next" + ] + }, + { + "name": "search_project_entities_by_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/{entity}/search-v2", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "trtc", + "test-runs", + "test-plans" + ] + } + ], + "body": [ + { + "name": "query", + "type": "string", + "example": "tc", + "description": "Search query string", + "json_path": "/q/query" + }, + { + "name": "owner", + "type": "string", + "example": "14", + "json_path": "/q/owner" + }, + { + "name": "reviewers", + "type": "string", + "example": "14,15", + "description": "Filter by reviewer, same form as `owner` \u2014 comma-separated TM user ids as a string, `$none` for none.", + "json_path": "/q/reviewers" + }, + { + "name": "last_executed_by", + "type": "string", + "example": "14", + "description": "Filter by who last executed the case, same form as `owner`. A non-string value raises server-side rather than returning empty.", + "json_path": "/q/last_executed_by" + }, + { + "name": "folder_id", + "type": "string", + "example": 29529465, + "description": "Filter by folder ID (single value or array)", + "json_path": "/q/folder_id" + }, + { + "name": "folder_ids", + "type": "string", + "example": [ + 125382, + 125385, + 125386 + ], + "description": "Filter by multiple folder IDs (comma-separated string or array)", + "json_path": "/q/folder_ids" + }, + { + "name": "priority", + "type": "string", + "example": [ + 1268, + 1270, + 1269, + 1267 + ], + "description": "Filter by priority IDs (comma-separated string or array)", + "json_path": "/q/priority" + }, + { + "name": "status", + "type": "string", + "description": "Filter by status IDs (comma-separated string or array)", + "json_path": "/q/status" + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Filter by issue type (e.g., jira, github)", + "json_path": "/q/issue_type" + }, + { + "name": "issue_ids", + "type": "string", + "description": "Filter by issue IDs (comma-separated string or array)", + "json_path": "/q/issue_ids" + }, + { + "name": "tags", + "type": "string", + "description": "Filter by tags (comma-separated string or array)", + "json_path": "/q/tags" + }, + { + "name": "case_type", + "type": "string", + "description": "Filter by case-type option ids (comma-separated string or array).", + "json_path": "/q/case_type" + }, + { + "name": "automation_state", + "type": "string", + "description": "Option ids, or the literals `automated` / `manual` which the server resolves.", + "json_path": "/q/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Filter by automation status.", + "json_path": "/q/automation_status" + }, + { + "name": "review_status", + "type": "string", + "description": "Filter by review status (only meaningful where review/approval is configured).", + "json_path": "/q/review_status" + }, + { + "name": "created_at", + "type": "string", + "example": "7_day", + "description": "Relative token `_` with a SINGULAR unit (`day`, `hour`, `week`, `month`,\n`year`) \u2014 e.g. `7_day`, `24_hour`; `_gte` inverts it (`7_day_gte` = older than).\nAlso accepts `all_time` or an absolute `\"YYYY-MM-DD YYYY-MM-DD\"` range. A PLURAL\nunit such as `7_days` is an unhandled server error (500), not a 400.", + "json_path": "/q/created_at" + }, + { + "name": "updated_at", + "type": "string", + "example": "1_month", + "description": "Same form as `created_at`.", + "json_path": "/q/updated_at" + }, + { + "name": "date_range", + "type": "string", + "description": "Absolute created-at range as epoch milliseconds: `\",\"`.", + "json_path": "/q/date_range" + }, + { + "name": "ids", + "type": "string", + "description": "Fetch specific cases by integer id (comma-separated string or array).", + "json_path": "/q/ids" + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Restrict to archived (or non-archived) cases.", + "json_path": "/q/is_archived" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "179720": [ + "Yes" + ] + }, + "json_path": "/q/custom_fields" + }, + { + "name": "match", + "type": "object", + "example": { + "tags": "all", + "custom_fields": { + "179720": "none" + } + }, + "description": "Per-field **operator** map \u2014 how to combine a field's values, NEVER the values\nthemselves. The value stays beside `match` under its own key; `match` only says\nhow to read it. A value placed inside `match` sets a meaningless operator and\napplies NO filter, which is the silent no-op described on `q`.\n\nAny filterable field may appear here, plus `custom_fields` keyed by field id.\nModes: `all`, `any`, ", + "json_path": "/q/match" + }, + { + "name": "empty", + "type": "object", + "example": { + "system_fields": [ + "priority" + ], + "custom_fields": [ + "179720" + ] + }, + "description": "Is-empty / is-unset filter. `system_fields` accepts the payload names `status`,\n`priority`, `case_type`, `automation_state`; `custom_fields` takes field ids.", + "fields": [ + { + "name": "system_fields", + "type": "array" + }, + { + "name": "custom_fields", + "type": "array" + } + ], + "json_path": "/q/empty" + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page number", + "minimum": 1, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "example": 5, + "description": "Results per page. Rows are large and vary with the data, so probe rather than assume: send p=1 with a moderate per_page and take the rows that actually come back as your ceiling (for entity=test-cases a row is ~40 keys / 5-9 KB, so start around 5). Then keep per_page FIXED for the whole walk \u2014 the offset is (p-1)*per_page, so changing it part-way shifts the window and SKIPS rows (page 1 at 5 then ", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + { + "name": "fields", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated JOINED columns to hydrate (owner, tags, issues, reviewers, priority, status, case_type, automation_state, defects; unlisted names pass through). It selects joins ONLY \u2014 it can never remove the base fields (name, description, preconditions, steps, test_case_steps, custom_fields, attachments), so naming fewer changes how many rows fit per page, NOT the order of magnitude, and it cann", + "json_path": "/required/fields" + }, + { + "name": "custom_fields", + "type": "string", + "example": "121,119", + "json_path": "/required/custom_fields" + }, + { + "name": "result_custom_fields", + "type": "string", + "description": "Same idea for test-RESULT custom fields, on the result-bearing listings.", + "json_path": "/required/result_custom_fields" + } + ], + "intent": "Search for entities within a project (v2 - POST with body)", + "guidance": [ + "Tag and status filtering WORK here. Send `tags` (and `status`) as ordinary body fields - their json_path maps them under `q`, which is the shape the server wants.", + "The general way to find cases by attribute. Ask for a large page when collecting ids to feed into another call, so the follow-up gets the whole set rather than the first page." + ], + "shape": "discovered", + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "description": "Search results. Flat body, no `data` envelope: {success, test_cases, folders, info, filter_details}. The rows are the same shared test-case record the v1 search returns, including `attachments` and the per-value `valueDetails` bindings.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "description": "Matching rows for entity=test-cases.", + "items": { + "$schema": "V1TestCaseSearchRow__SearchProjectEntitiesByFilter" + } + }, + "folders": { + "type": "object", + "description": "KEYED BY FOLDER ID \u2014 {\"764093\": [{id, name, parent_id, ui_position}, \u2026]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." + }, + "info": { + "$schema": "PageInfo" + }, + "filter_details": { + "type": "object", + "description": "The filters you sent, resolved to display objects. `{}` unless filter_details was requested and q was non-empty." } - }, - "SearchFieldValue__SearchProjectEntities": { - "type": "object", - "description": "SearchFieldValue as returned by `search_project_entities`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SearchFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "returns": [ + "success", + "test_cases", + "folders", + "info", + "filter_details" + ] + }, + { + "name": "search_project_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users/search", + "mode": "read", + "entity": "user", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Matches FULL NAME only, case-insensitive contains. Email is not searched \u2014 a query that looks like an address returns nothing. Omit it to list everyone in the workspace." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Paging overflows to the last page rather than returning an empty one." + }, + { + "name": "sort", + "type": "string", + "values": [ + "created_at", + "id", + "identifier" + ], + "default": "created_at", + "description": "Sort column. The allowlist is shared across entities \u2014 `identifier` is not a user column and has no effect. An unlisted value is ignored rather than rejected, leaving the default order." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Lower-case only; an unlisted value silently falls back to the default ordering." + } + ], + "intent": "Find people by name and read their role, permissions and product access \u2014 the search behind owner and assignee pickers; note it searches the whole workspace, not just this project's members", + "returns": [ + "users", + "info", + "id", + "browserstack_user_id", + "full_name", + "role", + "productRole", + "permissions", + "has_access", + "onboarded", + "group_id", + "reports_and_notification_enabled", + "customisable_dashboard_accessible", + "webhook_accessible", + "is_build_listing_enabled", + "is_delighted_visible", + "is_product_intro_visible", + "is_onboarding_project_header_visible", + "is_lcnc_explore_dismissed", + "is_new_project_settings_visible", + "is_jira_app_project_panel_visible" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectUsersSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This is NOT project-scoped, despite the path and the name. The query filters on the caller's group only \u2014 `project_id` resolves the route and is never used in the lookup \u2014 so every user in the workspace comes back, including people with no access to this project.", + "`has_access` on each row is the user's product-access flag, not project membership. Do not read it as 'is on this project'; there is no project-membership field in this response at all.", + "`q` matches full_name only, case-insensitive contains. Searching by email returns an empty list, which looks identical to 'no such person'.", + "`id` is the Test Management user id \u2014 the one owner and assignee fields take. `browserstack_user_id` is the BrowserStack account id for the same person and is a different number.", + "Page size is fixed at 30: the `page_size` parameter that was published here is not read, and asking for 100 still returns 30.", + "Paging overflows to the LAST page \u2014 a `p` beyond the end returns the final page again rather than an empty list. Stop on `info.next == null`, never on 'the page came back empty', or you will loop forever on the last page.", + "Default order is creation date descending, so the newest members appear first; it is not alphabetical.", + "`permissions` is computed per request from the user's product role and this workspace's feature flags, so the same person can show a different permission list in a different workspace, and the list is not a stored attribute.", + "Not permission-gated \u2014 the search-filter controller has no RBAC entry." + ] + }, + { + "name": "search_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/tags/search", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search, trimmed; an all-whitespace value is treated as absent and you get the backing service's default first page. This is the ONLY query parameter the handler reads." + } + ], + "intent": "Check whether a test-case tag name exists in a project, or complete a partial one, before tagging a case or building a tag filter", + "returns": [ + "tags", + "tag_objects", + "info", + "name", + "created_at", + "usage_count" + ], + "max_page_size": 100, + "paginated": false, + "responses": { + "200": { + "$response": "TagsSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This search cannot be paged. `p` and `page_size` are accepted by the URL and never read \u2014 no page parameter reaches the backing service, so every call returns its first page and a request for page 2 silently returns the same rows. If a project has more tags than one page, narrow with `q` rather than paging.", + "The response still carries an `info` block; it describes the one page you were given, not a page you can navigate away from.", + "`tags` is an array of names, `tag_objects` the same tags with `created_at` and `usage_count`. No tag ids here \u2014 use the group-level tag search when you need an id.", + "If the project cannot be resolved the handler returns 200 with `{tags: []}`. An empty vocabulary and an unknown/inaccessible project are indistinguishable, so do not read an empty result as 'this project has no tags'.", + "Test-case tags only. Run tags are a separate vocabulary on a separate path, and a name present in one says nothing about the other.", + "Not permission-gated \u2014 the search-filter controller has no RBAC entry." + ] + }, + { + "name": "search_test_cases_by_folder", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/search", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "id", + "type": "integer", + "description": "Exact integer test case id. Blank values are skipped." + }, + { + "name": "name", + "type": "string", + "description": "EXACT case title match \u2014 not a substring search. Blank values are skipped." + }, + { + "name": "identifier", + "type": "string", + "description": "Exact identifier match. Blank values are skipped." + } + ], + "intent": "Legacy id/name/identifier lookup against the deprecated local test-case table \u2014 do NOT use it to find test cases; use the project search or the folder listing instead.", + "returns": [ + "id", + "name", + "identifier" + ], + "responses": { + "200": { + "$response": "LegacyTestCaseSearchResponse" + }, + "400": { + "$response": "BadRequest" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DEPRECATED AND EFFECTIVELY DEAD. It queries the legacy local test-case table, which is no longer the system of record \u2014 for any project whose data lives in the current backend it returns an empty list with 200, whatever you search for. An empty result here is not evidence that a case does not exist.", + "It does NOT accept a free-text `query` parameter. The only filters read are `id`, `name` and `identifier`, each matched for EXACT equality, and a blank value is skipped.", + "The response is a BARE JSON ARRAY of legacy rows \u2014 no `success`, no `info`, no `test_cases` wrapper, and the rows are the old table's columns, not the serialised test case the rest of this surface returns.", + "The folder id in the path is not used to scope the query; only the project and group are.", + "Use the project-wide entity search for finding cases by text, or the folder listing to enumerate a folder." + ], + "disabled": true + }, + { + "name": "send_report_email_now", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/send_email_now", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "emails", + "type": "array", + "required": true, + "example": [ + "qa-leads@company.com" + ], + "description": "Recipient addresses. MUST be an array, even for one address. 1-10 entries, each matching a standard address syntax \u2014 invalid entries fail the whole call 400 and are listed in the message. Any address is accepted; recipients need not be TM users." + }, + { + "name": "file_type", + "type": "array", + "values": [ + "csv", + "pdf", + "xlsx" + ], + "example": [ + "pdf" + ], + "default": [ + "pdf" + ], + "description": "Format to attach. MUST be an array with EXACTLY ONE entry \u2014 two entries, or an explicit empty array, crash the allow-list check and return 500. Omit the field entirely to default to [\"pdf\"]." + } + ], + "intent": "Email an existing report to specific people right now, as a PDF/CSV/XLSX attachment, without changing its schedule", + "responses": { + "200": { + "description": "Generation job enqueued; the mail is sent asynchronously.", + "schema": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Report generation started. Email will be sent shortly." } - }, - "V1TestCaseSearchRow__SearchProjectEntitiesByFilter": { - "type": "object", - "description": "V1TestCaseSearchRow as returned by `search_project_entities_by_filter`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseSearchRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "case_type": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "priority": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the only way to get a report's file out of the product through the API \u2014 the download capability hands back a channel id you cannot resolve. Mail it to yourself to obtain the file.", + "Send a SINGLE entry in `file_type`. The server checks the allow-list with a splat, so an array of two formats (or an explicit empty array) raises an ArgumentError and comes back 500, not 400. Call the endpoint once per format you need.", + "Both fields must be JSON arrays even for one value \u2014 a bare string is 400 `emails should be an array` / `file_type should be an array`.", + "`emails` is effectively required: it defaults to an empty array which then fails the count check with 400 `Please provide between 1 and 10 email addresses.` Between 1 and 10 addresses, each syntactically valid; invalid ones are named in the 400 rather than skipped.", + "Recipients are arbitrary addresses \u2014 they need not be TM users, and the mail goes out immediately with no further confirmation. Treat this as an irreversible external side effect.", + "The 200 only means the job was enqueued (`Report generation started. Email will be sent shortly.`). Generation or delivery can still fail afterwards and there is no job id and no status endpoint \u2014 you cannot confirm the mail was sent.", + "Unlike every other report call, this one is NOT permission-gated: any user with access to the project can share a report. An unknown report id is 404 `Report not found`.", + "The report's own `mail_to` recipients are untouched: this does not add them, and it does not affect the schedule." + ], + "returns": [ + "success", + "message" + ] + }, + { + "name": "delete_folder_and_subtree", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm", + "mode": "destructive", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "DOES NOT restore a deleted folder \u2014 this route currently deletes the folder and its entire subtree, identically to the delete call; there is no folder-restore API", + "returns": [ + "data", + "success", + "folder", + "path_folders", + "id", + "name", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "description": "Folder successfully restored", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder" }, - "status": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "id": { - "type": "integer", - "example": 1102619 - }, - "key": { - "type": "string", - "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" - }, - "name": { - "type": "string", - "example": "probe.txt" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" - } - } - } + "success": { + "type": "boolean" } + } } - }, - "SearchFieldValue__SearchProjectEntitiesByFilter": { - "type": "object", - "description": "SearchFieldValue as returned by `search_project_entities_by_filter`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SearchFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Do not call this to undo a deletion. The handler is a one-line alias that forwards to the delete action, and the two arguments it passes to signal 'restore' are never read by that action \u2014 the request that reaches the backing service is exactly the same DELETE the remove endpoint sends.", + "The effect is therefore identical to deleting the folder: the folder, every subfolder beneath it and every test case in the subtree are removed, with test cases landing in the recycle bin only if the group's Move to Bin flag is on.", + "It is also the cheaper way to cause that damage: this route is gated on the folder-EDIT permission while the real delete route requires folder-DELETE, so an edit-only caller can destroy a folder through it.", + "There is no folder-restore capability anywhere in this API. Binned TEST CASES can be listed and restored from the project bin; folders cannot be brought back at all. If you need the structure again, recreate it.", + "The response is the delete response: {data:{folder, path_folders, success}}, with the echoed folder's counts zeroed." + ], + "disabled": true + }, + { + "name": "unlink_test_runs_from_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/unlink", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_runs", + "type": "array", + "required": true, + "description": "Run ids to unlink, as the INTEGER `uuid` from a run read \u2014 NOT the TR-NNN string that run endpoints return in `id`. Sent at the TOP LEVEL of the body: this call has no `test_plan` wrapper, unlike plan create/update." + } + ], + "intent": "Detach test runs from a test plan without deleting them \u2014 the runs survive and become plan-less, ready to be linked somewhere else", + "responses": { + "200": { + "description": "`{\"success\": true}` on an unlink. Also returned with `{\"success\": false, \"error\": \"Please check test runs\"}` when `test_runs` is missing or not an array \u2014 a 200 that did nothing.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" } - }, - "Dataset__UpdateDataset": { - "type": "object", - "description": "Dataset as returned by `update_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__UpdateDataset" - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is FLAT here \u2014 {\"test_runs\": [...]} at the top level. Wrapping it in `test_plan` the way create and update require means the handler sees nothing and the call silently no-ops.", + "A missing test_runs, or one that is not an array, returns HTTP 200 with {success: false, error: 'Please check test runs'} and changes nothing. This is the main failure mode and it does not look like a failure.", + "Use each run's integer `uuid`. Run endpoints put the TR-NNN string in `id`, and passing that fails upstream rather than being rejected here.", + "The 200 is returned without checking what actually happened: ids that were never linked to this plan, or that do not exist, are absorbed silently. Read the plan's run list before and after if you need to report what changed.", + "This only breaks the plan\u2194run link. The runs themselves, and their test cases and results, are untouched \u2014 they just stop belonging to any plan.", + "To re-link runs, send them in the plan update's test_runs list (which REPLACES the whole link set) rather than looking for a link endpoint here." + ], + "returns": [ + "success", + "error" + ], + "disabled": true + }, + { + "name": "update_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id holding the option. Not validated by this handler." + }, + { + "name": "option_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric option id, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." + } + ], + "body": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label. REQUIRED even when unchanged \u2014 a missing or null value is a 400. Must stay unique within the dataset." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "REQUIRED even when unchanged \u2014 a missing or null value is a 400. Setting it true does not clear any other option's default." + } + ], + "intent": "Rename one dropdown choice, or make it the default \u2014 the option id stays the same, so cases already holding this value follow the new label", + "responses": { + "200": { + "$response": "CustomFieldAdminOptionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A full replace of the option's label and default flag: both option_value and is_default must be sent, even when only one is changing. A missing or null either way is a 400 {success:false, error:'Invalid Params'}.", + "`parent_option_id` cannot be changed here \u2014 the handler does not forward it, so an option cannot be re-parented in a nested_dropdown. Delete and recreate instead, which drops the stored values.", + "The option id is stable across a rename, and stored values reference the id, so renaming does not lose data and can be undone by renaming back.", + "The rename lands in every project the dataset is linked to, with no per-project variant.", + "Making an option the default does not clear the previous default; on a single-select dropdown clear the old one yourself. On a multi_dropdown every option should stay is_default false.", + "Neither path id nor the option id is validated in this layer \u2014 a wrong triple surfaces as a bare 404 {success:false} with no message. A duplicate label is relayed verbatim from upstream; every other failure is masked as 400 'The API request is invalid or improperly formed'." + ], + "returns": [ + "success", + "data", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id" + ] + }, + { + "name": "update_custom_field_dataset_project_mapping", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/project-mappings/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Validated only on the synchronous path; an async (>300 project) request does not check it at all." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Validated only on the synchronous path; an async request does not check it at all." + } + ], + "body": [ + { + "name": "link_projects", + "type": "array", + "description": "Integer project ids to ADD. Note the spelling \u2014 `linked_projects` is silently dropped here. Send [] rather than omitting it: unless select_all is present, a missing array makes the request 500." + }, + { + "name": "unlink_projects", + "type": "array", + "description": "Integer project ids to REMOVE. Destroys those projects' stored values for this field, irreversibly. Send [] rather than omitting it \u2014 unless select_all is present, a missing array makes the request 500." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Auto-link projects created after this call. Forwarded as-is; omitting it sends null." + }, + { + "name": "select_all", + "type": "boolean", + "description": "true = link every project in the workspace except unlink_projects; false = UNLINK every project except link_projects, deleting their stored values. Only literal true/false are recognised \u2014 any other value falls back to the two explicit lists. Over 300 projects the call goes async." + } + ], + "intent": "Change which projects a custom field's dataset applies to \u2014 link projects so they see the field, or unlink them, which deletes the values those projects already stored", + "responses": { + "200": { + "$response": "CustomFieldDatasetProjectMappingUpdateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send BOTH `link_projects` and `unlink_projects`, even when one is empty. Unless `select_all` is present, the handler adds their two lengths together, and a missing array raises an unhandled error \u2014 the call comes back 500, not 400.", + "The spelling is link_projects / unlink_projects here. The create calls use linked_projects / linked_to_all_projects; those names are silently dropped on this path, so the call returns 200 with nothing changed.", + "Unlinking is destructive to data: the values every case in those projects stored for this field are removed, and there is no undo. Name and count the projects before proceeding.", + "`select_all: true` links every project in the workspace except those in unlink_projects. `select_all: false` is the inverse and UNLINKS every project except those in link_projects \u2014 a whole-workspace data deletion from a single boolean. Only the literal strings/values true and false are recognised.", + "Above 300 affected projects the call switches to a background job and returns {success: true, async: true, unique_id: \"\"} with NO dataset body. The unique_id is a job handle only; there is no endpoint here to poll it, so verify by re-reading the dataset.", + "In that async case the path ids are never validated, so a bad field or dataset id is accepted with a 200 and the work fails silently in the worker.", + "A project may belong to only one dataset per field. Linking a project already held by a sibling dataset fails with the upstream message relayed verbatim (e.g. 'Projects already linked to a different dataset for this custom field...', 'invalid project mapping', 'Dataset id is invalid').", + "On the synchronous path the response is the re-read dataset under `data`, with its options included." + ], + "returns": [ + "success", + "data", + "async", + "unique_id", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects", + "options" + ] + }, + { + "name": "update_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "field_name", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Display label. REQUIRED even when unchanged. Stripped before comparison and storage." + }, + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "REQUIRED for validation. Send the field's CURRENT type \u2014 this surface does not compare it to what is stored, it forwards it." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "description": "REQUIRED, literal JSON boolean. Omitting it, or sending the string \"true\", is a 400." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Used only to select the valid field_type set and the reserved-name list; never sent upstream, so it cannot move the field to a different entity. Send the field's real entity_type." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "Hint text. Omitting it sends null and CLEARS the stored value \u2014 resend it to keep it." + }, + { + "name": "default_value", + "type": "boolean", + "description": "Applied only when field_type is `boolean`. For any other type the handler sends null regardless of what you pass, clearing any stored default." + } + ], + "intent": "Rename a custom field, or change whether it is mandatory, its placeholder or its boolean default \u2014 a full replace of the field's attributes, not a partial edit", + "responses": { + "200": { + "$response": "CustomFieldAdminWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a REPLACE, not a patch. field_name, field_type and is_required must all be present or the call is 400 'Invalid Params' \u2014 read the field first and resend the whole set.", + "Omitting place_holder_text sends null and clears the stored hint text. Likewise, for any non-boolean field the handler always sends default_value as null, so a stored default on a string/int/date field does not survive this call. Resend every value you want to keep.", + "field_type must be sent and must be valid for the entity_type, but this surface does NOT compare it against the stored type \u2014 it is forwarded upstream. Send the field's CURRENT type. (Its sibling on the v2 custom-fields surface rejects a changed type outright with 400 'Field type cannot be changed'.)", + "entity_type is used only to pick the valid field_type list and the reserved-name list; it is never sent upstream, so a field cannot be moved between TestCase / TestResult / TestPlan. Send the field's real entity_type or the validation applies the wrong rules.", + "Datasets, options and project links are NOT touched here \u2014 this call carries no `datasets` key upstream. Change option values through the dataset option calls and project scope through the dataset project-mapping call.", + "Renaming onto a reserved name (Title, Description, Priority, State, Owner, Tags, \u2026) is a 400, except when the field already carries that name \u2014 an import-created field keeps its name and stays editable.", + "There is no version history for a field definition: an overwritten label, placeholder or default cannot be restored. A rename is workspace-wide and lands in every project the field's datasets link to.", + "Upstream duplicate-name errors come back verbatim; every other upstream failure is masked as 400 {success:false, message:'The API request is invalid or improperly formed'}, and a field id that does not exist comes back as a bare 404 {success:false} with no message." + ], + "returns": [ + "success", + "data", + "id", + "field_name", + "field_type", + "is_required", + "default_value", + "place_holder_text" + ] + }, + { + "name": "update_dataset", + "method": "PUT", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "uuid", + "type": "string", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "json_path": "/dataset/name", + "description": "Dataset name. REQUIRED on update despite reading like a partial edit \u2014 update shares the create request schema. A name already used by another dataset in the project returns 400 {success:false, message:'Dataset name already exists.'}." + }, + { + "name": "variables", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 40, + "json_path": "/dataset/variables", + "description": "The complete new column list \u2014 this REPLACES the existing columns, it does not merge. Required. Send each column's existing `uuid` (from a read) for the columns you are keeping; omit it only for genuinely new columns. Blank-named or entirely-empty columns are silently dropped. 40 is the default cap, 100 for groups with the raised-limit flag.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", + "description": "Letters, digits and underscores only, must start with a letter. Duplicate names (case-insensitive) fail the call." + }, + { + "name": "uuid", + "type": "string", + "description": "The existing column's uuid, taken from a read. Forwarded to the store so the column keeps its identity; leave it out and you are creating a new column." + } + ] + }, + { + "name": "rows", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 100, + "json_path": "/dataset/rows", + "description": "The complete new row set \u2014 REPLACES the existing rows. Required. Same normalisation as create: blank rows dropped, cells trimmed, duplicate rows collapsed, row_number reassigned 1..N. 100 is the default cap, 200 for groups with the raised-limit flag.", + "fields": [ + { + "name": "row_number", + "type": "integer", + "required": true, + "description": "Required by the schema, then overwritten with the row's final position." + }, + { + "name": "row_data", + "type": "array", + "required": true, + "minItems": 1, + "description": "Strings only, positional against `variables`; length must match the surviving column count after empty-column pruning." + }, + { + "name": "uuid", + "type": "string", + "description": "The existing row's uuid from a read, for rows you are keeping." + } + ] + }, + { + "name": "tags", + "type": "array", + "maxItems": 20, + "json_path": "/dataset/tags", + "description": "Replaces the dataset's tags when the key is present; omit the key entirely to leave existing tags alone. Sending [] clears them. Each tag is a string of at most 50 characters, at most 20 tags.", + "fields": [ + { + "name": "tag", + "type": "string", + "maxLength": 50 + } + ] + } + ], + "intent": "Replace a dataset's contents \u2014 rename it, or write a new set of columns and rows over the existing table", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "created_at", + "created_by" + ], + "responses": { + "200": { + "description": "Dataset updated; the full stored dataset is returned at the top level as `dataset`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__UpdateDataset" } - }, - "DatasetUserRef__UpdateDataset": { - "type": "object", - "description": "DatasetUserRef as returned by `update_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + } + } + }, + "400": { + "description": "Request-schema rejection ({error:'Invalid JSON data', details:[...]}) \u2014 including a body missing name/variables/rows \u2014 or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" + }, + "422": { + "description": "{success:false, message:...}. 'Feature not available' = no Team Pro entitlement; any other message is a dataset-shape validation failure." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a full replace, not a patch, and `name`, `variables` and `rows` are ALL required \u2014 the update reuses the create request schema verbatim. A body with only `name` is rejected with 400 {error:'Invalid JSON data'}; the spec marking these optional is wrong.", + "To rename without losing data: read the dataset first and send its existing `variables` and `rows` (uuids included) back alongside the new name.", + "There is no undo. Datasets keep no version history and there is no restore endpoint, so an overwritten column or row set is gone. Read the current contents before writing.", + "`metadata` passes validation here and is then silently discarded \u2014 the handler forwards only name, columns, rows and tags. Sending metadata returns 200 with metadata unchanged. It can only be set at creation.", + "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key. Nothing beyond the declared keys is permitted anywhere in the body (additionalProperties:false at every level) \u2014 an extra or misspelled key fails the whole call with 400.", + "The `uuid` in the path is the dataset's uuid string, resolved by looking it up in the project's dataset list; an unknown one is 404 {success:false, message:'Dataset not found.'}.", + "Rows and columns come back normalised (blank rows and empty columns removed, cells trimmed, duplicate rows collapsed, row_number renumbered) \u2014 trust the response, not your payload.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad body; any other 422 message is a real validation failure.", + "Needs the test-case write permission (tc_write)." + ] + }, + { + "name": "update_exploratory_session", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "body": [ + { + "name": "title", + "type": "string", + "example": "Updated checkout exploration", + "description": "New title. `name` is accepted by the request schema and silently dropped, so use this field.", + "json_path": "/exploratory_session/title" + }, + { + "name": "description", + "type": "string", + "description": "Replacement description.", + "json_path": "/exploratory_session/description" + }, + { + "name": "charter", + "type": "string", + "description": "Replacement testing charter.", + "json_path": "/exploratory_session/charter" + }, + { + "name": "timebox_duration", + "type": "integer", + "example": 90, + "description": "Planned duration. Rendered as minutes by TM's own exports but documented upstream as seconds; passed through unconverted.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/timebox_duration" + }, + { + "name": "duration", + "type": "integer", + "example": 45, + "description": "Tracked duration. Same unit ambiguity as timebox_duration.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/duration" + }, + { + "name": "actual_duration", + "type": "integer", + "example": 50, + "description": "Final actual duration. This is the ONLY place it can be set \u2014 the close call ignores any duration you send it \u2014 so write it here before closing the session.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/actual_duration" + }, + { + "name": "folder_id", + "type": "integer", + "example": 789, + "description": "Move the session to a different folder; a numeric string is also accepted.", + "json_path": "/exploratory_session/folder_id" + }, + { + "name": "test_plan_id", + "type": "integer", + "example": 77, + "description": "Integer id of the test plan to link (not a TP-NNN identifier). Only reachable through the wrapped body.", + "json_path": "/exploratory_session/test_plan_id" + }, + { + "name": "owner", + "type": "integer", + "example": 55, + "description": "TCM user id of the new owner; translated to the upstream user id. Wins over `assignee` when both are sent. An unresolvable id clears the assignee instead of failing.", + "json_path": "/exploratory_session/owner" + }, + { + "name": "assignee", + "type": "integer", + "example": 55, + "description": "Genuine alias for `owner` on this endpoint \u2014 both are translated here. (On create only `owner` is, so prefer `owner` in code shared between the two.)", + "json_path": "/exploratory_session/assignee" + }, + { + "name": "tags", + "type": "array", + "example": [ + "smoke", + "payment" + ], + "description": "REPLACES the session's tags with this list; an empty array clears them. Non-string entries are rejected by the request schema.", + "json_path": "/exploratory_session/tags" + }, + { + "name": "configurations", + "type": "array", + "example": [ + 2, + 4 + ], + "description": "REPLACES the session's configuration ids (integers or numeric strings). An empty array clears them.", + "json_path": "/exploratory_session/configurations" + }, + { + "name": "attachments", + "type": "array", + "description": "REPLACES the attached blob ids. Every id you leave out is detached and its blob freed for reuse. Ids must be integer blob ids from a prior generic attachment upload; an invalid set returns 422 with the bare string body \"attachment is invalid\".", + "json_path": "/exploratory_session/attachments" + }, + { + "name": "issues", + "type": "array", + "description": "REPLACES the linked issues. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the whole list to empty. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. A per-entry metadata object is accepted. Applied asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/exploratory_session/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "101": "Regression" + }, + "description": "Custom field values as a map of {custom_field_id: value} for entity type ExploratorySession, converted server-side. Not the array shape returned on read. Only sent when non-empty \u2014 an empty object is ignored rather than clearing values.", + "json_path": "/exploratory_session/custom_fields" + } + ], + "intent": "Change fields on an existing exploratory session \u2014 retitle it, revise the charter, record the time spent, reassign it, move it to another folder, or replace its tags, configurations, attachments and linked issues", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "description": "Exploratory session updated successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" } - }, - "FormFieldDescriptor": { - "type": "object", - "description": "One form field on the run-creation form: its selectable values plus the link and count that describe the value set.\n", - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/test-case/status" - } - } - }, - "total_count": { - "type": "integer", - "example": 8 - }, - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "is_default": { - "type": "integer", - "example": 0 - }, - "name": { - "type": "string", - "example": "Passed" - }, - "value": { - "type": "integer", - "example": 318610 - }, - "value_category": { - "type": "string", - "example": "completed" - }, - "internal_name": { - "type": "string", - "example": "passed" - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key.", + "Scalar fields are patched by presence: only title, description, charter, timebox_duration, duration, actual_duration, folder_id and test_plan_id keys actually present in the body are sent. Omitted keys are left alone.", + "The collection fields are NOT patched, they are replaced. Sending `tags`, `configurations`, `issues` or `attachments` overwrites the whole list; sending an empty array clears it. Omitting the key is the only way to leave a collection untouched.", + "Replacing `attachments` detaches every blob you left out and frees it for reuse. An invalid set is rejected with 422 whose body is the bare JSON string \"attachment is invalid\" \u2014 not the usual {success:false, message} envelope, so do not parse it like one.", + "`owner` and `assignee` are both translated to the upstream user id here (unlike create, where only `owner` is). `owner` wins when both are sent. An id that resolves to no user clears the assignee rather than failing.", + "There is no way to change `session_state` through this call, and no reopen endpoint exists \u2014 closing a session is one-way.", + "`name` is accepted by the request schema and then dropped by the parameter filter; retitle with `title`.", + "Exploratory sessions keep no version history and there is no undo: an overwritten title, description, charter or tag list cannot be restored.", + "Issue linking is applied asynchronously after the 200, so the session echoed back may not yet reflect the new links.", + "Upstream validation failures all arrive as 400 {success:false, message:'The API request is invalid or improperly formed'} \u2014 the same text for a bad configuration id, a folder in another project or a malformed value." + ] + }, + { + "name": "update_exploratory_session_log", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + }, + { + "name": "log_id", + "type": "integer", + "required": true, + "example": 789 + } + ], + "body": [ + { + "name": "content", + "type": "string", + "example": "

Confirmed the spinner issue on iOS 17 as well.

", + "description": "Replacement rich-text HTML. Sanitised on write. Re-sending it re-derives the log's image attachments from the blob ids in the HTML, so an image removed from the HTML is detached. A null is stripped rather than clearing the field \u2014 send an empty string to blank it.", + "json_path": "/session_log/content" + }, + { + "name": "status", + "type": "string", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "example": "bug", + "description": "Replacement outcome. `skipped` is valid; anything outside the seven is rejected by the request schema. A null is stripped rather than clearing the status.", + "json_path": "/session_log/status" + }, + { + "name": "elapsed_time", + "type": "integer", + "example": 180, + "description": "Replacement time spent, in seconds. Must be an integer. A null is stripped rather than clearing the value.", + "json_path": "/session_log/elapsed_time" + }, + { + "name": "defects", + "type": "array", + "description": "REPLACES the log's defect links when non-empty; omit the key to leave them untouched. An empty array is treated as 'not supplied' and does NOT clear the links. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the list to empty; a bad jira id fails the whole call. Applied asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/session_log/defects" + } + ], + "intent": "Revise an observation already recorded in an exploratory session \u2014 correct the note, change its pass/fail/bug outcome, adjust the time spent or restate which defects it raised", + "returns": [ + "success", + "session_log", + "id", + "session_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "description": "Session log updated successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_log": { + "$schema": "ExploratorySessionLog__UpdateExploratorySessionLog" } - }, - "TestRunV2Response__ListTestRun": { - "type": "object", - "description": "TestRunV2Response as returned by `get_test_run`. Composes TestRunV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunV2Response" - } - ], - "properties": { - "test_run": { - "type": "object", - "properties": { - "test_cases": { - "type": "object", - "properties": { - "case_type": { - "type": "string", - "example": "Other" - }, - "configuration_id": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "example": "user@example.com" - }, - "dataset": { - "type": "string", - "nullable": true - }, - "execution_id": { - "type": "string", - "example": "ab5c1e3c40fc614f2020a9ba4dd1a8e4c075bba8fe0336a4f30646030b6f6d43" - }, - "folder_path": { - "type": "integer", - "example": 764138 - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "last_updated_at": { - "type": "string", - "example": "2026-09-18T12:53:07.732Z" - }, - "latest_result_id": { - "type": "integer", - "example": 2150413868 - }, - "latest_status": { - "type": "string", - "example": "passed" - }, - "planned_execution_date": { - "type": "string", - "nullable": true - }, - "priority": { - "type": "string", - "example": "High" - }, - "source_project_id": { - "type": "string", - "example": "PR-2005" - }, - "status": { - "type": "string", - "example": "Active" - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "updated_by": { - "type": "string", - "example": "user@example.com" - }, - "urls": { - "type": "object", - "properties": { - "self": { - "type": "string" - } - } - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Both ids matter and both are integers: log_id identifies the entry, session_id in the path identifies its parent session. A log_id that belongs to a different session is not addressable here.", + "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. Only content, status, elapsed_time and defects are read, and only the keys present are forwarded \u2014 everything else is left as it is.", + "You cannot blank a field out: nulls are stripped from the outgoing payload, so sending content:null or status:null leaves the existing value in place. Send an empty string for content instead, and expect status and elapsed_time to be un-clearable.", + "`defects`, when non-empty, REPLACES the log's defect links; omit the key to leave them alone. The same coercion applies as on create \u2014 bare id strings become jira issues, malformed entries silently reduce the list to empty, and a bad jira id fails the whole call. Linking is applied asynchronously after the 200.", + "You cannot unlink every defect from a log: sending defects:[] is treated as 'not supplied' and the existing links survive. This differs from the session update, where an empty tags/configurations/issues array does clear the list.", + "Re-sending `content` re-derives the log's image attachments from the blob ids in the HTML: an image you drop from the HTML loses its attachment link, and if any referenced blob id is not visible to your group or project then ALL of the call's attachment links are silently dropped while the update still succeeds.", + "`content` is sanitised on write, so the stored HTML can differ from what you sent.", + "Session logs keep no version history and there is no undo \u2014 an overwritten note cannot be recovered.", + "`status` must be one of pass, fail, bug, retest, block, note, skipped; anything else is a 400 from the request schema." + ] + }, + { + "name": "update_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "write", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "description": "Required on every update, even when you are only changing the conditions. Read the filter first and resend its current name rather than inventing one \u2014 the value you send is what it is renamed to." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Required on every update, but not applied: a saved view's list type cannot be changed, and a different value returns success while changing nothing. Resend the filter's existing entity_type. An unlisted value returns 400 'Invalid JSON data'." + }, + { + "name": "is_project_level", + "type": "boolean", + "description": "Forwarded whenever the key is present, including `false` \u2014 this is how you demote a project-wide view back to private. Omit the key to leave the current scope alone." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement condition set, in the same UI filter-state shape as create_filter's `filters` (systemFields option objects, extraSystemFields.issue_type, matchAllMatchAny) \u2014 send the whole set, not just the change. Omit to leave the stored conditions untouched. `{}` is treated as absent, so conditions cannot be cleared to empty through this call." + } + ], + "intent": "Rename a saved filter view, replace its conditions, or switch it between private and project-wide \u2014 resending name and entity on every call", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterUpdateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a POST, not a PATCH or PUT, and it is only half a partial update: `name` and `entity` are required by the request-body schema on every call, while `is_project_level` and `filters` are forwarded only when you send them. Read the filter first and resend name + entity_type unchanged.", + "The body is flat and closed \u2014 an extra key returns 400 'Invalid JSON data' before the handler runs.", + "`filters: {}` is dropped as blank, so there is no way to empty a view's conditions here; delete and recreate instead.", + "Saved filter views keep no version history, so an overwritten name or condition set cannot be restored. Read the filter before you overwrite it if you may need the old value.", + "Changing is_project_level to true publishes a previously private view to the whole project immediately.", + "An unknown filter_id comes back as 400 `{success:false, message:...}`, not 404 \u2014 this action only translates the backing service's 400, and every other failure from it becomes a 500.", + "Not permission-gated: the filter controller has no RBAC entry, so a project-level view can be edited by anyone with product access to the project, not only its owner." + ] + }, + { + "name": "update_latest_test_result_for_test_case", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "configuration_id", + "type": "string" + }, + { + "name": "mapping_id", + "type": "string" + } + ], + "body": [ + { + "name": "status_id", + "type": "integer", + "description": "Result status id (resolve via the statuses-and-states concept \u2014 this path takes ids, not names; a `status` name is accepted by the filter and then discarded).", + "json_path": "/test_result/status_id" + }, + { + "name": "description", + "type": "string", + "description": "Rich text note on the result. Links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent.", + "json_path": "/test_result/description" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom field values keyed by custom field id (not by field name \u2014 that is the v2 contract).", + "json_path": "/test_result/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects. Each entry is an OBJECT \u2014 a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_result/issues" + }, + { + "name": "attachments", + "type": "array", + "example": [ + 10288829 + ], + "description": "Integer blob ids from a prior generic attachment upload \u2014 not filenames and not file bytes.", + "json_path": "/test_result/attachments" + }, + { + "name": "elapsed_time", + "type": "integer", + "description": "Accepted by the parameter filter and then DROPPED before the write on this path \u2014 setting it here does nothing and reports no error.", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration's latest result to patch. TOP level \u2014 the server does not read it from inside `test_result`." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target a specific run/case mapping. Top level." + } + ], + "intent": "Amend the most recent result logged for a test case in a run \u2014 correct its status, note, linked defects or custom fields in place, without adding another attempt to the case's history", + "returns": [ + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "custom_fields_count", + "test_run_step_result", + "attachments", + "issues", + "links" + ], + "responses": { + "200": { + "description": "Test result successfully updated", + "schema": { + "$schema": "TestResult" + } + }, + "400": { + "description": "Bad request - Test run is closed or invalid parameters", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "status": { + "type": "integer", + "example": 400 + }, + "error": { + "type": "string", + "example": "Cannot Update Closed Test Run" } - }, - "Project__ListProjects": { - "type": "object", - "description": "Project as returned by `list_projects`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Project" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "repository": { - "type": "string", - "example": "/api/v1/projects/1234/repository" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234" - }, - "test_plans": { - "type": "string", - "example": "/api/v1/projects/1234/test-plans" - }, - "test_runs": { - "type": "string", - "example": "/api/v1/projects/1234/test-runs" - }, - "users": { - "type": "string", - "example": "/api/v1/projects/1234/users" - } - } - }, - "duplicates_tc_count": { - "type": "integer", - "example": 0 - }, - "jira_mapped": { - "type": "boolean", - "example": false - }, - "normalisedName": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "projectVisibilityBanner": { - "type": "string", - "nullable": true - }, - "starred": { - "type": "boolean", - "example": false - }, - "test_cases_count": { - "type": "integer", - "example": 272 - }, - "test_plans_count": { - "type": "integer", - "example": 75 - }, - "test_runs_count": { - "type": "integer", - "example": 99 - }, - "thProjectId": { - "type": "integer", - "example": 1234 - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No test results found", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "status": { + "type": "integer", + "example": 404 + }, + "message": { + "type": "string", + "example": "No test results found" } - }, - "TestPlanV2__CreateSubTestPlan": { + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This EDITS the latest existing result; it never creates one. With nothing logged yet it answers 404 {success:false, message:'No test results found'} \u2014 log a result first, then amend it.", + "A closed test run is refused with 400 {success:false, error:'Cannot Update Closed Test Run'} before any field is written. A closed run cannot be reopened, so its results are final \u2014 record new results in a clone of the run instead.", + "Send the fields flat. The `test_result` wrapper is added for you from each field's json_path, and sending your own `test_result` object is rejected before the call as an unknown body key. Sending no fields at all is legal and re-saves the current values unchanged.", + "Only description, status_id, custom_fields, attachments and issues survive to the write. `status` (the name) and `elapsed_time` are accepted by the parameter filter and then dropped on this path \u2014 set the status by id here, and use the v2 result surface if you only have a status name.", + "Keys you omit keep their current values (the handler re-reads the result and fills them in), so this is a genuine partial update.", + "configuration_id and mapping_id are read at the TOP level \u2014 query string or body root, never from inside `test_result`. Without one of them 'latest' means latest across all configurations, which on a configuration matrix is usually not the row you meant.", + "Unlike the id-addressed result update, this path applies NO authorship check \u2014 only the standard project permission gate runs, so it will overwrite a result logged by someone else. The edit is in place and the previous values are not recoverable; log a new result instead if you want the earlier outcome preserved.", + "The 200 body is the bare updated result object at the TOP level \u2014 no `data` envelope and no `success` key, unlike the create-result endpoint on the same path." + ], + "disabled": true + }, + { + "name": "update_project_settings", + "method": "POST", + "path": "/api/v1/projects/{project_id}/settings", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Turn a project's configuration flags on or off \u2014 review/approval gating, the custom test-case id prefix, test-case sharing, the manual-execution timer, Part 11 re-authentication, Jira/Azure linking and test-plan review", + "responses": { + "200": { + "description": "Written settings echoed back as one object per setting. A body containing no recognised setting name also returns 200, with success:false and an empty array \u2014 nothing was written.", + "schema": { + "type": "object", + "required": [ + "success", + "project_settings" + ], + "properties": { + "success": { + "type": "boolean", + "description": "False (with HTTP 200) when nothing was written \u2014 including when the body contained no recognised setting name." + }, + "project_settings": { + "type": "array", + "description": "One single-key object per setting written, {settingName: value}. Note this differs from the read, which returns a single flattened object at index 0.", + "items": { + "type": "object" + } + } + } + } + }, + "400": { + "description": "Invalid test_case_id_prefix (format, reserved, or already used elsewhere) or an unknown review-status token." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Timer settings written without the project-settings write permission." + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "New project settings not enabled for this group/user, or a plan gate (Team Pro / Enterprise) not met." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Settings are sent as FLAT top-level body keys, one per setting \u2014 there is no wrapper object and no generic {key, value} pair. Only the names listed below are read; everything else is ignored.", + "The silent no-op to watch for: if the body contains none of the known setting names, the request still returns HTTP 200 with {success:false, project_settings:[]} and nothing is written. A 200 here is not confirmation \u2014 check `success` and check that the setting you sent comes back in project_settings.", + "This is a partial update: only the keys you send are touched, and the others keep their stored values. It does NOT reset omitted settings.", + "There is no undo and no history \u2014 the previous value is gone the moment the write lands. Re-read get_project_settings first if you need to be able to restore it.", + "Feature-gated for the whole endpoint: without the new-project-settings flag for the group or user the call fails 422 'Feature not available' before any key is examined.", + "Per-setting gates, all raised before the write: review_approve and the two eligible-review-status settings need a Team Pro plan (422); part_11_reauth and part_11_reauth_send_for_review need an Enterprise plan (422); the four timer settings need the project-settings write permission (403 'Only admins can update Time Tracking settings').", + "test_case_id_prefix is validated twice \u2014 locally against 2-10 uppercase alphanumerics starting with a letter and not equal to 'TC', then against the backing service for reserved prefixes and cross-project collisions. Either failure returns 400 with the reason, and it is checked before anything else in the body is written.", + "The two eligible-review-status settings take a comma-separated list drawn from approved / rejected / in_review / pending / changes_requested. An unknown token fails the whole request with 400; an empty string is valid and means gating is OFF.", + "Values are stored as strings in an EAV table, so send booleans as the strings \"true\"/\"false\" and numbers as numeric strings; that is also how they read back.", + "The response echoes one object PER setting written ([{name: value}, ...]) \u2014 a different shape from the read, which flattens everything into a single object at project_settings[0].", + "Removing a setting entirely is a different operation (a delete on the same path with the attribute name) and is not exposed as a capability here; writing an empty string is not the same as unsetting it." + ], + "body": [ + { + "name": "review_approve", + "type": "string", + "description": "Enable the test-case review/approval workflow. Requires a Team Pro plan \u2014 otherwise 422 'Feature not available'. Send \"true\"/\"false\" as strings." + }, + { + "name": "test_run_eligible_review_statuses", + "type": "string", + "description": "Comma-separated review statuses a test case must be in before it can be added to a run. An unknown token fails the whole request with 400. Empty string = no gating. Requires a Team Pro plan." + }, + { + "name": "test_execution_eligible_review_statuses", + "type": "string", + "description": "Comma-separated review statuses a test case must be in before results can be logged against it. Same validation and plan gate as the run variant." + }, + { + "name": "default_test_case_approval_state", + "type": "string", + "description": "The review state newly created test cases start in." + }, + { + "name": "test_case_id_prefix", + "type": "string", + "description": "Custom identifier prefix for this project's test cases. 2-10 characters, uppercase letters and digits, must start with a letter, and cannot be \"TC\". Also checked against reserved prefixes and other projects' prefixes; either check failing returns 400 with the reason." + }, + { + "name": "tc_sharing", + "type": "string", + "description": "Allow this project's test cases to be shared into other projects." + }, + { + "name": "part_11_reauth", + "type": "string", + "description": "Require re-authentication for regulated (21 CFR Part 11) actions. Requires an Enterprise plan \u2014 otherwise 422." + }, + { + "name": "part_11_reauth_send_for_review", + "type": "string", + "description": "Require re-authentication specifically when sending for review. Requires an Enterprise plan \u2014 otherwise 422." + }, + { + "name": "preferred_jira_host", + "type": "string", + "description": "Default Jira site URL used when linking issues from this project." + }, + { + "name": "preferred_azure_org", + "type": "string", + "description": "Default Azure DevOps organisation used when linking work items from this project." + }, + { + "name": "sync_jira_defect_attachments", + "type": "string", + "description": "Copy attachments to the linked Jira defect when one is raised." + }, + { + "name": "link_jira_requirements", + "type": "string", + "description": "Enable linking Jira requirements from this project." + }, + { + "name": "link_jira_test_case_requirement", + "type": "string", + "description": "Enable linking a Jira requirement to a test case." + }, + { + "name": "link_jira_test_run_requirement", + "type": "string", + "description": "Enable linking a Jira requirement to a test run." + }, + { + "name": "jira_create_allowed_hosts", + "type": "string", + "description": "Allow-list of Jira site URLs that may create or update requirements, as a JSON-ENCODED ARRAY STRING (e.g. the JSON text of an array of site URLs). Omitting the key means all hosts are allowed; the string \"[]\" means none." + }, + { + "name": "time_tracking_enabled", + "type": "string", + "description": "Per-project master switch for the manual-execution timer; the feature is on only when this is exactly \"true\". Requires the project-settings write permission \u2014 403 otherwise." + }, + { + "name": "timer_auto_start", + "type": "string", + "description": "Start the execution timer automatically when a tester opens a case. Requires the project-settings write permission." + }, + { + "name": "timer_elapsed_required", + "type": "string", + "description": "Require an elapsed time before a result can be submitted. Requires the project-settings write permission." + }, + { + "name": "timer_min_threshold_seconds", + "type": "string", + "description": "Minimum elapsed seconds before a result is accepted, as a numeric string. Requires the project-settings write permission." + }, + { + "name": "test_plan_review_enabled", + "type": "string", + "description": "Enable the review workflow for test plans." + }, + { + "name": "test_plan_review_sub_plan_mode", + "type": "string", + "description": "How sub-plans participate in test-plan review." + }, + { + "name": "test_plan_review_default_state", + "type": "string", + "description": "The review state newly created test plans start in." + } + ], + "returns": [ + "success", + "project_settings" + ] + }, + { + "name": "update_report", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/reports/{report_id}", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "example": 10000, + "description": "Integer project id the rewritten filter rows are filed under. Not enforced by the server: omit it and rows are written against project 0 and become invisible, at 200. Send the report's own project id." + }, + { + "name": "project_ids", + "type": "array", + "example": [ + 10000, + 10001 + ], + "description": "Integer project ids for a cross-project report. Only applied when present \u2014 omitting it leaves the stored set alone. Max 60 (5 for User Workload Report), and multi-project only for the seven supported types; the cap is checked against the report_type in THIS body." + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary", + "description": "Report type as its DISPLAY NAME. Required, and it also selects which filter-rewrite branch runs \u2014 omit it and the report_type column is nulled AND the filters are silently left untouched. `Defects Summary` / `Defects Detailed Report` have no data branch and no filter branch." + }, + { + "name": "title", + "type": "string", + "example": "Updated Title", + "description": "Written unconditionally \u2014 omit it and the title is set to null." + }, + { + "name": "description", + "type": "string", + "example": "", + "description": "Written unconditionally \u2014 omit it and the description is set to null." + }, + { + "name": "report_timeframe", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "The window the report covers. `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500); any other value NULLS the stored custom_date_range. The five entity timeframes carry no dates and each reads one matching `report_filters` key." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "example": "custom_report", + "description": "Written unconditionally. Unlike create, `system_generated` here does NOT re-fetch requirements for a traceability report \u2014 send `report_filters.requirements` yourself." + }, + { + "name": "is_dynamic", + "type": "boolean", + "description": "Written unconditionally from the body \u2014 omit it and the report's dynamic flag is set to null (unlike create, it is not inferred from the filters)." + }, + { + "name": "grain", + "type": "string", + "values": [ + "test_case", + "test_plan" + ], + "description": "Immutable after creation. Send the report's persisted value or omit it; anything different is 400 'grain cannot be changed after report creation'." + }, + { + "name": "test_run", + "type": "object", + "fields": [ + { + "name": "created_at", + "type": "string", + "required": true + }, + { + "name": "status", + "type": "array", + "required": true + }, + { + "name": "owner", + "type": "array", + "required": true + }, + { + "name": "automation_status", + "type": "array", + "required": true + }, + { + "name": "type", + "type": "array", + "required": true + } + ], + "json_path": "/dynamic_filters/test_run", + "description": "Dynamic run selection, accepted under either `dynamic_filters` or `filters`. Only rewritten for a Requirement Traceability Report; on other types it is read but the JQL config is not updated." + }, + { + "name": "status", + "type": "array", + "json_path": "/report_filters/status" + }, + { + "name": "priority", + "type": "array", + "json_path": "/report_filters/priority" + }, + { + "name": "case_type", + "type": "array", + "json_path": "/report_filters/case_type" + }, + { + "name": "assignee", + "type": "array", + "json_path": "/report_filters/assignee" + }, + { + "name": "automation_status", + "type": "array", + "json_path": "/report_filters/automation_status" + }, + { + "name": "automation_state", + "type": "array", + "json_path": "/report_filters/automation_state" + }, + { + "name": "test_runs", + "type": "array", + "description": "Run ids as INTEGERS \u2014 a quoted id or TR-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored run selection.", + "json_path": "/report_filters/test_runs" + }, + { + "name": "test_plans", + "type": "array", + "description": "Plan ids as INTEGERS \u2014 a quoted id or TP-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored plan selection.", + "json_path": "/report_filters/test_plans" + }, + { + "name": "test_cases", + "type": "object", + "description": "Case selection \u2014 must be an OBJECT (an array is 400), folder-keyed like SelectionData: folder ids are STRING keys, case ids INTEGERS. Read the current value from get_selected_report_testcases and send it back; omitting the key deletes the saved selection.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "required": true + }, + { + "name": "folders", "type": "object", - "description": "TestPlanV2 as returned by `create_sub_test_plan`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestPlanV2" - } - ], - "properties": { - "parent_plan_id": { - "type": "string", - "example": "TP-1616" - } + "required": true + }, + { + "name": "unique_test_case_count", + "type": "integer", + "required": true + } + ], + "json_path": "/report_filters/test_cases" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cross-project case selection: { \"\": } for non-primary projects. A project id outside the report's project set is dropped silently.", + "json_path": "/report_filters/shared_selection" + }, + { + "name": "session_ids", + "type": "array", + "description": "Exploratory session ids \u2014 pairs with report_timeframe=specific_sessions.", + "json_path": "/report_filters/session_ids" + }, + { + "name": "requirements", + "type": "object", + "description": "{ issue_type: [issue_id, ...] } \u2014 must be an object, not an array. Pairs with report_timeframe=requirements and is NOT auto-populated on edit even for system_generated reports.", + "json_path": "/report_filters/requirements" + }, + { + "name": "test_plan_selection", + "type": "object", + "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. A malformed entry is discarded silently rather than rejected.", + "json_path": "/report_filters/test_plan_selection" + }, + { + "name": "test_runs_by_project", + "type": "object", + "description": "Cross-project run selection: { \"\": [run_id, ...] }. Numeric-string keys and numeric-id arrays only, else 400. Replaces the flat `test_runs` key when present.", + "json_path": "/report_filters/test_runs_by_project" + }, + { + "name": "test_plans_by_project", + "type": "object", + "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Replaces the flat `test_plans` and `test_plan_selection` keys when present.", + "json_path": "/report_filters/test_plans_by_project" + }, + { + "name": "builds", + "type": "array", + "json_path": "/report_filters/builds", + "description": "Internal build ids paired with `test_runs`. The report read returns them as `build_ids`; send them back unchanged or the paired rows are wiped on the next write." + }, + { + "name": "projects", + "type": "array", + "json_path": "/report_filters/projects" + }, + { + "name": "folder", + "type": "array", + "json_path": "/report_filters/folder" + }, + { + "name": "test_case_tags", + "type": "array", + "json_path": "/report_filters/test_case_tags" + }, + { + "name": "tc_custom_fields", + "type": "object", + "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity`; elsewhere it is dropped silently. Persisted and read back under the name `custom_fields`.", + "json_path": "/report_filters/tc_custom_fields" + }, + { + "name": "custom_date_range", + "type": "string", + "example": "2025-04-16 2025-04-24", + "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED when report_timeframe is `custom` (omitting it is a 500); NULLED on the record for every other timeframe." + }, + { + "name": "users", + "type": "array", + "description": "TM user ids to mail the report to. Every id must be in your group or the request is 400. Only applied when the report is already scheduled.", + "json_path": "/mail_to/users" + }, + { + "name": "external_mails", + "type": "array", + "description": "Email addresses outside the group. Only applied when the report is already scheduled.", + "json_path": "/mail_to/external_mails" + }, + { + "name": "frequency", + "type": "string", + "values": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly", + "description": "Send with `frequency_details` to keep or change the schedule. Omitting EITHER unschedules the report \u2014 both columns are nulled and it stops emailing. `once` is not a value: it passes validation and then raises a 500." + }, + { + "name": "time", + "type": "string", + "example": "08:00", + "description": "24-hour HH:MM, UTC. Required whenever frequency is sent.", + "json_path": "/frequency_details/time" + }, + { + "name": "day", + "type": "string", + "example": "monday", + "description": "Required for weekly (lowercase English day name) and monthly (integer 1-28). Omit for daily.", + "json_path": "/frequency_details/day" + } + ], + "intent": "Rewrite a saved report \u2014 its type, window, filters, schedule or recipients \u2014 replacing the whole definition in one call", + "returns": [ + "data", + "report_data", + "report_filters", + "dynamic_filters" + ], + "responses": { + "200": { + "$response": "UpdateReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This PATCH is a FULL REPLACE, not a merge. `title`, `description`, `report_type`, `report_timeframe`, `report_creation_mode` and `is_dynamic` are written from the body unconditionally, so any one you omit is set to NULL. Read the report first and send the complete object back with your change applied.", + "Omitting `frequency` / `frequency_details` UNSCHEDULES the report: both columns are nulled and it stops emailing. Resend the persisted values to keep the schedule. Likewise `custom_date_range` is nulled whenever `report_timeframe` is not `custom`.", + "`projectId` in the body is effectively required even though it is not marked so: it is the project every rewritten filter row is filed under. Omit it and the rows are written against project 0, which no read ever finds \u2014 the call still returns 200 with a report whose filters have silently vanished.", + "`report_filters` is authoritative: a key you omit has its stored rows deleted. Send the full filter set, not a delta. Use the report read and get_selected_report_testcases to recover the current values first.", + "Filters are only rewritten for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Plan Detailed Report, Test Case Activity, User Workload Report, Requirement Traceability Report and Exploratory Session Summary. On `Defects Summary` / `Defects Detailed Report`, or when `report_type` is omitted, the filter update is skipped entirely and `report_filters` comes back null \u2014 at 200.", + "There is no undo and no version history. The previous definition and its filter rows are gone once this returns.", + "`grain` cannot be changed after creation \u2014 a differing value is 400. Resending the persisted value is accepted.", + "`mail_to` is only applied when the report is already scheduled AND `mail_to` is present in the body; on an unscheduled report recipients are ignored.", + "The response is NOT the create envelope: there is no `success` key. The report is at `data`, the computed figures at `report_data`, the resolved filters at `report_filters`. `report_data` is computed inline here, so a large report makes this call slow.", + "The report is resolved by id within your group \u2014 the project in the path does not scope the lookup, so a report belonging to a sibling project still resolves (404 `Schedule not found` only when no such report exists in the group). Requires the report_update permission.", + "The same cross-project caps as create apply, and they are re-checked against the incoming `report_type`: 60 projects overall, 5 for User Workload Report, and multi-project only for the seven supported types." + ] + }, + { + "name": "update_shared_step", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "write", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Required on every call even when you are only changing steps. Read the shared step first and resend its current title; whatever you send becomes the new title." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Shared-field folder to move the block into. Forwarded only when the key is present, so omitting it leaves the block where it is; send null to move it to the root (Unassigned)." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "description": "Plain tag name strings; the list you send replaces the block's tags. The key is always forwarded, so omitting it sends a null tags value rather than preserving the current tags \u2014 resend the existing tags if you want to keep them." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The FULL replacement step list, not a patch. Rows you leave out are removed from the block. Required \u2014 omitting the key fails with a flat 400 'Validation Error'.", + "fields": [ + { + "name": "id", + "type": "integer", + "description": "Existing step-row id, from a prior read. Rows sent with an id are updated in place and keep their identity; rows sent without one are created fresh, which breaks the shared_step_detail_id that referencing test-case steps already hold." + }, + { + "name": "order", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Required here too, not optional: local validation demands order >= 1 on every row, so a row sent without an order fails the whole update." + }, + { + "name": "step", + "type": "string", + "description": "Step body, HTML. Each row must carry at least one of step or result." + }, + { + "name": "result", + "type": "string", + "description": "Expected result, HTML. Each row must carry at least one of step or result." + }, + { + "name": "test_data", + "type": "string" + } + ] + } + ], + "intent": "Replace a shared step's title, folder and its whole ordered step list \u2014 the edit lands at once in every test case that embeds the block", + "returns": [ + "success", + "data", + "id", + "title", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$schema": "SharedStepsIDResponse__UpdateSharedStep" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Blast radius first: a shared step is embedded by reference, so this edit changes the steps rendered by every test case that embeds the block \u2014 immediately, without touching or versioning those cases. Read the block (or find it in the listing) and check `test_case_count` before you write: that is how many test cases you are about to change.", + "shared_step_details is a full replacement, not a merge. Any existing row you omit is deleted, and deleting a row orphans the `shared_step_detail_id` that referencing test-case steps point at. Read the current rows, edit that array, send it back whole.", + "Rows carrying their existing `id` are updated in place; rows without one are created new. Dropping the ids and resending the same text rebuilds every row with fresh ids and breaks every reference into the block.", + "It is a PATCH but not a partial one: title and shared_step_details are both required on every call, and an omitted `tags` is sent as null rather than left alone.", + "Shared steps keep no version history \u2014 only test cases, test plans and builds do \u2014 so there is no undo for an overwritten title or step list, and no restore-previous-version call to fall back on.", + "The handler reads the block before writing, so a wrong or deleted shared_step_id returns 404 `{data:null, success:false}` before anything changes.", + "Every local validation failure is the same opaque 400 `{data:null, success:false, message:\"Validation Error\"}` \u2014 no field name. The usual causes are a missing shared_step_details, a row with no order (or order 0), or a row with neither step nor result.", + "Requires tc_write." + ] + }, + { + "name": "update_template", + "method": "PATCH", + "path": "/api/v2/templates/{id}", + "mode": "write", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id \u2014 a plain integer with no prefix, discovered from the template list.", + "pattern": "^[0-9]+$" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "description": "New template name. Only applied when the key is present; omit it to leave the name alone.", + "minLength": 1 + }, + { + "name": "enabled", + "type": "boolean", + "description": "true re-activates the template, false disables it. Read as truthiness: send a real JSON boolean, because the STRING \"false\" is truthy and ENABLES the template. Disabling is reversible and is the alternative to deleting." + }, + { + "name": "is_default", + "type": "boolean", + "description": "Make this the workspace default template for its entity type. Setting a new default displaces the current one; the system default template cannot be disabled or deleted." + }, + { + "name": "description_fields", + "type": "array", + "description": "REPLACES the description section wholesale \u2014 it is not merged. Anything you leave out is removed from the template. Read the template first and resend every field you want to keep.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + }, + { + "name": "properties_fields", + "type": "array", + "description": "REPLACES the properties section wholesale. Sending this key also silently re-adds the Review & Approve system fields (reviewers, review status), so the stored set is a superset of what you sent and those two cannot be removed by omission.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + } + ], + "intent": "Rename a template, enable or disable it, make it the workspace default, or replace its description and property field layout.", + "guidance": [ + "A name-only PATCH works and returns a bare {success:true} - unlike the create, the update does NOT echo the template back, so re-read it if you need the result.", + "Sparse update: only the keys actually present in the body are touched, everything else keeps its current value. Sending an empty body is accepted and changes nothing.", + "Field arrays REPLACE, they do not merge. Templates have no version history in this product, so the previous layout cannot be restored \u2014 read the template, edit the returned arrays, and send them back whole.", + "Editing the field layout changes the fields shown on every test case using this template, in every linked project. Establish how many projects are affected before writing.", + "Sending properties_fields re-injects the Review & Approve system fields, and reads filter those same fields out, so a read-modify-write round-trip neither loses them nor can remove them. Do not report them as deleted.", + "enabled is evaluated for truthiness, so the string \"false\" enables rather than disables. Use a JSON boolean.", + "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is changed \u2014 check the flag, not the status code.", + "Success returns only success:true with no template body. Re-read the template to confirm the new layout.", + "Project links are not changed here; linking and unlinking projects is a separate operation on the template." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TemplateWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_test_plan_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/update", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/test_plan/name", + "description": "Plan name. EFFECTIVELY REQUIRED on any content edit: the server's edit branch is gated on name being present, so omitting it rejects the whole call with 400 'Invalid Update Params' however valid the rest is. Read the plan first and echo the current name back unchanged." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text; rewritten server-side, so the stored value can differ from what you sent. Dropped entirely when the same request also changes plan_status." + }, + { + "name": "start_date", + "type": "string", + "description": "ISO-8601 date. Only settable while the plan is still `new` \u2014 once started, the stored start date is used and this field is ignored. REQUIRED in the same request when moving plan_status to 'started'.", + "format": "date", + "json_path": "/test_plan/start_date" + }, + { + "name": "end_date", + "type": "string", + "description": "ISO-8601 date. Must not be earlier than the start date (400 'Invalid end_date, should be greater than start_date'). REQUIRED in the same request when moving plan_status to 'completed'.", + "format": "date", + "json_path": "/test_plan/end_date" + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "Target status. Only moves forward one step: new -> started -> completed. Sending a value that differs from the current one switches the call into transition mode, in which EVERY other content field in the body is discarded.", + "json_path": "/test_plan/plan_status" + }, + { + "name": "owner", + "type": "integer", + "description": "Owner as the internal TM user id (the `id` from the project users read \u2014 not a BrowserStack user id or email). An id that does not resolve inside the caller's group clears the owner instead of erroring. Only applied on a content edit, never alongside a status transition.", + "json_path": "/test_plan/owner" + }, + { + "name": "parent_plan_id", + "type": "integer", + "description": "Re-parent a SUB-plan under a different parent plan (integer id). It CANNOT convert a top-level plan into a sub-plan (400 'Cannot update a parent test plan to a sub plan') nor promote a sub-plan to top level (400 'Cannot update a sub test plan to a parent plan'), and the move is only applied while the plan is still `new`.", + "json_path": "/test_plan/parent_plan_id" + }, + { + "name": "tags", + "type": "array", + "description": "Plain tag strings; the list you send REPLACES the plan's tags. Discarded when the same request changes plan_status.", + "json_path": "/test_plan/tags" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer user ids; the list REPLACES the current reviewers. Omit the key to leave reviewers untouched, send [] to clear them. A reviewers-only body is the one edit that does not need `name`.", + "json_path": "/test_plan/reviewers" + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment ids already uploaded via the attachments surface. An attachment that does not verify returns 422 with the verifier's own body rather than the usual envelope.", + "json_path": "/test_plan/attachments" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom-field values keyed as the test-plan form-fields read returns them. Unlike the other content fields, these ARE applied even alongside a plan_status transition. A bad key or value is 400 {success: false, error: }.", + "json_path": "/test_plan/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked tracker issues. Structured \u2014 NOT a flat array of keys. Only url and ticket_id survive inside metadata. Linking is processed on a background job, so the plan echoed back may not show them yet.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_plan/issues" + }, + { + "name": "test_runs", + "type": "array", + "description": "The plan's linked test runs, as run INTEGER `uuid`s. This REPLACES the whole link set rather than appending; [] unlinks everything; omitting the key leaves the linkage alone. Applied even alongside a status transition.", + "json_path": "/test_plan/test_runs" + } + ], + "intent": "Edit a test plan or sub-plan \u2014 rename it, change its dates, owner, tags, custom fields or reviewers, move its status forward, or replace the set of test runs it groups", + "responses": { + "200": { + "description": "The updated plan, re-read and returned under a top-level `test_plan` key (no `data` wrapper) \u2014 same shape as the plan detail read.\n", + "schema": { + "$schema": "V1TestPlanDetail__UpdateTestPlanByIntegerId" + } + }, + "400": { + "description": "A local rule violation: `{success: false, error: ...}` where error is one of 'Invalid Update Params', 'Cannot update completed test plan', 'Cannot revert started test plan', 'Cannot directly complete test plan', 'Cannot update a parent test plan to a sub plan', 'Cannot update a sub test plan to a parent plan', 'Invalid start_date', 'Invalid end_date', 'Invalid end_date, should be greater than start_date', 'Invalid field plan_status', 'Invalid Test Plans Params'. Also a rejected custom field.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" } - }, - "V1ProjectDetail__GetProjectByIntegerId": { - "type": "object", - "description": "V1ProjectDetail as returned by `get_project_by_integer_id`. Composes V1ProjectDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1ProjectDetail" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "project": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "PR-NNN identifier — the v2 {project_id}. (Integer -> PR-NNN translation output.)", - "example": "PR-1" - }, - "id": { - "type": "integer", - "description": "Integer project id — the value passed in the path. Same as thProjectId.", - "example": 11046 - }, - "thProjectId": { - "type": "integer", - "example": 11046 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "description": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "nullable": true - }, - "test_runs_count": { - "type": "integer", - "nullable": true - }, - "test_plans_count": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "starred": { - "type": "boolean" - }, - "jira_mapped": { - "type": "boolean" - }, - "links": { - "type": "object" - }, - "import_id": { - "type": "integer", - "nullable": true, - "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." - }, - "normalisedName": { - "type": "string", - "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." - }, - "projectVisibilityBanner": { - "type": "object", - "nullable": true, - "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." - }, - "permissions": { - "type": "string", - "example": "project_write" - }, - "user_role": { - "type": "string", - "example": "Admin" - } - } - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Upstream rejected the update (for example the review gate on completing an unapproved plan): `{success: false, error_code, error}`. Attachment verification failures also land on 422 with the verifier's own body.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error_code": { + "type": "string" + }, + "error": { + "type": "string" } - }, - "UsersV2Response__ListProjectUsers": { - "type": "object", - "description": "UsersV2Response as returned by `list_project_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "UsersV2Response" - } - ], - "properties": { - "users": { - "type": "array", - "items": { - "$schema": "User__ListProjectUsers" - } - } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Everything goes inside a `test_plan` object. A flat body is rejected before any field is read.", + "name IS REQUIRED on every content edit, including one that only touches test_runs or tags. Omit it and the call is 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt.", + "A status transition and a content edit are mutually exclusive branches, not one merged write. When plan_status differs from the plan's current status, ONLY the dates and the new status are applied \u2014 name, description, tags, issues, owner and parent_plan_id in the same body are silently discarded, with a 200 and no warning. Rename first, then transition.", + "Moving to 'started' requires a valid start_date in the SAME request (400 'Invalid start_date'); moving to 'completed' requires end_date (400 'Invalid end_date'). The plan's stored dates are not used as a fallback.", + "Status only moves forward one step: new -> started -> completed. new -> completed is 400 'Cannot directly complete test plan' and started -> new is 400 'Cannot revert started test plan'.", + "A completed plan is FROZEN \u2014 even a rename is refused with 400 'Cannot update completed test plan'. Complete it last.", + "Run ids in test_runs are the INTEGER `uuid` from a run read, NOT the TR-NNN string that every run endpoint puts in `id`. And linking MOVES a run: a run belongs to at most one plan, so linking silently unlinks it from whichever plan held it, with nothing in the 200 to say so. Read the runs' current membership first.", + "test_runs REPLACES the link set \u2014 send the full list you want, not just the additions. An empty array unlinks every run.", + "parent_plan_id cannot promote or demote: a top-level plan can never become a sub-plan and a sub-plan can never become top-level. Moving a sub-plan between parents only takes effect while the plan is still `new`.", + "Plans keep no version history. An overwritten name, description, date or tag list is gone for good \u2014 there is no prior revision to restore.", + "Upstream rejections (for example the review gate that blocks completing an unapproved plan) come back as 422 {success: false, error_code, error}, while the local rule violations above are 400 {success: false, error}.", + "The success body is the freshly re-read plan under a top-level `test_plan` key \u2014 the same shape as the plan detail read, with no `data` wrapper." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "assignee", + "created_by", + "test_runs_count", + "total_test_runs_count", + "links" + ] + }, + { + "name": "update_test_run", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' \u2014 it is not skipped." + }, + { + "name": "run_state", + "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it. The partial update: on a closed run it answers 404 'Test Run ID is invalid' rather than a clear refusal.", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "json_path": "/test_run/run_state" + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings; the list you send replaces the run's tags." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read. Duplicates are collapsed." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^$|^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^$|^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "The run's COMPLETE intended case list \u2014 NOT a list to add. Any case already in the run and missing from this list is removed, with its run row, assignee and results; the run's own fields (owner, tags, description) are kept. To ADD cases, use bulk_update_test_run_test_cases with add_test_cases instead \u2014 it is the only truly additive call. If you must use this field, send the run's current case identifiers (list_test_run_test_cases) plus the new ones: retained cases then keep their run row id and assignee, and only the new cases are inserted." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders" + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list \u2014 cases you list elsewhere are still included, with a null-configuration row." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "json_path": "/test_run/filter_scope", + "description": "Accepted by the parameter filter but NOT honoured on either update path \u2014 the selection is always built in global mode here. Only create reads it." + }, + { + "name": "shared_test_cases", + "type": "array", + "fields": [ + { + "name": "project_id", + "type": "integer" + }, + { + "name": "test_case_ids", + "type": "array" + } + ], + "json_path": "/test_run/shared_test_cases", + "description": "Cases shared in from other projects, as [{project_id, test_case_ids: [...]}]. Only this partial-update call resolves them; the full-replace call on the same run accepts the key and ignores it." + }, + { + "name": "is_automation", + "type": "boolean", + "json_path": "/test_run/is_automation", + "description": "Only honoured on this partial-update call (and only for the literal values true/false); the full-replace call on the same run ignores it. It turns an existing manual run into an automation run \u2014 this is the only way to get one, since both run creates ignore the flag. The reply carries no is_automation, so confirm with the v1 run read." + } + ], + "intent": "Change just the fields you name on a test run \u2014 rename it, move its state on, link or unlink a test plan, attach configurations or a dynamic filter \u2014 leaving everything you omit untouched", + "guidance": [ + "Two different operations share this path. This PATCH applies only the keys present in `test_run`; the POST rebuilds the whole payload and resets what you omit. This is the safe default for a targeted edit.", + "Unlinking is explicit: send test_plan_id (or sub_test_plan_id) as null or \"\" to detach the plan. Omitting the key keeps the current link.", + "run_state is validated here: an unknown value returns 400 echoing it. Both 'new_run' and 'new' are accepted. Omit the key to leave the state alone.", + "is_dynamic lives inside filter_test_cases and nowhere else. true needs at least one other criterion in the same object or the call 400s; false flips a dynamic run back to static; omitting it leaves the flag as it is.", + "Composition keys interact: sending only configurations re-reads the run's existing case selection so the case list survives, and sending selection keys without configurations re-reads the existing configuration list. Send neither and the run's composition is left completely untouched.", + "Case membership is REPLACED, not unioned: the selection you send becomes the run's whole case list, and any case missing from it is removed with its run row, assignee and results \u2014 with a 200. Only the run's own fields follow the partial rule (omitted owner, tags and description are kept). To add cases use bulk_update_test_run_test_cases (add_test_cases); if you use this call anyway, read the current cases and send existing + new, then re-read to confirm the count.", + "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "There is no undo: test runs keep no version history, so an overwritten name, description or tag list cannot be restored.", + "The updated run comes back at the top level as `testrun` \u2014 one word, no underscore, and no `data` envelope. This differs from the read endpoints, which use `test_run`.", + "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", + "`urls.self` is a link to the run in the web UI, not an API path \u2014 it is built from the frontend base URL, so calling it returns HTML.", + "`assignee` comes back as a plain email string \u2014 or null when the owner cannot be resolved \u2014 and `configurations` as a list of configuration ids. Neither is a list of objects.", + "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", + "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", + "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", + "The run's case list is not on this reply \u2014 `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", + "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write, and `tags` can come back [] while the stored tags are unchanged. Re-read the run when the exact stored values matter.", + "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunWriteV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "upload_ai_attachments", + "method": "POST", + "path": "/api/v1/projects/{project_id}/generic/attachments/ai_uploads", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Upload a document as AI context for a project \u2014 the same store as a generic upload but with the restricted type and size rules that the test-case generation flow requires", + "returns": [ + "id", + "name", + "content_type", + "download_url", + "size", + "url" + ], + "responses": { + "200": { + "$response": "GenericAttachmentsResponse" + }, + "400": { + "description": "A file failed the AI type allowlist or its per-type size cap. All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "NOT INVOCABLE through this interface. Identical handler to the generic upload: it reads `attachments` as an array of uploaded FILES from a multipart form, so the one input it needs cannot be expressed as a JSON argument and this record carries no body parameter.", + "With no files it returns 200 and `generic_attachment: []` rather than an error \u2014 a call from here looks successful and uploads nothing.", + "Only the path differs from the generic upload, and the difference is validation. Allowed types: pdf, txt, csv, json, rtf, doc, docx, xls, xlsx, pptx, jpeg, png. Anything else is rejected with 'Invalid file type for AI uploads'.", + "Size caps are PER TYPE, not the generic 50 MB: 500 KB for csv/txt/json/xls/xlsx, 5 MB for jpeg/png, 10 MB for pptx, 25 MB for pdf/doc/docx/rtf. Passing linked_jira_data=true replaces all of them with a flat 3 MB cap and files under a separate storage prefix.", + "Any text/* file that is byte-sniffed as something outside the allowlist (markdown read as text/html, for example) is silently normalised to text/plain and accepted.", + "On a real multipart upload the status is 200, not the 201 the spec claims. `id` is the blob id the test-case creation flow consumes; `url`/`download_url` are pre-signed for 30 minutes and `size` is a human-readable string." + ], + "disabled": true + }, + { + "name": "upload_generic_attachments", + "method": "POST", + "path": "/api/v1/projects/{project_id}/generic/attachments", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Upload a file to a project and get back its blob id plus pre-signed view/download URLs \u2014 the first half of the attach flow, needed before a file can be linked to a test case, result or rich-text field", + "returns": [ + "id", + "name", + "content_type", + "download_url", + "size", + "url" + ], + "responses": { + "200": { + "$response": "GenericAttachmentsResponse" + }, + "400": { + "description": "At least one file failed validation (size). All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "NOT INVOCABLE through this interface. The handler reads `attachments` as an array of uploaded FILES from a multipart form and streams their bytes to storage; a file cannot be expressed as a JSON argument, which is why this record carries no body parameter at all.", + "Worse than an error: with no files the handler takes the empty path and answers 200 with `generic_attachment: []`. A call from here looks like it succeeded and uploads nothing \u2014 do not report a successful upload on that response.", + "If you need a blob id, the file has to be uploaded out of band (the product UI, or a direct multipart request built by the caller). Once it exists, the blob id is what the test-case, test-result and step attach capabilities take.", + "On a real multipart upload the status is 200, not the 201 the spec claims, and the body is {generic_attachment: [...]}: `id` is the blob id to carry forward, `url` and `download_url` are pre-signed and expire after 30 minutes, and `size` is a HUMAN-READABLE string ('300 KB') rather than a byte count.", + "No type allowlist is enforced on this generic path; the only limit is 50 MB per file. SVG/HTML/XML uploads are served with a forced-download disposition rather than inline.", + "A single invalid file fails the whole request: every blob already written is deleted, and the 400 body reports only the LAST failing file's errors, under errors.message." + ], + "disabled": true + }, + { + "name": "link_attachments_to_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Link already-uploaded files to a test case by their blob ids \u2014 the second half of the two-step attach flow; it does not carry file content", + "returns": [ + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "description": "Attachments uploaded successfully", + "schema": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentResponse" + } + }, + "success": { + "type": "boolean" } - }, - "User__ListProjectUsers": { - "type": "object", - "description": "User as returned by `list_project_users`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DOES NOT WORK. A well-formed request returns a bare 500. The handler calls an attach method that the object it holds does not have, so the call raises before anything is stored. There is no request that makes it succeed \u2014 do not retry with a different body.", + "Despite the name and the published spec this is NOT a multipart file upload. The body is JSON and `attachments` is a list of INTEGER blob ids returned by a prior generic attachment upload. No file bytes cross this boundary.", + "Send the list flat. The `test_case` wrapper is added for you from the field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "Each blob must belong to the caller's group or to this project, and must not already be attached to anything. Reusing a blob that is attached elsewhere fails the WHOLE call with 422 'the attachment has been referenced elsewhere' \u2014 upload a fresh copy for each attachment point.", + "An id that resolves to no blob gives 422 'attachment not found'. An EMPTY array is accepted, does nothing, and still answers 200 with the case's existing attachments \u2014 it does not clear them.", + "The write is additive: the ids you list are added to whatever the case already has. Removal is the delete-attachment capability, one id at a time.", + "`folder_id` and `test_case_id` must both be integers or the route does not match; folder_id itself is not used to locate the case.", + "Success returns the case's attachments (id, filename, content_type, byte_size) at the top level alongside `success` \u2014 no `data` envelope." + ], + "body": [ + { + "name": "attachments", + "type": "array", + "required": true, + "json_path": "/test_case/attachments", + "example": [ + 10288829, + 10288830 + ], + "description": "Integer blob ids from a prior generic attachment upload \u2014 NOT filenames and NOT file content. Each blob must be unused; one already attached elsewhere fails the whole request with 422. An empty array is a silent no-op, not a clear." + } + ], + "disabled": true + }, + { + "name": "get_user_workload_drilldown", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/drilldown", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "user_id", + "type": "integer", + "required": true, + "example": 12345, + "description": "BrowserStack user id to break down \u2014 the id carried by the report's `user_table` rows, NOT the TM user id. Missing or <= 0 is 422. A Member-role caller may only pass their own id (403 otherwise)." + } + ], + "intent": "Break one person's workload out of a User Workload Report \u2014 the test cases they executed, the runs they took part in, and their day-by-day activity", + "paginated": false, + "responses": { + "200": { + "$response": "UserWorkloadDrilldownResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only works on a report whose stored `report_type` is `User Workload Report`. Any other report is 400 `Drill-down is only available for User Workload Reports`; an id not in your group is 404.", + "`user_id` is the BrowserStack user id, the same id the report's `user_table` rows carry \u2014 not the TM-internal user id. A missing or non-positive value is 422 (not 400), with `user_id is required for drill-down`.", + "A caller whose role is Member may only drill into themselves: any other `user_id` is 403 `Forbidden: cannot drill into another user`. Admins and above can drill into anyone.", + "It is a POST but it changes nothing \u2014 safe to call repeatedly. The window and run scope are taken from the REPORT's saved timeframe and filters; you cannot override them from the body.", + "Response is the analytics payload verbatim \u2014 there is no `success` flag and no envelope. Expect `tcs` (cases executed), `trs` (runs participated in) and `days` (daily timeline). An upstream failure surfaces as 422 `User Workload drill-down failed: ...`, so do not read an empty body as 'this person did nothing'.", + "NOT paginated: page and per-page values in the body are ignored \u2014 the full breakdown comes back in one response. Do not loop pages.", + "A report saved on a relative or custom timeframe that resolves to no dates falls back to a five-year window when it has specific runs selected, and is 422 otherwise.", + "Requires the report_view permission." + ], + "returns": [ + "tcs", + "trs", + "days" + ], + "disabled": true + }, + { + "name": "update_test_case", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id the v1 test-case endpoints take does NOT resolve here and comes back 404 'Test case not found'." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Checking the login functionality", + "json_path": "/test_case/name", + "description": "Case title. Omit to leave it alone; sending an empty or whitespace-only string is rejected 422 'Test case name cannot be empty' rather than clearing it. Note the response calls this field `title`, not `name`." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description", + "description": "Rich-text field. Links are stripped server-side, so the stored value can differ from what you sent." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions", + "description": "Rich-text field, same server-side link stripping as description." + }, + { + "name": "test_case_folder_id", + "type": "integer", + "example": 21, + "json_path": "/test_case/test_case_folder_id", + "description": "Move the case to this folder as part of the edit. This is the name the parameter filter accepts \u2014 `folder_id`, the name the response and the published schema use, is DROPPED here and the case silently stays put. Unlike the v1 edit path, a value equal to the current folder is not stripped before the write." + }, + { + "name": "priority", + "type": "string", + "example": "Medium", + "json_path": "/test_case/priority", + "description": "The project's display value for priority (e.g. 'Low', 'Medium', 'High') or that value's numeric field id. Matched on the display value first, then on the id; anything else fails the WHOLE call with 400 'Invalid default field value priority'. Resolve the allowed set from the project's system field values first \u2014 it is per project, not a fixed list." + }, + { + "name": "status", + "type": "string", + "example": "Active", + "json_path": "/test_case/status", + "description": "The project's display value for test-case status, or its numeric field id. Same matching and same 400 'Invalid default field value status' as priority. Not the run/result status \u2014 this is the case's own lifecycle field." + }, + { + "name": "case_type", + "type": "string", + "example": "Smoke & Sanity", + "json_path": "/test_case/case_type", + "description": "The project's display value for case type, or its numeric field id. Stored internally as the category, and a wrong value fails the whole call with 400 'Invalid default field value case_type'." + }, + { + "name": "automation_status", + "type": "string", + "values": [ + "not_automated", + "automated", + "automation_not_required", + "cannot_be_automated", + "obsolete" + ], + "example": "automated", + "json_path": "/test_case/automation_status", + "description": "Internal name, not a display label. Validated against this closed list before the request leaves the API with 400 'Invalid automation status', and then matched against the project's own automation-state options." + }, + { + "name": "owner", + "type": "string", + "example": "jane@acme.com", + "json_path": "/test_case/owner", + "description": "Owner as an email address, or a TM user id. DANGER: an address that does not resolve to a user in this group is NOT an error \u2014 it is written as null, clearing the owner, and the call still returns 200. Verify the user before sending." + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "smoke" + ], + "json_path": "/test_case/tags", + "description": "Plain tag-name strings. The list you send REPLACES the case's tags, so read the current tags and send the union if you mean to add one; `[]` clears them all; omit the key to leave them untouched." + }, + { + "name": "issues", + "type": "array", + "example": [ + "TES-204" + ], + "json_path": "/test_case/issues", + "description": "Linked issue/defect ids as plain STRINGS on this path \u2014 the parameter filter here permits only scalars, so the {issue_id, issue_type} objects the v1 edit body takes are dropped and the links never happen. The list replaces the case's linked issues." + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "Sprint": "24", + "Component": [ + "auth", + "billing" + ] + }, + "json_path": "/test_case/custom_fields", + "description": "Keyed by the custom field's NAME here \u2014 not by its numeric id, which is what the v1 edit and bulk paths use. A key the project does not define is skipped SILENTLY and the call still returns 200; a defined field with an unusable value fails the whole call with 400 'Custom field validation failed for '. Dropdown values are the option label; a user-type field takes an email." + }, + { + "name": "test_case_steps", + "type": "array", + "example": [ + { + "step": "Open the login page", + "result": "The login form is shown" + } + ], + "fields": [ + { + "name": "step", + "type": "string" + }, + { + "name": "result", + "type": "string" + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer" + }, + { + "name": "order", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps", + "description": "The case's ordered step list. `order` is overwritten by array position, so the array order is what counts. Omit the key to keep the existing steps. Rejected with 422 for a BDD-template case \u2014 send feature/background/scenario instead. This is the only surface that writes steps: the bulk edit refuses them outright." + }, + { + "name": "template", + "type": "string", + "example": "test_case_steps", + "json_path": "/test_case/template", + "description": "Template NAME, not an id. Omit it and the case's current template is kept \u2014 the action fills it in from the stored case before the write." + }, + { + "name": "template_id", + "type": "integer", + "example": 42, + "json_path": "/test_case/template_id", + "description": "Custom-template id. Must be a TestCase-entity template visible to the caller's group; an unknown or cross-group id is rejected 400 rather than quietly falling back to the system default. Explicit null is a no-op, not a clear." + }, + { + "name": "feature", + "type": "string", + "json_path": "/test_case/feature", + "description": "BDD-template cases only; 422 on a non-BDD case. Omit to keep the stored value \u2014 sending an EMPTY string is rejected 400 'Cannot clear feature and scenario for BDD test cases.'" + }, + { + "name": "background", + "type": "string", + "json_path": "/test_case/background", + "description": "BDD-template cases only; 422 on a non-BDD case. Omitted or blank keeps the stored background." + }, + { + "name": "scenario", + "type": "string", + "json_path": "/test_case/scenario", + "description": "BDD-template cases only; 422 on a non-BDD case. Like feature, an empty string is rejected 400 rather than clearing it." + }, + { + "name": "reviewers", + "type": "array", + "example": [ + "jane@acme.com" + ], + "json_path": "/test_case/reviewers", + "description": "Reviewer EMAILS. An array replaces the whole reviewer set ([] unassigns everyone). An object form {value: [...], operation: replace|add|remove} is also accepted for an additive or subtractive change. A bare string or null is dropped by the parameter filter and can never become a reviewer write. Revoked reviewers are emailed. Naming the case owner is rejected 422." + }, + { + "name": "send_for_review", + "type": "boolean", + "json_path": "/test_case/send_for_review", + "description": "Moves the case into review and mails the reviewers, independently of `reviewers`. String booleans ('true'/'false'/'1'/'0'/'on'/'off') are cast; an ambiguous string such as 'yes' is rejected 422 rather than guessed." + }, + { + "name": "review_status", + "type": "string", + "values": [ + "pending", + "in_review" + ], + "json_path": "/test_case/review_status", + "description": "Assignment states only \u2014 a verdict (approved / rejected / changes_requested) is one reviewer's decision and is rejected 422. OMIT this on a metadata-only edit: sending `pending` on an approved case invalidates the approval and writes an audit row. The pending/in_review narrowing only applies once the reviewer feature flag is on; with it off the value is passed through unvalidated." + }, + { + "name": "identifier", + "type": "string", + "json_path": "/test_case/identifier", + "description": "External id for the case, used only when the workspace is configured for project-level ids; it is stored as the case's third-party identifier. On any other workspace it is ignored. `third_party_identifier` is accepted as a synonym." + }, + { + "name": "expected_result", + "type": "string", + "json_path": "/test_case/expected_result", + "description": "ACCEPTED BUT NOT WRITTEN on this path \u2014 the parameter filter permits it and the request builder never copies it, so it is dropped before the write and you still get 200. The v1 edit paths do persist it. Never report an expected result as saved from here." + }, + { + "name": "estimate", + "type": "string", + "json_path": "/test_case/estimate", + "description": "ACCEPTED BUT NOT WRITTEN \u2014 passes validation and is then dropped before the write, exactly as on the v1 create/edit paths. Never report an estimate as saved." + }, + { + "name": "test_case_dataset", + "type": "object", + "json_path": "/test_case/test_case_dataset", + "description": "ACCEPTED BUT NEVER APPLIED on this path: the builder only regenerates dataset mappings when the modified-dataset flag is set, and that flag is not accepted by this action, so the existing mappings are always kept. Returns 200 with the dataset unchanged." + }, + { + "name": "old_id", + "type": "string", + "required": false, + "description": "Legacy id carried over from an import; free text, not validated against anything.", + "json_path": "/test_case/old_id" + }, + { + "name": "expectation", + "type": "string", + "required": false, + "description": "Expected outcome, the counterpart to `preconditions`. Distinct from `expected_result`, which belongs to a step.", + "json_path": "/test_case/expectation" + }, + { + "name": "attachment_id", + "type": "integer", + "required": false, + "description": "Id of a previously stored attachment to associate with the case.", + "json_path": "/test_case/attachment_id" + }, + { + "name": "ids", + "type": "array", + "required": false, + "description": "Ids of other cases this write applies to, for the shared-write path.", + "json_path": "/test_case/ids" + }, + { + "name": "issue_tracker", + "type": "object", + "required": false, + "description": "Tracker context for `issues` \u2014 which external system the ids belong to. Validated together with `issues`.", + "json_path": "/test_case/issue_tracker" + }, + { + "name": "created_at", + "type": "string", + "required": false, + "description": "Explicit creation timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", + "json_path": "/test_case/created_at" + }, + { + "name": "updated_at", + "type": "string", + "required": false, + "description": "Explicit update timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", + "json_path": "/test_case/updated_at" + } + ], + "intent": "Change one existing test case \u2014 rename it, rewrite its steps, preconditions or description, set priority/status/type/owner, retag it, relink issues or put it into review \u2014 sending only the fields you want changed", + "guidance": [ + "This is the SINGLE-case update, addressed by one identifier. The bulk edits take a selection (ids or a filter) instead, refuse step lists, and key custom fields by numeric id \u2014 so when you are changing one case, and always when you are changing its steps, this is the one to use.", + "Both ids in the path are prefixed strings (PR-1 / TC-42). The case id is resolved by an identifier lookup inside the project, so the internal integer id that the v1 edit endpoints take returns 404 'Test case not found' here.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key. Send at least one field: a 400 saying 'Invalid request format: test_case parameter is missing or malformed' means the body reached the server empty, not that you must nest it.", + "Only the keys you send are written \u2014 omitted fields keep their stored values. There is no undo, but the case keeps version history, so a bad edit can be recovered by restoring the previous version.", + "Accepted and then silently discarded on this path, each returning 200: expected_result, estimate, is_shared, the dataset payload, and `folder_id` (use test_case_folder_id to move). Re-read the case before telling anyone these landed.", + "priority, status and case_type take the PROJECT's display value or that value's numeric id; one bad value fails the whole call with 400 'Invalid default field value '. Resolve the allowed values from the project's system field values first rather than guessing 'P1' or 'Open'.", + "custom_fields is keyed by field NAME here (the v1 paths key by numeric id). An unknown name is skipped without error, so a typo means a 200 that changed nothing \u2014 confirm by reading the case back.", + "An owner that does not resolve to a user in this group is written as null and CLEARS the owner instead of erroring.", + "BDD and step-based cases are mutually exclusive: steps on a BDD case are 422, feature/background/scenario on a non-BDD case are 422, and clearing feature or scenario to an empty string is 400.", + "reviewers, send_for_review and review_status run a gate ladder (Team-Pro plan, the project's Review & Approve setting, 21 CFR Part 11) and are applied AFTER the case update has committed. A 502 with error_code reviewer_write_failed and test_case_updated true means the edit landed but the reviewer change did not \u2014 retry with only the reviewer field, never with the whole body.", + "Changing anything here can move the case out of an approved review state, and reviewer removals send email immediately.", + "Sharing cannot be changed here. `is_shared` is checked before the write \u2014 sending it on a project without test-case sharing enabled returns 400 \u2014 and is then discarded, so the value never takes effect. Use the bulk edit to change sharing.", + "Four fields are entitlement-gated and are dropped without error when the workspace lacks the entitlement: `created_at` and `updated_at` need external-API timestamps, and `identifier` applies only to project-level-id workspaces. Read the case back if you need to confirm one landed.", + "Parts of the echoed case are conditional, so test for the key rather than for an empty value: `feature`, `background` and `scenario` come back only on a Gherkin case (where `steps` is then an empty array), and `reviewers` and `review_status` appear only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps`, `custom_fields` and `attachments` come back as empty arrays when nothing is set \u2014 note that `attachments` is empty here, not missing, unlike the create response, and that editing a case never adds one: files are attached through the attachment upload capability." + ], + "returns": [ + "success", + "test_case", + "identifier", + "title", + "folder_id", + "priority", + "status", + "case_type", + "automation_status", + "owner", + "tags", + "template", + "description", + "preconditions", + "steps", + "custom_fields", + "issues", + "attachments", + "is_shared", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "responses": { + "200": { + "description": "The updated test case, in the v2 public shape (title, not name; no integer id).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true + "test_case": { + "$schema": "V2PublicTestCase" } + } } - }, - "ReportDetail__GetReportDetail": { - "type": "object", - "description": "ReportDetail as returned by `get_report_detail`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "is_dynamic": { - "type": "boolean", - "example": false + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "The test case WAS updated but the reviewer change could not be applied (error_code reviewer_write_failed, test_case_updated true). Also returned with error_code review_approve_unknown, in which case nothing was written at all." + } + } + }, + { + "name": "move_test_case", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/move", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API. It is also the only project involved \u2014 the move cannot leave it." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id returns 404 'Test case not found'." + } + ], + "body": [ + { + "name": "destination_folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 5, + "description": "Target folder, as a plain positive INTEGER folder id \u2014 folders are not identified by a prefixed string. Sent at the TOP LEVEL of the body, not inside a `test_case` object (the bulk move is the opposite and nests it). Missing, non-numeric, zero or negative is refused before anything is read. A numeric string is accepted and coerced." + } + ], + "intent": "File one test case in a different folder \u2014 use this to reorganise the tree, move a case out of an inbox/triage folder, or put a case where a run or plan expects to find it", + "guidance": [ + "Within ONE project only: there is no destination-project field, and the case is looked up in the same project it is moved inside. A cross-project move is not available here.", + "One case per call. To move many, use the bulk move, which takes a selection under a `test_case` object and runs asynchronously past ~30 cases; this one is always synchronous and is the cleaner choice for a single case.", + "The destination is a plain integer folder id and is NOT guessable \u2014 resolve it from the project's folder tree first. Nothing in this layer checks that the folder belongs to this project, so a stray id from another project is only caught (or not) downstream.", + "Moving a case into the folder it is already in is accepted and returns 200 with the case unchanged. Unlike the v1 edit path, the same-folder value is not stripped before the write, so the upstream write still happens.", + "Capture the case's current folder before you call if you may want to undo: the response reports only the NEW folder, and moving back means calling this again with the old id.", + "Nothing but the folder changes \u2014 the identifier, steps, tags, owner and review state are untouched \u2014 and the response carries the whole case in the v2 public shape.", + "The same folder change can be made as part of a single-case update by sending test_case_folder_id in its body; use this endpoint when the move is all you want, because it needs no other field and cannot accidentally rewrite one." + ], + "returns": [ + "success", + "test_case", + "message", + "identifier", + "title", + "folder_id", + "priority", + "status", + "case_type", + "automation_status", + "owner", + "tags", + "template", + "description", + "preconditions", + "steps", + "custom_fields", + "issues", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "responses": { + "200": { + "description": "The moved test case in the v2 public shape, with folder_id now the destination, plus a top-level message 'Test case moved successfully'.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } + "test_case": { + "$schema": "V2PublicTestCase" } + } + }, + "message": { + "type": "string" } - }, - "PathFolder__MoveFolderByIntegerId": { - "type": "object", - "description": "PathFolder as returned by `move_folder_by_integer_id`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PathFolder" - } - ], - "properties": { - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765883 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "copy-of-__mcp-probe-subfolder-20260921" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - }, - "total_cases_count": { - "type": "integer", - "example": 2 - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case_histories", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/history", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case identifier: TC-NNN, or the third-party identifier set on the case. The route deliberately leaves this segment unconstrained, but it is resolved through identifier lookup before anything is read \u2014 a bare internal numeric case id is not a match and comes back 404 'Test case not found'." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "example": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read \u2014 you silently get page 1 again. Page size is fixed at 30 and is not caller-controllable here: `per_page` and `page_size` are accepted by the URL but ignored. A `p` that is not a positive integer (blank, zero, negative, non-numeric, repeated) is refused with 400 'Invalid Params'." + } + ], + "intent": "See what changed on a test case, when, and who changed it \u2014 the revision trail, newest first, naming the fields touched in each edit; the read to answer 'what did this case look like before that edit?' or 'which revision should we roll back to?'", + "guidance": [ + "Rows come newest first. Each row's `version_name` is a positional label \u2014 V1 is the oldest revision still visible, and the highest number is the newest edit on page 1.", + "`version_name` is the only handle a row gives you: the v2 rows carry no history record id. Strip the leading V and pass the number as the version number to the single-version read to pull that revision's full test case snapshot.", + "Only the newest row on each page is enriched with the per-field old/new `modified` map \u2014 the rest carry `modified_fields` (the field names only), so a per-field before/after for an older edit needs the single-version read for that version. The creating revision never carries `modified` at all, even when it is the only row on the page, because a create has nothing to diff against \u2014 so a single-revision history is expected to come back with `modified_fields` null and no `modified` map.", + "Without an Enterprise or Team Pro plan the caller sees only the last 30 days of history. Older revisions are simply absent and nothing in the response flags that the window was applied, so a short list is not proof that the case was rarely edited. Version numbers count only what is visible, so they renumber as old revisions age out.", + "Listing degrades gracefully across plans, but comparing two versions and restoring one are paid-plan only \u2014 fall back to listing plus single-version reads when those are refused.", + "Each record carries `source` ('create' on the first revision, 'update' for an edit, 'restore' after a rollback), `created_at`, `modified_fields`, and `updated_by` \u2014 the editor's email, null when that user cannot be resolved in this group. The raw `user_id` is the BrowserStack user id, not a Test Management one.", + "Page with `p`; the page size is fixed at 30, so a long-lived test case is walked 30 revisions at a time and the total is in `info.count`.", + "Every row also carries `origin_channel`, recording how that edit arrived: ui, api, jira_app, azure, import, automation or ai. Anything written through this API is recorded as api, so the field only discriminates once a case has also been touched in the web app, through an issue-tracker app, or by an import." + ], + "returns": [ + "success", + "history", + "info", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "comment", + "created_at", + "updated_by", + "user_id", + "testcase_id", + "origin_channel" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "V2HistoryListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_runs", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (50, 100, 301) is refused with a 400 'parameter_error' rather than clamped, so ask for 300 directly when you want a large project in few calls." + }, + { + "name": "include_closed", + "type": "boolean", + "default": false, + "description": "Include closed runs. Closed runs are EXCLUDED from this listing by default, so counts and 'all runs' answers are silently short without this. Pass the literal string 'true'." + }, + { + "name": "run_state", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "description": "Filter by lifecycle state. Supplying it also lifts the default closed-run exclusion on its own, so run_state=closed works without include_closed." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com,lead@acme.com", + "description": "Comma-separated owner emails, resolved against this group. This filter FAILS OPEN: an address that matches no user in the group is silently dropped from the filter instead of erroring, so a typo widens the result set and still returns 200. Verify the emails resolve before trusting a filtered count." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^TP-\\d+$", + "example": "TP-1", + "description": "Restrict to runs linked to this test plan. An identifier that matches no plan in the project is a 404 ('Test Plan ID is invalid'), not an empty list." + }, + { + "name": "created_after", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Lower bound on creation date, YYYY-MM-DD. May be sent on its own. A value in a different format is a 400; a date later than tomorrow is a 400." + }, + { + "name": "created_before", + "type": "string", + "format": "date", + "example": "2026-03-31", + "description": "Upper bound on creation date, YYYY-MM-DD. May be sent on its own. Sending a created_after later than this is a 400 naming the start date." + }, + { + "name": "closed_after", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Lower bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." + }, + { + "name": "closed_before", + "type": "string", + "format": "date", + "example": "2026-03-31", + "description": "Upper bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." + } + ], + "intent": "Find test runs in a project \u2014 filter by owner, lifecycle state, linked plan or date to locate a run, count how many there are, or collect the run ids you need before linking runs to a test plan", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 \u2014 any other number is a 400 'parameter_error', not a clamp \u2014 so pass 300 when you want the project in as few calls as possible.", + "Closed runs are excluded by default. Without include_closed=true (or a run_state filter, which lifts the exclusion by itself) a 'how many runs do we have?' answer quietly omits every closed run and still comes back 200.", + "Rows carry the run's `identifier` (TR-14) and nothing numeric \u2014 this listing's serializer emits no `id` and no `uuid` field at all. TR-NNN is exactly what the newer single-run and run-test-case capabilities want in their paths, so for reading a run onward this is all you need.", + "Linking runs to a test plan is the exception: a plan's linked-run array takes the INTEGER run id. The TR-NNN string reaches the backing service uncoerced and 502s there, and this listing does not give you the integer. Resolve it first \u2014 the v1 project run listing (list_test_runs_by_integer_id) carries the integer as `uuid` on each run \u2014 and send that, never the TR-NNN string.", + "Rows are a summary projection, not the single-run read. They never carry `issues` or `issue_tracker`, however many issues a run actually has linked \u2014 read the run itself when you need those.", + "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none', and read the run itself to see them.", + "`test_plan` appears only on runs that are linked to a plan, and the key is omitted entirely rather than sent as null when there is no link. `overall_progress` behaves the same way \u2014 omitted, not zeroed, for a run with nothing executed yet.", + "The assignee filter fails open while the plan and date filters fail closed: an unresolvable email is dropped silently, but an unknown TP-NNN is a 404 and a malformed or out-of-order date is a 400. Only the assignee filter can quietly widen your result.", + "closed_after / closed_before are not just a date window \u2014 either one restricts the whole result to closed runs.", + "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 with error_code 'request_limit_exceeded'. Narrow with a filter rather than walking a large project to the end.", + "Rows are run headers only \u2014 progress is aggregated counts. To see which cases are in a run, read that run's test cases." + ], + "returns": [ + "success", + "test_runs", + "info", + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "is_dynamic", + "overall_progress", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "TestRunsListV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "429": { + "description": "Deep-paging guard. Returned when page x page_size exceeds the account's row ceiling (30,000 by default).", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Page count exceeds limit. Requests with more than 1000 pages are temporarily disabled. Please reduce the page count and try again." + }, + "error_code": { + "type": "string", + "example": "request_limit_exceeded" } - }, - "TagsSearchResponse__SearchGroupTags": { - "type": "object", - "description": "TagsSearchResponse as returned by `search_group_tags`. Composes TagsSearchResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TagsSearchResponseBody" - } - ], - "properties": { - "tags": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 541125 - } - } - }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "example": 5 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - } + } + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Optional in practice despite the published schema marking it required: omitting it names the run 'Test Run DD/MM/YYYY' from today's date rather than failing." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, as an email resolved inside this group. Unlike the run edit paths, an address that does not resolve is NOT an error here \u2014 it is dropped and the run is created owned by the calling user, so a typo produces a wrongly-owned run with a 200." + }, + { + "name": "run_state", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "default": "new_run", + "example": "new_run", + "json_path": "/test_run/run_state", + "description": "Lifecycle state, checked against this fixed list before any case selection is built. On create an absent or unrecognised value is not rejected \u2014 it is silently stored as new_run \u2014 so send the state you want explicitly rather than relying on a validation error." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "sprint-24" + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "example": [ + 101, + 102 + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read, and not the display names the published schema's example shows. Duplicates are collapsed. Overwritten entirely when configuration_map is also sent." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link at creation. Resolved by identifier \u2014 a TP-NNN that matches no plan in this project fails the whole create with 404, the run is not created. Mutually exclusive with sub_test_plan_id; when both are sent test_plan_id wins." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link at creation. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "Test case identifiers to put in the run. This, shared_test_cases, folder_ids and include_all are the ONLY inputs that actually place cases in a run \u2014 filter_test_cases alone does not." + }, + { + "name": "shared_test_cases", + "type": "array", + "fields": [ + { + "name": "project_id", + "type": "integer" + }, + { + "name": "test_case_ids", + "type": "array" + } + ], + "json_path": "/test_run/shared_test_cases", + "description": "Cases shared in from other projects: a list of {project_id, test_case_ids: [...]} objects. The flat/scalar form is stripped by the parameter filter and silently yields zero mapped cases, so always send the object shape. Not present in the published schema." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Put every test case in the project into the run." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "example": [ + 1, + 2 + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs (integers) whose cases go into the run." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders", + "description": "Descend into sub-folders of folder_ids. Sending this key with folder_ids absent or empty is a hard 400 ('Cannot process nested folders of empty folder array'), so only send it alongside a non-empty folder_ids." + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "match", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Forward-matching rule for the run, and the only place is_dynamic is read. Sending it ALONE does not select cases: the run is created empty and marked dynamic. Labels ('High', 'Draft') are resolved to ids; an owner email that does not resolve in this group fails the create with 400. is_dynamic:true needs at least one other key here or it is a 400." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "default": "global", + "json_path": "/test_run/filter_scope", + "description": "How filter_test_cases combines with folder_ids: 'global' unions them across the project, 'within_folders' intersects the filter with the listed folders. Honoured on create only \u2014 the run update paths accept it and ignore it." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Sending it REPLACES the top-level configurations list with the ids used here. It is an overlay, not the case list \u2014 cases selected elsewhere still come in, with a null-configuration row." + }, + { + "name": "test_case_assignee", + "type": "string", + "example": "tester@acme.com", + "json_path": "/test_run/test_case_assignee", + "description": "Default assignee applied to the cases placed in the run, as an email. Applied by the asynchronous population step, not at create time. Not present in the published schema." + }, + { + "name": "is_automation", + "type": "boolean", + "default": false, + "json_path": "/test_run/is_automation", + "description": "Accepted and then DROPPED \u2014 it does not make the run an automation run. It is still read to decide which write permission is demanded, so sending true can 403 a caller who could have created a manual run. To get an automation run, create it without this flag and then call update_test_run with is_automation: true on the returned identifier; confirm with the v1 run read." + } + ], + "intent": "Create a test run in a project and load it with the test cases to be executed \u2014 the starting point for recording results, and what you make before assigning testers or linking the run to a plan", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Every field goes inside a `test_run` object. A flat body is rejected before a single field is read.", + "Only test_cases, shared_test_cases, folder_ids or include_all actually put cases in the run. filter_test_cases is a forward-sync rule, NOT a selection: a create carrying only filter_test_cases returns 200 with an EMPTY run and silently marks it dynamic even though you did not ask. If the user named cases, list them.", + "Create runs STATIC unless the user explicitly asked for one that keeps picking up new cases. A dynamic run matches on nine fields only \u2014 priority, status, category, owner, automation_status, automation_state, folder, tags, issues \u2014 so a 'cases created in the last 7 days' filter is stored, echoed back on read, and never matched, and the run over-collects forever.", + "is_dynamic belongs inside filter_test_cases and nowhere else; a top-level is_dynamic is stripped by the parameter filter and has no effect. is_dynamic:true with no other key inside filter_test_cases is a 400 saying filter criteria are required.", + "A bare 400 'invalid data' is about the run's own fields \u2014 it is raised while converting the run header, before any case selection is built \u2014 most often an owner or reviewer inside filter_test_cases that resolves to no user in this group. It is never a complaint about the shape of your selection.", + "Populating the run is asynchronous. A 200 means the run exists, not that its cases are attached yet; re-read the run's test cases to confirm. A 202 with build_pending:true means the run was created but is not readable back yet \u2014 poll for it, never retry the create, which would make a second run.", + "include_sub_folders is only meaningful next to a non-empty folder_ids; on its own it is a hard 400.", + "A run created here keeps no version history. If the wrong cases land in it, adding is easy but removing is a separate operation, and the run's own metadata cannot be rolled back \u2014 only overwritten or the run deleted.", + "The new run comes back at the top level as `test_run`, not under `data` \u2014 unlike the v1 create, which wraps its payload in `data`.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunV2Response" + }, + "202": { + "description": "The run was created but is not yet readable back \u2014 its build is still being provisioned and case population is in progress. Poll for the run rather than retrying the create.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "build_pending": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Test run was created; its build is still being created and population is in progress." + }, + "test_run": { + "$schema": "TestRun" } - }, - "ExploratorySessionLog__UpdateExploratorySessionLog": { + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_plans", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." + }, + { + "name": "count", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page; read first when both this and page_size are sent. Defaults to 30. Above 300 the call is refused with 400 'Page limit exceeds the maximum limit of 300' rather than clamped." + }, + { + "name": "page_size", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Alias for count, read only when count is absent. Same 300 ceiling and the same 400 above it." + } + ], + "intent": "List the test plans in a project \u2014 the read that turns a plan name the user said into the TP-NNN id every other plan call needs, and answers 'what plans do we have?'", + "guidance": [ + "Page with `p` (NOT `page`) and size with `count` (or `page_size`). Default 30, hard ceiling 300 \u2014 asking for more errors instead of clamping, so ask for 300 in one go rather than walking 30 at a time.", + "Deep paging is capped on rows, not pages: once p x page_size passes the account's limit (30,000 by default) the call returns 429 'request_limit_exceeded'.", + "There is NO filtering on this route \u2014 no name search, status, owner, tag or sort parameter is read here. Page through and match client-side, or use the v1 plan listing, which does take status/sort/include_sub_plans.", + "Whether sub-plans (STP-NNN) appear among these rows is decided by the backing service and is not steerable from here. Read each row's parent_plan_id rather than assuming, and list a parent's children through the sub-plans listing.", + "Rows are the same plan shape the single-plan read returns, minus the embedded runs. Carry `identifier` (TP-NNN / STP-NNN) forward \u2014 it is what the v2 plan paths take, not the numeric id the v1 plan capabilities use.", + "Page against the `info` envelope (page, page_size, count, next, prev) instead of probing for an empty page." + ], + "returns": [ + "success", + "test_plans", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls", + "info" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "TestPlanV2ListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Release 2.0 plan", + "json_path": "/test_plan/name", + "description": "Plan name, and the only required field. Blank or missing fails the whole call with 400 'Name field cannot be blank' before anything is created." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/test_plan/start_date", + "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. An unparseable value is dropped silently rather than refused." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-01-31", + "json_path": "/test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "release-2.0" + ], + "json_path": "/test_plan/tags", + "description": "Plain tag strings. A non-string entry makes the whole list fall back to empty \u2014 no error, just an untagged plan." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123", + "JIRA-456" + ], + "json_path": "/test_plan/issues", + "description": "Plain issue keys, not objects: the v1 structured {issue_id, issue_type, metadata} form is filtered out here. Each key is stored as a jira-type link unless issue_tracker says otherwise. Linking is queued asynchronously, so the issues may not yet appear on an immediate re-read." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker, adding ticket metadata. Needs BOTH name ('jira') and host; a host that matches no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'. Omit it and issues are stored as bare jira references." + }, + { + "name": "created_at", + "type": "string", + "format": "date-time", + "json_path": "/created_at", + "description": "Backdated creation time, for migrating plans in from another tool. Sits at the TOP level of the body, not inside test_plan, and is only honoured for groups with the external-API timestamp feature enabled \u2014 otherwise it is ignored silently. Unparseable is 400." + }, + { + "name": "updated_at", + "type": "string", + "format": "date-time", + "json_path": "/updated_at", + "description": "Backdated last-update time. Top level like created_at, same feature gate. Must be >= created_at (or >= now when created_at is absent), else 400." + } + ], + "intent": "Create a test plan to group the test runs for a release, sprint or milestone \u2014 the TP-NNN it returns is what runs and sub-plans then hang off", + "guidance": [ + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own wrapper \u2014 `test_plan`, `plan` or `sub_test_plan` \u2014 is rejected before the call as an unknown body key.", + "The plan is always created in the `new` state: a plan_status in the create body is not read at all. Move it to started/completed afterwards with the update capability.", + "Owner is always the calling user \u2014 there is no owner field on this route, unlike the v1 create.", + "This route always creates a TOP-LEVEL plan: a parent_plan_id you send is filtered out before the plan is built, so the created plan has no parent and the response never carries a parent_plan_id key at all - not even as null. To create a child, use the sub-plan create under its parent; parentage cannot be changed afterwards on v2.", + "Reviewers, attachments and custom fields are v1-only on plans; sending them here changes nothing and is not reported.", + "Recoverable: a plan created by mistake can be archived or deleted afterwards, so nothing here is one-way.", + "The response is the created plan under `test_plan`; take `identifier` (TP-NNN) from it. There is no numeric id field, but the numeric plan id and project id are both embedded in the web-app link under urls.self - that is where to recover the numeric id the v1 plan capabilities take." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$schema": "TestPlanV2Response__CreateTestPlan" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_plan", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan identifier in TP-NNN form, or STP-NNN for a sub-plan \u2014 both match this route. The integer plan id the v1 plan capabilities take does NOT match here and gives a routing 404." + } + ], + "intent": "Read one test plan or sub-plan \u2014 dates, status, owner-facing description, tags, linked issues, how many runs and sub-plans it holds, and a first glimpse of the runs inside it", + "guidance": [ + "Pass an STP-NNN to read a sub-plan: the same route serves both, and the plan still comes back under the `test_plan` key (never `sub_test_plan`).", + "The embedded `test_runs` array is a preview, not the membership list: only identifier and name, and only the first 30 runs of the plan. Use the plan's test-runs read to page through them or to see run state and progress.", + "If the upstream run fetch fails, the call still returns 200 with the `test_runs` and `links` keys ABSENT. Absent is not the same as an empty plan \u2014 check test_runs_count before concluding a plan has no runs.", + "test_runs_count splits active/closed and counts this plan's own runs only. parent_plan_id appears ONLY when the plan you read is a sub-plan, and carries the parent's TP-NNN, not its numeric id; on a top-level plan the key is omitted from the payload entirely rather than returned as null, so test for the key's presence, not for a null value. sub_plans_count comes back on both - it is the number of children, and reads 0 on a sub-plan.", + "urls.self is a web-app deep link built from numeric ids, and is the only place those numeric ids appear; urls.sub_test_plans, the address of this plan's children listing, is added only when the plan has no parent. links.self and links.test_runs are relative API paths and come and go with the test_runs preview.", + "active_state is derived: 'completed' once the plan is completed, otherwise 'active'. It does not distinguish a new plan from a started one.", + "A wrong, mistyped or other-project id is a 404 'Test Plan ID is invalid. Please enter valid Test plan ID and try again' \u2014 the same message for every not-found case.", + "Whether an archived plan is readable here is not settled by this route; treat a 404 on a plan the user insists exists as a possible archive and check the archived listing." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "test_runs", + "sub_plans_count", + "parent_plan_id", + "start_date", + "end_date", + "created_at", + "project_id", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestPlanV2DetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_sub_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/sub-test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "The PARENT plan, as TP-NNN. This is where the new child hangs; it is not the id of the plan being created. An unknown parent is a 404 before anything is created." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Payments \u2014 phase 1", + "json_path": "/sub_test_plan/name", + "description": "Sub-plan name, and the only required field. Blank or missing fails the call with 400 'Name field cannot be blank'." + }, + { + "name": "description", + "type": "string", + "json_path": "/sub_test_plan/description", + "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/sub_test_plan/start_date", + "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. Not validated against the parent plan's dates \u2014 a child may sit entirely outside its parent's window." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-01-15", + "json_path": "/sub_test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression" + ], + "json_path": "/sub_test_plan/tags", + "description": "Plain tag strings. Tags are not inherited from the parent plan. A non-string entry makes the whole list fall back to empty, with no error." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123" + ], + "json_path": "/sub_test_plan/issues", + "description": "Plain issue keys, not objects. Stored as jira-type links unless issue_tracker says otherwise, and queued asynchronously so they may lag an immediate re-read." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/sub_test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." + } + ], + "intent": "Break a test plan into a child plan \u2014 use this when a release plan needs per-team, per-phase or per-platform slices that each hold their own runs", + "guidance": [ + "Send the fields flat. The wrapper applied for you here is `sub_test_plan`, NOT `test_plan`; that difference is handled by the json_paths, and sending either wrapper yourself is rejected before the call as an unknown body key.", + "The parent comes from the path; there is no parent_plan_id field, and a sub-plan cannot be re-parented or promoted afterwards on v2.", + "Every response from this route carries parent_plan_id - the parent's TP-NNN, not its numeric id - because the route only ever creates children. It also carries sub_plans_count, which is 0 on a plan that was just created; that field is not suppressed for sub-plans. urls.self is a web-app deep link and the only place the new plan's numeric id appears; the sub_test_plans link is added to top-level plans only, so a sub-plan does not get one.", + "The created plan comes back under `sub_test_plan` (not test_plan) and its identifier is STP-NNN. That STP id is accepted wherever a plan id is taken on these v2 plan routes \u2014 read it, edit it, list its runs.", + "Runs are not moved here: a new sub-plan starts empty, and a run is attached by setting its plan on the run itself, which unlinks it from whichever plan held it before.", + "Recoverable: an unwanted sub-plan can be deleted or archived afterwards.", + "Completed sub-plans are hidden from the sub-plan listing, so a child you create and later complete stops appearing there even though it still exists." + ], + "returns": [ + "success", + "sub_test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$response": "SubTestPlanV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_plan_test_runs", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/test-runs", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan identifier in TP-NNN form, or STP-NNN to list a sub-plan's own runs. The integer plan id the v1 capabilities take does not match this route." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. This is the ONLY paging control here \u2014 the page size is fixed." + } + ], + "intent": "List the test runs that belong to a test plan, with each run's state, assignee and pass/fail progress \u2014 the read behind 'how is this release going?' and 'which runs are in this plan?'", + "guidance": [ + "Page with `p` only. The page size is FIXED at 30 and cannot be raised: `count` and `page_size` are documented on this route but not read, so asking for 100 still returns 30. Page against the `info` envelope.", + "Rows carry `identifier` (TR-NNN) but NO numeric run id \u2014 the integer uuid some plan-linking calls need is not in this payload, so read the run itself when you need it.", + "Rows here omit `test_plan` entirely \u2014 the plan is already the scope you asked for. Do NOT read its absence as the run being unlinked; the project-wide run listing does carry the plan link for the same run.", + "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none'.", + "Each row also carries a `urls` object whose `self` is a deep link to the run in the web app, alongside the `links` object of API references.", + "There is no include-sub-plan-runs switch here (v1 has one). To cover a parent and its children, list each sub-plan's runs separately.", + "When the upstream run service fails, the body STILL says success: true with test_runs null while the HTTP status carries the real failure. Check that test_runs is an array before reading 'no runs' as an empty plan.", + "Progress arrives as overall_progress, a map of status name to count for the run's cases; a run with nothing executed may omit the key entirely rather than report zeros.", + "This lists runs, not results or cases \u2014 drill into a run's test cases for case-level state." + ], + "returns": [ + "success", + "test_runs", + "info", + "identifier", + "name", + "description", + "run_state", + "active_state", + "assignee", + "tags", + "configurations", + "filter_test_cases", + "is_dynamic", + "overall_progress", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestRunsListV2Response" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/update", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan to edit, as TP-NNN \u2014 or STP-NNN to edit a sub-plan through this same route. The integer plan id the v1 capabilities take does not match here." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Release 2.0 plan", + "json_path": "/test_plan/name", + "description": "New name. Optional here, unlike the v1 plan edit which rejects any body without it. An empty string is ignored and the current name is kept \u2014 the name cannot be cleared." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "New description. An empty string is ignored and the stored description is kept, so a description cannot be cleared here. Sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/test_plan/start_date", + "description": "Plain date (YYYY-MM-DD). Applied only when non-empty; omitted leaves the current date. Once the plan is started this is fixed and further values for it are ignored." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-02-28", + "json_path": "/test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Applied only when non-empty. Earlier than the plan's start date is refused with 400 'Invalid end_date, should be greater than start_date' and nothing is saved." + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "example": "started", + "json_path": "/test_plan/plan_status", + "description": "Lifecycle move. Only forward, one step at a time: new -> started -> completed. 'started' stamps start_date (today unless you send one) and 'completed' stamps end_date the same way." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "release-2.0" + ], + "json_path": "/test_plan/tags", + "description": "REPLACES the plan's tags with this list. An empty array is treated as 'not sent' and leaves the existing tags in place, so tags cannot be cleared through this call." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123" + ], + "json_path": "/test_plan/issues", + "description": "Plain issue keys, not the v1 structured objects. Sent non-empty they replace the linked set; an empty array is ignored, so links cannot be removed here. Processing is queued, so an immediate re-read may lag." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." + } + ], + "intent": "Change an existing test plan or sub-plan \u2014 rename it, move its dates, retag it, relink issues, or start/complete it; note that attaching or detaching a test run is NOT done from the plan on v2, it is set on the run", + "guidance": [ + "Merge, not replace: only the fields you send change and anything omitted keeps its current value, so a one-field edit is safe. (The v2 run 'update' on the same path shape behaves the opposite way \u2014 do not carry that habit here.)", + "Linking runs is the trap. A `test_runs` list in this body is SILENTLY DISCARDED \u2014 200, plan unchanged, nothing linked, no warning. It is a v1-only field. On v2 you set the run's plan from the run side (its test_plan_id / sub_test_plan_id), and that MOVES the run: a run belongs to at most one plan, so the plan that held it loses it with nothing in the response to say so. Check the runs' current plans first and tell the user which plans will lose runs.", + "Unlike the v1 plan edit, name is NOT required \u2014 an update carrying only dates, tags or a status move is accepted.", + "Lifecycle is one-way and one step at a time: new -> completed is 400 'Cannot directly complete test plan', started -> new is 400 'Cannot revert started test plan', and once completed the plan is FROZEN \u2014 every later edit, including a rename, is 400 'Cannot update completed test plan'. Complete a plan last.", + "No undo: plans keep no version history, so a name, description or date this call overwrites cannot be restored and there is nothing to roll back to. Read the plan first if the old value matters.", + "Clearing is not possible through this call: empty strings and empty arrays are read as 'not sent' for name, description, tags and issues.", + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path \u2014 including when the target is a sub-plan (STP-NNN), where `sub_test_plan` is NOT the key this route reads. Sending your own wrapper is rejected before the call as an unknown body key.", + "parent_plan_id comes back only when the plan you edited is a sub-plan, carrying the parent's TP-NNN; a top-level plan's response omits the key entirely rather than returning null. This call cannot change parentage, so the field never appears or disappears as a result of the edit. sub_plans_count is always present, and test_runs and links are not - they belong to the single-plan read.", + "Owner, reviewers, attachments, custom fields and re-parenting are v1-only on plans; sending them here changes nothing and is not reported.", + "A rejected edit returns 400 with {success: false, error: ...} and saves nothing \u2014 the plan is left exactly as it was." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$schema": "TestPlanV2Response__UpdateTestPlan" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_custom_fields", + "method": "GET", + "path": "/api/v2/custom-fields", + "mode": "read", + "entity": "custom_field", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page` is not read on this listing: send it and you silently get page 1 again. A zero, negative, non-numeric or repeated value is refused with 400 'Invalid Params' rather than coerced." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Rows per page. Defaults to 30 and the real ceiling is 300 \u2014 a larger value is REFUSED with 400 'Page limit exceeds the maximum limit of 300', not clamped, so ask for 300 directly on a large workspace. The published spec claims a maximum of 100; the code enforces 300." + } + ], + "intent": "Discover the test-case custom fields defined in this workspace \u2014 list each field with its name, type, required flag and linked-project count, and pick up the numeric field id needed before reading, editing or filling in a field", + "guidance": [ + "Page with `p` and `count` \u2014 not `page` / `per_page`, which this listing ignores. `count` above 300 is a 400 naming the limit, so request 300 when you want the workspace in as few calls as possible.", + "Only TestCase fields are listed. The entity type is fixed server-side, so test-result, test-plan and exploratory-session fields never appear here whatever you send \u2014 the workspace field listing on the admin-settings surface is the one that covers those.", + "Rows are field metadata only: no dropdown options, no dataset and no list of linked projects, just a linked_projects_count. Read the field's datasets when you need the option values or the actual project links.", + "This is the newer public custom-fields surface. The admin-settings custom-field capabilities read the same stored definitions but answer in a different envelope and cover every entity type \u2014 prefer this one for test-case fields, and fall back to those for result/plan fields and for dataset or option editing.", + "Carry the numeric `id` from each row forward: that is exactly what the single-field read and the field edit take in their path.", + "Needs the field-view permission when role-based access is on. Without it the answer is 403 'This action is not permitted for the user', not an empty list." + ], + "returns": [ + "success", + "custom_fields", + "info", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "placeholder", + "system_name", + "is_bulk_editable", + "is_filterable", + "linked_projects_count", + "group_id" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "CustomFieldsV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_custom_field", + "method": "POST", + "path": "/api/v2/custom-fields", + "mode": "write", + "entity": "custom_field", + "body": [ + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "example": "dropdown", + "description": "The kind of field. Mandatory in practice even though the body schema marks nothing required \u2014 anything outside this list is a flat 400 'Invalid Params'. It is fixed for the life of the field: the edit capability will not change it.", + "minLength": 1 + }, + { + "name": "field_name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 256, + "example": "Test Environment", + "description": "Label shown on the test case. Mandatory; trimmed before storage, so ' Priority' is stored as 'Priority'. Over 256 characters or blank is a 400 'Invalid Params'. Names that collide with a system field are refused separately (see guidance)." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "example": false, + "description": "Whether a test case must fill this field. Mandatory, and it must be a real JSON boolean: the string \"true\" fails the parameter check with a flat 400 'Invalid Params' that names no field." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "example": "Select an environment", + "description": "Hint text shown in the empty field. Trimmed before storage; over 256 characters is a 400 'Invalid Params'." + }, + { + "name": "default_value", + "type": "string", + "description": "Accepted by the schema but NOT settable here: for every non-boolean type it is discarded before the write, and for boolean the checks contradict each other (a JSON boolean fails the body schema, the string 'true' fails the parameter check). Set it afterwards with the field edit capability." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase" + ], + "default": "TestCase", + "description": "What the field attaches to. Only TestCase exists on this route \u2014 any other value is 400 'Only TestCase entity type is supported'. Result, plan and exploratory-session fields are created on the admin-settings surface instead." + }, + { + "name": "options", + "type": "array", + "description": "Choices for dropdown / multi_dropdown. Persisted ONLY when the request also carries applies_to_all_projects or assigned_projects \u2014 sent alone they are silently dropped while still being echoed in the response. For multi_dropdown every is_default is forced to false; for dropdown only the first is_default:true survives.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The choice as the user sees it. Must be non-empty.", + "minLength": 1 + }, + { + "name": "is_default", + "type": "boolean", + "description": "Pre-selected choice. At most one per dropdown; ignored for multi_dropdown." + } + ] + }, + { + "name": "applies_to_all_projects", + "type": "boolean", + "default": false, + "description": "Link the new field to every project that exists today. Heavy: the server enumerates the whole workspace (stopping around 3000 projects) and links each one. NOTE this is not the same flag as on the edit capability, where it only toggles future-project linking." + }, + { + "name": "assigned_projects", + "type": "array", + "example": [ + "PR-12345", + "PR-67890" + ], + "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ], + "items": { + "type": "string" + } + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "default": false, + "description": "Automatically offer this field in projects created later. Only meaningful alongside applies_to_all_projects or assigned_projects, because it is stored on the dataset those build." + }, + { + "name": "levels", + "type": "array", + "minItems": 2, + "description": "Hierarchy tiers, REQUIRED for field_type nested_dropdown and ignored for every other type. At least two entries, each with name and level_type; the schema refuses a nested_dropdown create without them.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Tier label, e.g. COUNTRY." + }, + { + "name": "level_type", + "type": "string", + "required": true, + "description": "Widget for the tier, e.g. dropdown." + }, + { + "name": "index", + "type": "integer", + "description": "Position of the tier, top tier first." + } + ] + }, + { + "name": "parent_custom_field_id", + "type": "integer", + "minimum": 1, + "description": "For nested_dropdown the KEY must be present even when the value is null \u2014 the upstream nested-create path is chosen on the key's presence. Set to an existing nested field's id to add one more tier to it, in which case only field_name is validated and the rest is copied from the parent." + }, + { + "name": "datasets", + "type": "array", + "description": "Advanced, forwarded upstream as sent. Lets one call define option values and project links together, including a recursive children tree for nested_dropdown. Prefer options + assigned_projects / applies_to_all_projects; reach for this only when you need nested option trees or explicit unlinks.", + "fields": [ + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "Expand to every project in the workspace before sending." + }, + { + "name": "linked_projects", + "type": "array", + "description": "Numeric project ids to link on this dataset (this path takes ids, not PR-NNN identifiers)." + }, + { + "name": "unlinked_projects", + "type": "array", + "description": "Numeric project ids to unlink on this dataset." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Link projects created later." + }, + { + "name": "options", + "type": "array", + "description": "Option rows; each may carry a children array one tier down for nested_dropdown." + } + ] + } + ], + "intent": "Define a new test-case custom field \u2014 choose its type, name it, say whether filling it is mandatory, and optionally give a dropdown its choices and link it to the projects that should offer it", + "guidance": [ + "field_type, field_name and is_required are all mandatory although the body schema marks nothing required, and is_required must be a real JSON boolean. Every failure of that validator comes back as the same 400 'Invalid Params' with no field named, so check all three before retrying.", + "options are only stored when the same request also carries applies_to_all_projects or assigned_projects \u2014 the choices live on a dataset, and no dataset is built without one of those. Sent alone they are dropped, and the response still echoes them, so a dropdown can look configured while holding no values. Re-read the field's datasets to confirm.", + "The returned custom_field is partly an echo of the request: options, assigned_projects, applies_to_all_projects and link_to_future_projects are replayed from what you sent (or defaulted to false / empty), not read back from storage. Only id, field_name, field_type, entity_type and is_required are authoritative.", + "assigned_projects takes PR-NNN identifiers, not numeric project ids, and one unresolvable identifier fails the whole create with 400 \u2014 resolve the projects first. applies_to_all_projects:true instead walks every project in the workspace (up to about 3000) and links them all, which is a slow call on a large account.", + "default_value cannot be set at create time for any type. Create the field, then set it through the field edit capability, where a boolean default is sent as the string 'true' or 'false'.", + "These names are reserved and rejected with 400 'Field with same name already exists. Use a unique name.': Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status.", + "Choose the type carefully \u2014 it can never be edited. Changing a field's type in practice means deleting it and creating it again, which destroys every value already stored on test cases.", + "nested_dropdown needs levels with at least two entries (name + level_type each) and the parent_custom_field_id key present even when null. To append one more tier to an existing nested field, send parent_custom_field_id plus field_name and nothing else.", + "Same stored fields as the admin-settings create capability, different contract: this route is TestCase-only, takes PR-NNN project identifiers rather than numeric ids, and answers with a serialized custom_field object instead of the raw upstream record.", + "Needs the field-write permission; 403 otherwise. The new field can be removed again, but any values captured on test cases in the meantime go with it.", + "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null \u2014 and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "options", + "applies_to_all_projects", + "assigned_projects", + "link_to_future_projects", + "system_name", + "is_bulk_editable", + "is_filterable", + "placeholder", + "group_id", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2DetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_custom_field", + "method": "GET", + "path": "/api/v2/custom-fields/{id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The field's numeric id, as the custom-fields listing returns it. It is validated as a positive integer before anything else runs: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer'. The published spec showing a prefixed 'cf-123' string is wrong." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the datasets fetched behind this field, reflected in dataset_ids / dataset_urls. `p` is accepted as a legacy alias for the same thing. Zero or non-numeric falls back to 1 instead of erroring." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 20, + "description": "Datasets per page behind this field. Defaults to 20 here (not 30) and is clamped at 300 rather than refused. Most fields have a single dataset, so this rarely matters." + } + ], + "intent": "Read one custom field by its numeric id \u2014 confirm its name, type, placeholder, default and whether it is mandatory, and pick up the ids of the datasets that hold its option values", + "guidance": [ + "applies_to_all_projects and link_to_future_projects come back hardcoded false on this read. They are shape fillers, not the field's real project configuration \u2014 never report project scope from this response; read the field's datasets for that.", + "No option values are included. What you get is dataset_ids plus ready-made dataset_urls and datasets_url; follow one of those to see the choices a dropdown offers and which projects it is linked to.", + "An id that exists nowhere is 404 'Custom field not found', while an id that is not a positive integer is 400 \u2014 the two are distinguishable, so do not treat a 400 as 'no such field'.", + "The `project` query parameter documented in the spec is not read by this route. Filtering a field's option values by project is done on the dataset listing, not here.", + "Needs the field-view permission when role-based access is on; 403 otherwise.", + "Same stored definition as the admin-settings single-field read, but a different body: that one returns the field with its datasets and options inline, this one returns metadata plus dataset references.", + "Built by the same serializer as the create and update replies, which omits any field without a value \u2014 so several keys are absent rather than null, and `is_bulk_editable` and `is_filterable` are omitted when FALSE as well as when unset, which makes those two indistinguishable from unset in the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "placeholder", + "system_name", + "is_bulk_editable", + "is_filterable", + "group_id", + "applies_to_all_projects", + "link_to_future_projects", + "dataset_ids", + "datasets_url", + "dataset_urls", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2WithDatasetsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_custom_field", + "method": "PATCH", + "path": "/api/v2/custom-fields/{id}", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The field's numeric id. Validated as a positive integer before the field is loaded: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer', and an id with no field behind it is 404 'Custom field not found'." + } + ], + "body": [ + { + "name": "field_name", + "type": "string", + "minLength": 1, + "maxLength": 256, + "example": "Test Environment", + "description": "New label. Omit it to keep the current one \u2014 this is a partial update. An empty string does NOT clear the name, it leaves the stored value untouched. Renaming onto a reserved system-field name is refused with 400." + }, + { + "name": "is_required", + "type": "boolean", + "description": "Whether the field must be filled. Omit to keep the current setting; false is honoured (unlike the text attributes, this one is applied even when falsy). Must be a real JSON boolean." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "New hint text, trimmed before storage. Omit to keep the current one. An empty string does not clear it \u2014 the stored value survives." + }, + { + "name": "default_value", + "type": "string", + "description": "New default. Unlike the create call this one is applied, for every field type. On a boolean field send the STRING \"true\" or \"false\" \u2014 it is coerced to \"1\"/\"0\", a real JSON boolean is rejected by the body schema, and an empty or falsy value leaves the stored default in place." + }, + { + "name": "options", + "type": "array", + "description": "Replaces the dropdown's option list WHOLE on the field's existing dataset \u2014 options you leave out are removed and the values test cases hold for them are gone, in every linked project, with no undo. Send the full intended list. Ignored on non-dropdown field types.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The choice as the user sees it.", + "minLength": 1 + }, + { + "name": "is_default", + "type": "boolean", + "description": "Pre-selected choice." + } + ] + }, + { + "name": "assigned_projects", + "type": "array", + "example": [ + "PR-12345", + "PR-67890" + ], + "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ], + "items": { + "type": "string" + } + }, + { + "name": "linked_projects", + "type": "array", + "example": [ + "PR-12345" + ], + "description": "Projects to ADD, as PR-NNN identifiers. Sending this (or unlinked_projects) switches off the assigned_projects diff entirely, so existing links you do not mention are left alone. An identifier that resolves to no project fails the call with 400.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ] + }, + { + "name": "unlinked_projects", + "type": "array", + "example": [ + "PR-99999" + ], + "description": "Projects to REMOVE, as PR-NNN identifiers. Unlinking discards the values those projects' test cases hold for this field and cannot be undone. An unresolvable identifier fails the call with 400.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ] + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Whether projects created later automatically offer this field. Applied to the field's dataset; sending it is itself enough to trigger the dataset write." + }, + { + "name": "applies_to_all_projects", + "type": "boolean", + "description": "Legacy alias that here ONLY sets the future-projects flag \u2014 it does not link the projects that already exist, unlike the same flag at create time. An explicit link_to_future_projects wins over it. Use linked_projects to reach existing projects." + }, + { + "name": "levels", + "type": "array", + "description": "Nested-dropdown tiers, forwarded upstream but currently ignored there for renames. Rename or retype a tier by editing that tier's own field id instead.", + "fields": [ + { + "name": "id", + "type": "integer", + "description": "Existing tier field id." + }, + { + "name": "name", + "type": "string", + "description": "Tier label." + }, + { + "name": "level_type", + "type": "string", + "description": "Widget for the tier." + }, + { + "name": "index", + "type": "integer", + "description": "Position of the tier." + } + ] + } + ], + "intent": "Change an existing test-case custom field \u2014 rename it, make it mandatory or optional, adjust its placeholder or default value, replace a dropdown's choices, or change which projects offer it", + "guidance": [ + "This is a PARTIAL update: every attribute you leave out keeps its stored value, so sending field_name alone is a valid rename. It differs from the admin-settings update capability, which is a full replace demanding field_name, field_type and is_required together.", + "None of it can be undone. Option lists and project links are rewritten in place, there is no bin and no prior revision: an option dropped from `options` is removed from the dataset and every value test cases hold for it disappears across all linked projects.", + "assigned_projects is read as the complete desired set, not an addition \u2014 any currently linked project you omit is unlinked, and the values stored there go with it. Count and name the projects you are about to drop before sending, or use linked_projects / unlinked_projects to change links one at a time.", + "field_type is not editable here. A field_type in the body is ignored, the stored type is kept and the call still answers 200, so never report a type change as done. A guard meant to refuse it with 400 exists but cannot fire, because the stored field is loaded after that check runs.", + "Empty strings do not clear anything: field_name, place_holder_text and default_value are applied only when non-empty, so the stored value survives an attempt to blank it. is_required is the exception \u2014 false is written.", + "Dataset-side changes (options, assigned_projects, linked_projects, unlinked_projects, link_to_future_projects, applies_to_all_projects) only take effect on dropdown, multi_dropdown and nested_dropdown fields. On any other type they are accepted, answered 200 and quietly ignored. If the field has no dataset yet, one is created.", + "applies_to_all_projects means something different here than at create time: it only toggles the future-projects flag and never links existing projects.", + "The two halves are not atomic. Field attributes are written first, the dataset second, so a failure on the dataset call leaves the rename or required-flag change already applied. Re-read the field before retrying.", + "The response replays options, assigned_projects, applies_to_all_projects and link_to_future_projects from your request rather than from storage \u2014 only the field attributes in it are read back. Confirm project links and option values by reading the field's datasets.", + "Needs the field-write permission; 403 otherwise. Unknown id is 404, a non-integer id is 400.", + "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null \u2014 and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "options", + "applies_to_all_projects", + "assigned_projects", + "link_to_future_projects", + "system_name", + "is_bulk_editable", + "is_filterable", + "placeholder", + "group_id", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2DetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_reports", + "method": "GET", + "path": "/api/v2/projects/{project_id}/reports", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "example": 2, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again with a 200. Rows come back newest-created first." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (5, 50, 100, 301) is refused with a 400 naming the two allowed values rather than clamped, so ask for 300 directly when you want a whole project in few calls." + }, + { + "name": "is_scheduled", + "type": "boolean", + "example": true, + "description": "Narrows to recurring (scheduled) reports only, and it is a one-way switch: the filter is applied only when the value is exactly the string 'true'. is_scheduled=false applies NO filter and returns both kinds, so it cannot be used to find one-off reports \u2014 read the is_scheduled flag on each row for that." + }, + { + "name": "report_type", + "type": "string", + "example": "Test Run Summary", + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Test Case Activity", + "Requirement Traceability Report", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "description": "Exact-match filter on the stored report type DISPLAY NAME, including spaces and capitals. A near-miss ('test run summary', 'Test Runs Summary') is not an error \u2014 it returns 200 with an empty list, which reads exactly like 'this project has no such reports'." + }, + { + "name": "q[query]", + "type": "string", + "example": "regression", + "description": "Case-insensitive substring search across report title AND report type, matched together. The parameter is nested and must go on the wire as q[query]=term. A flat q=term is not an error and not a search: the filter is skipped and you get the full unfiltered list with a 200." + } + ], + "intent": "Find a project's reports \u2014 search saved and scheduled reports by title or type, see which ones run on a recurring schedule and when each next fires, or pick up the SC-NNN identifier of a report you then want to read or download", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 \u2014 any other number is a 400, not a clamp \u2014 so pass 300 when you want the project in as few calls as possible.", + "Both one-off and scheduled reports are listed by default; is_scheduled=true narrows to scheduled ones, and nothing narrows to one-off ones.", + "scheduled_report_count and total_report_count describe the WHOLE filtered result set, not the page you are holding \u2014 use them to answer 'how many?' without paging, but remember any report_type / q / is_scheduled filter you sent is already baked into both numbers.", + "Each row's `id` is the SC-NNN identifier, and that is what the v2 single-report read and the v2 download take. It is NOT the integer report id the v1 report capabilities (report detail, report sections, drilldown, send-email-now) require: SC-NNN is a per-group sequence number, so stripping the prefix gives the wrong id. Get the integer from the v1 schedules listing, which returns `id` and `identifier` side by side on the same records.", + "Rows carry the report's CONFIGURATION only \u2014 type, timeframe, filters, schedule, recipients. No figures, no report data. Reading the numbers a report contains is a separate call.", + "`latest_attachment` is derived from the newest generated file in the whole PROJECT, not from this report \u2014 a row can advertise an attachment produced by a different report. Treat it as 'something was generated here recently', not as this report's output.", + "`dynamic_filters` is always null on every row, even for reports created with dynamic filters; only `report_filters` echoes back what was configured.", + "Cross-project reports appear under every project they cover, so the same SC-NNN can show up in more than one project's listing." + ], + "returns": [ + "success", + "reports", + "info", + "scheduled_report_count", + "total_report_count", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "V2ReportListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_report", + "method": "POST", + "path": "/api/v2/projects/{project_id}/reports", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project the report is filed under, in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. For a multi-project report this project is still the owner; the full set goes in project_ids." + } + ], + "body": [ + { + "name": "report_type", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Case Activity", + "Requirement Traceability Report", + "Exploratory Session Summary" + ], + "description": "What the report reports on, as a DISPLAY NAME - the set is closed and matched exactly, spaces and capitals included. Only these six can be created here. Other report types exist in the product and appear on reports created elsewhere - Test Plan Detailed Report, User Workload Report, Defects Summary, Defects Detailed Report - but every one of them is refused by this create. Never substitute a neighbour: an activity question is 'Test Case Activity', not 'Test Run Summary'." + }, + { + "name": "report_timeframe", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_sessions" + ], + "description": "What the report covers: either a rolling window, or a mode that says the scope comes from report_filters (specific_test_runs / specific_test_plans / specific_sessions). 'custom' pairs with custom_date_range. Two further values exist in the stored data and can be seen on reports created elsewhere - a requirements scope and a specific-test-cases scope - but neither is accepted by this create, and both are refused by body validation before any report-specific message is reached." + }, + { + "name": "report_filters", + "required": true, + "description": "Required - a create without this key is refused by body validation before anything else is checked, even when the report needs no scoping. Send an empty object for a rolling-window report. When you do scope it, this is a STRICT ALLOW-LIST of exactly four keys - test_runs, test_plans, session_ids, projects. Any other key, including a typo or an advanced-filter name such as status, priority, assignee, requirements, folder or per-case custom fields, is refused by name. Nothing is silently dropped." + }, + { + "name": "mail_to", + "required": true, + "description": "Required - a create without this key is refused by body validation, even for a report nobody will be emailed. Send both inner lists empty for that case. Recipients are wired up only when the report is genuinely recurring; on a one-off the value is accepted and then ignored, so nobody is emailed." + }, + { + "name": "frequency", + "required": true, + "values": [ + "once", + "daily", + "weekly", + "monthly" + ], + "description": "Required - a create without this key is refused by body validation. 'once' means a one-off, unscheduled report and is what you send when nothing should recur. A recurring value only takes effect when frequency_details is sent with it - frequency alone is silently ignored and you get a one-off back, created successfully." + }, + { + "name": "title", + "description": "Display name. Genuinely optional: omit it and the server fills in the report type followed by today's date, which is easy to mistake later for a report someone else made. Titles are not unique, and this is the field the list search matches on." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "description": "Whether the report is one the caller defines or one the product generates. REQUIRED \u2014 the request schema rejects a body without it, so a create that omits it fails before anything is stored. It was missing from this contract entirely, which made the capability impossible to call successfully from here." + } + ], + "intent": "Set up a new report over a project's test data \u2014 either a one-off you will download, or a recurring daily/weekly/monthly schedule that emails chosen people; this saves the report's definition (type, window, scope, recipients) and hands back its SC-NNN identifier, it does not produce any figures", + "guidance": [ + "`report_creation_mode` is required and takes `custom_report` or `system_generated`. Sending `system_generated` with a traceability report type is treated specially by the server, so use `custom_report` unless you specifically want the generated form.", + "This returns a report DEFINITION, not report content. The created body is the saved configuration echoed back - identifier, type, timeframe, filters, schedule, recipients - and contains no counts, rows or figures at all. Never present it as the report's findings.", + "Getting the content afterwards is a separate, ASYNCHRONOUS step: request a download for the new report identifier and the server answers with a job id and a status URL, having queued the generation; then poll the matching download-status read until status flips from 'processing' to 'ready' and a file URL appears (pdf or csv only). Nothing in this create waits for that.", + "The alternative route to content is the v1 report reads (report detail and its section/widget reads), but those take the INTEGER report id and this API never exposes it - the public identifier is a per-group sequence, so you cannot derive it. Resolve it through the v1 schedules listing, which carries both ids on the same record.", + "Six fields are required, not three: report_type, report_timeframe and report_creation_mode, plus report_filters, mail_to and frequency. The last three are required even when they carry nothing meaningful, and omitting any of them is refused up front by body validation with a message naming the missing property. An empty object for report_filters, empty lists for both parts of mail_to, and 'once' for frequency are all accepted and are exactly what an unscheduled, unmailed report needs.", + "Scheduling needs frequency AND frequency_details together, with frequency one of daily/weekly/monthly. Send frequency alone, or frequency 'once', and you get an unscheduled one-off back with no warning - check the scheduled flag and the next-run time in the response before telling anyone a schedule exists.", + "Reading a report back always shows a frequency of 'once' when no schedule is stored - that is a stand-in for an empty value, not evidence that a one-time run was scheduled. The scheduled flag and the next-run time are the fields that actually distinguish the two.", + "Recipients are wired up only for a recurring report: recipients on a one-off are accepted and then ignored, so nobody is emailed. When you do send them, include BOTH the in-group users and the external addresses (empty lists are fine, and there is a cap of 50 external addresses) - a missing inner key fails after the record is written, and the server deletes the half-built report and answers with an unprocessable-entity error.", + "Validation happens in two stages with two different error shapes. A missing required key, a value outside a published set, or a wrong type is caught first by body validation, which answers with a generic 'invalid JSON data' message plus a details list naming each problem. Everything after that - unknown emails, unresolvable identifiers, unsupported filter keys - is answered as a single message string prefixed 'Validation error.' Branch on the status, not on the body's shape.", + "Every identifier in the body is resolved before anything is written: an unknown email or an unknown run, plan or project identifier comes back as a 400 with field-level errors, and nothing is persisted.", + "Cross-project: more than one project is allowed only for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Case Activity and Requirement Traceability Report, is capped at 60 projects, and for every one of those except Test Case Activity also needs the cross-project feature enabled for the group - without it the answer is a forbidden error. The product's own multi-project list also names two report types this create will not accept at all, so ignore those here.", + "The only way back from this call is deleting the report, which is itself irreversible - there is no archive or trash for reports. Re-running a create with the same title does not replace anything: titles are not unique and you get a second report.", + "The attachment summary on the response is not this report's: it is the newest generated file anywhere in the project, and its ready flag is set whenever any such file exists. A brand-new report can therefore appear to already have output. Do not use it to decide whether this report has been generated." + ], + "returns": [ + "success", + "data", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "responses": { + "201": { + "$response": "V2ReportDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report", + "method": "GET", + "path": "/api/v2/projects/{project_id}/reports/{report_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. It is authoritative: the report must belong to this project or the read is a 404, even inside the same group." + }, + { + "name": "report_id", + "type": "string", + "required": true, + "example": "SC-42", + "pattern": "^SC-\\d+$", + "description": "Report identifier in SC-NNN form \u2014 the `id` field of the v2 report listing. The route is constrained to SC-\\d+, so the integer report id used by the v1 report capabilities gives a routing 404 here, and SC-NNN is a per-group sequence number that cannot be converted to that integer by stripping the prefix." + } + ], + "intent": "Read one report's setup by its SC-NNN identifier \u2014 what it covers (type, time window, the runs/plans/sessions it is scoped to), whether it is on a schedule and when it next runs, who receives it, and whether a generated file exists; configuration only, no figures", + "guidance": [ + "This returns the report's CONFIGURATION, never its data \u2014 there is no report_data, no counts and no rows in the response. For the numbers inside a report, either fetch the generated file (request a download for this SC-NNN, then poll the download status until it is ready) or use the v1 report detail / section reads, which take the INTEGER report id this API never exposes; the v1 schedules listing is where SC-NNN and that integer appear together.", + "Every miss is the same 404 'Report not found': an identifier that does not exist, one from another group, and one that exists but belongs to a different project. A 404 therefore does not mean the report is gone \u2014 retry under the project that actually owns it, and for a cross-project report any of its projects will serve it.", + "`latest_attachment` is NOT this report's attachment. It is the newest generated file anywhere in the project, so it can belong to a different report, and it reports ready:true whenever it is present. Its file_type is a MIME type such as application/pdf, not the 'pdf'/'csv' the spec advertises. Only a download job you started yourself tells you about this report's file.", + "`dynamic_filters` is hard-coded to null on this read even when the report was created with them; only report_filters reflects configured scope.", + "report_filters echoes public identifiers rather than the internal ids stored (TR-NNN, TP-NNN, assignee emails). Two caveats: a sub-plan filter comes back as TP-NNN because the sub-plan marker is not kept, and if the upstream identifier lookup fails the values degrade to prefix+raw-id which may not be a real identifier.", + "`frequency` reads 'once' for an unscheduled report \u2014 that is a substitution for a stored null, not something the report was created with. Trust `is_scheduled` and `next_run_at` for whether anything will actually fire.", + "`project_ids` lists every project a cross-project report covers, and `project_id` is only the owning one." + ], + "returns": [ + "success", + "data", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "responses": { + "200": { + "$response": "V2ReportDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_entity_attachments", + "method": "GET", + "path": "/api/v2/projects/{project_id}/{entity_type}/{entity_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "example": "test-results", + "values": [ + "test-cases", + "test-results", + "test-plans" + ], + "description": "Which kind of parent item to read attachments from. A CLOSED set of exactly these three hyphenated plurals, enforced in the route. Singular or snake_case spellings (test-case, test_case, testcase, test_results) do not match the route and come back as a routing 404 with no JSON body, not a validation error naming the field. Test runs and exploratory sessions are not attachable here." + }, + { + "name": "entity_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Form depends on entity_type: TC-NNN identifier for test-cases (resolved by identifier lookup; the internal integer case id gives 404 'Test case not found'), the plain INTEGER result id for test-results (no TR- prefix - that is a run), TP-NNN or STP-NNN for test-plans. The route leaves this segment unconstrained, so a wrong form is a 404 from the resolver rather than a 400." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page. Unlike the v2 run and result listings this is a real range, not an allow-list of 30/300 - any value up to 300 is honoured. Above 300 the call is refused with 400 'parameter_error' ('Page limit exceeds the maximum limit of 300'). A non-numeric or absent value is NOT an error: it silently falls back to 30, so a typo quietly shortens your page." + }, + { + "name": "file_type", + "type": "string", + "example": "image", + "description": "Filter by the attachment's content type, matched as a prefix - `image` keeps every image/* row, `application/pdf` only PDFs. Applied upstream; an unmatched value returns an empty list rather than an error." + }, + { + "name": "uploaded_by", + "type": "integer", + "example": 1234, + "description": "Filter by uploader, as the INTERNAL NUMERIC user id - not an email and not a username. The value is coerced with to_i, so a non-numeric value such as an email address becomes 0 and matches nothing while still returning 200. Resolve the user id from a project users read before filtering." + } + ], + "intent": "List the files attached to one test case, test result or test plan - use this to see what screenshots, logs or evidence are on the item and to get the URL for reading each file", + "guidance": [ + "One route serves all three parent types; entity_type is a closed set of exactly `test-cases`, `test-results` and `test-plans`, spelled hyphenated and plural. Anything else, including the singular or snake_case form, fails to match the route and returns a bare 404 rather than telling you the value was wrong.", + "entity_id is the trap: it is an IDENTIFIER for two of the three types and an integer for the other. test-cases wants TC-NNN (the internal integer id 404s 'Test case not found'), test-plans wants TP-NNN or STP-NNN, and test-results wants the plain integer result id - the one a v2 test-results listing returns as `id`. There is no route constraint on the segment, so the wrong form always surfaces as a 404 from the resolver, never a 400.", + "A test-result id that belongs to another project is rejected with 404 'Test result not found' rather than an empty list, so a 404 here means wrong id or wrong project, not an item with no attachments.", + "Page with `p` (NOT `page`) and `per_page`. per_page defaults to 30 and the real ceiling is 300; ask for 300 directly instead of walking pages of 30. Over 300 is a 400 'parameter_error'; a non-numeric value is silently treated as 30.", + "Rows are deliberately thin: id, name, size in bytes, created_at and url. Despite what the reference spec shows, this listing carries no content_type, byte_size, checksum or download_url. Read the file straight from `url` - there is no separate download capability for these attachments.", + "Inline attachments are always included. Images embedded in a description through the rich-text editor come back alongside real file attachments and carry an extra `data_id`, so this count can exceed what the item's attachment panel shows.", + "Prefer this over the v1 test-case attachments read whenever you hold a TC-/TP- identifier, need a test result's or plan's files, or want paging and file-type / uploader filtering. The v1 read handles test cases only, needs the integer project, folder and case ids, and returns every row in one unpaginated response.", + "`info` carries page, page_size, count, prev and next - use `count` to decide whether another page is worth fetching instead of paging until you get an empty array.", + "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments." + ], + "returns": [ + "success", + "attachments", + "info", + "id", + "name", + "size", + "created_at", + "url", + "data_id" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "AttachmentListV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_results", + "method": "GET", + "path": "/api/v1/test-results", + "mode": "read", + "entity": "result", + "query": [ + { + "name": "issue_id", + "type": "string", + "example": "BUG-123", + "description": "The tracker issue whose linked results you want \u2014 the issue KEY as the tracker shows it (Jira 'BUG-123', not a numeric id). Only filters when issue_type is sent with it; on its own it is ignored and you get the unfiltered account-wide list back. Forwarded with no validation, so a wrong or unknown key comes back as an empty list with success:true, never an error. This is the filter the Jira/ADO issue panel uses." + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Which tracker issue_id belongs to. REQUIRED for the issue filter to apply at all \u2014 the search only builds the issue predicate when both issue_id and issue_type are present, so sending issue_id alone silently returns every recent result in the account rather than that issue's results. Trackers the product stores links for: jira, azure, asana, linear, clickup, confluence, figma, devrev, gitlab, github, trello, onedrive, google-drive, sharepoint, zoho, custom. Not validated." + }, + { + "name": "priority", + "type": "string", + "example": "high", + "description": "ACCEPTED AND IGNORED. It is forwarded to the search service but is not a filterable field for results, and the underlying query never joins the test case, so there is nothing to match a case priority against. Sending it changes nothing: you get the same rows you would have got without it, not an empty list. Filter by priority yourself from the test_case object you get with compact=false." + }, + { + "name": "compact", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "true", + "example": "false", + "description": "Shape switch, compared as a literal STRING against 'false'. Only compact=false expands each row with its test_run and test_case objects; every other value (including omitting it, and including boolean false serialised oddly) gives the lean shape with issues and configuration only. Rows always carry issues and configuration whatever you send." + }, + { + "name": "include_relations", + "type": "string", + "description": "Read and then thrown away: the action overwrites it unconditionally from the compact switch before the search runs, on BOTH branches. Asking for a relation here has no effect at all and no error. Use compact=false to get test_run and test_case; there is no way to request any other relation on this endpoint." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 2, + "description": "PAGE NUMBER, and the parameter is named `p` \u2014 `page=2` is not read and silently returns page 1. A value that is not a strictly positive integer (0, -1, 'abc', or a repeated/array form) is rejected before the action with HTTP 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "description": "NOT a page size \u2014 a presence flag, and its value is discarded. Sending per_page at ALL (per_page=100, per_page=1, anything non-blank) SHRINKS the page to 10 rows; omitting it entirely gives 30 rows, the maximum this endpoint can return. Omit it whenever you want the fewest round trips." + }, + { + "name": "q", + "type": "object", + "description": "Optional wrapper for the same filters, e.g. q[issue_id]=BUG-123&q[issue_type]=jira. When q is present the flat top-level filters are IGNORED and only q's contents are read, and they reach the search service in a different (query_object) shape. It must be a nested param hash \u2014 a JSON-encoded STRING here raises and 500s. p and per_page are still read from the top level. Prefer the flat form.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "example": "BUG-123", + "description": "Same as the top-level issue_id, and it still needs issue_type beside it to filter anything." + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Same as the top-level issue_type, and equally required for the issue filter to apply." + }, + { + "name": "priority", + "type": "string", + "example": "high", + "description": "Same as the top-level priority: accepted and ignored." + }, + { + "name": "compact", + "type": "string", + "example": "false", + "description": "Same string-literal 'false' switch as the top-level compact." + }, + { + "name": "include_relations", + "type": "string", + "description": "Overwritten from compact exactly as at the top level; it does nothing." + } + ] + } + ], + "intent": "Find test results anywhere in the account without knowing the project, run or case \u2014 above all, list every result linked to a tracker issue (a Jira/ADO ticket key) to answer 'what testing covers this bug?', optionally narrowed by case priority", + "guidance": [ + "This is NOT a project read and it takes no project. The action replaces whatever project context arrived with a placeholder before calling the search service, so the search spans every project in the authenticated user's group (account) and is scoped by nothing but that group and your filters. That placeholder also skips the per-project access check, so rows from projects you would not otherwise be able to open can appear here. Passing a project_id query param cannot narrow it \u2014 a numeric one you lack access to only makes the call 404 before the search runs, and a PR-NNN one is ignored outright.", + "The ONLY filter that works is issue_id AND issue_type sent together. Either one alone is dropped, and `priority` is accepted but never applied \u2014 none of them produce an error, they just widen the call back to an account-wide dump of recent results. If a filtered call returns suspiciously many rows spanning many projects, that is what happened.", + "Use this when the only handle you have is an issue key, or when you need results across projects and runs. When you already know project + run + case and want that case's result history, use the per-case reads (list_test_results_for_test_case_by_integer_id / list_test_results_for_test_case) instead \u2014 they are scoped, richer, and cannot silently span the account.", + "Failures are swallowed. An upstream search error is logged and the request STILL answers HTTP 200 with success:true, an empty test_results array and info:null. An empty list is therefore not evidence that nothing matched \u2014 treat info:null as 'the search did not run' and retry rather than reporting zero results.", + "Page with `p`, never `page`, and do NOT send per_page: its value is discarded and its mere presence cuts the page from 30 rows to 10. 30 is the hard ceiling here; there is no way to ask for more, so a large issue's results must be walked page by page.", + "Row shape changes with compact. With compact=false you get test_run and test_case objects, and `test_run_id` is then overwritten with the run's TR-NNN identifier string. Without it there are no test_run/test_case objects and test_run_id / test_case_id come from the result mapping as integers \u2014 so the same field name carries a different type depending on one query flag. Never assume test_run_id is numeric.", + "Read the outcome from result_status.field_value, which is display case ('Passed'/'Failed'/'Blocked'). The flat `status` beside it is the lowercase internal token and is null on almost every row \u2014 it is the more obvious field name and the wrong one to reason over.", + "Several declared fields are structurally empty on this endpoint because the account-wide search does not load them: attachments is ALWAYS null (never an array - guard before iterating), updated_by is ALWAYS null, and started_at, finished_at, time_elapsed and created_by_imported are always null. The per-case and single-result reads do populate them.", + "Rows carry no custom fields and no step results, and `links.self` appears only for rows that came back with a project_id. To read a result's custom fields, follow up with the project-scoped single-result reads using the row's project_id and id.", + "Rows here are NOT the same shape as the project-scoped result reads \u2014 a different shape entirely, not a subset. This one carries author, backtrace, links and a full result_status object, plus updated_by and attachments keys that are always null here; the v2 per-case read carries created_by, custom_fields, dataset, step_result, urls and a result_status cut down to field_value alone. Do not write one parser for both.", + "issue_id and issue_type are always forwarded even when absent, so calling with no filters at all is a legal account-wide dump of recent results \u2014 expensive and rarely what you want." + ], + "returns": [ + "success", + "test_results", + "info", + "id", + "author", + "updated_by", + "status", + "result_status", + "description", + "error_description", + "backtrace", + "failure_type", + "log_type", + "expanded", + "started_at", + "finished_at", + "time_elapsed", + "created_at", + "updated_at", + "created_by_imported", + "attachments", + "issues", + "configuration", + "test_case", + "test_run", + "test_case_id", + "test_run_id", + "test_run_results_mapping_id", + "project_id", + "links" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestResultSearchResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_test_case_comments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", + "mode": "read", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "pattern": "^(TR-)?[0-9]+$", + "example": "TR-14", + "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The test case's NUMERIC id, not its TC-NNN identifier \u2014 the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." + } + ], + "query": [ + { + "name": "mappingId", + "type": "integer", + "minimum": 1, + "example": 90731, + "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "example": 1, + "description": "PAGE NUMBER \u2014 the parameter really is named `p`; `page=2` is ignored and you get page 1 again. Page size is fixed at 30 and cannot be changed. A page past the end does NOT error or come back empty: the server serves the LAST page again, so stop when info.next is null rather than when a page is empty. 0 or a non-number is a 400." + } + ], + "intent": "List the comments left on a test case inside a specific test run \u2014 use this to read the discussion or review feedback on that execution, find who said what and when, or discover a comment's id", + "guidance": [ + "Needs the test-case VIEW permission (a 403 'This action is not permitted for the user' otherwise) AND a paid plan: on a free plan the call still returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} \u2014 check `success` in the body, not the status code.", + "Three ids in the path, two conventions: project_id and test_case_id are plain INTEGERS (a TC-NNN in the case slot never matches the route), while test_run_id takes the TR-NNN identifier or the bare integer. Get the numeric case id from the `id` field of a v1 test case read or of the v1 run test-case listing.", + "A run/case pair that does not resolve is a 400 with message 'TRTC not found for given testrun_id:\u2026' \u2014 not a 404 \u2014 and an automation (Automate/Observability) run is a 400 'Can't comment on automation test'. A test case that does not exist in your group is a bare {success:false} 404.", + "Comments are threaded per run/case/configuration cell. If the case is mapped to more than one configuration in the run, pass mappingId to pick the cell; without it the server resolves a cell itself and you may read a different thread than the UI shows.", + "Newest first (by id), 30 per page, no page-size control. `info.count` is the total across pages; walk `p` until `info.next` is null \u2014 asking for a page beyond the end silently returns the last page again, so an 'empty page' loop never terminates.", + "Each row's `comment` is HTML with @mentions already rendered to display names; `user_id` is an OBJECT {id, full_name, email} whose `id` is the internal TM user id (null for imported comments, which show the original author's name). `edited` is true when the comment was changed after posting.", + "This is the only way to discover comment ids. Reading comments still works on an archived test case; posting does not.", + "Comments on the same test case outside a run are a separate thread with its own capability - nothing posted there appears in this listing, and nothing posted here appears there. Test-plan comments are a further separate surface.", + "Rows also carry group_id, project_id, parent_id, entity_id and entity_type. On this surface entity_type is always TRTC and entity_id identifies the run/case mapping cell, not the test case and not the run; parent_id is always null because replies are not threaded. review_status never appears on this surface - only comments recorded as part of a review verdict carry it, and nothing writes one against a run." + ], + "returns": [ + "comments", + "success", + "info", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "comment", + "edited", + "is_editable" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "CommentsListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run_test_case_comment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", + "mode": "write", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "pattern": "^(TR-)?[0-9]+$", + "example": "TR-14", + "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The test case's NUMERIC id, not its TC-NNN identifier \u2014 the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." + } + ], + "query": [ + { + "name": "mappingId", + "type": "integer", + "minimum": 1, + "example": 90731, + "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." + } + ], + "body": [ + { + "name": "comment", + "type": "string", + "required": true, + "json_path": "/comment/comment", + "example": "

Failed on Safari 17 only \u2014 retest after the fix for TM-1234 lands.

", + "description": "Comment body as HTML (rich text), nested as {\"comment\": {\"comment\": \"...\"}}. Whitespace-only or tag-only content is a 400 'Please enter valid comment' (an counts). Any other key inside the `comment` object is a 400 'Invalid JSON data'. To @mention: Display Name with the BrowserStack user id \u2014 all three attributes, as the product's editor writes them; without data-mention the post succeeds but the name shows as plain text, not a tag. Someone outside the group is a 403." + } + ], + "intent": "Post a comment on a test case within a test run \u2014 use this to leave review feedback on an execution, ask the run owner a question, record why a result was marked as it was, or @mention a colleague", + "guidance": [ + "Send the body as application/json with the nested shape {\"comment\": {\"comment\": \"

text

\"}} \u2014 the field is named comment inside an object also named comment. Without a JSON content type the schema check is skipped and the call fails later with a less helpful 400.", + "Needs the test-case VIEW permission (not a write permission) and a paid plan. Below a paid plan the call returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} and nothing is posted \u2014 check `success` in the body.", + "SIDE EFFECT: the run owner is notified of every new comment, and every @mentioned user gets a mention alert, immediately and outside this API. The author never alerts themselves. Confirm wording with the user before posting on their behalf.", + "Cannot post on an automation run (400 'Can't comment on automation test') or on an ARCHIVED test case (bare {success:false} 400 \u2014 reading still works). A run/case pair that does not resolve is a 400 'TRTC not found for given testrun_id:\u2026', not a 404.", + "If the case is mapped to several configurations in the run, pass mappingId so the comment lands on the intended cell; each cell has its own thread and the listing for a different mappingId will not show it.", + "@mentions must be the span markup carrying data-lexical-mention, data-mention (the display name) and data-id (the person's BrowserStack user id). Leaving out data-mention is NOT refused: the post returns 200 and the span is stored, but it shows as plain text and no tag appears. A mention with no id is a 403 'Missing User Id in comment', one for a user not in this group is a 403 'User does not belong to this project', and either fails the whole post. Plain '@name' text mentions nobody.", + "Link markup in the HTML is normalised server-side and mention spans are rewritten, so the stored `comment` returned in the response can differ from what you sent; use the returned `id` for any follow-up.", + "Success is HTTP 200 (not 201) with the new comment under data.comment. Recoverable: the author can edit or delete the comment afterwards in the product (delete hides it rather than destroying it) \u2014 those operations are not part of this pass.", + "A comment posted here attaches to the run/case cell, not to the test case itself: entity_type comes back as TRTC and entity_id is the mapping cell's id, never the test case id or the run id. It is visible only in the in-run comment listing and never in the test case's own comment thread, which is a separate capability. Test-plan comments are a further separate surface.", + "The returned comment never carries review_status on this surface - nothing writes a review verdict onto a comment attached to a run. On a freshly created comment edited is always false, is_editable is always true, and parent_id is always null because replies are not threaded." + ], + "returns": [ + "data", + "success", + "comment", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "edited", + "is_editable" + ], + "responses": { + "200": { + "$response": "CommentResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "export_test_run_csv", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/download", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." + } + ], + "body": [ + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv" + ], + "example": "csv", + "description": "Export format. Only 'csv' is accepted; any other value is a 422 'Only CSV file type is supported.' before anything is queued." + }, + { + "name": "required", + "type": "object", + "description": "Optional column selection keyed by entity: {test_run_test_case:{fields:[..]}, test_run:{fields:[..]}, test_result:{fields:[..]}}. Omit to export EVERY supported column. `fields` may be an array or comma-separated string; 'all' expands to every supported column. Unknown column names are NOT rejected: the call still returns 200 and the export fails later on the realtime channel.", + "fields": [ + { + "name": "test_run_test_case", "type": "object", - "description": "ExploratorySessionLog as returned by `update_exploratory_session_log`. Composes ExploratorySessionLog and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ExploratorySessionLog" - } - ], - "properties": { - "attachment_relations": { - "type": "string", - "nullable": true - } - } - }, - "ReportDetail__UpdateReport": { + "description": "{fields:[...]} \u2014 supported: id, title, priority, test_case_status, owner, folder_id, folder_path, case_type, automation_status, description, preconditions, template, steps, issues, tags, created_at, created_by, updated_at, updated_by, attachments, result_status, assignee, configurations, dataset_values, project, estimated_duration_seconds." + }, + { + "name": "test_run", "type": "object", - "description": "ReportDetail as returned by `update_report`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "is_dynamic": { - "type": "boolean", - "example": false - }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - }, - "SharedStepsIDResponse__UpdateSharedStep": { + "description": "{fields:[...]} \u2014 supported: id, title, created_at, created_by, state, tags, owner, type, updated_by, updated_at, requirements." + }, + { + "name": "test_result", "type": "object", - "description": "SharedStepsIDResponse as returned by `update_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SharedStepsIDResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "step_count": { - "type": "integer", - "example": 1 - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_case_count": { - "type": "integer", - "example": 0 - }, - "created_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } - } - }, - "shared_step_details": { - "type": "object", - "properties": { - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "example": "user=probe" - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } - } - } - } - } + "description": "{fields:[...]} \u2014 supported: attachments, defects, updated_at, updated_by, description, time_elapsed." + } + ] + } + ], + "intent": "Start a CSV export of a test run's cases and their latest results \u2014 use this when the user wants the run's contents as a downloadable spreadsheet file rather than data returned inline", + "guidance": [ + "The response carries no file and no download link. It only confirms that a background job was queued and returns a `channel` uuid. The CSV is built asynchronously, stored, and its time-limited download link is pushed to the requesting user's web session over a realtime channel; that push carries the same uuid as `id` so the UI can match it. Nothing in this HTTP API returns the link afterwards, so tell the user the file will appear in their Test Management browser session (download notification) rather than promising to hand it back.", + "A 200 means 'queued', not 'exported'. Failures inside the job (bad column names, upstream errors) surface only as a failure message on that realtime channel, never as an HTTP error, so this call cannot be polled for completion.", + "The whole run is exported: no filter or search criteria are honoured, and the export is capped at 20,000 rows fetched 300 at a time \u2014 a larger run is silently truncated at 20,000 rows.", + "Column selection defaults to ALL supported columns when `required` is omitted. Each test-case step after the first becomes its own extra row under the case, so row counts in the file exceed the case count.", + "This is a write only in the sense that it creates a stored export artifact; it does not change the run. It carries no run-specific permission check in this service (the action is absent from the permissions map), so any user who can reach the project can trigger it; plan or product-access gates still apply as a 403.", + "Works for automation runs as well as manual ones (the run lookup deliberately ignores run type). Closed runs export too.", + "Unlike the newer run endpoints, project_id must be the plain integer; the run may be given as TR-14 or its integer id." + ], + "returns": [ + "channel", + "success" + ], + "responses": { + "200": { + "$response": "DownloadReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Project not found, or the test run identifier resolves to no run in this project ('Test Run ID is invalid. Please enter valid Test Run ID and try again' / 'Test run not found.').", + "schema": { + "$schema": "ErrorResponse" + } + }, + "422": { + "description": "file_type was not 'csv': {success:false, message:'Only CSV file type is supported.'}", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "get_test_run_progress", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/progress", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." + } + ], + "query": [ + { + "name": "configuration_id", + "type": "integer", + "minimum": 1, + "example": 101, + "description": "Narrow to ONE configuration of the run. Changes the response shape: with it you get a single `overall_progress_by_status_id` breakdown (an empty object when there is no data); without it you get a paginated `configurations` array, each row carrying its own breakdown. Configuration ids come from the run detail's `configurations` list." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number for the configurations list, and the parameter really is named `p` (`page` is ignored). Only meaningful without configuration_id. 0, negative or non-numeric is a 400 'Invalid Params'. Page size is fixed at 30 and cannot be changed \u2014 there is no per-page parameter on this endpoint." + } + ], + "intent": "Get pass/fail/untested progress of a test run broken down by result status \u2014 per configuration (browser/device) across the whole run, or for one configuration \u2014 to answer 'how far along is this run on each configuration?'", + "guidance": [ + "Two response shapes from one endpoint, chosen by configuration_id. Without it: {success, configurations:[{id, name, ..., overall_progress_by_status_id}], info}. With it: {success, overall_progress_by_status_id}. Read the shape you asked for.", + "overall_progress_by_status_id is a map keyed by result-status id (as a string), each value {count, result_status:{id, field_value, internal_name, field_name, colour}}. Status ids are project-specific \u2014 use result_status.internal_name (e.g. 'passed') to compare across projects, not the numeric key.", + "A configuration row without any executed results may carry no overall_progress_by_status_id key at all, and the single-configuration form returns {} when there is no breakdown. Treat a missing or empty breakdown as 'nothing recorded', not as an error.", + "Paging: pass `p`; the page is always 30 configurations and there is no way to ask for more per page. When `p` is omitted no paging hints are sent upstream and the default page size is decided by the backing service, so send p=1 explicitly when you intend to walk pages. `info` is the backing service's paging block, passed through unchanged.", + "For a run-wide total (not per configuration) use the single-run detail instead \u2014 its overall_progress already aggregates across configurations.", + "Any upstream failure inside the calculation is reported as a 500 {success:false, message:'Failed to calculate configuration progress: ...'}, not as a 404 or 400. An unknown run identifier is still a 404 because the run is resolved before the calculation.", + "No run-specific permission is checked in this service (the action is absent from the permissions map); it is also one of the endpoints reachable with a public share-link token for a run." + ], + "returns": [ + "success", + "configurations", + "info", + "overall_progress_by_status_id" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestRunConfigurationProgressResponse" + }, + "400": { + "description": "`p` was 0, negative or non-numeric: {success:false, error:'Invalid Params'}", + "schema": { + "$schema": "ErrorResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Project not found, or the test run identifier resolves to no run in this project.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "description": "Progress calculation failed (including upstream errors): {success:false, message:'Failed to calculate configuration progress: '}", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "generate_test_case_automation", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/lcnc", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer test case id (not TC-NNN). The case is loaded from the entity store first, so an unknown id is a 404 before anything is submitted." + } + ], + "body": [ + { + "name": "version", + "type": "string", + "values": [ + "v1", + "v2" + ], + "default": "v1", + "description": "Payload flavour forwarded to the automation service. Anything other than 'v2' takes the v1 path and forwards `steps`; 'v2' forwards `test_case_details` + `tags` (+ preTestConfig) instead and REQUIRES test_case_details \u2014 with version=v2 and no test_case_details the request 500s." + }, + { + "name": "test_name", + "type": "string", + "example": "Login with valid credentials", + "description": "Display name for the generated automation test. Forwarded verbatim; not validated here." + }, + { + "name": "base_url", + "type": "string", + "example": "https://staging.example.com", + "description": "URL the generated automation starts from. Forwarded verbatim (also lifted into batch defaults on the bulk-pipeline path); not validated here." + }, + { + "name": "credentials", + "type": "string", + "description": "Login credentials the automation may use, forwarded verbatim to the automation service. Not stored in Test Management." + }, + { + "name": "local_enabled", + "type": "boolean", + "default": false, + "description": "Run against a BrowserStack Local tunnel. Defaults to false when omitted." + }, + { + "name": "lcnc_link", + "type": "string", + "description": "Existing automation build link (the case's metadata.lcnc_build_map link) when RE-automating. Its mere presence switches the submission from mode 'new' to 'reautomate'; omit it for a first-time automation." + }, + { + "name": "steps", + "type": "array", + "description": "v1 flow only: the test steps as plain strings. Ignored when version=v2." + }, + { + "name": "test_case_details", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "identifier", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "test_case_steps", + "type": "array" + } + ], + "description": "v2 flow only: {name, identifier, description, preconditions, test_case_steps[]} of the case, echoed to the automation service (it does not re-read the case). Required when version=v2 (missing \u2192 500); ignored otherwise." + }, + { + "name": "feedback", + "type": "string", + "description": "v2 flow only: free-text feedback on a previous generation, forwarded inside test_case_details." + }, + { + "name": "score", + "type": "number", + "description": "v2 flow only: rating of a previous generation, forwarded inside test_case_details." + }, + { + "name": "tags", + "type": "array", + "description": "v2 flow only: tags forwarded with the payload. Elements are strings." + }, + { + "name": "preTestConfig", + "type": "object", + "description": "v2 flow only: pre-test configuration object (setup steps/profile) forwarded verbatim when present." + }, + { + "name": "test_case_folder_id", + "type": "integer", + "description": "Folder id of the case. Only read on the bulk-pipeline submission path (environment flag); harmless otherwise." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder id of the case; when sent the folder is loaded for an ownership check and passed to the case lookup. Optional; an unknown folder fails the call." + } + ], + "intent": "Kick off no-code (LCNC / Low Code Automation) generation of an automated test for ONE test case \u2014 use this when the user wants a manual case automated or re-automated; it queues work in the automation service and returns immediately", + "guidance": [ + "Submission is handed to a separate automation service, and that service's own failures are not translated before they reach you: they arrive as 422 with the service's raw message copied into the `message` field as text. A 422 here does not mean your request was invalid, and the text in it is not addressed to you \u2014 do not retry against it field by field.", + "422 is the status used for that relayed failure regardless of what actually went wrong upstream, so it carries no information about whether the problem is transient. Treat it as 'submission did not happen' and check the case before retrying.", + "Nothing is recorded when submission fails: the case's automation status and its timestamps are untouched, so a failed attempt leaves the case immediately resubmittable rather than locked. Re-reading the case is how you tell a failed submission from a queued one.", + "A queued submission cannot be followed to completion through this index. Re-reading the case exposes its automation status, but no capability here exposes the resulting build link, so completion can only be confirmed in the product." + ], + "returns": [ + "status", + "body" + ], + "responses": { + "422": { + "description": "The automation service refused or failed the submission and its response was relayed as-is: `{\"success\": false, \"message\": }`. The string is frequently itself structured text rather than a sentence. This status is used for every upstream failure, not only for invalid input, and it is not a validation result for the body you sent. Nothing is queued and the case is unchanged.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Always false." + }, + "message": { + "type": "string", + "description": "The automation service's own response, relayed verbatim as text. Not a caller-facing message; do not parse or display it." } - }, - "FilterListRow": { - "type": "object", - "description": "One saved view as list_filters returns it: its id, name and scope only.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "is_project_level": { - "type": "boolean" - } + } + } + } + }, + "disabled": true + }, + { + "name": "list_binned_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/bin", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `page` is accepted as an alias and WINS if both are sent. Only `p` is validated (non-positive / non-numeric \u2192 400 'Invalid Params'); a bad `page` is forwarded as-is." + }, + { + "name": "limit", + "type": "integer", + "minimum": 1, + "description": "Rows per page, forwarded unchanged to the backing service, which owns the default and the ceiling (its client docs say default 25, max 100; not enforced in this codebase \u2014 see open questions). Ask for what you need and trust metadata.page_size for what you got." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match on test case title or identifier." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Only cases that lived in this folder when they were binned." + }, + { + "name": "moved_to_bin_by", + "type": "string", + "description": "Filter by who moved the case to the bin, as BrowserStack user id(s), comma-separated. `updated_by` is an accepted alias (moved_to_bin_by wins). These are browserstack_user_ids, NOT the internal user ids the review endpoints take." + }, + { + "name": "owner", + "type": "string", + "description": "Filter by case owner as BrowserStack user id(s), comma-separated." + }, + { + "name": "date_from", + "type": "string", + "example": "2026-09-01", + "description": "Binned on or after this ISO 8601 date." + }, + { + "name": "date_to", + "type": "string", + "example": "2026-09-16", + "description": "Binned on or before this ISO 8601 date." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "trashed_at", + "updated_at", + "name", + "identifier" + ], + "default": "trashed_at", + "description": "Sort column; default is when the case was binned." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "ASC", + "DESC" + ], + "default": "DESC", + "description": "Sort direction; upper-case only." + }, + { + "name": "status", + "type": "string", + "description": "Filter by status as numeric status ID(s), comma-separated \u2014 despite the UI-style name this takes ids, not display values (resolve them via get_system_field_values). `status_id` is an accepted alias (status wins)." + }, + { + "name": "priority", + "type": "string", + "description": "Filter by priority as numeric priority ID(s), comma-separated, not display values. `priority_id` is an accepted alias (priority wins)." + }, + { + "name": "type", + "type": "string", + "description": "Filter by case type as numeric category ID(s), comma-separated, not display values. `category_id` is an accepted alias (type wins)." + }, + { + "name": "automation_state", + "type": "string", + "description": "Filter by automation state as numeric ID(s), comma-separated. `automation_state_id` is an accepted alias (automation_state wins)." + }, + { + "name": "tags", + "type": "string", + "description": "Filter by tag name(s), comma-separated." + } + ], + "intent": "List the test cases sitting in a project's recycle bin (moved to bin / soft-deleted, awaiting purge) with who binned them and when \u2014 use this to find a case to restore or inspect; for just 'how many are in the bin' use count_binned_test_cases instead", + "guidance": [ + "Rows here are the WIDE v1 case shape, not the trimmed v2 one: `priority` and `status` arrive as objects carrying `field_value`, `colour`, `field_name` and `value_category` rather than as plain strings. Code written against a v2 case listing will read them as objects and compare wrongly.", + "Feature-gated: when the project/user lacks the Move to Bin or New Project Settings flags the call is 403 {success:false, error:'... is not enabled for this user'} \u2014 that is a feature gap, not a permission problem. Needs the tc_view permission under RBAC.", + "Paging metadata is under `metadata` {total, page, page_size, total_pages}, NOT the `info` {count, next, prev} envelope other v1 listings use \u2014 there is no `next`; stop when page >= total_pages.", + "Rows carry the integer `id`, the TC-NNN `identifier`, `trashed_at`, `days_remaining` until automatic purge, and `folder_name`/`folder_path` of where the case was. `updated_by` and `owner` are raw BrowserStack user ids; the resolved people are in `updator_details` (who binned it) and `owner_details` {id, browserstack_user_id, full_name, email}.", + "priority and status come back as small objects {id, field_value, internal_name} (status also has colour), not strings.", + "Only the filters listed exist. Binned cases are excluded from every other test case listing and search, so this is the only way to see them.", + "A backing-service failure is rendered as {success:false} with that service's status and no message." + ], + "returns": [ + "success", + "test_cases", + "metadata", + "id", + "identifier", + "name", + "priority", + "status", + "owner", + "owner_details", + "tags", + "issues", + "folder_name", + "folder_path", + "updated_by", + "updator_details", + "trashed_at", + "days_remaining", + "total", + "page", + "page_size", + "total_pages", + "template", + "created_by", + "creator_details" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BinnedTestCasesListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "count_binned_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/bin-count", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "intent": "Return just the number of test cases currently in a project's recycle bin \u2014 the cheap way to answer 'how many binned/deleted cases are there?' without pulling any rows into context (use list_binned_test_cases when you need the cases themselves)", + "guidance": [ + "Same feature gate as the bin listing: 403 {success:false, error:'... is not enabled for this user'} when Move to Bin / New Project Settings are off for this user. Needs tc_view under RBAC.", + "Counts the whole bin; it takes no filters. For a filtered count, call the listing with the filter and read metadata.total.", + "A backing-service failure is {success:false} with that service's status and no count." + ], + "returns": [ + "success", + "count" + ], + "responses": { + "200": { + "description": "Number of test cases in the bin.", + "schema": { + "type": "object", + "required": [ + "success", + "count" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "count": { + "type": "integer", + "example": 12 } - }, - "Filter__CreateFilter": { + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "submit_test_cases_for_review", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-review", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the test cases to send for review. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", "type": "object", - "description": "Filter as returned by `create_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions", - "properties": { - "priority": { - "type": "string", - "example": "P1" - } - } - } - } - }, - "Dataset__CreateDataset": { + "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." + } + ], + "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." + }, + { + "name": "q", + "type": "object", + "description": "SCOPE for select_all \u2014 the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project \u2014 verify the count before sending." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_case/reviewers", + "example": [ + 4821 + ], + "description": "INTERNAL Test Management user ids of the reviewers (the `id` from search_project_users / list_project_users \u2014 not browserstack_user_id, not email). Replaces the current reviewer set. Each id must belong to the caller's account (else 422 reviewer_not_found) and, when the project keeps an allowed-reviewer list, be on it (else 422). Omit to change status only. Elements are integers." + }, + { + "name": "review_status", + "type": "string", + "json_path": "/test_case/review_status", + "values": [ + "in_review" + ], + "example": "in_review", + "description": "The review state to move the selected cases into. Send 'in_review' to submit for review; it is forwarded to the backing store as a replace. Verdicts (approved/rejected/changes_requested) are NOT applied here \u2014 use apply_test_case_review_verdict." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Note to the reviewers. It is only carried in the reviewer notification when reviewers are given \u2014 it is NOT stored as a comment on the cases." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "format": "uuid", + "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use \u2014 a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." + } + ], + "intent": "Send one or many test cases for review \u2014 set them to 'in review' and assign the reviewers who must approve them; use it for a single case (ids with one element) or a whole selection/folder at once", + "guidance": [ + "Needs the project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 {success:false, message:'Feature not available'}). No RBAC permission entry exists for this action, so any authenticated project member with those two passes.", + "Part 11 e-signature: when the project setting part_11_reauth_send_for_review is 'true', the caller must first re-authenticate (email + password at the reauth endpoint, not in this index) and pass the returned reauth_token; missing/expired/reused \u2192 401 {success:false, error_code:'reauth_required'} and the block is written to the audit log.", + "Selections over 100 cases run asynchronously: the response is {success:true, async:true, unique_id} with NO testCases \u2014 the change lands in the background and you re-read the cases to confirm. Under the threshold the updated cases are returned inline.", + "Reviewer ids are the internal Test Management user id; the same people are addressed by browserstack_user_id in the bin listing filters, so do not carry ids across. A reviewer who is not on the project's allowed-reviewer list is refused (422) for the whole call \u2014 nothing is partially applied.", + "Cases locked by another bulk operation are refused with 400 {success:false, message:'TestCases in progress with another operation'}; retry after it finishes.", + "Every other failure collapses to 400 {errors:'An error occurred during bulk review'} \u2014 including backing-service errors \u2014 so a 400 is not necessarily a bad request.", + "Recoverable: review state is a tracked status; a reviewer verdict or a later edit moves the case on, and each case keeps version history.", + "Sending reviewers notifies them (in-app alert / email) immediately." + ], + "returns": [ + "success", + "testCases", + "async", + "unique_id", + "id", + "identifier", + "name", + "review_status", + "reviewers", + "review_sent_date", + "owner", + "status", + "priority", + "test_case_folder_id" + ], + "responses": { + "200": { + "$response": "TestCaseReviewOutcomeResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "ReauthRequired" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "apply_test_case_review_verdict", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-approve", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the test cases to give a verdict on. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", "type": "object", - "description": "Dataset as returned by `create_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__CreateDataset" - } + "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." + } + ], + "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." + }, + { + "name": "q", + "type": "object", + "description": "SCOPE for select_all \u2014 the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project \u2014 verify the count before sending." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." + }, + { + "name": "review_status", + "type": "string", + "required": true, + "json_path": "/test_case/review_status", + "values": [ + "approved", + "rejected", + "changes_requested" + ], + "example": "approved", + "description": "The verdict: 'approved' passes the case, 'rejected' fails it, 'changes_requested' sends it back to the author for edits. Applied to every selected case; there is no per-case verdict in one call." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Review comment. Unlike the submit-for-review comment this one IS stored: one comment is created on each selected case, tagged with the verdict, and echoed back under `comment`. Strongly recommended for rejected/changes_requested." + }, + { + "name": "notify", + "type": "boolean", + "json_path": "/test_case/notify", + "default": false, + "description": "Compared as the string 'true'. Also email the case owners the verdict (in-app personal alerts go out regardless)." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "format": "uuid", + "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use \u2014 a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." + } + ], + "intent": "Record a reviewer's verdict on test cases that are under review \u2014 approve them, reject them, or request changes \u2014 for one case or a whole selection in a single call", + "guidance": [ + "Every selected case must currently be 'in_review'. If any is not (the author edited it and it dropped back to pending, or it was already decided) the WHOLE call is refused with 422 {success:false, error_code:'invalid_review_state'} and nothing is applied \u2014 re-read the cases and resubmit only the ones still under review.", + "Needs project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 'Feature not available'). No RBAC permission entry exists for this action beyond project access.", + "Part 11 e-signature: when project setting part_11_reauth is 'true', every verdict value requires a fresh reauth_token from the re-authentication step (not in this index); missing/expired/reused \u2192 401 {success:false, error_code:'reauth_required'}.", + "Selections over 100 cases are applied asynchronously: {success:true, async:true, unique_id} with no testCases; re-read to confirm. Below that the updated cases come back inline, plus `comment` (the created comment objects) when a comment was sent.", + "Cases locked by another bulk operation \u2192 400 {success:false, message:'TestCases in progress with another operation'}.", + "When the backing store rejects the verdict (e.g. the audit-log write fails) its status is passed through with {success:false, error_code, errors}; the verdict was not applied. Any other failure is a generic 400 {success:false, errors:'An error occurred during bulk approval'}.", + "Recoverable: a verdict is a tracked review status \u2014 the author can edit and resubmit, a reviewer can decide again, and every case keeps version history. Nothing is deleted.", + "Side effects outside the data: owners get an in-app alert for every verdict, an email too when notify is 'true', and Part 11 projects write an audit-log row." + ], + "returns": [ + "success", + "testCases", + "comment", + "async", + "unique_id", + "id", + "identifier", + "name", + "review_status", + "reviewers", + "owner", + "status", + "priority", + "test_case_folder_id" + ], + "responses": { + "200": { + "$response": "TestCaseReviewOutcomeResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "ReauthRequired" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Verdict refused: a selected case is no longer under review (error_code invalid_review_state), or the account plan lacks the feature.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error_code": { + "type": "string", + "example": "invalid_review_state" + }, + "error": { + "type": "string", + "example": "Test case in Pending state cannot be approved. It was modified and moved back to Pending \u2014 refresh to review the latest changes." + }, + "errors": { + "type": "string" + }, + "message": { + "type": "string", + "description": "Present instead of error/errors on the plan gate ('Feature not available')." } - }, - "DatasetUserRef__CreateDataset": { + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "validate_bdd_export_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/exports/bdd/validate", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the cases you intend to export. Non-BDD cases among them are silently skipped by the validator. Send ONE of ids, select_all or folders." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Validate every BDD case in scope (q + folder_id, or the whole project) minus de_selected_ids. Compared as the string 'true'." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude from a select_all scope." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", "type": "object", - "description": "DatasetUserRef as returned by `create_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + "description": "{select_all: bool, ids: [int], de_selected_ids: [int]} for that folder." + } + ], + "description": "Cross-folder selection keyed by folder id; the union is validated. Takes precedence over ids/select_all. An empty map validates nothing (400 'No BDD Test Case(s) selected.')." + }, + { + "name": "q", + "type": "object", + "description": "Filter scope for select_all \u2014 same filter object as the test-case search, sent as a sibling of test_case. Only read when select_all is true; an unknown key is silently dropped (scope widens)." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all validation to. Ignored for explicit ids." + } + ], + "intent": "Check, before exporting test cases to a BDD feature file, that every selected BDD case shares the same Feature and Background text so they can be merged into one file \u2014 returns a pass/fail verdict and message, nothing is exported or changed", + "guidance": [ + "Pure check despite the POST: it only reads the selected cases' steps and compares them; no export job, file or record is created. Run it before the real export when the user wants a single merged feature file.", + "Pass and fail share ONE body shape {success, message}: HTTP 200 + success:true on pass, HTTP 400 + success:false on any failure. Read `message` to tell the failures apart \u2014 'No BDD Test Case(s) selected.' means the selection contained no BDD-template cases at all.", + "Hard cap: a selection resolving to more than 300 BDD cases is refused with 400, but with the MISLEADING message 'Validation failed. Selected test cases belong to different features or backgrounds.' \u2014 the same text as a genuine mismatch. Check the selection size yourself before trusting that message.", + "Only BDD-template cases are considered: text/step cases in the selection are filtered out silently and a pass says nothing about them.", + "Comparison is exact after trimming whitespace, on the Feature and Background of each case's FIRST step only.", + "No RBAC permission is attached to this action; project access and authentication suffice." + ], + "returns": [ + "success", + "message" + ], + "responses": { + "200": { + "$response": "BddExportValidationResponse" + }, + "400": { + "$response": "BddExportValidationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "create_test_case_comment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", + "mode": "write", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." + } + ], + "body": [ + { + "name": "comment", + "type": "string", + "required": true, + "json_path": "/comment/comment", + "example": "Checked against the latest build - step 3 still fails on Safari.", + "description": "The comment text, as rich text (HTML); plain text is accepted and kept as sent. Content that is only whitespace or empty tags is rejected as invalid, though an image on its own counts as content. To mention a colleague, send the span exactly as the product's own editor writes it: Display Name, with data-id set to their BrowserStack user id. All three attributes are needed: without data-mention the post still succeeds and the span is stored, but it shows as plain text rather than a tag. Mentioning someone outside the account is refused." + } + ], + "intent": "Post a comment on a test case.", + "guidance": [ + "The comment is attributed to the calling user and the case owner is notified the moment it lands; anyone mentioned in the text is notified separately. Those messages leave the product and cannot be recalled, so confirm the wording before sending.", + "The author can edit or delete their own comment afterwards - each is a separate operation - which is what makes this recoverable; an edit replaces the earlier text and keeps no previous version, and a delete leaves no self-serve way back.", + "Only the author can change a comment, so posting on someone's behalf leaves them unable to correct it.", + "Commenting on an archived test case is refused; restore the case first.", + "This is a paid-plan feature. On a plan without it the call still succeeds at the transport level and reports in the payload that the feature is unavailable, so check the success flag rather than assuming the comment landed.", + "Comments are flat: every one is posted against the case itself, and there is no way to reply beneath an existing comment.", + "Success is HTTP 200, not 201, and the new comment comes back one level down, inside a data envelope that holds a success flag alongside the comment object. Take the comment's id from there for any later edit or delete.", + "The returned comment carries id, user_id, group_id, project_id, parent_id, entity_id, entity_type, created_at, comment, edited and is_editable. user_id is the AUTHOR as an object with id, full_name and email rather than a bare id, and that id is the internal user id, not the BrowserStack one. parent_id is always null because replies are not threaded.", + "On this surface entity_type is always TestCase and entity_id is the test case's own id, so this thread is distinct from the one on the same case inside a test run: a comment posted here never appears in the in-run listing, and one posted there never appears here.", + "Two fields are fixed on a freshly created comment: edited is always false, and is_editable is always true. review_status is never present on a comment posted this way - it only ever appears on comments recorded as part of a review verdict.", + "Link markup in the body is normalised and mention markup is rewritten before the comment is stored, so the text returned can differ from the text sent; treat the returned text as authoritative." + ], + "returns": [ + "data", + "success", + "comment", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "edited", + "is_editable" + ], + "responses": { + "200": { + "$response": "CommentCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_folder", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "description": "Only the literal value true has any effect. When true, a name that clashes with a sibling (case-insensitive, trimmed) is created as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/folder/name", + "example": "Checkout", + "description": "Folder name. Trimmed; must not be empty after trimming and must be unique among siblings ignoring case, otherwise 400 'Folder with same name exists in this location' (unless auto_rename)." + }, + { + "name": "description", + "type": "string", + "json_path": "/folder/description", + "example": "Payment and cart flows", + "description": "Free-text notes shown on the folder. Use this key, not `notes` (the internal-API key): `notes` and any other unknown key inside `folder` are rejected with 400 'Invalid JSON data'. HTML is sanitised on save." + }, + { + "name": "parent_id", + "type": "integer", + "json_path": "/folder/parent_id", + "example": 12, + "description": "Integer id of the existing folder to nest under. Omit it to create a root folder (null cannot be sent through this transport). An id not in this project returns 404. 0 is not an alias for root." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "description": "Array of integer ids of already-uploaded attachment blobs to attach to the folder. Any invalid id fails the whole create with 422." + } + ], + "intent": "Create a folder, or a sub folder under a parent.", + "guidance": [ + "Resolve the project's PR- identifier first via the projects list; the numeric project id used by v1 capabilities is not accepted here.", + "Send the description as `description`. The `notes` key used by the internal API is rejected, and so is any key this capability does not declare: the body is schema-validated with unknown keys forbidden and the request fails with 400 'Invalid JSON data'.", + "Sibling names must be unique ignoring case and surrounding whitespace; a clash returns 400. auto_rename=true creates ' (1)' with the name lowercased instead, so read the returned name rather than assuming the one you sent.", + "parent_id 0 is not root: omit the field for a root folder. An unknown parent_id returns 404.", + "The response carries the new folder's integer id \u2014 carry it forward as parent_id for sub folders, as folder_id when creating test cases, or as the move destination.", + "cases_count and sub_folders_count come back null on creation rather than 0, even though a folder is empty the moment it is made. Read them as 0; every other folder capability reports the same brand-new folder as 0, and the rename and move capabilities return real integers.", + "A folder whose name already exists in the same parent is refused with a 400 and `{success: false, message: ...}` naming the collision \u2014 the only body on that path, and the reason a repeated create with a fixed name fails after the first. Vary the name or read the parent's children first." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_case", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-12", + "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 105, + "description": "Integer id of the folder the case is created in. This is authoritative: a different folder id in the body is ignored. Discover it from the folder listing." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Login with valid credentials", + "description": "Title of the test case. The only always-required field; comes back as `title` in the response.", + "json_path": "/test_case/name" + }, + { + "name": "description", + "type": "string", + "example": "Verifies the happy-path login flow.", + "description": "Rich-text body. Send plain text or HTML, not a JSON document.", + "json_path": "/test_case/description" + }, + { + "name": "preconditions", + "type": "string", + "example": "User account exists and is verified.", + "json_path": "/test_case/preconditions" + }, + { + "name": "template", + "type": "string", + "values": [ + "test_case_steps", + "test_case_bdd" + ], + "default": "test_case_steps", + "example": "test_case_steps", + "description": "Step style. `test_case_steps` takes `test_case_steps` rows; `test_case_bdd` takes Gherkin in `feature` / `background` / `scenario` and refuses step rows (422). Mixing the two is refused with 422.", + "json_path": "/test_case/template" + }, + { + "name": "template_id", + "type": "integer", + "example": 42, + "description": "Id of a test-case template from list_templates. Omit or send null for the project's default template. An unknown id, or one from another workspace, is refused with 400 rather than falling back to the default. Choose one whose step_type matches `template`.", + "json_path": "/test_case/template_id" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "Ordered step rows for a `test_case_steps` case. `order` is assigned by array position, so any `order` you send is overwritten. Not allowed for `test_case_bdd` (422). A differently named key such as `steps` is dropped and the case is created with NO steps, so verify against `steps` in the response.", + "fields": [ + { + "name": "step", + "type": "string", + "description": "Action the tester performs." + }, + { + "name": "result", + "type": "string", + "description": "Expected result of this step." + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer", + "description": "Reuse a shared step instead of inline text." + }, + { + "name": "shared_step_detail_id", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps" + }, + { + "name": "feature", + "type": "string", + "description": "Gherkin Feature text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", + "json_path": "/test_case/feature" + }, + { + "name": "background", + "type": "string", + "description": "Gherkin Background text; optional, BDD cases only (422 otherwise).", + "json_path": "/test_case/background" + }, + { + "name": "scenario", + "type": "string", + "description": "Gherkin Scenario text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", + "json_path": "/test_case/scenario" + }, + { + "name": "owner", + "type": "string", + "example": "jane@acme.com", + "description": "Owner as a workspace user's email (a user id is also accepted). An owner that cannot be resolved silently falls back to the caller \u2014 check `owner` in the response.", + "json_path": "/test_case/owner" + }, + { + "name": "status", + "type": "string", + "example": "Active", + "description": "Matched against the project's configured status display values (defaults: Active, Draft, In Review, Rejected, Outdated; a numeric option id also matches). Unknown value \u2192 400 'Invalid default field value'. Omit for the project default. Resolve the live set with get_system_field_values.", + "json_path": "/test_case/status" + }, + { + "name": "priority", + "type": "string", + "example": "High", + "description": "Matched against the project's configured priority display values (defaults: Low, Medium, High, Critical; a numeric option id also matches). Unknown value \u2192 400. Omit for the project default.", + "json_path": "/test_case/priority" + }, + { + "name": "case_type", + "type": "string", + "example": "Functional", + "description": "Matched against the project's configured type display values (defaults include Functional, Regression, Smoke & Sanity, Acceptance, Security, Performance, Usability, Accessibility, Compatibility, Destructive, Automated, Manual, Other). Unknown value \u2192 400. Omit for the project default.", + "json_path": "/test_case/case_type" + }, + { + "name": "automation_status", + "type": "string", + "default": "not_automated", + "example": "not_automated", + "description": "Built-in values: not_automated, automated, automation_not_required, cannot_be_automated, obsolete (matched case-insensitively by internal name or display label). Workspaces that manage their own automation statuses accept their custom labels too. Unknown value \u2192 400.", + "json_path": "/test_case/automation_status" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login", + "smoke" + ], + "description": "Free-text tag names; created if new.", + "json_path": "/test_case/tags" + }, + { + "name": "issues", + "type": "array", + "example": [ + "TES-204" + ], + "description": "Linked issues. Either bare issue keys (treated as Jira) or objects {issue_id, issue_type}. Linking is processed asynchronously after the create returns.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "description": "Issue key, e.g. TES-204." + }, + { + "name": "issue_type", + "type": "string", + "description": "Tracker name, e.g. jira." + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "Component": "Checkout", + "Regression candidate": true + }, + "description": "Map of custom-field DISPLAY NAME \u2192 value. Dropdown: option label; multi-dropdown: array of labels; user: email; boolean: true/false; date, int, url, text as typed. A name that is not a custom field on this project is SILENTLY SKIPPED (200, value absent); a reserved system-field name (e.g. 'Automation Status') is refused; a value failing its type check fails the whole call with 400.", + "json_path": "/test_case/custom_fields" + }, + { + "name": "identifier", + "type": "string", + "example": "LOGIN-001", + "description": "Custom identifier for the case. Honoured ONLY when the workspace uses project-level custom ids; otherwise ignored. A duplicate is refused with 400 'Test Case ID already exists'.", + "json_path": "/test_case/identifier" + }, + { + "name": "review_status", + "type": "string", + "description": "Review assignment state. Reviewer write: gated on a Team Pro plan and the project's Review & Approve setting when the reviewer API is enabled for the workspace. Omit unless the user asked to put the case in review. Known states include pending and in_review; the accepted set is not enforced here, so an unknown value is refused by the server.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "example": [ + "jane@acme.com" + ], + "description": "Reviewer emails. Requires Team Pro plan and Review & Approve enabled on the project (422/400 otherwise). The owner cannot be a reviewer (422). A non-array value is silently ignored.", + "json_path": "/test_case/reviewers" + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Moves the new case into review and emails reviewers. Same plan/project gates as `reviewers`. Ambiguous strings like 'yes'/'no' are refused with 422.", + "json_path": "/test_case/send_for_review" + }, + { + "name": "created_at", + "type": "string", + "format": "date-time", + "description": "Backdated creation timestamp for migrations. Honoured only when the workspace has the external-API timestamp feature enabled; otherwise dropped silently.", + "json_path": "/test_case/created_at" + }, + { + "name": "updated_at", + "type": "string", + "format": "date-time", + "description": "Backdated update timestamp for migrations. Same feature gate as `created_at`; must not be earlier than `created_at` (400).", + "json_path": "/test_case/updated_at" + } + ], + "intent": "Create a test case in a folder.", + "guidance": [ + "Resolve the project's PR-NNN identifier and the destination folder's integer id first; the folder in the path is authoritative and a folder id in the body is ignored.", + "Decide the step style before writing: a Gherkin case needs template=test_case_bdd plus feature and scenario and must carry no test_case_steps; a classic case must carry no feature/background/scenario. Either mix is refused with 422, and a template_id whose step_type disagrees is the usual cause.", + "status, priority and case_type take the project's DISPLAY values (e.g. 'Active', 'High', 'Functional'), which a workspace can rename or extend \u2014 resolve them with get_system_field_values rather than guessing; unknown values are refused with 400.", + "custom_fields is keyed by display name and an unknown name is dropped without error, so confirm each field exists on the project (get_project_form_fields) and verify the response's custom_fields before reporting them saved.", + "The response echoes the case with `title` (not name), `steps` as [{step, result}] and `owner` as an email, and carries NO integer id \u2014 carry data.test_case.identifier forward for later reads, updates and moves.", + "Recoverable: the new case can be deleted or archived afterwards, so this is an ordinary write.", + "The echoed case omits keys rather than returning them empty. A freshly created case has no `attachments` key at all \u2014 not an empty array \u2014 because nothing can be attached yet; `feature`, `background` and `scenario` are present only on a Gherkin case; `reviewers` and `review_status` only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps` and `custom_fields` are always present, empty when nothing is set, so only those three are safe to iterate unguarded." + ], + "returns": [ + "identifier", + "title", + "description", + "preconditions", + "template", + "folder_id", + "status", + "priority", + "case_type", + "automation_status", + "owner", + "tags", + "issues", + "steps", + "feature", + "background", + "scenario", + "custom_fields", + "attachments", + "is_shared", + "review_status", + "reviewers", + "created_at", + "created_by", + "last_updated_at", + "updated_by", + "urls" + ], + "responses": { + "200": { + "$response": "V2TestCaseResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "error_code=review_approve_unknown \u2014 the project's Review & Approve setting could not be read, so nothing was written. Retry.", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "edit_project", + "method": "POST", + "path": "/api/v1/projects/{project_id}/edit", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "example": 11046, + "description": "Integer project id (the numeric `id`, not the PR-NNN identifier). Discover it through the projects list. A project the caller cannot see returns 404, not 403." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/project/name", + "example": "Checkout Regression", + "description": "New project name. Leading/trailing spaces are trimmed. Must be unique within the account and must not contain | ; > < ` + ? % \" / or backslash; violations return 400 with the reason in `error`. Omit to keep the current name." + }, + { + "name": "description", + "type": "string", + "json_path": "/project/description", + "example": "Owns all checkout-flow test cases", + "description": "New description. HTML is escaped on the way in and anything past 16 KB is silently truncated. Send an empty string to clear it; omit to keep the current one." + }, + { + "name": "access_type", + "type": "string", + "json_path": "/project/access_type", + "values": [ + "private", + "restricted", + "org_wide" + ], + "example": "org_wide", + "description": "Project visibility: `private` (owner only), `restricted` (owner plus named users), `org_wide` (everyone in the account). Other values return 400 'Invalid access type'. Honoured only on accounts with project-level access control; elsewhere silently ignored and the response still says success. Server may adjust it: private with named users becomes restricted, restricted with none becomes private." + }, + { + "name": "users", + "type": "array", + "json_path": "/project/users", + "fields": [ + { + "name": "user", + "type": "string", + "required": true, + "description": "Email address of a user in the same account." + }, + { + "name": "role", + "type": "string", + "required": true, + "description": "Product role name to grant on this project (account-configured, e.g. Product Admin, Tester, Viewer)." + } + ], + "example": [ + { + "user": "qa.lead@example.com", + "role": "Tester" + } + ], + "description": "Per-user access grants. Processed only when `access_type` is also sent, otherwise ignored. Each entry is added or has its role changed; users not listed keep their existing access, so this does not replace the list. Entries whose email is not in the account or whose role is not recognised are skipped silently, not rejected." + } + ], + "intent": "Rename a project, or change its description or visibility.", + "guidance": [ + "There is no undo. The previous name and description are overwritten; the change is logged in the project's activity feed and version history, so the old value can be looked up and re-entered by hand, but nothing restores it.", + "Send at least one of name, description or access_type inside the `project` object; an empty `project` object is rejected 400 before anything is looked up.", + "Only account owners/admins or the project owner may edit; anyone else gets 403 with the reason in `error`. A project the caller cannot see comes back 404, not 403.", + "Visibility is silently ignored on accounts without project-level access control: the call returns 200 and the unchanged value. Read `access_type` back from the response to confirm the change took.", + "The `users` list is only read alongside `access_type`. To change one user's role without touching visibility, resend the current access_type with the users entry.", + "Name conflicts and blocked characters are enforced by the backend and surface as 400 with a human-readable `error`; validation runs after the permission check, so a 403 means permissions, not the name." + ], + "returns": [ + "id", + "thProjectId", + "identifier", + "name", + "description", + "created_at", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "links", + "normalisedName", + "starred", + "jira_mapped", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project edited. Envelope is data.success and data.project, same as the single-project read; project also carries access_type, normalisedName and projectVisibilityBanner, and the counts are carried over from before the edit rather than recomputed.", + "schema": { + "$schema": "V1ProjectDetail" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_folder_attachments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "List the files attached to a folder itself.", + "guidance": [ + "This is the folder's own shelf of files. Files attached to the test cases in the folder are a separate list and none of them appear here.", + "It does not look inside subfolders, so walk the folder tree and ask for each folder you care about.", + "Each entry carries links to the stored file; read the file from there, as there is no separate download step.", + "A folder with nothing attached comes back as an ordinary success with an empty list, so an empty list is an answer and not a failure.", + "Alongside the exact byte count each entry also carries a ready-formatted size for display; use the byte count when you need to compare or add sizes up.", + "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments.", + "This lists attachments held on the FOLDER itself, which is a different collection from the attachments on the cases inside it \u2014 attaching a file to a case never makes a row appear here." + ], + "returns": [ + "success", + "attachments", + "id", + "name", + "content_type", + "byte_size", + "key", + "checksum", + "url", + "download_url", + "product_id" + ], + "responses": { + "200": { + "$response": "FolderAttachmentsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_exploratory_session_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/form-fields", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "values", + "type": "boolean", + "default": false, + "description": "Set to true to get each field's configured choices alongside its definition. Left off, the reply is a compact list of the field definitions with the choice lists omitted, which is enough to learn the field names but not the values they accept.", + "example": true + } + ], + "intent": "List the form fields for authoring an exploratory session.", + "guidance": [ + "Asking for the configured choices does not currently work: any truthy value for the choices flag is answered with a not-found, on every project, while leaving it off succeeds. Call it without that flag and expect field names and types but no choice lists, and resolve a dropdown's accepted values another way.", + "The choices flag is matched against the exact lowercase word true; anything else, including a capitalised spelling, is treated as off.", + "Only the fields your team added are listed. The built-in session details such as name, charter and status are never described here - the built-in list is always returned empty - so an empty reply means nothing extra was configured, not that the session has no fields.", + "Both lists can be empty on a perfectly valid project. Do not read that as a failure.", + "Configuration is per project, so fetch it again for each project - a field set up in one project need not exist in another.", + "Each field carries both an internal name and the display name your team sees; when a person names a field in plain words, match it against the display name.", + "A field describing a dependent choice points at the field it hangs off, so resolve the parent's value first when one is present.", + "Errors here do not carry the enveloped error object described elsewhere in this registry: a not-found comes back as nothing more than a success flag set to false, with no code, message or details to branch on." + ], + "returns": [ + "success", + "custom_fields", + "default_fields", + "id", + "field_name", + "field_user_name", + "field_type", + "entity_type", + "is_required", + "optional", + "option_values", + "field_values", + "default_value", + "placeholder", + "is_bulk_editable", + "is_filterable", + "parent_custom_field_id", + "links" + ], + "responses": { + "200": { + "$response": "ExploratorySessionFormFieldsResponse" + }, + "404": { + "description": "Returned for a project that cannot be read, and - currently - for every request that asks for the configured choices. The body carries only a false success flag: no error object, no code, no message.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + } + } + } + } + } + }, + { + "name": "get_lcnc_user_test_config", + "method": "GET", + "path": "/api/v1/projects/{project_id}/lcnc/user-test-config", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project the setup is read for." + } + ], + "intent": "Get the caller's saved low-code test setup for a project.", + "guidance": [ + "The setup itself belongs to BrowserStack's low-code automation product and is handed back exactly as that product produced it. Test Management does not define, validate or reshape those fields, so they are not guaranteed here and may change without a change in Test Management - read them each time rather than relying on a remembered shape.", + "The status reported inside the payload is the one the other product returned, echoed as the transport status as well, so its failure surfaces as that same code here with the detail in the payload.", + "The setup is per person: it is the calling user's own default, so two people on the same project can correctly get different answers, and it is not a project-wide setting you can read on someone else's behalf." + ], + "returns": [ + "status", + "body" + ], + "shape": "discovered", + "responses": { + "200": { + "description": "The low-code automation service's own response, passed through unchanged. `status` echoes that service's status code and `body` is its parsed payload, whose fields are owned by that service and are not guaranteed by Test Management.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "description": "Status code echoed from the upstream service." + }, + "body": { + "type": "object", + "description": "Upstream payload, passed through unchanged. Field set may vary; see the `discovered` shape." } - }, - "FolderWriteResponse__CreateRootFolder": { + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_shared_components", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-components", + "mode": "read", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project whose shared fields are listed." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, starting at 1. The page size is fixed by the service and cannot be raised from here; read the returned page details to see the total and whether another page exists." + }, + { + "name": "q[query]", + "type": "string", + "description": "Text matched against the shared field's title only - its step content is not searched. Send it in this bracketed form; a bare q value is ignored and the unfiltered list comes back looking like a successful search." + }, + { + "name": "q[tags]", + "type": "string", + "example": "smoke,regression", + "description": "Comma-separated tag names. Only shared fields carrying at least one of them are listed." + }, + { + "name": "q[empty][system_fields][]", + "type": "array", + "values": [ + "tags" + ], + "example": [ + "tags" + ], + "fields": [ + { + "name": "system_field", + "type": "string" + } + ], + "description": "Lists shared fields that have nothing set for a system field. Only tags is understood; any other name is dropped without an error, which silently widens the result. Combines with a tag filter as an either/or rather than narrowing it." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Restrict the listing to one kind of shared field. Omit to list every kind." + }, + { + "name": "folder_id", + "type": "string", + "example": "null", + "description": "Collection to list from: the collection's numeric id, or the literal four-character text null to list the Unassigned root where uncollected fields sit. That is the word null as a value, not an absent one. Omit the parameter entirely to list the project's shared fields regardless of collection." + }, + { + "name": "include_sub_collections", + "type": "boolean", + "default": false, + "description": "Set true to include the chosen collection's sub-collections as well as the collection itself. Only the exact value true turns it on, it does nothing without a collection, and it never applies to the Unassigned root, which has no children." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort order for the listing, passed straight through to the service that stores shared fields. Omit it for the default order; an unrecognised value is not reported as an error here." + }, + { + "name": "pre_fetch", + "type": "boolean", + "default": false, + "description": "When true the response also carries the steps of the first shared field on the page, saving a follow-up call. Honoured only on page 1 and only when the page has at least one row." + } + ], + "intent": "List or search a project's shared fields.", + "guidance": [ + "Shared fields and shared steps are different objects with separate listings and separate id sets; an id taken from one will not resolve in the other, so confirm which the user means before acting.", + "The listing carries each field's title, type and usage count but not its step content - fetch a single shared field when the actual steps are needed.", + "The usage count is what tells you how wide the blast radius of an edit would be; read it before suggesting a change to a field.", + "Filtering by collection is the only way to reproduce what the user sees in a collection view - an unfiltered list spans the whole project and will look like the filter was ignored." + ], + "returns": [ + "data", + "id", + "title", + "field_type", + "step_count", + "test_case_count", + "tags", + "created_by", + "updated_by", + "info", + "pre_fetched_shared_step", + "success" + ], + "paginated": true, + "responses": { + "200": { + "$response": "SharedComponentsGetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_case_comments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", + "mode": "read", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, starting at 1. Each page holds at most 30 comments and the size cannot be raised; the response reports the total and the next page number." + } + ], + "intent": "Read the comments on a test case.", + "guidance": [ + "The newest comment is first on page 1, so a 'what was said last' question is answered without paging; page on only when the reported total exceeds what you already hold.", + "Comments deleted by their author are gone from every page and from the total, so a thread can be shorter than a screenshot or an email suggests.", + "Bodies are rich text (HTML). A mention of a colleague arrives as markup whose visible text is that person's display name, so strip tags before showing the text plainly.", + "An archived test case still returns its comments, even though nothing new can be added to it.", + "Needs the test-case view permission and a paid plan. Below a paid plan the call still comes back as HTTP 200 with a false success flag and a message saying the feature is for paid customers, so read the success flag in the body rather than trusting the status code.", + "The page parameter is named p, and page size is fixed at 30 with no way to raise it. Asking for a page past the end neither errors nor returns an empty list - it serves the last page again - so stop when the reported next page is null, never when a page comes back empty.", + "Each row's user_id is the AUTHOR as an object with id, full_name and email rather than a bare id; that id is the internal user id and is null for comments brought in by an import, where full_name carries the original author's name instead. Rows also carry group_id, project_id, parent_id, entity_id and entity_type, and parent_id is always null.", + "On this surface entity_type is always TestCase and entity_id is the test case's own id. Comments left on the same case inside a test run are a separate thread with its own capability - nothing posted there shows up here.", + "review_status appears on a row only when that comment was recorded as part of a review verdict, which is how the bulk review flow on test cases writes it; a comment posted through the ordinary comment endpoint never carries it. Treat the field as optional and absent by default.", + "This listing is the only way to discover a comment's id, which every later edit or delete needs." + ], + "returns": [ + "comments", + "success", + "info", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "comment", + "edited", + "is_editable" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-12", + "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-1024", + "description": "The case's TC-NNN identifier, or its customer-defined identifier when the workspace uses project-level custom ids; matched exactly within the project. A bare integer id is NOT accepted here (404) \u2014 use get_test_case_by_integer_id for integer ids." + } + ], + "intent": "Get one test case in full by its TC-NNN identifier.", + "guidance": [ + "Returns `title` rather than name, `steps` as [{step, result}], custom_fields as [{name, value|values}] and owner as an email; there is no integer id in the body.", + "feature/background/scenario are present only for a Gherkin (test_case_bdd) case and `steps` is then empty; reviewers/review_status appear only when the project has Review & Approve enabled.", + "A 404 means the identifier does not resolve in THIS project; a case moved to another project keeps its identifier but must be read there." + ], + "returns": [ + "identifier", + "title", + "description", + "preconditions", + "template", + "folder_id", + "status", + "priority", + "case_type", + "automation_status", + "owner", + "tags", + "issues", + "steps", + "feature", + "background", + "scenario", + "custom_fields", + "attachments", + "is_shared", + "review_status", + "reviewers", + "created_at", + "created_by", + "last_updated_at", + "updated_by", + "urls" + ], + "responses": { + "200": { + "$response": "V2TestCaseResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "Upstream read failed with an unknown status; retry.", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "search_all_projects", + "method": "GET", + "path": "/api/v1/global/search", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "entity", + "type": "string", + "required": true, + "example": "test_case,test_run,project", + "description": "Comma-separated list of the entity types to search. Allowed tokens: test_case, test_run, test_plan, project, shared_step, report. They are snake_case and SINGULAR \u2014 the hyphenated plurals used by the project-scoped search (test-cases, test-runs) are rejected here with a 400 naming the invalid token. Omitting this is a 400, not a search of everything." + }, + { + "name": "q[query]", + "type": "string", + "required": true, + "example": "login", + "description": "The text to search for. Required \u2014 a blank or missing value is a 400. %, _ and \\ are literal characters here and are not escaped or treated as wildcards." + }, + { + "name": "q[project_ids]", + "type": "string", + "example": "101,102", + "description": "Comma-separated NUMERIC project ids to restrict the search to. These are integer ids, not PR-NNN identifiers. Omit to search every project the caller can see." + }, + { + "name": "q[owner]", + "type": "string", + "example": "14,15", + "description": "Comma-separated Test Management user ids; the server converts them to the underlying user ids. Applied to test cases, runs and plans, and to reports. Deliberately NOT applied to project or shared_step hits, whose stored user is a creator rather than an owner, so an owner filter does not narrow those two at all." + }, + { + "name": "q[tags]", + "type": "string", + "example": "regression,smoke", + "description": "Comma-separated tag ids applied to every entity type being searched. A per-entity tag filter overrides it for that entity." + }, + { + "name": "q[issue_ids]", + "type": "string", + "example": "JIRA-1,JIRA-2", + "description": "Comma-separated linked-issue ids. Applied to test cases only at the shared level; use the per-entity form for runs." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "7_day", + "description": "Relative token `_` with a SINGULAR unit (day, hour, week, month, year) \u2014 e.g. 7_day, 24_hour; a `_gte` suffix inverts it (7_day_gte = older than). Also accepts `all_time` (resolved as one year) or an absolute \"YYYY-MM-DD YYYY-MM-DD\" range. A plural unit such as 7_days raises server-side rather than returning a 400." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "1_month", + "description": "Same form as q[created_at]." + }, + { + "name": "q[created_between]", + "type": "array", + "example": [ + "2026-04-05T00:00:00.000Z", + "2026-05-05T23:59:59.999Z" + ], + "minItems": 2, + "maxItems": 2, + "fields": [ + { + "name": "0", + "type": "string" + }, + { + "name": "1", + "type": "string" + } + ], + "description": "Absolute creation range as a positional 2-element array: index 0 is the start, index 1 the end, ISO 8601. Takes precedence over q[created_at] when both are sent." + }, + { + "name": "q[updated_between]", + "type": "array", + "example": [ + "2026-04-05T00:00:00.000Z", + "2026-05-05T23:59:59.999Z" + ], + "minItems": 2, + "maxItems": 2, + "fields": [ + { + "name": "0", + "type": "string" + }, + { + "name": "1", + "type": "string" + } + ], + "description": "Absolute last-updated range, same positional 2-element form as q[created_between]. Takes precedence over q[updated_at]." + }, + { + "name": "q[test_case][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, test cases only. Overrides the shared q[tags] for this entity." + }, + { + "name": "q[test_case][priority_id]", + "type": "string", + "example": "1268,1270", + "description": "Comma-separated priority option ids. Ids, not labels \u2014 resolve them from the project's system field values first." + }, + { + "name": "q[test_case][status_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated status option ids." + }, + { + "name": "q[test_case][automation_state_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated automation-state option ids." + }, + { + "name": "q[test_case][category_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated case-type (category) option ids." + }, + { + "name": "q[test_case][issue_ids]", + "type": "string", + "example": "JIRA-1,JIRA-2", + "description": "Comma-separated linked-issue ids, test cases only." + }, + { + "name": "q[test_run][tags]", + "type": "string", + "example": "sprint-1", + "description": "Comma-separated tag ids, test runs only." + }, + { + "name": "q[test_run][issue_ids]", + "type": "string", + "example": "JIRA-3", + "description": "Comma-separated linked-issue ids, test runs only." + }, + { + "name": "q[test_plan][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, test plans only." + }, + { + "name": "q[project][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, project hits only. Projects and shared steps accept tags and the date ranges only \u2014 no owner, priority or status filter reaches them." + }, + { + "name": "q[shared_step][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, shared-step hits only." + }, + { + "name": "q[match][tags]", + "type": "string", + "example": "all", + "description": "How to combine the tag values supplied elsewhere \u2014 an OPERATOR, never the values themselves: all (has every tag), any, none. A value placed here instead of beside it sets a meaningless operator and applies NO filter, silently widening the result. The operator is broadcast to every entity being searched." + }, + { + "name": "combine", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "true merges all entity types into one list sorted newest-first with an entity_type on each row. Case-insensitive. It forces page 1 and caps the merged list at 5 rows while info.count still reports the real total, so it is a preview, not a way to read results." + }, + { + "name": "typeahead", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "true takes a fast path returning only entity_type, id, identifier, name, project_id and owner per hit, with no folder path, status or description enrichment. It also DROPS reports from the search entirely even when they are listed in `entity`. Case-insensitive." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "Exactly the lowercase string `true` adds a filter_details block resolving the filter ids you sent back to names. Any other spelling, including True, is ignored. If resolution fails the block is simply absent \u2014 the search itself still succeeds." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number, applied to every entity type at once. A value below 1 becomes 1. `page` is accepted as an alias. Ignored when combine is true." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 30, + "default": 30, + "example": 30, + "description": "Rows per entity type per page, ceiling 30. A larger value is silently reduced to 30 rather than refused. `count` is accepted as an alias." + } + ], + "intent": "Search every entity type across the account at once.", + "guidance": [ + "Account-wide by default. There is no project path segment; narrow it with the numeric project-ids filter when the user did name a project, otherwise every project the caller can see is searched.", + "Entity tokens here are snake_case and singular (test_case, test_run, test_plan, project, shared_step, report). This is a different vocabulary from the project-scoped searches, which take hyphenated plurals - passing those gets a 400 that names the invalid token.", + "Both the entity list and the query text are mandatory, and each missing one comes back as HTTP 400 whose body is a single bare error string and nothing else - no success flag, and no code, message or details sub-fields. Detect the failure from the status, not from the body's shape.", + "Pagination is per entity type, not overall: page 2 advances every type together, so a type with 3 hits contributes nothing to page 2 while another is still producing rows. Read each type's own info block to know when that type is exhausted.", + "Reports are the exception to the page-size setting: that bucket is fixed at 30 rows per page no matter what you ask for, and its own info block reports 30 back. Page through reports rather than trying to widen the page.", + "Combining results is a preview only - it pins you to page 1 and returns at most 5 rows across all types while still reporting the true total in the count, so a count of 200 beside 5 rows is expected and not a bug. Leave it off and read the per-type results when you actually need the matches.", + "Combining also replaces the reply: the per-entity buckets are gone and you get one merged list with a single info block, plus the project lookup and the test-case folder lookup when there is anything in them. That info block's page size is the per-type page sizes added together, so a three-type search reports 90 - it is not a row count and not a limit you can use.", + "Priority, status, automation-state and case-type filters take option ids, not the labels a user says. Resolve them first; a wrong-form value matches nothing and still returns a successful empty result.", + "Projects and shared steps only honour tags and the date ranges. An owner filter does not narrow them at all, so a query scoped by owner still returns every matching project.", + "Freshly created records are not findable here immediately - indexing lags creation by minutes. If you have just made something and need to confirm it exists, read it back directly instead of searching for it." + ], + "returns": [ + "test_case", + "test_run", + "test_plan", + "project", + "shared_step", + "report", + "project_details", + "data", + "info", + "test_case_folders", + "filter_details" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "GlobalSearchResponse" + }, + "400": { + "description": "Missing entity list, missing query text, or an unrecognised entity token. The body is a single bare error string - there is no success flag and no nested error object.", + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "description": "The reason, as plain text.", + "example": "No entities provided for search" + } + } + } + } + } + }, + { + "name": "initiate_export", + "method": "POST", + "path": "/api/v1/projects/{project_id}/{entity_type}/exports/{file_type}", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric project id, not the PR-NNN identifier." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-cases" + ], + "example": "test-cases", + "description": "Only test cases can be exported through this capability. Dashboard analytics has its own export capability; asking for it here is accepted, records an export and returns an id, then the background job fails." + }, + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv", + "bdd" + ], + "example": "csv", + "description": "csv produces a spreadsheet of every field, custom field and step. bdd produces Gherkin .feature output and silently restricts the export to BDD-template cases only." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "example": [ + 101, + 102, + 103 + ], + "fields": [ + { + "name": "id", + "type": "integer" + } + ], + "json_path": "/test_case/ids", + "description": "The exact test cases to export, by NUMERIC id. A TC-NNN identifier or a customer-defined id is not matched here. Ignored when select_all is true or when a folder selection map is sent. Omit the whole test_case object at your peril: with no selection form at all the export job is started against an empty id set." + }, + { + "name": "select_all", + "type": "boolean", + "example": true, + "json_path": "/test_case/select_all", + "description": "true exports everything matching the q filters within folder_id instead of an explicit id list, minus de_selected_ids. Sent as false (or omitted) the explicit id list is used instead." + }, + { + "name": "de_selected_ids", + "type": "array", + "example": [ + 104 + ], + "fields": [ + { + "name": "id", + "type": "integer" + } + ], + "json_path": "/test_case/de_selected_ids", + "description": "Numeric ids to exclude from a select_all export. Has no effect unless select_all is true." + }, + { + "name": "folders", + "type": "object", + "example": { + "125382": { + "select_all": true + }, + "125385": { + "ids": [ + 9001 + ] + } + }, + "json_path": "/test_case/folders", + "description": "Cross-folder selection: an object KEYED BY FOLDER ID, each value carrying that folder's own select_all / ids / de_selected_ids choice; the server resolves it to a union of case ids. Present, it WINS over both select_all and ids at the top level. An empty map resolves to no cases, never to the whole project.", + "fields": [ + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "ids", + "type": "array" + }, + { + "name": "de_selected_ids", + "type": "array" + } + ] + }, + { + "name": "folder_id", + "type": "integer", + "example": 29529465, + "description": "Folder to scope a select_all export to. Only the folder itself unless subfolders are opted into." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "example": true, + "description": "true also pulls cases from the folder's descendants. Off by default, so a folder export silently returns only the cases sitting directly in that folder." + }, + { + "name": "q", + "type": "object", + "example": { + "priority": "1268", + "tags": "12,13" + }, + "fields": [ + { + "name": "priority", + "type": "string" + }, + { + "name": "status", + "type": "string" + }, + { + "name": "tags", + "type": "string" + }, + { + "name": "owner", + "type": "string" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "description": "Filters selecting which cases a select_all export covers. Same vocabulary and same id-based values as the project test-case search, so resolve option ids first. An unrecognised key is dropped rather than refused, which widens the export instead of erroring." + }, + { + "name": "merge_into_single_file", + "type": "boolean", + "example": true, + "json_path": "/metadata/merge_into_single_file", + "description": "BDD exports only: merge every selected case into one .feature file instead of one file per case. The merge needs a single shared feature and background across the selection." + }, + { + "name": "common_feature", + "type": "string", + "json_path": "/metadata/common_feature", + "description": "BDD merge only: the Feature block to write at the top of the merged file. Omit it and the first exported case's own feature is used." + }, + { + "name": "common_background", + "type": "string", + "json_path": "/metadata/common_background", + "description": "BDD merge only: the Background block for the merged file. Omit it and the first exported case's background is used." + } + ], + "intent": "Start a background export of test cases to CSV or Gherkin.", + "guidance": [ + "THE FILE NEVER COMES BACK THROUGH HERE. This only queues the job and hands you an export id. Downloading the finished file is deliberately not part of this catalogue \u2014 a file download cannot be served over this transport \u2014 and there is no status or polling capability either. So after a successful call, tell the user plainly that the export is being prepared and that they must collect it from the exports list in the Test Management UI. Do not promise to fetch, attach or summarise the file.", + "A 200 with an export id means the job was accepted, not that it succeeded. The work runs afterwards and can still fail, in which case the id stays valid and no file ever appears \u2014 which the caller cannot observe from here.", + "Selection has three forms and the server picks one in a fixed order: a folder-keyed selection map beats everything; otherwise select_all exports everything matching the filters within the folder minus the de-selected ids; otherwise only the listed ids. Send exactly one form \u2014 the losing ones are silently ignored, not merged.", + "Explicit ids are matched on the numeric primary key only. Feeding TC-NNN identifiers there exports nothing and still returns a successful export id.", + "Choosing BDD restricts the selection to BDD-template cases behind your back, so exporting a folder of step-template cases as BDD produces an empty file with no error. Validate a BDD selection for a shared feature and background before merging into one file.", + "This creates an export record against the caller's account each time it is invoked; repeating it produces a new id and a new job rather than reusing the previous one." + ], + "returns": [ + "export_id" + ], + "responses": { + "200": { + "$response": "DashboardExportInitiatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "link_entities_to_jira_issue", + "method": "POST", + "path": "/api/v1/integration/jira-app/jira-ticket/link-entities", + "mode": "write", + "entity": "issue", + "body": [ + { + "name": "issue_id", + "type": "string", + "required": true, + "example": "TES-121", + "description": "The external tracker's own ticket key, e.g. the Jira key TES-121. It is not a Test Management id and is not minted here \u2014 take it from the tracker. A missing value is refused with 400 'Missing issue_id'; a key that does not exist in the tracker is NOT validated and the link is stored anyway." + }, + { + "name": "project_id", + "type": "string", + "required": true, + "example": "10001", + "description": "Integer id of the Test Management project that owns the entities being linked, sent as a string. A missing value is refused with 400 'Missing project_id'. This endpoint does not check it is numeric, so a non-numeric value fails deeper as a 500 rather than a clean 400." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-case", + "test-run" + ], + "example": "test-case", + "description": "What is being linked. Matched case-insensitively. Anything outside the list is refused with 400 'Unsupported entity_type'. This also decides which other body field is required: 'test-case' requires `selection`, 'test-run' requires `entity_ids`." + }, + { + "name": "entity_ids", + "type": "array", + "example": [ + 27912, + 27399, + 27398 + ], + "description": "Integer ids of the test runs to link. Required ONLY when entity_type is 'test-run' (400 'Missing entity_ids for testrun' otherwise). For 'test-case' this field is read but never used \u2014 cases are resolved from `selection`, so ids sent here are silently discarded." + }, + { + "name": "selection", + "type": "object", + "example": { + "select_all": false, + "unique_test_case_count": 12, + "folders": {} + }, + "description": "Which test cases to link, in the same folder/filter selection shape the selection preview returns. Required ONLY when entity_type is 'test-case' (400 'Missing selection for testcase' otherwise). Ignored for 'test-run'.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "description": "Select every case matching the selection rather than an explicit list." + }, + { + "name": "unique_test_case_count", + "type": "integer", + "description": "Caller-supplied count of cases this selection resolves to. The server trusts it: it is the only input to the >200 async decision and is never recomputed against the selection." + }, + { + "name": "folders", "type": "object", - "description": "FolderWriteResponse as returned by `create_root_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "FolderWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "integer", - "example": 2522 - }, - "root": { - "type": "boolean", - "example": true - }, - "updated_by": { - "type": "integer", - "example": 2522 - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/766097" - } - } - } - } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-17T08:01:58.000Z" - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "probe edit_project 2026-09-17T10:47:41Z" - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - } - } + "description": "Folder-keyed selection map, taken verbatim from the selection preview." + } + ] + }, + { + "name": "shared_selection", + "type": "object", + "description": "Additional selection covering test cases shared in from other projects. Only read on the synchronous test-case path; the queued path above 200 cases drops it, so shared cases are not linked in an async batch." + }, + { + "name": "build_ids", + "type": "array", + "description": "Build ids matching `entity_ids` one-for-one, for accounts on build-based run ingestion. Omit the field entirely to let the server resolve runs the legacy way. If you do send it, it must hold exactly one positive integer per entry in `entity_ids` with no duplicates, or the call is refused with 400." + } + ], + "intent": "Link test cases or runs to a tracker ticket.", + "guidance": [ + "Which body field carries the entities depends on what you are linking: cases are selected by a selection object, runs by an explicit list of ids. Sending ids alongside a case selection links nothing extra.", + "Above 200 entities the work is queued to a background worker and the response comes back immediately with async true and no per-entity outcome. Nothing has been linked yet at that point, and a worker failure is never reported back \u2014 re-read the ticket's linked test cases to confirm.", + "The 200 threshold for a case selection is read from the count you supplied, not from the selection itself. Understate it and the call takes the synchronous path, which resolves at most 200 cases and links only those \u2014 reporting success for the whole selection.", + "Build the case selection from the selection preview for the same ticket and project rather than assembling it by hand; the preview returns exactly the object this field expects.", + "The ticket key is not verified against the tracker. A typo produces a link to a ticket that does not exist, with a 200 and a success message.", + "Linking is additive and idempotent per entity; relinking an already-linked entity does not duplicate it.", + "Every failure on the argument-validation path comes back as 400 with a plain message, including cases that are really 'not found'." + ], + "returns": [ + "success", + "message", + "async" + ], + "responses": { + "200": { + "description": "The linking was accepted. Nothing about WHICH entities were linked comes back - no ids, no per-entity results, no failure list - so the only way to know what the ticket now holds is to read its linked entities back.\n", + "schema": { + "type": "object", + "required": [ + "success", + "message", + "async" + ], + "properties": { + "success": { + "type": "boolean", + "description": "True on this response. It reports that the request was accepted, not that every entity was linked - when `async` is true nothing has been linked yet. A refusal comes back as a 4xx or 5xx with success false and the reason in `message`.", + "example": true + }, + "message": { + "type": "string", + "description": "Human-readable outcome: 's linked successfully' when the work was done in-request, 's successfully queued for linking' when it was handed to a background worker. The entity type is echoed from your request verbatim, so it is your own wording plus an 's' - do not parse it, read `async` instead.", + "example": "test-runs linked successfully" + }, + "async": { + "type": "boolean", + "description": "False means the entities were linked before this response was sent, and reading the ticket's linked entities now will show them. True means NOTHING has been linked yet: the batch was over 200 entities and was handed to a background worker, and this response carries no job id and no way to poll it. Treat true as 'accepted, not done' - an empty read straight afterwards is expected and is NOT a failure. Do not resend the same request on the strength of an empty read: there is no de-duplication and the resend simply queues the same linking work again. Wait and re-read instead. Note also that for a test-case batch the 200-entity threshold is measured from the count YOU supplied inside the selection, not from a count the server recomputes, so a wrong count there decides this flag." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_linked_test_case_selection", + "method": "GET", + "path": "/api/v1/integrations/{issue_type}/test-cases/selection", + "mode": "read", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the caller is acting from. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." + } + ], + "query": [ + { + "name": "issue_id", + "type": "string", + "required": true, + "example": "TES-121", + "description": "The external tracker's ticket key whose current test-case selection is wanted. Not validated here: a blank or unknown key returns an empty selection rather than an error." + }, + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer id of the Test Management project the selection is scoped to. This endpoint does not check it is present or numeric, so a missing or non-numeric value fails deeper as a 5xx instead of a clean 400 \u2014 always send a real project id." + } + ], + "intent": "Preview the test cases a tracker ticket would link.", + "guidance": [ + "This is the step before linking, not the list of what is already linked. The selection object it returns is the exact object the link call expects, folder structure and all \u2014 pass it straight through rather than rebuilding it.", + "The count the link call uses to decide between the immediate and the queued path is the one you carry from this selection. Change the selection by hand and that count stops matching what it resolves to.", + "Unlike its Jira-specific twin, this route validates neither the ticket key nor the project id, so a missing argument surfaces as a server error rather than a 400.", + "During a platform downtime window this returns an empty test-case list rather than a selection object." + ], + "returns": [ + "success", + "selection" + ], + "responses": { + "200": { + "description": "The ticket's linked test cases expressed as a reusable selection - hand this object straight back as the case selection when linking. While the service is unavailable an empty test-case collection is returned in its place: `{success: true, test_cases: []}`, with no `selection` key at all, so check for `selection` before using it.\n", + "schema": { + "type": "object", + "required": [ + "success", + "selection" + ], + "properties": { + "success": { + "type": "boolean", + "description": "True on this response. A fixed acknowledgement, not a report on the selection - an empty selection still comes back as true.", + "example": true + }, + "selection": { + "$schema": "SelectionData", + "description": "The selection as passed through from the linking service. `folders` is an empty map and `unique_test_case_count` is 0 when the ticket has no linked cases in this project." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_configuration_groups", + "method": "GET", + "path": "/api/v1/configurations/groups", + "mode": "read", + "entity": "configuration", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. Page 0 is treated as page 1." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "example": 30, + "description": "Rows per page, ceiling 30. Anything above 30 \u2014 and 0, a negative, or a non-numeric value \u2014 is silently replaced with 30 rather than refused, so a big page never arrives and you must walk pages. Above 300 the call is refused outright with a page-size error instead." + } + ], + "intent": "List the account's configuration groups and their ids.", + "guidance": [ + "A group carries only the dimension's name \u2014 the selectable values live under it as its options and are fetched separately using the group id returned here, so this is step one of two whenever you need a concrete value.", + "Nothing here is project-scoped: the same groups come back whichever project the user is working in, and they cannot be filtered to one project. Do not offer this as a way to see what a particular project can run against.", + "Page number travels as `p`; a `page` parameter is not read on this call and is ignored, which silently pins you to page 1.", + "There is no name search or sort on this listing \u2014 walk the pages and match names yourself." + ], + "returns": [ + "id", + "name", + "is_system", + "group_id", + "created_at" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "ConfigurationGroupListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/tags/v3", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run", + "test_plan", + "test_recording", + "shared_step" + ], + "description": "Which kind of record's labels to list. There is no option meaning 'all kinds'. Leaving it out or sending anything outside the listed set is refused outright rather than answered with an empty list.", + "example": "test_case" + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, counting from 1. Anything that is not a positive whole number is refused before the labels are read.", + "example": 1 + }, + { + "name": "page", + "type": "integer", + "minimum": 1, + "description": "Accepted as an alternative spelling of the page number, and used only when the page number was not supplied under the shorter name. Prefer the shorter name, which every other listing here uses." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "How many labels to return per page. The default is small, so ask for the largest page you need. A value above 100, zero, a negative number or anything non-numeric is refused rather than quietly clamped.", + "example": 100 + }, + { + "name": "search", + "type": "string", + "description": "Narrows the list to labels matching this text. Surrounding spaces are trimmed and an all-blank value counts as no filter at all. 'query' and 'q' are accepted as equivalents; whichever of the three is non-blank first is the one used." + } + ], + "intent": "List or search a project's labels, with their ids.", + "guidance": [ + "This is the only label listing that reaches records other than test cases, and the only one that hands back the id a rename or a merge needs. The test-case-only listings return names and usage counts, so start here whenever an id is required downstream.", + "Name the kind of record explicitly every time. There is no way to ask for every kind at once, and a misspelling is refused rather than silently widened.", + "Labels are scoped to a project and to a kind of record, so the same word can exist as separate labels for test cases and for test runs, each with its own id.", + "Ask for a large page in one go rather than walking small pages; the ceiling is 100 per page." + ], + "returns": [ + "tags", + "info", + "id", + "name" + ], + "shape": "discovered", + "paginated": true, + "max_page_size": 100, + "responses": { + "200": { + "description": "A page of labels for the requested kind of record. The body is passed through from the labels service unchanged, so it carries NO success flag - the absence of `success` here is not an error signal. A failure is a 4xx or 5xx with a plain `error` string instead.\n", + "schema": { + "type": "object", + "required": [ + "tags", + "info" + ], + "properties": { + "tags": { + "type": "array", + "description": "The labels in use on the requested kind of record in this project, one entry per label, narrowed by the search text when one was given. An empty list means the project has no labels of that kind yet - it is not an error.", + "items": { + "type": "object", + "description": "A label. `id` is the handle to use wherever a call takes label ids; `name` is what people see. The entry is passed through from the labels service and may carry extra bookkeeping keys such as a usage count and timestamps - read id and name, and treat anything else as informational.", + "properties": { + "id": { + "type": "integer", + "description": "The label's id - the value to pass wherever a call takes label ids." + }, + "name": { + "type": "string", + "description": "The label's text, as people see it." + } } + } + }, + "info": { + "$schema": "PageInfo", + "description": "Pagination for the label list. Page size is capped at 100 and defaults to 30." } - }, - "FolderWriteResponse__CreateSubFolder": { - "type": "object", - "description": "FolderWriteResponse as returned by `create_sub_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "FolderWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "integer", - "example": 6013 - }, - "root": { - "type": "boolean", - "example": false - }, - "updated_by": { - "type": "integer", - "example": 6013 - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/766098" - } - } - } - } - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "list_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users", + "mode": "read", + "entity": "user", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "example": 11046, + "description": "Integer project id. It gates access only; the returned list is not filtered to this project's members." + } + ], + "intent": "List the account's active users, for assigning a case or run.", + "guidance": [ + "Returns all active users of the caller's account, alphabetical by full name, in one unpaginated response. Appearing here does not mean the user has access to the project.", + "No role field. For project membership with roles use search_project_users; for a paged, name-filterable list of account users use list_project_users.", + "Assign owners and assignees with `id` (the TM user id), not `browserstack_user_id`.", + "`myself` is the caller, reduced to id, full_name and reports_and_notification_enabled; look the caller up in `users` for the full record.", + "Deactivated users and users with no name are omitted, so a colleague missing from the list may simply not have completed onboarding." + ], + "returns": [ + "success", + "users", + "myself", + "id", + "browserstack_user_id", + "full_name", + "email", + "group_id", + "onboarded", + "reports_and_notification_enabled" + ], + "responses": { + "200": { + "$response": "UsersListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "move_folder", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}/move", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 54, + "description": "Integer id of the folder to move; everything nested inside it moves with it. Never an FL- string." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "default": true, + "description": "Defaults to true: if a folder with the same name (case-insensitive, trimmed) already exists under the destination, the moved folder is silently renamed to ' (1)' with the name lowercased. Send false to get a 400 on a clash instead." + } + ], + "body": [ + { + "name": "parent_id", + "type": "integer", + "example": 12, + "description": "Integer id of the new parent folder in the SAME project. To move the folder to the project root, omit this field entirely: the server treats a missing key as root, and null cannot be sent through this transport. 0 is not root and is refused with 422. Must differ from folder_id." + } + ], + "intent": "Move a folder and its contents under a different parent.", + "guidance": [ + "Only moves within the same project are possible here; a destination in another project is refused with 422. Cross-project moves exist only in the internal API.", + "To move to root, omit parent_id; the server treats the missing key as root. A body that accidentally lost the key therefore also returns 200 and re-parents the folder to root, so double-check before sending without it. 0 is refused (422) rather than treated as root.", + "Name clashes at the destination are resolved by renaming the moved folder to ' (1)', lowercased, unless auto_rename=false is sent. Read the returned name either way.", + "Every pre-flight failure \u2014 same source and destination, unknown destination, unknown source folder, other-project destination \u2014 is a 422 whose text sits under `error`, not `message`. Only a failure inside the storage update comes back as 400.", + "Reversible by moving the folder back to its previous parent_id, so read the folder before moving; a name changed by auto-rename is not restored by moving back.", + "Completes synchronously and returns the moved folder; there is no async job id to poll, unlike the internal cross-project move." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "description": "The storage update was refused (e.g. an invalid destination such as the folder's own subtree).", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" } - }, - "PageInfo__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "PageInfo as returned by `list_test_results_for_test_case_by_integer_id`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "truncated": { - "type": "boolean", - "example": false - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "A pre-flight validation failed: cross-project destination, destination equal to source, destination not in this project, or source folder not found. The reason is under `error`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" } - }, - "TestResult__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "TestResult as returned by `list_test_results_for_test_case_by_integer_id`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestResult" - } - ], - "properties": { - "author": { - "$schema": "User__ListTestResultsForTestCaseByIntegerId" - }, - "result_status": { - "$schema": "ResultStatus__ListTestResultsForTestCaseByIntegerId" + } + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "rename_folder", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 54, + "description": "Integer id of the folder to update, as returned by the folder list or create. Never an FL- string. 0 is rejected." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "description": "Only the literal value true has any effect. When true, a new name that clashes with a sibling (case-insensitive, trimmed) is saved as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/folder/name", + "example": "Checkout v2", + "description": "New folder name. Optional server-side despite the spec marking it required \u2014 a description-only update is valid. Must be unique among siblings ignoring case, otherwise 400 (unless auto_rename)." + }, + { + "name": "description", + "type": "string", + "json_path": "/folder/description", + "example": "Updated notes", + "description": "New notes for the folder; replaces the existing text. HTML is sanitised on save, so tags may come back stripped or escaped." + } + ], + "intent": "Rename a folder or change its description.", + "guidance": [ + "There is no undo and folders keep no version history: read the folder first and keep the old name and description if you may need to put them back.", + "Neither name nor description is required: send only the one you want to change, and a description-only update is valid.", + "Keys this capability does not declare are silently dropped rather than rejected, so a misspelt field returns 200 with nothing changed. Compare the returned folder to what you sent.", + "A new name matching a sibling's (case-insensitive, trimmed) returns 400 'Folder with same name exists in this location'. auto_rename=true makes the server lowercase the name and append ' (1)' instead \u2014 that is a different result from the rename you asked for.", + "Renaming does not move the folder; use the move capability to change its parent." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case_linked_issues", + "method": "GET", + "path": "/api/v1/integrations/{issue_type}/test-case/results/{test_case_id}", + "mode": "read", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the caller is acting from. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Integer id of the test case whose details and execution results are wanted. Must belong to the caller's own group." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of execution results. Sending it also switches the read to include every result rather than the server default set, so send p=1 explicitly when you want the full result history \u2014 omitting it quietly narrows what comes back." + }, + { + "name": "per_page", + "type": "integer", + "example": 10, + "description": "Results per page. Only read when `p` is also sent; on its own it has no effect." + } + ], + "intent": "Get a test case's linked tracker tickets.", + "guidance": [ + "Everything is nested one level under a `data` wrapper, with the case itself under `test_case` inside it; reading the top level finds nothing.", + "The linked tickets are on the `issues` field of the returned case, each carrying the tracker's own ticket key. That is the list to read before unlinking, because unlinking does not let you choose between several linked tickets.", + "Ownership is checked against the caller's own group, so a case id from another account comes back as a body saying access was denied while the transport status is still 200 \u2014 check the success flag rather than the status.", + "During a platform downtime window this returns a success body with a null case instead of failing; treat a null case as 'unknown', not as 'no such case'." + ], + "returns": [ + "id", + "identifier", + "name", + "description", + "preconditions", + "expected_result", + "project_id", + "test_case_folder_id", + "issues", + "status", + "priority", + "case_type", + "automation_state", + "tags", + "custom_fields", + "steps", + "test_case_steps", + "template", + "assignee", + "creator_details", + "created_at", + "updator_details", + "updated_at", + "links", + "test_run_results", + "test_run_results_count", + "test_run_results_issues_count", + "metadata" + ], + "responses": { + "200": { + "description": "`{\"data\": {\"success\": true, \"test_case\": }}`. The case object also carries `test_run_results`, the page of execution results, and `issues`, the tracker tickets it is linked to. Also returned with `test_case` null during a downtime window.\n", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - "updated_by": { - "type": "string", - "nullable": true, - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - } - } + "test_case": { + "$schema": "TestCase" } + } } - }, - "User__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "User as returned by `list_test_results_for_test_case_by_integer_id`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "unlink_test_case", + "method": "POST", + "path": "/api/v1/integrations/{issue_type}/unlink-test-case", + "mode": "write", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the link belongs to. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." + } + ], + "body": [ + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Integer id of the test case to unlink. This, the caller's group and the tracker type are the whole match key \u2014 the case must belong to the caller's own group or the call is refused." + }, + { + "name": "issue_id", + "type": "string", + "example": "TES-121", + "description": "The tracker ticket key the case should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the case, the group and the tracker type only. Send it for the refreshed list that comes back, and see the guidance if the case has more than one linked ticket." + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of the refreshed linked-test-case list returned on success. Pages are fixed at 10 per page. Must be a plain scalar \u2014 an array or hash value is ignored and the first page is returned." + } + ], + "intent": "Unlink a test case from a tracker ticket.", + "guidance": [ + "On the failure path the body is just `success` and `status`, with no collection \u2014 identical to the test-run unlink, which shares this render. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", + "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. Always read the success flag \u2014 checking the status alone reports every miss as a win.", + "Which link is removed is decided by the test case, the caller's group and the tracker type, not by the ticket you name. If the case is linked to several tickets in the same tracker, the first matching link is removed and it may not be the one you meant \u2014 read the case's linked tickets first and expect to verify afterwards.", + "On success the refreshed list of test cases still linked to the ticket is returned, so no follow-up read is needed to confirm.", + "Nothing is deleted: the case, the run and the ticket all survive and the same link can be created again.", + "Nothing is written back to the external tracker \u2014 the ticket itself is not modified.", + "During a platform downtime window an empty list with a success flag is returned and nothing is unlinked." + ], + "returns": [ + "success", + "test_cases", + "info", + "recent_folder", + "recent_project" + ], + "responses": { + "200": { + "description": "On success, the refreshed list of test cases still linked to the ticket: `{\"success\": true, \"test_cases\": [...], \"info\": {...}, \"recent_folder\": ..., \"recent_project\": ...}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found, and as `{\"success\": false, \"error\": \"Invalid Access\", \"status\": 401}` when the test case is not in the caller's group \u2014 the numbers in these bodies are not the transport status.\nrecent_folder and recent_project are screen state carried alongside the result rather than part of it - see their own descriptions. During service downtime the whole body degrades to `{success: true, test_cases: []}`, with no info and no recent_* keys.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "LinkedIssueTestCase" + } + }, + "info": { + "$schema": "PageInfo" + }, + "recent_folder": { + "type": "object", + "nullable": true, + "description": "The folder this person most recently linked a case from, so a linking panel can reopen where they left off. A convenience for whoever is driving the screen, not part of the unlink result - nothing about it reflects the case you just unlinked, and it is unchanged by the unlink. Null when there is no such history. Returned only alongside the first page of the refreshed list; asking for a later page omits it." + }, + "recent_project": { + "type": "object", + "nullable": true, + "description": "The project this person most recently linked a case from. Paired with recent_folder and carrying the same caveats: screen state, null when there is no history, and returned only alongside the first page." + }, + "status": { + "type": "integer", + "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen \u2014 a successful unlink omits it. 404 means no matching link was found, which is also what a run or case that was already unlinked returns." } - }, - "ResultStatus__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "ResultStatus as returned by `list_test_results_for_test_case_by_integer_id`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "unlink_test_run", + "method": "POST", + "path": "/api/v1/integrations/{issue_type}/unlink-test-run", + "mode": "write", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the link belongs to. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." + } + ], + "body": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer id of the test run to unlink. Must belong to the caller's own group, and the run is looked up inside `project_id`, which therefore becomes mandatory as soon as this is sent." + }, + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer id of the project that owns the run. Required whenever `test_run_id` is sent; omitting it is refused with a body saying the project id was not provided." + }, + { + "name": "issue_id", + "type": "string", + "example": "TES-121", + "description": "The tracker ticket key the run should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the run, the group and the tracker type only. Send it for the refreshed list that comes back." + }, + { + "name": "build_id", + "type": "integer", + "description": "Build id for the run, for accounts on build-based run ingestion. Omit the field entirely and the server resolves the run itself \u2014 that is the normal call. Sending a value that is not a positive integer is refused with 400." + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of the refreshed linked-test-run list returned on success. Pages are fixed at 10 per page." + } + ], + "intent": "Unlink a test run from a tracker ticket.", + "guidance": [ + "On the failure path the whole body is just `success` and `status` \u2014 there is no `test_runs` array. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", + "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. The same is true when the project id is missing or the run belongs to another group. Always read the success flag.", + "Which link is removed is decided by the test run, the caller's group and the tracker type, not by the ticket you name. If the run is linked to several tickets in the same tracker, the first matching link is removed \u2014 verify afterwards.", + "The run is resolved inside a project, so the project id must accompany the run id even though the ticket is the subject of the call.", + "On success the refreshed list of test runs still linked to the ticket is returned.", + "Nothing is deleted: the run and the ticket both survive and the same link can be created again. Nothing is written back to the external tracker.", + "Read the link first and look at its entity_type. When it is 'Build', this project stores runs as builds and the call needs build_id set to the run's uuid \u2014 omitting it returns a 200 with success false and a 404 body that looks exactly like 'no such link'. Confirmed by invocation: two identical calls without build_id were refused, the same call with it succeeded.", + "During a platform downtime window this refuses with a success flag set to false and a 404 in the body, on a 200 \u2014 indistinguishable from 'no such link' without checking platform status." + ], + "returns": [ + "success", + "test_runs", + "info" + ], + "responses": { + "200": { + "description": "On success, the refreshed list of test runs still linked to the ticket: `{\"success\": true, \"test_runs\": [...], \"info\": {...}}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found or during downtime, and as `{\"success\": false, \"error\": ..., \"status\": 400|401|404}` for a missing project id, a run outside the caller's group, or a run that does not exist \u2014 the numbers in these bodies are not the transport status.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_runs": { + "type": "array", + "items": { + "$schema": "TestRun" + } + }, + "info": { + "$schema": "PageInfo" + }, + "status": { + "type": "integer", + "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen \u2014 a successful unlink omits it entirely. 404 means no matching link was found, which is also what you get for a run that was already unlinked." } - }, - "TestCase__ReorderTestCasesByFolder": { - "type": "object", - "description": "TestCase as returned by `reorder_test_cases_by_folder`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" - }, - "priority": { - "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_tag", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/tags/{id}", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "id", + "type": "integer", + "required": true, + "description": "The label's own id, which only the listing that returns ids gives you. The label's name will not work in its place, so look the id up first.", + "example": 1 + } + ], + "body": [ + { + "name": "new_name", + "type": "string", + "required": true, + "description": "The label's new wording. Surrounding spaces are trimmed, and a blank or spaces-only value is refused. This is the only thing a rename changes - nothing else about the label can be edited here.", + "example": "smoke-critical" + } + ], + "intent": "Rename a label across a project.", + "guidance": [ + "The new wording replaces the old one everywhere the label is already applied, in one step, with no per-record confirmation.", + "There is no undo and no saved history for labels, so the only way back is to rename it a second time - note the old wording before you start, because nothing else records it.", + "Only account owners and admins may do this; anyone else is refused, so check who is asking before promising it.", + "This changes wording only. Folding two labels into one is a different operation, and renaming one to match another does not combine them.", + "Look up the id first: this addresses a single label by id, and a project can hold same-named labels for different kinds of record, each needing its own rename.", + "The reply is the whole label record, not just what changed: its id and new wording, the workspace it belongs to, its colour palette, and when it was created and last updated. Nothing else about the label can be edited through this call, but everything about it comes back.", + "A value that was never set is left out of the reply rather than returned as empty, so read a missing colour palette or timestamp as 'not configured', not as a failure." + ], + "returns": [ + "id", + "name", + "group_id", + "color_preset", + "created_at", + "updated_at" + ], + "shape": "discovered", + "responses": { + "200": { + "description": "The renamed label, as a complete label record.", + "schema": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "description": "The label's own id, unchanged by the rename." + }, + "name": { + "type": "string", + "description": "The label's wording after the rename, with surrounding spaces trimmed." + }, + "group_id": { + "type": "integer", + "description": "The workspace the label belongs to. Labels are workspace-wide, not per project." + }, + "color_preset": { + "type": "string", + "description": "The label's colour palette, as one comma-separated list of hex colours rather than a single colour. Preserved by a rename. Omitted when the label has no palette set.", + "example": "#FBF6FE,#F7EAFF,#792395,#2C023F,#5C007A,#EFD4FF" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the label was first created. Omitted when not recorded." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the label was last changed, which this call advances. Omitted when not recorded." + }, + "deleted_at": { + "type": "string", + "nullable": true + }, + "last_used_at": { + "type": "string", + "nullable": true + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "record_status": { + "type": "string", + "example": "active" + }, + "restore_blocked_reason": { + "type": "string", + "nullable": true + }, + "updated_by": { + "type": "integer", + "example": 6013 } - }, - "TestCaseFieldValue__ReorderTestCasesByFolder": { - "type": "object", - "description": "TestCaseFieldValue as returned by `reorder_test_cases_by_folder`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } + } + } + } + } + }, + { + "name": "verify_test_case_tags", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-case/tags/verify_tag", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "tags", + "type": "array", + "required": true, + "description": "The label wordings to check, as a list of plain strings. Omit the whole list and the request is refused. An empty list is accepted and simply reports nothing new.", + "example": [ + "smoke", + "regression" + ] + } + ], + "intent": "Check which label names a project already uses.", + "guidance": [ + "Nothing is saved by this check, despite how it reads. A label only comes into being when it is actually applied to a test case, so follow this with the step that puts the label on the case.", + "Two lists come back: one holds the wordings not yet used on the project's test cases, the other holds the ones already in use. Neither is an error - being already in use is usually the answer you wanted.", + "The accompanying message says to verify the labels whenever any wording is already in use. It reads like a failure and is not one; go by the two lists.", + "Comparison is exact, so a difference in capitalisation or spacing counts as a brand new label and will leave you with two near-identical ones if you apply it.", + "Only labels on test cases are considered. A wording already used on test runs or plans still comes back as new here.", + "Every outcome, including the one where no wording is new, is returned as an ordinary success - read the flag and the lists rather than the status." + ], + "returns": [ + "success", + "tags", + "error_tags", + "error" + ], + "responses": { + "200": { + "description": "The verdict on the submitted wordings. 'tags' holds those not yet used on the project's test cases, 'error_tags' those already in use, and 'error' carries an advisory message whenever any wording was already in use.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "description": "False only when none of the submitted wordings were new." + }, + "tags": { + "type": "array", + "description": "Wordings not yet used on this project's test cases.", + "items": { + "type": "string" + } + }, + "error_tags": { + "type": "array", + "description": "Wordings already in use on this project's test cases. Present only when at least one was.", + "items": { + "type": "string" + } + }, + "error": { + "type": "string", + "description": "Advisory message shown when some or all wordings were already in use. Not a transport failure." } - }, - "TestRunWriteResponse__CloseTestRun": { - "type": "object", - "description": "TestRunWriteResponse as returned by `close_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "import_id": { - "type": "string", - "nullable": true - } - } - }, - "testrun": { - "type": "object", - "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17579204 - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "object", - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } - } - } - } - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "create_shared_component", + "method": "POST", + "path": "/api/v1/projects/{project_id}/shared-components", + "mode": "write", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project the shared field belongs to." + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the block, shown in the Shared Fields list." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "example": "background", + "description": "Which kind to create. OMITTING IT SILENTLY CREATES A `step` - it is not rejected, so a caller meaning to add a background or a precondition and forgetting this key gets a shared step instead, with no error to notice." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The ordered rows making up the block. REQUIRED. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "description": "1-based position. Required on every row." + }, + { + "name": "step", + "type": "string", + "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." + }, + { + "name": "result", + "type": "string", + "description": "Expected result; send an empty string when the kind has no result." + }, + { + "name": "test_data", + "type": "string", + "description": "Additional data for the row." + } + ] + }, + { + "name": "tags", + "type": "array", + "items": { + "type": "string" + }, + "description": "Plain tag name strings on the block itself." + }, + { + "name": "folder_id", + "type": "integer", + "nullable": true, + "description": "Collection to file it under. Omit or send null for the Unassigned root." + } + ], + "intent": "Create a reusable shared field - a step sequence, a Gherkin background block, or a precondition - that many test cases can then reference.", + "returns": [ + "success", + "data", + "id", + "title", + "field_type", + "test_case_count", + "step_count", + "tags", + "folder_id", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedComponentWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ONE CAPABILITY, THREE WORKFLOWS. `field_type` is the only difference between creating a shared step, a Gherkin background and a precondition - they are the same record and the same endpoint, not three features.", + "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "`field_type` defaults to `step`. There is no error for omitting it, so a create meant as a background or precondition succeeds as the wrong kind. Send it explicitly every time.", + "Sibling of create_shared_step on /shared-steps, which creates the same record but cannot set `field_type`. Use this route when the kind matters.", + "The response echoes `shared_step_details` with each row's assigned id; the LISTING returns only `step_count`. Read the echo if you need row ids.", + "All three field_type values can be created, and each lists back under the matching field_type filter." + ] + }, + { + "name": "update_shared_component", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/shared-components/{shared_component_id}", + "mode": "write", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project." + }, + { + "name": "shared_component_id", + "type": "integer", + "required": true, + "description": "Id of the shared field to change, from list_shared_components." + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the block. REQUIRED ON EVERY CALL even when you are only changing rows - sending rows alone is a 400." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Which kind the block is. Send its existing value unless you intend to change it." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The FULL replacement row list, not a patch - rows you leave out are removed. REQUIRED ON EVERY CALL even when you are only renaming: sending `title` alone is a 400. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "description": "1-based position. Required on every row." + }, + { + "name": "step", + "type": "string", + "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." + }, + { + "name": "result", + "type": "string", + "description": "Expected result; send an empty string when the kind has no result." + }, + { + "name": "test_data", + "type": "string", + "description": "Additional data for the row." + } + ] + }, + { + "name": "tags", + "type": "array", + "items": { + "type": "string" + }, + "description": "Plain tag name strings; the list you send replaces the block's tags." + }, + { + "name": "folder_id", + "type": "integer", + "nullable": true, + "description": "Collection to move it into." + } + ], + "intent": "Change an existing shared field - its title, rows, tags or collection - with the edit propagating to every test case that references it.", + "returns": [ + "success", + "data", + "id", + "title", + "field_type", + "test_case_count", + "step_count", + "tags", + "folder_id", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedComponentWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "BOTH `title` AND `shared_step_details` ARE REQUIRED ON EVERY CALL. Sending only one of them is a 400. There is no partial update here despite the PATCH verb.", + "`shared_step_details` REPLACES the row list. Read the block first and send back every row you want to keep, each with its `order`.", + "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "The edit propagates to every test case referencing the block - `test_case_count` tells you how many that is before you change it.", + "Sibling of update_shared_step on /shared-steps, which edits the same record but cannot set `field_type`." + ] + } + ], + "schemas": { + "TestRun": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "TR-17" + }, + "uuid": { + "type": "integer", + "example": 17 + }, + "identifier": { + "type": "string", + "example": "TR-17" + }, + "name": { + "type": "string", + "example": "Test Run1-20/05/2025" + }, + "description": { + "type": "string", + "example": "" + }, + "owner": { + "type": "integer", + "example": 2 + }, + "assignee": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "environment": { + "type": "string", + "nullable": true + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "test_cases_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ] + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ] + }, + "is_automation": { + "type": "boolean" + }, + "observability_url": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "string" + }, + "configurations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" } - }, - "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun": { + } + } + }, + "metadata": { + "type": "object", + "nullable": true, + "properties": { + "test_plan_error": { + "type": "string", + "nullable": true, + "description": "Why the run could not be linked to a plan, e.g. `PLAN_LINKED`. Present only when a plan link was attempted and refused.", + "example": "PLAN_LINKED" + } + } + }, + "assignee_imported": { + "type": "boolean" + }, + "is_dynamic": { + "type": "boolean" + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "archived_at": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-18T05:00:22.000Z" + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "example": "2026-10-18T00:00:00.000Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner": { + "type": "integer", + "example": 6013 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_sent_date": { + "type": "string", + "nullable": true + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "example": "2026-09-18T00:00:00.000Z" + }, + "sub_plans_count": { + "type": "integer", + "example": 14 + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T12:53:05.000Z" + }, + "user_id": { + "type": "integer", + "example": 6013 + } + } + } + }, + "attachments": { + "type": "array", + "description": "Files attached to the run. Normally returned as `[]` \u2014 present as a key but not populated.", + "items": { + "type": "object" + } + }, + "created_by": { + "$schema": "TestRunUserRef", + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." + }, + "updated_by": { + "$schema": "TestRunUserRef", + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Usually null, even on runs whose `run_state` is `done` \u2014 a closed run does not necessarily carry a close timestamp. Do not infer closure from this field; read `run_state`." + }, + "closed_by": { + "type": "integer", + "nullable": true, + "description": "Usually null, even on runs in `run_state: done`. Same caveat as closed_at." + } + } + }, + "Project": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the project." + }, + "id": { + "type": "integer", + "description": "Unique ID for the project." + }, + "description": { + "type": "string", + "description": "A brief description of the project." + }, + "identifier": { + "type": "string", + "description": "A short unique identifier (e.g., PR-674)." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp indicating when the project was created." + }, + "import_id": { + "type": "string", + "description": "An optional import identifier. May be null.", + "example": null, + "nullable": true + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Standard error response format.", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Application-specific error code.", + "example": "ERR_INVALID_PARAMS" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "The provided parameters are invalid." + }, + "details": { "type": "object", - "description": "V1TestRunTestCaseRow as returned by `bulk_set_test_case_assignee_in_run`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestRunTestCaseRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "result_status": { - "nullable": true, - "$schema": "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "test_assignee": { - "type": "integer", - "nullable": true, - "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 13450 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - } + "description": "Optional field with additional error details." + } + } + } + } + }, + "V1TestPlanBulkResponse": { + "type": "object", + "description": "Dual-shape response. Below the bulk threshold the work is done and you get\n`plans` + `count`. Above it the work was handed to a background job and you get\n`async: true` + `unique_id` \u2014 the plans are NOT changed yet at that point; re-read to\nconfirm (see the async-jobs concept).\n", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean", + "description": "Present and true only when the operation was dispatched asynchronously." + }, + "unique_id": { + "type": "string", + "description": "Correlation id of the async job. Present only when async is true." + }, + "plans": { + "type": "array", + "description": "Affected plans. Present only on the synchronous path.", + "items": { + "type": "object" + } + }, + "count": { + "type": "integer", + "description": "Number of affected plans. Present only on the synchronous path." + } + } + }, + "User": { + "type": "object", + "description": "The full user reference used by most TM serializers.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + } + } + }, + "TestCaseStep": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "order": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "result": { + "type": "string" + }, + "step": { + "type": "string" + }, + "test_case_id": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TestCase": { + "type": "object", + "description": "The project-level test-case record: TestCaseCore plus the three fields every project-level route adds. The run-mapping rows of list_test_run_test_cases_by_integer_id do NOT return these three and compose on TestCaseCore instead.\n", + "allOf": [ + { + "$schema": "TestCaseCore" + } + ], + "properties": { + "estimated_duration_seconds": { + "type": "integer", + "nullable": true, + "description": "Estimated duration in seconds. THIS is the real value \u2014 `estimate` is always null. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + }, + "template_name": { + "type": "string", + "nullable": true, + "description": "Display name of that template; null for cases on the plain-text template. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + }, + "test_case_dataset": { + "type": "object", + "description": "Dataset bound to this case; `{}` when none. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + } + } + }, + "PageInfo": { + "type": "object", + "description": "Pagination metadata returned with paged API responses.", + "required": [ + "page", + "page_size", + "count", + "prev", + "next" + ], + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "type": "integer", + "description": "Total number of items." + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true + } + } + }, + "TestCaseListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "description": "One row per case-x-configuration mapping in the run.", + "items": { + "$schema": "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId" + } + }, + "info": { + "$schema": "PageInfo" + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level, not inside a row: case ids the review gate excluded from this page. Present only when the gate excluded something.", + "items": { + "type": "integer" + } + } + } + }, + "Folder": { + "type": "object", + "properties": {} + }, + "Configuration": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the configuration." + }, + "group_id": { + "type": "integer", + "description": "ID of the group associated with the configuration." + }, + "name": { + "type": "string", + "description": "Name of the configuration." + }, + "record_status": { + "type": "string", + "description": "Status of the record (e.g., active)." + }, + "is_suggested": { + "type": "boolean", + "description": "Indicates if the configuration is suggested." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp." + }, + "is_valid_on_bstack": { + "type": "boolean", + "example": false + } + } + }, + "Dataset": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "variables": { + "type": "array", + "description": "List of dataset variables/columns.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the variable/column." + }, + "uuid": { + "type": "string", + "description": "Unique identifier for the variable/column." + } + } + } + }, + "rows": { + "type": "array", + "description": "List of dataset rows.", + "items": { + "type": "object", + "properties": { + "row_data": { + "type": "array", + "description": "Array of values for the row.", + "items": { + "type": "string" + } + }, + "row_number": { + "type": "integer", + "description": "Row number in the dataset." + }, + "uuid": { + "type": "string", + "description": "Unique identifier for the row." } + } + } + }, + "rows_count": { + "type": "integer", + "description": "Total number of rows in the dataset." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was created." + }, + "created_by": { + "$schema": "DatasetUserRef" + } + } + }, + "ExploratorySessionUserRef": { + "type": "object", + "description": "A user reference object returned in exploratory session responses.", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "description": "TCM user ID.", + "example": 1 + }, + "email": { + "type": "string", + "format": "email", + "description": "User email address.", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "description": "User full name.", + "example": "Jane Doe" + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to.", + "example": 10 + } + } + }, + "ExploratorySessionIssue": { + "type": "object", + "description": "A linked issue / defect associated with an exploratory session or session log.", + "required": [ + "issue_id", + "issue_type" + ], + "properties": { + "issue_id": { + "type": "string", + "description": "Identifier of the issue in the external issue tracker (e.g. Jira key).", + "example": "PROJ-123" + }, + "issue_type": { + "type": "string", + "description": "Type of the issue in the external tracker.", + "example": "Bug" + } + } + }, + "ExploratorySessionTimelineEntry": { + "type": "object", + "description": "A single entry on an exploratory session's timeline: {status, elapsed_time, created_by}. It carries no `action` or `timestamp`.", + "properties": { + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "elapsed_time": { + "type": "integer", + "example": 120 + }, + "status": { + "type": "string", + "example": "bug" + } + } + }, + "ExploratorySession": { + "type": "object", + "description": "A full exploratory testing session, as returned by the single-session read, create, update and clone. The LIST route returns a narrower projection \u2014 see ExploratorySessionListRow.\n", + "allOf": [ + { + "$schema": "ExploratorySessionCore" + } + ], + "properties": { + "description": { + "type": "string", + "description": "Optional description of the session.", + "example": "Exploring the checkout flow for edge cases.", + "nullable": true + }, + "charter": { + "type": "string", + "description": "Testing charter describing the scope and goals of this session.", + "example": "Explore payment gateway error handling", + "nullable": true + }, + "duration": { + "type": "integer", + "description": "Actual duration tracked during the session in minutes.", + "example": 45, + "nullable": true + }, + "actual_duration": { + "type": "integer", + "description": "Final recorded duration of the session in minutes (set on close).", + "example": 50, + "nullable": true + }, + "session_log_count": { + "type": "integer", + "description": "Number of logs recorded in this session.", + "example": 12 + }, + "closed_by": { + "$schema": "ExploratorySessionUserRef" + }, + "configurations": { + "type": "array", + "description": "Configurations associated with the session.", + "items": { + "type": "object" + } + }, + "issues": { + "type": "array", + "description": "Issues linked to this session.", + "items": { + "$schema": "ExploratorySessionIssue" + } + }, + "timeline": { + "type": "array", + "description": "Ordered list of lifecycle events for this session.", + "items": { + "$schema": "ExploratorySessionTimelineEntry" + } + }, + "owner": { + "type": "integer", + "nullable": true + }, + "record_status": { + "type": "string", + "description": "Lifecycle state of the record, e.g. `active`.", + "example": "active" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "attachment_relations": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Join rows linking the session to its attachments; parallel to `attachments`." + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "ExploratorySessionLog": { + "type": "object", + "description": "A log entry recorded within an exploratory testing session.", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the session log.", + "example": 789 + }, + "session_id": { + "type": "integer", + "description": "ID of the exploratory session this log belongs to.", + "example": 123 + }, + "content": { + "type": "string", + "description": "Rich-text HTML content of the log entry.", + "example": "

Found an issue with the payment button on mobile.

", + "nullable": true + }, + "status": { + "type": "string", + "description": "Test result status for this log entry.", + "enum": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note" + ], + "example": "fail", + "nullable": true + }, + "elapsed_time": { + "type": "integer", + "description": "Time elapsed for this step in seconds.", + "example": 120, + "nullable": true + }, + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "defects": { + "type": "array", + "description": "Defects / issues linked to this log entry.", + "items": { + "$schema": "ExploratorySessionIssue" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the log was created.", + "example": "2026-01-16T10:35:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the log was last updated.", + "example": "2026-01-16T10:40:00Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "attachment_relations": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Join rows linking the log to its attachments; parallel to `attachments`." + } + } + }, + "Filter": { + "type": "object", + "description": "Filter object representing a saved filter view", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the filter" + }, + "name": { + "type": "string", + "description": "Name of the filter" + }, + "entity_type": { + "type": "string", + "description": "Entity type the filter applies to", + "enum": [ + "testcase", + "testplan", + "testrun" + ] + }, + "is_project_level": { + "type": "boolean", + "description": "Whether this filter is available at project level" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the filter was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the filter was last updated" + }, + "owner": { + "type": "integer", + "description": "User ID of the creator" + }, + "project_id": { + "type": "integer", + "description": "Project ID this filter belongs to" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "project" + ], + "properties": { + "project": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + }, + "ReportType": { + "type": "string", + "description": "Report type, sent as its DISPLAY NAME (not a machine slug).\n\nSeven types produce data. `Defects Summary` and `Defects Detailed Report` are\naccepted on create/update but have no data branch on the server, so such a report\nalways reads back with `report_data: null` \u2014 never offer them as a way to report\non defects (use `Test Run Summary`, whose sections include `issues_by_priority` /\n`issues_by_status`).\n\nPick by what the user asked about:\n - test cases created / updated / deleted, churn, top creators \u2192 `Test Case Activity`\n - run execution + pass rates \u2192 `Test Run Summary`\n - per-case results within runs \u2192 `Test Run Detailed Report`\n - plan-level rollup \u2192 `Test Plan Summary`\n - requirement/issue coverage \u2192 `Requirement Traceability Report`\n - exploratory sessions \u2192 `Exploratory Session Summary`\n - per-user workload \u2192 `User Workload Report`\n", + "enum": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary" + }, + "ReportFrequency": { + "type": "string", + "description": "Scheduling cadence. Send `frequency` and `frequency_details` TOGETHER, or omit\nBOTH \u2014 omitting both creates a valid unscheduled, on-demand report.\n\nTwo consequences of omitting them: `mail_to` is only persisted for scheduled\nreports (recipients on an unscheduled report are silently dropped), and\n`next_run_at` stays null.\n\nThere is no `once` value \u2014 the server's cadence enum is daily/weekly/monthly only.\n", + "enum": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly" + }, + "ReportTimeframe": { + "type": "string", + "description": "The window a report covers. Also the value of the `reportTimeRange` query param\non the report-detail read.\n\nThe four relative windows compute a date range from \"now\". `custom` computes it\nfrom `custom_date_range` and **requires** that field \u2014 sending `custom` without it\nis an unhandled server error, not a 422.\n\nThe five remaining values carry no dates; they select entities through\n`report_filters` instead:\n `specific_test_runs` \u2192 report_filters.test_runs\n `specific_test_plans` \u2192 report_filters.test_plans\n `specific_test_cases` \u2192 report_filters.test_cases\n `specific_sessions` \u2192 report_filters.session_ids\n `requirements` \u2192 report_filters.requirements\n", + "enum": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week" + }, + "FrequencyDetails": { + "type": "object", + "description": "Shape depends on `frequency` \u2014 the server rejects the wrong shape with\n\"Invalid report frequency\", which names `frequency` even when `frequency` itself\nwas fine and only `day` was missing:\n\n daily \u2192 { \"time\": \"08:00\" }\n weekly \u2192 { \"time\": \"08:00\", \"day\": \"monday\" } day = lowercase day name\n monthly \u2192 { \"time\": \"08:00\", \"day\": 15 } day = integer 1-28\n\n`time` is 24-hour `HH:MM`, interpreted as UTC.\n", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + } + }, + "ReportOwner": { + "type": "object", + "required": [ + "id", + "email", + "full_name", + "group_id", + "browserstack_user_id", + "onboarded", + "reports_and_notification_enabled" + ], + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "email": { + "type": "string", + "format": "email", + "example": "fake1@example.com" + }, + "full_name": { + "type": "string", + "example": "Fake Name" + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "browserstack_user_id": { + "type": "integer", + "example": 2 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "MailToUser": { + "type": "object", + "required": [ + "users", + "external_mails" + ], + "properties": { + "users": { + "type": "array", + "items": { + "$schema": "ReportOwner" + } + }, + "external_mails": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ReportDetail": { + "type": "object", + "required": [ + "id", + "report_type", + "created_at", + "description", + "title", + "frequency", + "identifier", + "next_run_at", + "report_timeframe", + "group_id", + "frequency_details", + "project_id", + "report_creation_mode", + "owner", + "mail_to" + ], + "properties": { + "id": { + "type": "integer", + "example": 3 + }, + "report_type": { + "$schema": "ReportType" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-12T12:52:17.658Z" + }, + "description": { + "type": "string", + "example": "" + }, + "title": { + "type": "string", + "example": "Test Run Summary" + }, + "frequency": { + "type": "object" + }, + "identifier": { + "type": "string", + "example": "SC-3" + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-13T05:00:00.000Z", + "nullable": true + }, + "report_timeframe": { + "$schema": "ReportTimeframe" + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "frequency_details": { + "type": "object", + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + }, + "required": [ + "time" + ] + }, + "project_id": { + "type": "integer", + "example": 10000 + }, + "report_creation_mode": { + "type": "string", + "enum": [ + "custom_report", + "system_generated" + ], + "example": "custom_report" + }, + "custom_date_range": { + "type": "string", + "example": null, + "nullable": true + }, + "owner": { + "$schema": "ReportOwner" + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "included_projects": { + "type": "object" + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "slack_notification": { + "type": "string", + "nullable": true + }, + "slack_notification_supported": { + "type": "boolean", + "example": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T04:32:35.703Z" + } + } + }, + "CreateReportResponse": { + "type": "object", + "required": [ + "success", + "data", + "project" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$schema": "ReportDetail__CreateReportByIntegerId" + }, + "project": { + "$schema": "Project__CreateReportByIntegerId" + } + } + }, + "FolderHierarchy": { + "type": "object", + "description": "Folder hierarchy mapping. Each key is a folder ID (as a string), and the value is an array of ancestor folder objects ordered from the folder itself up to the root.\n", + "example": { + "9": [ + { + "id": 9, + "name": "1", + "parent_id": null + } + ], + "10": [ + { + "id": 10, + "name": "1.2", + "parent_id": 9 }, - "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } + { + "id": 9, + "name": "1", + "parent_id": null + } + ], + "11": [ + { + "id": 11, + "name": "1.2.2", + "parent_id": 10 }, - "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun": { - "type": "object", - "description": "ResultStatusFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatusFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } + { + "id": 10, + "name": "1.2", + "parent_id": 9 }, - "DuplicateGroup": { - "type": "object", - "description": "One group of test cases the dedupe model judged duplicates. Returned by the duplicates listing; the single-duplicate read returns the same group under `duplicate`.\n", - "properties": { - "id": { - "type": "integer", - "description": "The duplicate GROUP id — this is the `duplicate_id` the single-duplicate read and the write actions take." - }, - "duplicate_ids": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Ids of every pair in this group; usually `[id]`." - }, - "ai_score": { - "type": "number", - "description": "Similarity score from the dedupe model, 0-100." - }, - "avg_score": { - "type": "number", - "description": "Mean score across the group." - }, - "reason": { - "type": "string", - "description": "Model-written explanation of why the cases were judged duplicates." - }, - "duplicate_type": { - "type": "string", - "description": "How they matched, e.g. `semantic`." - }, - "case_count": { - "type": "integer", - "description": "Number of cases in the group." - }, - "folder_count": { - "type": "integer", - "description": "Number of distinct folders they span." - }, - "owner_count": { - "type": "integer", - "description": "Number of distinct owners." - }, - "suggested_base_id": { - "type": "integer", - "description": "The case the model suggests keeping." - }, - "test_cases": { - "type": "array", - "items": { - "type": "object" - }, - "description": "The cases in this group; each carries its own id and identifier." - } + { + "id": 9, + "name": "1", + "parent_id": null + } + ] + } + }, + "SharedStepBase": { + "type": "object", + "required": [ + "title", + "id", + "created_by", + "updated_by" + ], + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + } + } + }, + "SharedStepDetail": { + "type": "object", + "required": [ + "id", + "step", + "result", + "order" + ], + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string", + "description": "HTML formatted step content" + }, + "result": { + "type": "string", + "description": "HTML formatted expected result content" + }, + "order": { + "type": "integer" + }, + "test_data": { + "type": "string", + "description": "Optional test data for the step" + } + } + }, + "ResultStatus": { + "type": "object", + "description": "A configured status value. Shared by the v1 result reads, the v1 result create and the report detail; the v2 per-case result list returns a cut-down version of its own and does not use this shape.", + "required": [ + "id", + "field_value", + "internal_name", + "field_name", + "colour" + ], + "properties": { + "id": { + "type": "integer", + "example": 22 + }, + "field_value": { + "type": "string", + "example": "Failed", + "description": "Display-case name. This is the value to match on." + }, + "internal_name": { + "type": "string", + "example": "failed", + "description": "Lowercase internal token for the same status." + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "Attachment": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + } + } + }, + "TestResult": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "author": { + "$schema": "User" + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-25T20:02:05.000Z" + }, + "description": { + "type": "string" + }, + "error_description": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "example": null, + "nullable": true + }, + "failure_type": { + "type": "string", + "example": null, + "nullable": true + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "type": "string", + "example": "passed" + }, + "result_status": { + "$schema": "ResultStatus" + }, + "configuration_id": { + "type": "integer", + "description": "ID of the configuration this test result is associated with", + "example": 16, + "nullable": true + }, + "time_elapsed": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "size": { + "type": "string" } - }, - "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans": { + } + } + }, + "created_by_imported": { + "type": "boolean", + "nullable": true + }, + "issues": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" + }, + "custom_fields": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473/custom-fields" + } + } + }, + "custom_fields": { + "type": "object" + }, + "test_run_step_result": { + "type": "object", + "nullable": true + }, + "updated_by": { + "type": "string", + "nullable": true + } + } + }, + "Report": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Report ID" + }, + "report_type": { + "$schema": "ReportType" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp indicating when the scheduled report was created" + }, + "description": { + "type": "string", + "description": "Description of the scheduled report" + }, + "title": { + "type": "string", + "description": "Title of the scheduled report" + }, + "frequency": { + "type": "object" + }, + "identifier": { + "type": "string", + "description": "The report's SC-NNN identifier" + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "description": "Next scheduled iteration, or null when the report is not scheduled", + "nullable": true + }, + "report_timeframe": { + "$schema": "ReportTimeframe" + }, + "group_id": { + "type": "integer" + }, + "frequency_details": { + "type": "object", + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + }, + "required": [ + "time" + ] + }, + "project_id": { + "type": "integer" + }, + "report_creation_mode": { + "type": "string", + "enum": [ + "custom_report", + "system_generated" + ] + }, + "custom_date_range": { + "type": "string" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Id of the report owner" + }, + "email": { + "type": "string", + "description": "Email to which the report is to be sent" + }, + "full_name": { + "type": "string", + "description": "Name of the owner" + }, + "group_id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } + } + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "included_projects": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "slack_notification": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-21T13:41:14.555Z" + } + } + }, + "AttachmentResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "filename": { + "type": "string", + "example": "document1.pdf" + }, + "content_type": { + "type": "string", + "example": "application/pdf" + }, + "byte_size": { + "type": "integer", + "example": 102400 + } + } + }, + "configuration": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the configuration." + }, + "group_id": { + "type": "integer", + "description": "ID of the group associated with the configuration." + }, + "name": { + "type": "string", + "description": "Name of the configuration." + }, + "record_status": { + "type": "string", + "description": "Status of the record (e.g., active)." + }, + "is_suggested": { + "type": "boolean", + "description": "Indicates if the configuration is suggested." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp." + }, + "browser": { + "type": "string", + "example": "Chrome" + }, + "browser_version": { + "type": "string", + "example": "146" + }, + "device": { + "type": "string", + "nullable": true + }, + "is_valid_on_bstack": { + "type": "boolean", + "example": false + }, + "os": { + "type": "string", + "example": "Windows" + }, + "os_version": { + "type": "string", + "example": "10" + } + } + }, + "ConfigurationListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful." + }, + "configurations": { + "type": "array", + "description": "List of configuration objects.", + "items": { + "$schema": "configuration" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "DatasetVariable": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset column/variable." + }, + "name": { + "type": "string", + "description": "Name of the column/variable." + }, + "dataset": { + "type": "object", + "description": "Dataset information that this column belongs to.", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "count_rows": { + "type": "integer", + "description": "Number of rows in the dataset." + } + } + } + } + }, + "DatasetVariablesResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the request was successful." + }, + "variables": { + "type": "array", + "description": "List of dataset columns/variables.", + "items": { + "$schema": "DatasetVariable" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "MyselfUser": { + "type": "object", + "required": [ + "id", + "full_name", + "reports_and_notification_enabled" + ], + "properties": { + "id": { + "type": "integer", + "description": "User ID" + }, + "full_name": { + "type": "string", + "description": "Full name of the current user" + }, + "email": { + "type": "string", + "description": "Email address of the current user" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Whether reports and notifications are enabled for the user" + } + } + }, + "UsersV2Response": { + "type": "object", + "required": [ + "success", + "users", + "myself", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "users": { + "type": "array", + "items": { + "$schema": "User" + } + }, + "myself": { + "$schema": "MyselfUser" + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "V1ProjectDetail": { + "type": "object", + "description": "v1 project-detail envelope (GET /api/v1/projects/{project_id}, by INTEGER id). The translation output is data.project.identifier (the PR-NNN form), alongside data.project.id (the integer id you queried by).\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "project": { "type": "object", - "description": "V1TestPlanBulkResponse as returned by `bulk_restore_archived_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanBulkResponse" - } - ], "properties": { - "count": { - "type": "integer" - }, - "plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "sub_plans_count": { - "type": "integer" - } - } - } - }, - "success": { - "type": "boolean" - } + "identifier": { + "type": "string", + "description": "PR-NNN identifier \u2014 the v2 {project_id}. (Integer -> PR-NNN translation output.)", + "example": "PR-1" + }, + "id": { + "type": "integer", + "description": "Integer project id \u2014 the value passed in the path. Same as thProjectId.", + "example": 11046 + }, + "thProjectId": { + "type": "integer", + "example": 11046 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "description": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "nullable": true + }, + "test_runs_count": { + "type": "integer", + "nullable": true + }, + "test_plans_count": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "starred": { + "type": "boolean" + }, + "jira_mapped": { + "type": "boolean" + }, + "links": { + "type": "object" + }, + "import_id": { + "type": "integer", + "nullable": true, + "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." + }, + "normalisedName": { + "type": "string", + "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." + }, + "projectVisibilityBanner": { + "type": "object", + "nullable": true, + "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." + } } - }, - "V1TestPlanBulkResponse__BulkArchiveTestPlans": { - "type": "object", - "description": "V1TestPlanBulkResponse as returned by `bulk_archive_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanBulkResponse" - } - ], - "properties": { - "count": { - "type": "integer" - }, - "plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "sub_plans_count": { - "type": "integer" - } - } - } - }, - "success": { - "type": "boolean" - } + } + } + } + } + }, + "ProjectMinifyResponse": { + "type": "object", + "required": [ + "success", + "projects" + ], + "properties": { + "success": { + "type": "boolean" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "import_id": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "starred": { + "type": "boolean" + }, + "access_via_secondary_team": { + "type": "boolean", + "example": false } - }, - "V1TestPlanDetail__CreateTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `create_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "success": { - "type": "boolean" - }, - "test_plan": { - "type": "object", - "properties": { - "active_state": { - "type": "string" - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object" - } - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "owner": { - "type": "integer" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "review_settings": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "sub_plan_mode": { - "type": "string" - } - } - }, - "review_status": { - "type": "string" - }, - "reviewers": { - "type": "array", - "items": { - "type": "object" - } - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "test_plan_progress": { - "type": "object" - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "string" - } - } - } + } + } + } + } + }, + "ProjectsPaginatedResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "import_id": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "starred": { + "type": "boolean" + }, + "access_via_secondary_team": { + "type": "boolean", + "example": false } - }, - "SharedComponentDetail": { - "type": "object", - "description": "The shared field as echoed by a CREATE or UPDATE: the listed fields plus its collection and its actual rows. The LISTING returns neither - it gives `step_count` instead of the rows, and does not carry `folder_id` at all.\n", - "allOf": [ - { - "$schema": "SharedComponent" - } - ], - "properties": { - "folder_id": { - "type": "integer", - "nullable": true, - "description": "Collection it is filed under; null for the Unassigned root." - }, - "shared_step_details": { - "type": "array", - "description": "The ordered rows. Returned by the create and update echoes; the LISTING omits it and gives `step_count` instead.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string" - }, - "result": { - "type": "string", - "nullable": true - }, - "test_data": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer" - } - } - } - } + } + } + }, + "info": { + "$schema": "PageInfo" + } + }, + "required": [ + "info", + "projects", + "success" + ] + }, + "ReportFiltersDetail": { + "type": "object", + "required": [ + "custom_fields", + "test_run_ids", + "first_test_run" + ], + "properties": { + "custom_fields": { + "type": "object" + }, + "test_run_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "first_test_run": { + "type": "object", + "required": [ + "id", + "name", + "created_at", + "identifier", + "is_automation", + "run_state", + "updated_at", + "project_id", + "user_id", + "group_id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "environment": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "run_state": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + } + } + } + } + }, + "DynamicFilters": { + "type": "object", + "properties": { + "test_run": { + "type": "object", + "required": [ + "created_at", + "status", + "owner", + "automation_status", + "type" + ], + "properties": { + "created_at": { + "type": "string" + }, + "status": { + "type": "array", + "items": { + "type": "integer" + } + }, + "owner": { + "type": "array", + "items": { + "type": "integer" + } + }, + "automation_status": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "ReportData": { + "type": "object", + "required": [ + "test_run_data", + "issues_data", + "test_run_status", + "test_cases_status", + "user_result_stats", + "issues_by_status", + "issues_by_priority" + ], + "properties": { + "test_run_data": { + "type": "object", + "required": [ + "active", + "closed", + "test_cases", + "test_results" + ], + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + }, + "test_cases": { + "type": "integer" + }, + "test_results": { + "type": "integer" + } + } + }, + "issues_data": { + "type": "object", + "required": [ + "total", + "linked_with_test_runs", + "linked_with_test_results" + ], + "properties": { + "total": { + "type": "integer" + }, + "linked_with_test_runs": { + "type": "integer" + }, + "linked_with_test_results": { + "type": "integer" + } + } + }, + "test_run_status": { + "type": "object" + }, + "test_cases_status": { + "type": "array", + "items": { + "type": "object", + "required": [ + "count", + "result_status" + ], + "properties": { + "count": { + "type": "integer" + }, + "result_status": { + "$schema": "ResultStatus" } - }, - "FolderWriteResponseBody": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "folder": { - "$schema": "Folder" - } - } - } + } + } + }, + "user_result_stats": { + "type": "array", + "items": { + "type": "object", + "required": [ + "user_id", + "test_results", + "user_name" + ], + "properties": { + "user_id": { + "type": "integer" }, - "description": "Response body of `FolderWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "SharedStepsIDResponseBody": { + "test_results": { + "type": "integer" + }, + "user_name": { + "type": "string" + } + } + } + }, + "issues_by_status": { + "type": "array", + "items": { + "type": "object" + } + }, + "issues_by_priority": { + "type": "array", + "items": { + "type": "object" + } + }, + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "object" + } + } + }, + "ReportProjectDetail": { + "type": "object", + "required": [ + "id", + "group_id", + "user_id", + "name", + "slug", + "description", + "identifier", + "is_demo", + "publicly_readable", + "created_at", + "source", + "archived", + "team_id", + "default_team_id" + ], + "properties": { + "id": { + "type": "integer", + "example": 10000 + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "user_id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "slug": { + "type": "string", + "example": "Demo-Project-8e37e463" + }, + "description": { + "type": "string", + "example": "This project contains test cases and test runs for the demo project" + }, + "identifier": { + "type": "string", + "example": "PR-3" + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2024-06-28T00:00:00.000Z" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "archived": { + "type": "integer", + "example": 0 + }, + "team_id": { + "type": "integer", + "example": 1 + }, + "default_team_id": { + "type": "integer", + "example": null, + "nullable": true + }, + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "import_id": { + "type": "string", + "nullable": true + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "SelectionData": { + "type": "object", + "description": "A folder-keyed test-case selection: which cases, grouped by the folder they live in.\nAll three keys are required. Widen with `select_all` (whole project) or a folder's\n`test_case_select_all` (whole folder), then subtract with `deselected_test_cases`;\notherwise the cases are listed explicitly in `selected_test_cases`.\n", + "required": [ + "select_all", + "folders", + "unique_test_case_count" + ], + "properties": { + "select_all": { + "type": "boolean", + "description": "Select every case in the project.", + "example": false + }, + "folders": { + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection." + }, + "unique_test_case_count": { + "type": "integer", + "description": "Number of distinct cases the selection resolves to.", + "example": 3 + } + } + }, + "V1TestCaseDetail": { + "type": "object", + "description": "v1 test case by INTEGER ids (GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}). Translation output: data.test_case.identifier is the TC-NNN form. The row is the full shared test-case record, not a stub.\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "description": "The full test-case record. `identifier` is the TC-NNN id and `id` is the integer passed in the path. The display field is `name`; there is no `title`.", + "$schema": "V1TestCaseRecordRow__GetTestCaseByIntegerId" + } + } + } + } + }, + "TestCaseTrend": { + "type": "object", + "required": [ + "graphData", + "field", + "value" + ], + "properties": { + "graphData": { + "type": "object", + "properties": { + "Jun": { + "type": "integer" + }, + "Jul": { + "type": "integer" + }, + "Aug": { + "type": "integer" + }, + "Sep": { + "type": "integer" + }, + "Oct": { + "type": "integer" + }, + "Nov": { + "type": "integer" + }, + "Dec": { + "type": "integer" + }, + "Jan": { + "type": "integer" + }, + "Feb": { + "type": "integer" + }, + "Mar": { + "type": "integer" + }, + "Apr": { + "type": "integer" + }, + "May": { + "type": "integer" + } + } + }, + "field": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "History": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "history record ID" + }, + "entity_id": { + "type": "integer", + "description": "test case ID this history belongs to" + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "version_id": { + "type": "string", + "format": "uuid" + }, + "source": { + "type": "string", + "description": "Source of the change (e.g., \"update\", \"create\")" + }, + "modified_fields": { + "type": "array", + "description": "List of fields that were modified in this version", + "items": { + "type": "string" + } + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "comment": { + "type": "string", + "nullable": true + }, + "user_id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + } + }, + "version_name": { + "type": "string", + "nullable": true + }, + "user": { + "$schema": "User" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "description": "API endpoint to retrieve this specific history record", + "example": "/api/v1/projects/2022091/test-cases/36347792/histories/17604265" + } + } + }, + "origin_channel": { + "type": "string", + "example": "api" + } + } + }, + "HistoryListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "histories": { + "type": "array", + "description": "List of history records for the test case", + "items": { + "$schema": "History__ListTestCaseHistories" + } + }, + "info": { + "$schema": "PageInfo" + }, + "plan_restriction": { + "type": "boolean" + } + } + }, + "TestCaseDiffStep": { + "type": "object", + "required": [ + "id", + "step", + "order", + "result" + ], + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "result": { + "type": "string" + }, + "shared_step_id": { + "type": "integer", + "nullable": true + } + } + }, + "TestCaseConsolidatedResultFolders": { + "type": "object" + }, + "TestCaseConsolidatedListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "V1TestCaseListRow__ListTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "folders": { + "$schema": "TestCaseConsolidatedResultFolders" + } + } + }, + "V1TestPlanDetail": { + "type": "object", + "description": "v1 test-plan detail (GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}, by INTEGER ids). NOTE: there is NO `data` wrapper \u2014 the plan sits at the top level under `test_plan`. Translation output: test_plan.identifier (the TP-NNN form).\n", + "properties": { + "success": { + "type": "boolean" + }, + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { "type": "object", - "required": [ - "data", - "success" - ], "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + } + } + } + } + }, + "ResultCustomField": { + "type": "object", + "required": [ + "id", + "field_type", + "field_name", + "entity_type", + "field_user_name", + "optional", + "is_bulk_editable", + "is_filterable", + "links", + "is_required" + ], + "properties": { + "id": { + "type": "integer" + }, + "field_type": { + "type": "string", + "enum": [ + "field_string", + "field_text", + "field_user", + "field_dropdown", + "field_multi_dropdown", + "field_url", + "field_boolean", + "field_int", + "field_date", + "field_rte" + ] + }, + "field_name": { + "type": "string" + }, + "entity_type": { + "type": "string", + "enum": [ + "TestCase", + "TestResult", + "TestExecution", + "Build" + ] + }, + "field_user_name": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "is_bulk_editable": { + "type": "boolean" + }, + "is_filterable": { + "type": "boolean" + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string" + } + } + }, + "placeholder": { + "type": "string" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "object" + }, + "option_values": { + "type": "array", + "items": { + "type": "object" + } + }, + "field_values": { + "type": "array", + "items": { + "type": "object" + } + }, + "sort_options": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + } + } + }, + "TestResultCustomFieldsResponse": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$schema": "ResultCustomField" + } + }, + "info": { + "$schema": "PageInfo__GetTestResultsCustomFields" + } + } + }, + "V1TestRunDetail": { + "type": "object", + "description": "v1 test-run detail (GET /api/v1/projects/{project_id}/test-runs/{test_run_id}, by INTEGER ids). Translation output: data.test_run.identifier (the TR-NNN form). The integer run id is surfaced as data.test_run.uuid.\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_run": { + "$schema": "V1TestRunDetailRow" + } + } + } + } + }, + "TestResultStatusValue": { + "type": "object", + "required": [ + "internal_name", + "is_default", + "colour", + "value", + "name" + ], + "properties": { + "internal_name": { + "type": "string", + "description": "Internal system name for the status" + }, + "is_default": { + "type": "integer", + "description": "Whether this is the default status (1 or 0)", + "enum": [ + 0, + 1 + ] + }, + "colour": { + "type": "string", + "description": "Hex color code for the status" + }, + "value": { + "type": "integer", + "description": "Numeric value identifier for the status" + }, + "name": { + "type": "string", + "description": "Display name of the status" + } + } + }, + "TestResultStatusField": { + "type": "object", + "required": [ + "values", + "total_count", + "links" + ], + "properties": { + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "is_default": { + "type": "integer", + "example": 0 + }, + "name": { + "type": "string", + "example": "Passed" + }, + "value": { + "type": "integer", + "example": 318610 + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "total_count": { + "type": "integer", + "example": 8 + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/test-case/status" + } + } + } + } + }, + "CustomField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the custom field" + }, + "field_type": { + "type": "string", + "description": "Type of the custom field", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date" + ] + }, + "field_name": { + "type": "string", + "description": "Name of the custom field" + }, + "is_required": { + "type": "boolean", + "description": "Whether the custom field is required" + }, + "place_holder_text": { + "type": "string", + "description": "Placeholder text for the custom field", + "nullable": true + }, + "default_value": { + "type": "string", + "description": "Default value for the custom field", + "nullable": true + }, + "entity_type": { + "type": "string", + "description": "Type of entity this field is for", + "example": "TestCase" + }, + "options": { + "type": "array", + "description": "Options for dropdown or multi-dropdown fields", + "items": { + "type": "object", + "properties": { + "option_value": { + "type": "string" }, - "description": "Response body of `SharedStepsIDResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "TagsSearchResponseBody": { - "type": "object", - "required": [ - "tags", - "tag_objects", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "Simple array of tag names (for backward compatibility)", - "example": [ - "AI Generated", - "AI Generated Details" - ], - "items": { - "type": "string" - } - }, - "tag_objects": { - "type": "array", - "description": "Detailed tag objects with metadata", - "items": { - "$schema": "Tag" - } - }, - "info": { - "$schema": "PageInfo" - } + "is_default": { + "type": "boolean" + } + } + } + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Whether the custom field applies to all projects" + }, + "assigned_projects": { + "type": "array", + "description": "List of project identifiers this field is assigned to", + "items": { + "type": "string" + } + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Whether to link this field to future projects" + } + } + }, + "TestRunFormFieldsResponse": { + "type": "object", + "required": [ + "success", + "default_fields" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "default_fields": { + "type": "object", + "description": "Default field values for test results. Only 'status' field is included by default.", + "properties": { + "status": { + "$schema": "FormFieldDescriptor" + } + } + }, + "custom_fields": { + "type": "array", + "description": "Custom fields array. Only present when 'all' parameter is true.", + "items": { + "$schema": "CustomField" + } + } + } + }, + "DatasetMini": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "rows_count": { + "type": "integer", + "description": "Total number of rows in the dataset." + }, + "test_cases_count": { + "type": "integer", + "description": "Number of test cases using this dataset." + }, + "created_by": { + "$schema": "DatasetUserRef__ListDatasets" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was last updated." + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } + } + }, + "DatasetsListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the request was successful." + }, + "datasets": { + "type": "array", + "description": "List of datasets.", + "items": { + "$schema": "DatasetMini" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "V1DuplicatesListResponse": { + "type": "object", + "description": "Suggested-duplicate list. NOTE this exact shape with an empty `duplicates` is also what\nthe server returns when the group lacks the dedupe feature flag, so an empty list is not\nevidence that the project has no duplicates \u2014 check .../ai-duplicates/status.\n", + "properties": { + "success": { + "type": "boolean" + }, + "duplicates": { + "type": "array", + "items": { + "type": "object" + } + }, + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + } + } + }, + "ExploratorySessionLogListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_logs": { + "type": "array", + "items": { + "$schema": "ExploratorySessionLog" + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 50 + }, + "total_count": { + "type": "integer", + "example": 24 + }, + "total_pages": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "ExploratorySessionListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_sessions": { + "type": "array", + "items": { + "$schema": "ExploratorySession" + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 30 + }, + "total_count": { + "type": "integer", + "example": 150 + }, + "total_pages": { + "type": "integer", + "example": 5 + } + } + }, + "count": { + "type": "integer", + "description": "Total number of sessions matching the query.", + "example": 150 + } + } + }, + "ProjectsListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful.", + "example": true + }, + "projects": { + "type": "array", + "description": "List of project objects.", + "items": { + "$schema": "Project__ListProjects" + } + }, + "info": { + "$schema": "ProjectsPageInfo" + } + } + }, + "ReportScheduleGetResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "Contains an array of Scheduled reports", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "The total number of scheduled reports in the project." + }, + "total_report_count": { + "type": "integer", + "description": "The total number of reports in the project." + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "Tag": { + "type": "object", + "required": [ + "name", + "created_at" + ], + "properties": { + "name": { + "type": "string", + "description": "Tag name", + "example": "AI Generated" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the tag was first used", + "example": "2024-10-08T09:25:09.000Z" + }, + "usage_count": { + "type": "integer", + "description": "Number of test cases using this tag (optional)", + "example": 42 + } + } + }, + "ProjectUserDetail": { + "type": "object", + "required": [ + "id", + "full_name", + "onboarded", + "group_id", + "browserstack_user_id", + "role", + "has_access", + "permissions" + ], + "properties": { + "id": { + "type": "integer", + "description": "User ID", + "example": 240620 + }, + "full_name": { + "type": "string", + "description": "User's full name", + "example": "Jasbir Singh" + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status (1 = completed, 0 = pending)", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "group_id": { + "type": "integer", + "description": "Group this user belongs to", + "example": 1 + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user identifier", + "example": 10328238 + }, + "role": { + "type": "string", + "description": "User role in the system", + "enum": [ + "admin", + "user", + "viewer" + ], + "example": "user" + }, + "has_access": { + "type": "boolean", + "description": "Whether user has access to the project", + "example": true + }, + "permissions": { + "type": "array", + "description": "List of permissions granted to user", + "example": [ + "tc_write", + "project_write", + "test_run_execute" + ], + "items": { + "type": "string", + "enum": [ + "tc_write", + "tc_delete", + "tc_archive_test_cases", + "project_write", + "test_plan_write", + "test_plan_delete", + "test_run_write", + "test_run_delete", + "test_run_execute" + ] + } + }, + "is_delighted_visible": { + "type": "boolean", + "description": "Whether Delighted feedback widget is visible", + "example": false + }, + "is_product_intro_visible": { + "type": "boolean", + "description": "Whether product introduction is visible", + "example": true + }, + "is_onboarding_project_header_visible": { + "type": "boolean", + "description": "Whether onboarding header is visible", + "example": true + }, + "is_lcnc_explore_dismissed": { + "type": "boolean", + "description": "Whether low-code/no-code explore banner is dismissed", + "example": false + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Whether reports and notifications are enabled", + "example": true + }, + "customisable_dashboard_accessible": { + "type": "boolean", + "description": "Whether customizable dashboard is accessible", + "example": true + }, + "webhook_accessible": { + "type": "boolean", + "description": "Whether webhooks are accessible", + "example": true + }, + "is_build_listing_enabled": { + "type": "boolean", + "description": "Whether build listing feature is enabled", + "example": true + }, + "productRole": { + "type": "string", + "description": "Product-level role", + "example": "Product User" + }, + "is_new_project_settings_visible": { + "type": "boolean", + "description": "Whether new project settings UI is visible", + "example": true + }, + "is_jira_app_project_panel_visible": { + "type": "boolean", + "description": "Whether Jira app project panel is visible", + "example": false + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "is_re_arch_enabled": { + "type": "boolean", + "example": true + }, + "is_test_plan_detailed_report_enabled": { + "type": "boolean", + "example": false + } + } + }, + "ReportUpdateResponse": { + "type": "object", + "required": [ + "data", + "report_data", + "report_filters", + "dynamic_filters" + ], + "properties": { + "data": { + "$schema": "ReportDetail__UpdateReport" + }, + "report_data": { + "$schema": "ReportData" + }, + "report_filters": { + "$schema": "ReportFiltersDetail" + }, + "dynamic_filters": { + "$schema": "DynamicFilters" + }, + "filters": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + } + } + }, + "GenericAttachment": { + "type": "object", + "required": [ + "id", + "name", + "url", + "download_url", + "content_type", + "size" + ], + "properties": { + "id": { + "type": "integer", + "description": "Attachment ID", + "example": 10288829 + }, + "name": { + "type": "string", + "description": "Original filename", + "example": "screenshot.png" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Pre-signed S3 URL for viewing (inline, ~26 min expiry)", + "example": "https://example.com/media/genric/1/2921471/86de8094..." + }, + "download_url": { + "type": "string", + "format": "uri", + "description": "Pre-signed S3 URL for downloading (attachment, ~26 min expiry)", + "example": "https://example.com/media/genric/1/2921471/86de8094..." + }, + "content_type": { + "type": "string", + "description": "MIME type of the file", + "example": "image/png" + }, + "size": { + "type": "string", + "description": "Human-readable file size", + "example": "300 KB" + } + } + }, + "TestRunTestCaseV2": { + "type": "object", + "description": "One test case AS MAPPED INTO A TEST RUN \u2014 a case-plus-configuration row, not the project-level test case record. A case mapped to several configurations appears once per configuration. Shape as returned by the v2 run test-case listing.", + "properties": { + "identifier": { + "type": "string", + "example": "TC-42" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true, + "description": "The test case's description. Always present as a key; null for cases without one." + }, + "priority": { + "type": "string", + "description": "Resolved label, not the id." + }, + "status": { + "type": "string", + "description": "Test case status label." + }, + "case_type": { + "type": "string" + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." + }, + "latest_result_id": { + "type": "integer", + "description": "Id of the latest result recorded against this row; use it to read or amend that result. Populated even for never-executed rows \u2014 the result row is created when the case is mapped into the run \u2014 so it cannot be used to detect 'never executed'." + }, + "assignee": { + "type": "string", + "nullable": true, + "description": "Assignee of this row within the run." + }, + "folder_path": { + "type": "array", + "items": { + "type": "integer" + } + }, + "configuration_id": { + "type": "integer", + "nullable": true, + "description": "Configuration this row is for; null when the case is mapped without a configuration." + }, + "dataset": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "description": "Name of the dataset variable (the dataset's column) this value came from. Only ever present on a data-driven row \u2014 see the array description; there are no entries at all otherwise." }, - "description": "Response body of `TagsSearchResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "TestRunWriteResponseBody": { + "row": { + "type": "string", + "description": "The value bound to that variable for this row. Same condition as `variable`." + } + } + }, + "description": "The dataset values bound to this run row, one entry per dataset variable. CONDITIONAL CONTENT \u2014 the key is there, but the array is EMPTY for every row that is not dataset-driven, which is most rows: entries exist only when the case was mapped into the run against a dataset. So `variable` and `row` below are only ever seen on data-driven rows. Asking for the compact/minified row shape drops the key altogether instead of returning it empty." + }, + "project_id": { + "type": "string", + "example": "PR-1", + "description": "Project of the run being read." + }, + "source_project_id": { + "type": "string", + "example": "PR-7", + "description": "The project the case originates from, for a case shared in from elsewhere. On the older run backend the rows embedded in a single-run read report the current project rather than the true origin, because the origin is only resolved when the caller is the standalone case listing; the newer backend resolves it per row on both. Treat it as authoritative only when it differs from the project you asked about." + }, + "source_project_name": { + "type": "string", + "nullable": true, + "description": "Display name of the project the case lives in. CONDITIONAL \u2014 only the older run backend returns this key at all; on the newer run backend it is never returned, neither in the run's case listing nor in the rows embedded in a single-run read. Where it is returned it is null unless the case is shared in from another project. `source_project_id` is always present, so use that to identify the origin project and treat this as a display-only extra." + }, + "is_shared": { + "type": "boolean", + "description": "Whether this case is shared in from another project." + }, + "execution_id": { + "type": "string", + "nullable": true, + "description": "Identifier of the current execution attempt for this row." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the case was mapped into this run \u2014 the row's creation time, not the test case's." + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "When this run row was last updated. Note the name: it is the row's updated_at, renamed on the way out." + }, + "created_by": { + "type": "string", + "nullable": true, + "description": "Email of whoever created this run row." + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Email of whoever last updated this run row; null when it has not been updated since creation." + }, + "urls": { + "type": "object", + "description": "Web-app deep links for the row. Always present. DEFECT \u2014 the link is currently built without the run segment, so it does not resolve; construct run links from the run identifier instead.", + "properties": { + "self": { + "type": "string", + "description": "Intended as an absolute link to this case within the run in the web app; currently malformed, see above." + } + } + } + } + }, + "V2BulkResultOutcome": { + "type": "object", + "description": "Per-test-case outcome of a bulk result submission. Reports whether the entry was accepted for async processing, not whether it was applied.", + "properties": { + "test_case_id": { + "type": "string", + "example": "TC-101" + }, + "status": { + "type": "string", + "values": [ + "ACCEPTED", + "FAILED" + ], + "description": "ACCEPTED means queued, not applied." + }, + "message": { + "type": "string", + "example": "Will be processed." + }, + "error": { + "type": "object", + "description": "Present only when status is FAILED.", + "properties": { + "code": { + "type": "string", + "values": [ + "INVALID_STATUS_VALUE", + "REVIEW_STATUS_GATE" + ] + }, + "message": { + "type": "string" + } + } + } + } + }, + "V2TestResultListItem": { + "type": "object", + "description": "One recorded result for a test case in a test run, as the v2 read returns it. Deliberately narrower than the v1 result shape: no author, backtrace, attachments or full status object.", + "properties": { + "id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "description": "Email of the user who recorded the result; null when the user is not in the caller's group.", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "test_case_id": { + "type": "string", + "description": "Echoed back as the TC- identifier that was requested, not a numeric id.", + "example": "TC-101" + }, + "test_run_id": { + "type": "string", + "example": "TR-14" + }, + "result_status": { + "type": "object", + "description": "Cut down to the display-case status name on this endpoint. The id, internal_name, field_name and colour that the v1 result reads return are deliberately dropped here and never appear.", + "required": [ + "field_value" + ], + "properties": { + "field_value": { + "type": "string", + "example": "Passed", + "description": "Display-case status name." + } + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues; empty when the result has none.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "example": "BUG-123" + }, + "issue_type": { + "type": "string", + "example": "jira" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "custom_fields": { + "type": "array", + "description": "An ARRAY of entries, not an object keyed by field name; [] when the result has no custom field values. `field_name` is the field's system name. For dropdown / single-select fields `value` is the selected option's id rather than its label.", + "items": { + "type": "object", + "properties": { + "field_name": { + "type": "string" + }, + "value": { + "description": "Scalar value, or the selected option's id for option-backed fields.", + "nullable": true + } + } + } + }, + "step_result": { + "type": "object", + "description": "Present only for step-level results; null otherwise.", + "nullable": true, + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + }, + "result_status": { "type": "object", + "description": "Same cut-down shape as the parent result_status.", "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "$schema": "TestRun" - }, - "project": { - "$schema": "Project" - }, - "error": { - "type": "string", - "description": "Only present when success is false; the backing service's message." - } - } - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", - "items": { - "type": "integer" - } - } + "field_value": { + "type": "string" + } + } + } + } + }, + "configuration_id": { + "type": "integer", + "nullable": true + }, + "execution_id": { + "type": "string", + "nullable": true + }, + "dataset": { + "type": "array", + "description": "Dataset row this execution used, as {variable, row} pairs. Empty for non-data-driven runs.", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string" }, - "description": "Response body of `TestRunWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + "row": { + "type": "integer" + } + } } - }, - "responses": { - "TestRunWriteResponse": { - "description": "Successfully returned the test run after a WRITE\n(create / edit / assign / close / delete). NOTE the envelope key is `data.testrun` with NO\nunderscore on these operations, unlike clone and the plain reads, which use `data.test_run`.\n`skipped_test_case_ids` is a SIBLING of `data`, not inside it, and lists cases the review gate\nrefused to add. Client errors from the backing service replace the payload with\n{data: {success: false, error: }} at the 4xx status.\n", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "$schema": "TestRun" - }, - "project": { - "$schema": "Project" - }, - "error": { - "type": "string", - "description": "Only present when success is false; the backing service's message." - } - } - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", - "items": { - "type": "integer" - } - } - } + }, + "urls": { + "type": "object", + "nullable": true, + "description": "Web link for this result. Null when the result cannot be tied to a project, run, case and run row all at once.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's row inside the run." + } + } + } + } + }, + "V2TestRunResultDetail": { + "type": "object", + "description": "The case-level result written by a v2 result submission, returned under `test-result`. This is the no-step shape: a submission that carried step_results is answered with per-step entries instead and has no `test-result` at all, so nothing here ever describes a step.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the created result. Carry this forward for a follow-up update; it is the id domain the server expects back." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "example": "Passed", + "description": "Display-case status name, echoing the name that was sent." + }, + "issues": { + "type": "array", + "description": "Linked issues as plain KEY STRINGS, deduplicated \u2014 the same shape they are sent in, not objects.", + "items": { + "type": "string", + "example": "JIRA-12" + } + }, + "urls": { + "type": "object", + "description": "Web link for this submission.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's ROW IN THE RUN \u2014 its final path segment is the run row's id, not the id of the result just created." + } + } + } + } + }, + "Template": { + "type": "object", + "description": "A test-case template as returned in list responses: metadata and counts, without the field layout.", + "required": [ + "id", + "name", + "entity", + "enabled", + "is_default", + "is_system", + "future_projects_applicable", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "integer", + "description": "Template id \u2014 a plain integer." + }, + "name": { + "type": "string" + }, + "entity": { + "type": "string", + "description": "What the template applies to. Always TestCase on this surface.", + "example": "TestCase" + }, + "enabled": { + "type": "boolean", + "description": "True when the template is active. False means disabled \u2014 still stored, with its fields and project links, and re-enablable." + }, + "step_type": { + "type": "string", + "nullable": true, + "description": "Step style identifier for test-case templates; null for templates that do not define one." + }, + "is_default": { + "type": "boolean", + "description": "True for the workspace default template for this entity type. The default cannot be disabled or deleted." + }, + "is_system": { + "type": "boolean", + "description": "True for a built-in template. System templates cannot be deleted." + }, + "future_projects_applicable": { + "type": "boolean", + "description": "True when projects created later are linked to this template automatically." + }, + "field_count": { + "type": "integer", + "nullable": true, + "description": "Number of fields in the template. Null when the count was not computed for this response." + }, + "linked_to_all_projects": { + "type": "boolean", + "nullable": true + }, + "project_count": { + "type": "integer", + "nullable": true, + "description": "Number of linked projects. Null when the workspace-wide count was skipped server-side \u2014 treat null as 'not computed', not zero." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TemplateFieldMapping": { + "type": "object", + "description": "One field placed on a template, as returned by a template read or create. Note these are mapping records, not the ids sent when writing a template.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the mapping row, NOT the field id. When resending a field on an update, use custom_field_id or system_field_id, not this. It is not unique within a response: the duplicated automation row shares its original's value." + }, + "field_category": { + "type": "string", + "description": "Which section the field sits in.", + "values": [ + "description", + "properties", + "step_properties" + ] + }, + "custom_field_id": { + "type": "integer", + "nullable": true, + "description": "Set for a custom field; null for a system field. Which of the two it is also decides what field_data contains and which of its keys exist." + }, + "system_field_id": { + "type": "integer", + "nullable": true, + "description": "Set for a built-in field; null for a custom field." + }, + "order": { + "type": "integer", + "description": "Position within its section." + }, + "is_mandatory": { + "type": "boolean", + "description": "Placement-level required flag, defaulting to false when the stored mapping does not carry one. This is NOT the only required-ness signal: a field definition can separately mark itself required, and the two can disagree. Observed false on every row of every template inspected so far." + }, + "field_data": { + "type": "object", + "description": "The underlying field definition, passed through from the field service unchanged apart from two additions (`field_type_raw`, and `parent_custom_field_id` on nested dropdowns). The exact key set therefore varies by field kind \u2014 system fields and custom fields carry different subsets, and step fields carry more than the others. Treat the keys listed here as likely-present rather than guaranteed, and do not assume a missing key means an unset value.", + "properties": { + "field_type": { + "type": "string", + "description": "The product-facing type name in its prefixed form, for example field_string or field_multi_dropdown. For a small number of workspaces a nested-dropdown type outside the ordinary set can be substituted here, along with a parent field id." + }, + "system_name": { + "type": "string", + "description": "Internal name of a built-in field. Built-in fields only." + }, + "optional": { + "type": "boolean", + "description": "Built-in field's own removability flag. Built-in fields only." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "field_name": { + "type": "string", + "description": "The field's name as shown to authors. Carried on both system and custom field definitions; it is the value to match on when resolving a field a user named in words." + } + } + } + } + }, + "TemplateWithFields": { + "type": "object", + "description": "A test-case template together with its full field layout, as returned by a single-template read or a create.", + "allOf": [ + { + "$schema": "Template" + } + ], + "properties": { + "description_fields": { + "type": "array", + "description": "Fields in the description section, ordered.", + "items": { + "$schema": "TemplateFieldMapping" + } + }, + "properties_fields": { + "type": "array", + "description": "Fields in the properties section, ordered. The Review & Approve fields (reviewers, review status) are filtered out of this surface even when stored on the template.", + "items": { + "$schema": "TemplateFieldMapping" + } + }, + "step_fields": { + "type": "array", + "description": "Step-level custom fields, ordered. Always an empty array for workspaces without step custom fields enabled; the key is always present.", + "items": { + "$schema": "TemplateFieldMapping" + } + } + } + }, + "V2PublicTestCase": { + "type": "object", + "description": "A test case as the v2 public project API serialises it on read and on write. NOT the same shape as the `TestCase` schema the older endpoints return: the title is `title` (not `name`), priority/status/case_type are plain display strings (not objects), owner is an email string (not an integer id), custom fields are name/value pairs, and there is no integer `id` \u2014 the case is identified by `identifier`.", + "properties": { + "identifier": { + "type": "string", + "description": "TC-NNN, or the third-party identifier when the workspace issues project-level ids." + }, + "title": { + "type": "string", + "description": "The case title. Sent as `name` on a write, returned as `title`." + }, + "folder_id": { + "type": "integer", + "description": "Integer id of the folder the case now sits in." + }, + "priority": { + "type": "string", + "description": "The project's display value, e.g. 'Medium'. Null when unset." + }, + "status": { + "type": "string", + "description": "The project's display value for the case's own lifecycle status, e.g. 'Active'." + }, + "case_type": { + "type": "string", + "description": "The project's display value for the case type, e.g. 'Smoke & Sanity'." + }, + "automation_status": { + "type": "string", + "description": "Internal name, e.g. not_automated / automated / obsolete." + }, + "owner": { + "type": "string", + "description": "Owner's EMAIL, or the imported owner name for cases brought in from another tool. Null when the owner does not resolve to a user in this group." + }, + "description": { + "type": "string" + }, + "preconditions": { + "type": "string" + }, + "template": { + "type": "string", + "description": "Template name, e.g. test_case_steps or bdd." + }, + "is_shared": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links.", + "items": { + "type": "object", + "properties": { + "jira_id": { + "type": "string", + "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made \u2014 azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." } - }, - "BadRequest": { - "description": "Bad request due to invalid parameters.", - "schema": { - "$schema": "ErrorResponse" + }, + "description": "Only two keys exist on this shape and both are always present when the array is non-empty \u2014 nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." + } + }, + "steps": { + "type": "array", + "description": "Step list, flattened to step/result only \u2014 test_data, shared-step links and per-step custom fields are not echoed here.", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" } - }, - "Unauthorized": { - "description": "Unauthorized access. Authentication is required.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "custom_fields": { + "type": "array", + "description": "Name/value pairs, never the id-keyed map a write sends. A multi-select field carries `values` (array) instead of `value`. Always present, as [] when the case has none.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } } - }, - "NotFound": { - "description": "The requested resource was not found.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "attachments": { + "type": "array", + "description": "Files attached to the case. Comes back as [] when the case carries no files. Every entry carries name, size, content_type and url together \u2014 these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "File name as uploaded." + }, + "size": { + "type": "string", + "description": "Human-readable size, e.g. '1.2 MB' \u2014 not a byte count." + }, + "content_type": { + "type": "string", + "description": "MIME type recorded when the file was uploaded, e.g. image/png." + }, + "url": { + "type": "string", + "description": "Link to the stored file." } - }, - "InternalServerError": { - "description": "Internal server error.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp \u2014 named last_updated_at here, not updated_at." + }, + "created_by": { + "type": "string", + "description": "Email of the creating user." + }, + "updated_by": { + "type": "string", + "description": "Email of the last user to write to the case." + }, + "urls": { + "type": "object", + "description": "Web-app link for the case, for showing a human where it lives.", + "properties": { + "self": { + "type": "string" + } + } + } + } + }, + "V2History": { + "type": "object", + "description": "One revision of a test case as returned by the v2 history listing. The internal record id, entity_id, entity_type, project_id, group_id, the nested user object and the links block are stripped before the record is returned.", + "properties": { + "version_id": { + "type": "string", + "format": "uuid", + "description": "Opaque version identifier for this revision." + }, + "version_name": { + "type": "string", + "description": "Positional label for the revision \u2014 V1, V2, V3 \u2026 \u2014 where V1 is the oldest revision visible under the caller's plan. The number is what the single-version read takes.", + "example": "V2" + }, + "source": { + "type": "string", + "description": "What produced the revision: 'create' for the original, 'update' for an edit, 'restore' after a rollback.", + "example": "update" + }, + "modified_fields": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Names of the fields this revision changed. Null on the creating revision." + }, + "modified": { + "type": "object", + "description": "Per-field before/after map, e.g. {\"title\": {\"old\": \"...\", \"new\": \"...\"}}. Only the first record on each page is enriched with this detail; the other rows come back without it. The creating revision never carries it either, having nothing to diff against. Rows whose old and new are both empty are dropped.", + "additionalProperties": true, + "example": { + "title": { + "old": "Original Title", + "new": "Updated Title" + } + } + }, + "comment": { + "type": "string", + "nullable": true, + "description": "Optional note recorded with the change." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the revision was recorded.", + "example": "2025-08-29T08:33:58.000Z" + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Email of the user who made the change. Null when the user id cannot be resolved to a user in this group.", + "example": "user@example.com" + }, + "user_id": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id of the editor \u2014 the raw id updated_by is resolved from." + }, + "testcase_id": { + "type": "string", + "description": "Identifier of the test case this revision belongs to \u2014 the third-party identifier when one is set, otherwise the TC-NNN identifier. Falls back to TC- if the case cannot be fetched.", + "example": "TC-123" + }, + "origin_channel": { + "type": "string", + "description": "How the edit that produced this revision arrived. One of ui, api, jira_app, azure, import, automation or ai. Revisions written through this API are always recorded as api.", + "enum": [ + "ui", + "api", + "jira_app", + "azure", + "import", + "automation", + "ai" + ], + "example": "api" + } + } + }, + "TestPlanV2": { + "type": "object", + "description": "A test plan or sub-plan as the v2 plan surface serializes it. There is no numeric id field - `identifier` (TP-NNN / STP-NNN) is the only id, and project_id is the PR-NNN identifier rather than the numeric project id. The numeric plan and project ids are recoverable only by reading them out of the web-app link under urls.self. This is the shape every write and every listing returns; only the single-plan read adds the embedded run preview and the relative API links.", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "description": { + "type": "string", + "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." + }, + "active_state": { + "type": "string", + "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", + "enum": [ + "active", + "completed" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." + }, + "test_runs_count": { + "type": "object", + "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "sub_plans_count": { + "type": "integer", + "nullable": true, + "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." + }, + "start_date": { + "type": "string", + "nullable": true, + "format": "date-time", + "example": "2026-01-01T00:00:00Z" + }, + "end_date": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "string", + "description": "PR-NNN identifier of the owning project, not a number.", + "example": "PR-2" + }, + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + } + } + } + } + }, + "TestRunSummaryV2": { + "type": "object", + "description": "A test run as the v2 run listings serialize it \u2014 a summary shape shared by the project-wide listing and a plan's linked-run listing. It carries NO numeric id and no uuid: `identifier` (TR-NNN) is the only id present. It also never carries issues, issue_tracker or test_cases; those exist only on the single-run read.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-164" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Rich-text description, HTML-unescaped and with links rehydrated. Empty string rather than null for a run with no description." + }, + "run_state": { + "type": "string", + "description": "Lifecycle state; a brand-new run reports 'new_run'." + }, + "active_state": { + "type": "string", + "description": "Derived: 'closed' once the run is closed, otherwise 'active'.", + "enum": [ + "active", + "closed" + ] + }, + "assignee": { + "type": "string", + "nullable": true, + "description": "Email of the run's owner; null when it cannot be resolved.", + "example": "qa@acme.com" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "description": "DEFECT \u2014 currently always an empty array on both run listings, whatever configurations the run actually has. The key is always present; its emptiness carries no information. Read the run itself for its configurations. When populated it is configuration ids (integers), not configuration objects.", + "items": { + "type": "integer" + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "filter_test_cases": { + "type": "object", + "description": "Always present. Carries a single is_dynamic sub-field \u2014 whether the run's case list is filter-driven.", + "properties": { + "is_dynamic": { + "description": "Whether the run's case list is filter-driven. NOT consistently typed: the project-wide listing reports it as a boolean, the plan-scoped listing as the integer 0/1, for the same run. Test it truthily; an identity check against false is unsafe." + } + } + }, + "project_id": { + "type": "string", + "description": "The project the request was scoped to, in PR-NNN form.", + "example": "PR-2" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Omitted entirely, rather than sent as null, when the run has no recorded update time." + }, + "links": { + "type": "object", + "description": "API references for the run. Always present with exactly these two keys.", + "properties": { + "self": { + "type": "string", + "description": "Relative API reference for reading this run." + }, + "test_cases": { + "type": "string", + "description": "Relative API reference for this run's test cases." + } + } + }, + "urls": { + "type": "object", + "description": "Web-app deep links for the run. Always present on both run listings.", + "properties": { + "self": { + "type": "string", + "description": "Absolute link to this run in the web app." + } + } + } + } + }, + "V2ReportDetail": { + "type": "object", + "description": "A report's public v2 configuration record. Identifiers replace internal integer ids, and it carries no report data \u2014 only how the report is set up.", + "properties": { + "id": { + "type": "string", + "pattern": "^SC-\\d+$", + "example": "SC-42", + "description": "The report's identifier. A per-group sequence number, unrelated to the integer report id the v1 report capabilities take." + }, + "project_id": { + "type": "string", + "pattern": "^PR-\\d+$", + "description": "The owning project's identifier." + }, + "project_ids": { + "type": "array", + "items": { + "type": "string", + "pattern": "^PR-\\d+$" + }, + "description": "Every project the report covers. A single-project report lists just its own." + }, + "report_type": { + "type": "string", + "description": "Display name of the report type, e.g. 'Test Run Summary'." + }, + "title": { + "type": "string", + "description": "Display name. Auto-generated as '-MM/DD/YYYY' when the creator omitted it." + }, + "description": { + "type": "string", + "nullable": true + }, + "report_timeframe": { + "type": "string", + "description": "Rolling window or scope mode, e.g. last_one_week, custom, specific_test_runs." + }, + "report_creation_mode": { + "type": "string", + "values": [ + "custom_report", + "system_generated" + ] + }, + "custom_date_range": { + "type": "string", + "nullable": true, + "description": "Two space-separated YYYY-MM-DD dates; set only for the custom timeframe." + }, + "frequency": { + "type": "string", + "values": [ + "once", + "daily", + "weekly", + "monthly" + ], + "description": "'once' is substituted for an unscheduled report whose stored frequency is null." + }, + "frequency_details": { + "type": "object", + "nullable": true, + "description": "time (HH:MM) and, for weekly/monthly, day." + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the schedule next fires. Null for a one-off report." + }, + "is_scheduled": { + "type": "boolean", + "description": "True only when a valid frequency and frequency_details pair was supplied." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "object", + "nullable": true, + "description": "Creator, as email and full_name. No user id is exposed." + }, + "mail_to": { + "type": "object", + "description": "users (email + full_name objects) and external_mails (plain addresses). Populated only for scheduled reports." + }, + "report_filters": { + "type": "object", + "description": "Configured scope echoed back as public identifiers (TR-NNN, TP-NNN, assignee emails). Sub-plans collapse to TP-NNN; values fall back to prefix+internal id if the lookup fails." + }, + "dynamic_filters": { + "type": "object", + "nullable": true, + "description": "Always null on this surface, whatever was sent at create time." + }, + "latest_attachment": { + "type": "object", + "nullable": true, + "description": "Newest generated file in the PROJECT, not necessarily this report's: generated_at, file_type (a MIME type such as application/pdf) and ready, which is true whenever the object is present." + } + } + }, + "AttachmentListItem": { + "type": "object", + "description": "One attachment row as the v2 per-entity attachment listing serializes it - a deliberately thin projection, not the full stored attachment record.", + "properties": { + "id": { + "type": "integer", + "description": "Attachment id. This is the id the delete-attachment capabilities take." + }, + "name": { + "type": "string", + "description": "Original filename as uploaded." + }, + "size": { + "type": "integer", + "description": "File size in bytes. Renamed from the stored byte_size field, which is not present under this name here." + }, + "created_at": { + "type": "string", + "description": "When the file was attached." + }, + "url": { + "type": "string", + "description": "Location to read the file from. There is no separate download endpoint for these attachments." + }, + "data_id": { + "type": "integer", + "description": "Present ONLY on inline attachments (images embedded via the rich-text editor). Its presence is how an inline row is told apart from a real file attachment." + } + } + }, + "TestResultSearchItem": { + "type": "object", + "description": "One test result row from the account-wide result search. Leaner and differently keyed than TestResult: no custom_fields, no step result and no configuration_id, but it carries the owning project, run and case so a cross-project row can be traced back. Several fields exist on the row but are never loaded by this search \u2014 see their descriptions.", + "properties": { + "id": { + "type": "integer", + "description": "Result id. Project-scoped single-result reads need this together with project_id." + }, + "author": { + "type": "object", + "description": "Who logged the result. Six keys when the user resolves. When the result came from an import and no matching user exists, only full_name and email are present, sourced from the import; when neither applies the key is omitted entirely.", + "properties": { + "id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "updated_by": { + "type": "object", + "nullable": true, + "description": "Who last changed the result. ALWAYS NULL on this endpoint: the account-wide result search does not load the updating user's id, so no user is ever resolved and the object is never populated here - the key is always present and always null. The project-scoped single-result reads return the real object." + }, + "status": { + "type": "string", + "nullable": true, + "example": "blocked", + "description": "Lowercase internal status token, and null on most rows. Read result_status.field_value instead \u2014 it is the populated, display-case form of the same outcome." + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatus__SearchTestResults" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Result notes as rich text. User mentions are rewritten to display names in this payload." + }, + "error_description": { + "type": "string", + "nullable": true + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "failure_type": { + "type": "string", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Always null on this endpoint \u2014 the account-wide search does not load it. Use a project-scoped result read if you need it." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Always null on this endpoint, as for started_at." + }, + "time_elapsed": { + "type": "string", + "nullable": true, + "description": "Always null on this endpoint, as for started_at." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by_imported": { + "type": "string", + "nullable": true, + "description": "Name carried over from an import when the result has no BrowserStack author. Always null on this endpoint \u2014 not loaded by the account-wide search." + }, + "attachments": { + "type": "array", + "nullable": true, + "description": "ALWAYS NULL on this endpoint - the account-wide result search does not load attachments, so this is never an array here and iterating it without a guard throws. No attachment object is ever produced on this path, so there are no entry fields to read; use the project-scoped result reads, which return real entries." + }, + "issues": { + "type": "array", + "description": "Linked tracker issues. Always an array - empty on a result with no linked defects, so the entry fields below appear only on rows that actually carry a link. This endpoint loads only the issue key and its link time, plus a duplicate `jira_id` mirroring issue_id whatever the tracker actually is. There is no issue_type here, so a row cannot tell you which tracker the link belongs to - the project-scoped reads do carry it.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "example": "BUG-123" + }, + "jira_id": { + "type": "string", + "description": "Copy of issue_id, named jira_id for every tracker. Written for every entry, so it is present whenever the row has a linked issue at all." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the issue was linked to this result." } - }, - "BulkArchiveResponse": { - "description": "Test cases archived successfully", - "schema": { - "type": "object", - "required": [ - "success", - "folders" - ], - "properties": { - "success": { - "type": "boolean" - }, - "folders": { - "type": "object" - } - } + }, + "description": "`jira_id` is written as a copy of `issue_id` on every entry, so the two always agree and always appear together \u2014 the name is historical and holds the id in whatever tracker the link uses. This shape carries no tracker name; read the run or case surfaces if you need to know which tracker an id belongs to." + } + }, + "configuration": { + "type": "object", + "nullable": true, + "description": "Configuration the result was logged against. Always requested, so present whatever compact is set to; an empty object when the result has none." + }, + "test_case": { + "type": "object", + "nullable": true, + "description": "Present ONLY when compact=false was sent; absent otherwise." + }, + "test_run": { + "type": "object", + "nullable": true, + "description": "Present ONLY when compact=false was sent. Its `id` is overwritten with the run's TR-NNN identifier string, not a number." + }, + "test_case_id": { + "type": "integer", + "nullable": true, + "description": "Integer case id from the result mapping; omitted when the mapping is absent." + }, + "test_run_id": { + "description": "Integer run id from the result mapping \u2014 but replaced by the run's TR-NNN identifier STRING whenever the test_run relation is present (compact=false). Type-check before using it.", + "nullable": true + }, + "test_run_results_mapping_id": { + "type": "integer", + "nullable": true, + "description": "Id of the run/case mapping the result hangs off." + }, + "project_id": { + "type": "integer", + "nullable": true, + "description": "Numeric id of the project the result lives in. This is how you tell cross-project rows apart; it is present only when the search service returned it." + }, + "links": { + "type": "object", + "description": "Present only when the row carried a project_id. Holds `self` only \u2014 no custom_fields link on this path.", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" + } + } + } + } + }, + "Comment": { + "type": "object", + "description": "One comment as the v1 serializer emits it \u2014 a flat object (the published spec wraps it under an extra `comment` key that the server does not send). The same object is returned on both comment surfaces: a test case's own thread and a test case's thread inside a test run.", + "properties": { + "id": { + "type": "integer", + "description": "Comment id; the handle for later edit/delete.", + "example": 3121 + }, + "user_id": { + "type": "object", + "description": "The AUTHOR, despite the name. `id` is the internal TM user id (not the BrowserStack user id) and is null for comments imported from another tool, whose original author name is carried in full_name.", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "full_name": { + "type": "string" + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "group_id": { + "type": "integer", + "description": "Internal TM group id of the owning workspace." + }, + "project_id": { + "type": "integer" + }, + "parent_id": { + "type": "integer", + "nullable": true, + "description": "Always null through this path; replies are not threaded." + }, + "entity_id": { + "type": "integer", + "description": "What the comment is attached to, and it differs by surface: on a test case's own thread this is the test case id, while on a test case's thread inside a test run it is the id of the run/case mapping cell - neither the test case id nor the run id. Read it together with entity_type." + }, + "entity_type": { + "type": "string", + "description": "Which surface the comment lives on: 'TestCase' for a comment on the test case itself, 'TRTC' for a comment on that case inside a test run. The two are separate threads and never overlap.", + "example": "TestCase" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "comment": { + "type": "string", + "description": "HTML body with @mentions rendered to display names and links decorated; can differ from what was posted." + }, + "edited": { + "type": "boolean", + "description": "True once the comment has been changed after posting; always false on a comment that was just created." + }, + "is_editable": { + "type": "boolean", + "description": "False for system/imported comments that cannot be edited; always true on a comment created through the comment endpoints." + } + } + }, + "StatusProgressBreakdown": { + "type": "object", + "description": "Per-result-status progress of a run or one of its configurations. Keys are result-status ids as STRINGS ('1', '2', ...); each value is the count of run test cases currently in that status plus the status definition. Statuses with no cases may be absent. Empty object when nothing has been recorded.", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Number of test cases in the run (or configuration) whose latest result has this status.", + "example": 8 + }, + "result_status": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "field_value": { + "type": "string", + "description": "Display label, e.g. 'Passed'.", + "example": "Passed" + }, + "internal_name": { + "type": "string", + "description": "Stable machine name (passed, failed, blocked, untested, ...). Use this to compare across projects; ids differ per project.", + "example": "passed" + }, + "field_name": { + "type": "string", + "example": "Status" + }, + "colour": { + "type": "string", + "example": "#00FF00" } - }, - "Forbidden": { - "description": "Forbidden access. You don't have permission.", - "schema": { - "$schema": "ErrorResponse" + } + } + } + } + }, + "BinnedTestCase": { + "type": "object", + "description": "A test case as it appears in the recycle-bin listing: a slim row from the bin store plus resolved people, not the full TestCase shape.", + "properties": { + "id": { + "type": "integer", + "description": "Integer id \u2014 what restore/purge selections take." + }, + "identifier": { + "type": "string", + "example": "TC-3502" + }, + "name": { + "type": "string" + }, + "priority": { + "type": "object", + "description": "The priority as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", + "properties": { + "field_value": { + "type": "string", + "description": "The displayed name \u2014 the one to compare against." + }, + "colour": { + "type": "string", + "description": "Presentation only." + }, + "field_name": { + "type": "string", + "description": "Internal field name behind the value." + }, + "value_category": { + "type": "string", + "description": "Grouping the value belongs to." + }, + "internal_name": { + "type": "string", + "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming \u2014 `field_value` is what a person sees and can be edited." + } + } + }, + "status": { + "type": "object", + "description": "The status as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", + "properties": { + "field_value": { + "type": "string", + "description": "The displayed name \u2014 the one to compare against." + }, + "colour": { + "type": "string", + "description": "Presentation only." + }, + "field_name": { + "type": "string", + "description": "Internal field name behind the value." + }, + "value_category": { + "type": "string", + "description": "Grouping the value belongs to." + }, + "internal_name": { + "type": "string", + "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming \u2014 `field_value` is what a person sees and can be edited." + } + } + }, + "owner": { + "type": "integer", + "nullable": true, + "description": "Raw BrowserStack user id of the owner; see owner_details for the person." + }, + "owner_details": { + "type": "object", + "nullable": true, + "description": "Resolved owner {id, browserstack_user_id, full_name, email, group_id}; absent when owner is null or unknown." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "issue_id": { + "type": "string" + }, + "issue_type": { + "type": "string" } - }, - "UnprocessableEntity": { - "description": "The request was well-formed but contains semantic errors.", - "schema": { - "$schema": "ErrorResponse" + } + } + }, + "folder_name": { + "type": "string", + "nullable": true + }, + "folder_path": { + "type": "string", + "nullable": true, + "description": "Breadcrumb of the folder the case was in when binned." + }, + "updated_by": { + "type": "integer", + "nullable": true, + "description": "Raw BrowserStack user id of who moved it to the bin; see updator_details." + }, + "updator_details": { + "type": "object", + "nullable": true, + "description": "Resolved person who binned the case {id, browserstack_user_id, full_name, email, group_id}." + }, + "trashed_at": { + "type": "string", + "format": "date-time", + "description": "When the case was moved to the bin." + }, + "days_remaining": { + "type": "integer", + "description": "Days left before automatic permanent purge." + }, + "template": { + "type": "string", + "description": "The template the case was authored on, carried through from the case record." + }, + "created_by": { + "type": "integer", + "description": "Id of the user who created the case. `creator_details` carries the same person resolved." + }, + "creator_details": { + "type": "object", + "description": "The creating user resolved, or null when that user can no longer be found. Distinct from whoever binned the case.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "The account-level user id, not the id used for owner or assignee here." + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer", + "description": "Workspace the user belongs to." + } + } + } + } + }, + "V1DuplicatePair": { + "type": "object", + "description": "One AI-suggested duplicate test-case pair as this product serialises it. Used by the single-duplicate read and as the element type of the duplicates listing and search.", + "properties": { + "id": { + "type": "integer", + "description": "The duplicate id. This is the value the read, resolve and discard calls take." + }, + "ai_score": { + "type": "number", + "description": "Model confidence for the pair. A score below 100 is a semantic match; 100 is an exact one." + }, + "reason": { + "type": "string", + "description": "The model's stated reason for believing the two cases are duplicates." + }, + "duplicate_type": { + "type": "string", + "description": "exact or semantic.", + "values": [ + "exact", + "semantic" + ] + }, + "test_cases": { + "type": "array", + "description": "The two test cases in the pair. Always exactly two \u2014 except in a listing filtered by entity_id, where the filtered-on case is removed and only the other one remains.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Integer test case id \u2014 what the resolve call takes as source/target/archive id." + }, + "identifier": { + "type": "string", + "description": "Display identifier (TC-NNN), or the third-party identifier when the case came from an external tool." + }, + "folder_id": { + "type": "integer", + "description": "Id of the folder holding the case." + }, + "folder_path": { + "type": "string", + "description": "Human-readable folder path." + }, + "title": { + "type": "string", + "description": "The test case name (renamed from `name` on the way out)." + }, + "description": { + "type": "string", + "description": "Empty string rather than null when the case has no description." + }, + "owner_id": { + "type": "integer", + "description": "Raw BrowserStack user id of the owner." + }, + "owner": { + "type": "string", + "description": "Resolved owner display name; an empty string when the user cannot be resolved in this group." } - }, - "BulkCopyTestCasesResponse": { - "description": "Test cases copied successfully", - "schema": { - "type": "object", - "required": [ - "success", - "test_cases", - "info", - "cases_count", - "source_path_folders", - "destination_path_folders" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkCopyTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object" - }, - "source_path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "Folder__BulkCopyTestCases" - } - }, - "folders": { - "type": "object" - } - } + } + } + } + } + }, + "FolderV2": { + "type": "object", + "description": "A folder in its public (v2) shape. One shape for all three folder writes - create, rename and move all return exactly this object under `folder`.", + "properties": { + "id": { + "type": "integer", + "description": "The folder's numeric id. This is the value every later folder call takes in its path - the project is addressed by its PR-NNN identifier, but the folder is addressed by this number." + }, + "name": { + "type": "string", + "description": "The folder's name. On create and rename this is the value you sent, unless the project already had a folder of that name under the same parent and auto-renaming was allowed, in which case the server-chosen name comes back here." + }, + "description": { + "type": "string", + "nullable": true, + "description": "The folder's free-text notes. Null when the folder has none - which is every newly created folder, since create does not accept notes." + }, + "parent_id": { + "type": "integer", + "nullable": true, + "description": "The folder this one now sits under. Null for a folder at the top of the project. After a move this is the destination you asked for, so it is the field to check to confirm the move landed." + }, + "cases_count": { + "type": "integer", + "nullable": true, + "description": "Test cases directly in this folder (not descendants). The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." + }, + "sub_folders_count": { + "type": "integer", + "nullable": true, + "description": "Immediate child folders. The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." + }, + "links": { + "type": "object", + "description": "Ready-made API links for this folder. Always present on a folder write.", + "properties": { + "sub_folders": { + "type": "string", + "description": "Link for listing this folder's immediate child folders. Built with the project's PR-NNN identifier and this folder's numeric id." + } + } + }, + "urls": { + "type": "object", + "description": "Links for a person, not for an API client. Always present on a folder write.", + "properties": { + "self": { + "type": "string", + "description": "Absolute web-app address at which someone can open this folder. Note it is built from the project's NUMERIC id, whereas the API links above use the PR-NNN identifier - do not read a project identifier out of this string." + } + } + } + } + }, + "ConfigurationGroup": { + "type": "object", + "description": "An account-level configuration dimension (for example Operating System, Browser or Device) whose selectable values are held as its options.", + "properties": { + "id": { + "type": "integer", + "description": "Integer id of the group; use it to read or change the group's options." + }, + "name": { + "type": "string", + "description": "Display name of the dimension." + }, + "is_system": { + "type": "boolean", + "description": "True for groups the product ships rather than ones the account created." + }, + "group_id": { + "type": "integer", + "description": "The owning account (BrowserStack group). Identical on every row of a single response." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "LinkedIssueTestCase": { + "type": "object", + "description": "A test case as returned in a tracker ticket's linked-case list. Leaner than the full test case read, and carries issue-panel-only fields.", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "description": "The case's TC-NNN identifier, or the third-party identifier when the case was imported." + }, + "name": { + "type": "string" + }, + "folder_id": { + "type": "integer" + }, + "owner": { + "type": "object", + "description": "The resolved owner user, or null when the owner id cannot be resolved in the caller's group." + }, + "project": { + "type": "object", + "description": "Summary of the owning project, supplied so the panel can render across projects." + }, + "template": { + "type": "string" + }, + "template_step_type": { + "type": "string" + }, + "template_is_system": { + "type": "boolean" + }, + "datasets_linked": { + "type": "boolean" + }, + "custom_fields": { + "type": "array" + }, + "status": { + "type": "object" + }, + "case_type": { + "type": "object" + }, + "automation_state": { + "type": "object" + }, + "priority": { + "type": "string", + "description": "Flattened to the priority's internal name or display value, unlike the object form used elsewhere." + }, + "tags": { + "type": "array" + }, + "issues": { + "type": "array", + "description": "The tracker tickets this case is linked to." + }, + "reviewers": { + "type": "array" + }, + "review_status": { + "type": "string" + }, + "dropdown": { + "type": "object", + "description": "The project's default field option sets, repeated per case so the panel can label status and priority ids." + }, + "self_ui_link": { + "type": "string", + "description": "Web app path for this case \u2014 a link for a human, not an API path." + }, + "configuration": { + "type": "object" + }, + "last_test_info": { + "type": "object", + "description": "Present only when the case has been executed at least once. Absent \u2014 not null \u2014 for a never-run case.", + "properties": { + "last_run_result": { + "type": "string" + }, + "last_run_result_status": { + "type": "object" + }, + "last_run_name": { + "type": "string" + }, + "last_run_id": { + "type": "string", + "description": "The run's TR-NNN identifier, not its integer id, despite the name." + }, + "last_run_link": { + "type": "string" + }, + "last_run_status": { + "type": "string", + "description": "'closed' or 'active'." + } + } + } + } + }, + "SharedComponent": { + "type": "object", + "description": "A shared field: a reusable content block held under Shared Fields and referenced from many test cases. The same record backs all three kinds - step sequence, Gherkin background and precondition - distinguished by `field_type`. These are the fields every route returns, including the LISTING.\n", + "properties": { + "id": { + "type": "integer", + "description": "Shared-field id, from the same id space as the shared-steps endpoints." + }, + "title": { + "type": "string" + }, + "field_type": { + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Which kind of shared field this is. Defaults to `step` when the create omits it.", + "example": "background" + }, + "test_case_count": { + "type": "integer", + "description": "How many test cases reference this block." + }, + "step_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_by": { + "type": "object", + "description": "Reduced user reference - id and full_name only, not the wider User shape other TM serializers return.", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + } + } + }, + "V2TestCaseDetail": { + "type": "object", + "description": "A test case as the v2 public API serializes it (create, get, update and move). Differs from the internal `TestCase` shape: `title` instead of name, `steps` [{step, result}] instead of test_case_steps, owner/created_by/updated_by as emails, custom fields by display name, and NO integer id \u2014 `identifier` is the handle.", + "properties": { + "identifier": { + "type": "string", + "description": "TC-NNN, or the customer-defined identifier when the workspace uses its own ids. Use this in later calls.", + "example": "TC-1024" + }, + "title": { + "type": "string", + "description": "The case name (sent as `name` on write)." + }, + "description": { + "type": "string", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "template": { + "type": "string", + "description": "Step style: test_case_steps or test_case_bdd. The template id itself is not returned.", + "example": "test_case_steps" + }, + "folder_id": { + "type": "integer" + }, + "status": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. Active." + }, + "priority": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. High." + }, + "case_type": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. Functional." + }, + "automation_status": { + "type": "string", + "description": "Internal name, e.g. not_automated." + }, + "owner": { + "type": "string", + "nullable": true, + "description": "Owner's email; falls back to the imported owner label for migrated cases." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "jira_id": { + "type": "string", + "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made \u2014 azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." + } + }, + "description": "Only two keys exist on this shape and both are always present when the array is non-empty \u2014 nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." + }, + "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links." + }, + "steps": { + "type": "array", + "description": "Ordered steps for a classic case; always [] for a Gherkin case.", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + } + } + } + }, + "feature": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "background": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "scenario": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "custom_fields": { + "type": "array", + "description": "One entry per set custom field, keyed by display name. Multi-dropdowns carry `values` (array) instead of `value`. Always present, as [] when the case has none.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "description": "Scalar value; dropdown option label, user email, boolean, etc." + }, + "values": { + "type": "array", + "items": { + "type": "string" + } } + } + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "File name as uploaded." + }, + "size": { + "type": "string", + "description": "Human-readable size, e.g. '1.2 MB' \u2014 not a byte count." + }, + "content_type": { + "type": "string", + "description": "MIME type recorded when the file was uploaded, e.g. image/png." + }, + "url": { + "type": "string", + "description": "Link to the stored file." + } + } + }, + "description": "Files attached to the case. On a create the key is omitted ENTIRELY rather than returned as an empty array \u2014 a case created a moment ago has nothing attached \u2014 while a read of a case with no files returns []. Test for the key before iterating. Every entry carries name, size, content_type and url together \u2014 these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached." + }, + "is_shared": { + "type": "boolean", + "nullable": true + }, + "review_status": { + "type": "string", + "nullable": true, + "description": "Aggregate review state of the case. Omitted unless the project has Review & Approve enabled on a Team-Pro plan, and unset when the case has no review state." + }, + "reviewers": { + "type": "array", + "description": "Omitted entirely unless the project has Review & Approve enabled on a Team-Pro plan; shape depends on the workspace's reviewer read-shape flag (emails with review_status, or user objects).", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "nullable": true, + "description": "Creator's email." + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "Note the name: last_updated_at, not updated_at." + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Last editor's email." + }, + "urls": { + "type": "object", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case in the Test Management UI." + } + } + } + } + }, + "GlobalSearchEntityResults": { + "type": "object", + "description": "One entity type's slice of an account-wide search result. Present only for the types that were asked for. Used for the entity types whose rows are handed back as the search index stored them rather than re-serialized: each row carries the record's id, name or title, the project it lives in, and a relevance score, plus the `display_meta` block described on `data` below. Because the rows are passed through, treat any key beyond id, name and project as informational.", + "properties": { + "data": { + "type": "array", + "description": "The matching records for this entity type, best match first. Every row carries a `display_meta` block enriched with the owner's name and the project's name so the hit can be labelled without another call - except on the typeahead fast path, where only owner and project are filled in and the rest of display_meta is skipped. Rows whose project has since been deleted or archived are dropped before you see them, so this list can be shorter than `info.count` implies.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "TestRunCaseBulkEditRow": { + "type": "object", + "description": "A run's case row as returned by the synchronous bulk edit. Built by a different serializer from the run's case LISTING \u2014 it carries the mapping cell's own fields (mapping id, result status object, defects, result notes) rather than the listing's projection. Kept separate so corrections to the listing shape do not silently redescribe this one.", + "properties": { + "identifier": { + "type": "string", + "example": "TC-42" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "priority": { + "type": "string", + "description": "Resolved label, not the id.", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "field_name": { + "type": "string", + "example": "priority" + }, + "id": { + "type": "integer", + "example": 318591 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status": { + "type": "string", + "description": "Test case status label." + }, + "case_type": { + "type": "string" + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." + }, + "assignee": { + "type": "string", + "description": "Assignee of this row within the run.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "project_id": { + "type": "string", + "example": "PR-1", + "description": "Project of the run being read." + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "automation_state": { + "type": "object" + }, + "automation_status": { + "type": "object" + }, + "case_type_imported": { + "type": "string" + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "configuration": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:33:18.553Z" + }, + "creator_details": { + "type": "string", + "nullable": true + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "dataset_values": { + "type": "string", + "nullable": true + }, + "defects": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "example": false + }, + "estimate": { + "type": "string", + "nullable": true + }, + "execution_eligible": { + "type": "boolean", + "example": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1985020 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "last_executed_at": { + "type": "string", + "nullable": true + }, + "last_executed_by": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { + "type": "object" + }, + "links": { + "type": "object" + }, + "mapping_id": { + "type": "integer", + "example": 2150428149 + }, + "metadata": { + "type": "object" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "planned_execution_date": { + "type": "string", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority_imported": { + "type": "string" + }, + "project_name": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "result_custom_fields": { + "type": "string", + "nullable": true + }, + "result_notes": { + "type": "string", + "nullable": true + }, + "result_status": { + "type": "object" + }, + "status_imported": { + "type": "string" + }, + "step_count": { + "type": "integer", + "example": 1 + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "__mcp-probe-mergetag-20260922-a" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_assignee": { + "type": "string", + "nullable": true + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_folders_path": { + "type": "object" + }, + "test_case_id": { + "type": "integer", + "example": 1985020 + }, + "test_case_steps": { + "type": "string", + "nullable": true + }, + "test_run_id": { + "type": "integer", + "example": 17578918 + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:33:18.553Z" + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "TestRunV2Response": { + "description": "The test run at the top level under `test_run`, with no `data` envelope \u2014 the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "BulkDeleteTestCasesResponse": { - "description": "Response after bulk deleting test cases", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object" - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - } + "test_run": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" } - } - }, - "BulkMoveTestCasesResponse": { - "description": "CORRECTION of an existing key (only bulk_move_test_cases uses it). A synchronous bulk move renders the refreshed source listing, not {success, folders}. Above the async threshold the same 200 carries {success, async:true, unique_id} and none of these keys.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkMoveTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object", - "description": "Case counts keyed by folder id, for the source and destination folders." - }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "Folder__BulkMoveTestCases" - } - }, - "source_path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Only present when the request scoped a source folder." - }, - "moved_cases_count": { - "type": "integer", - "description": "Only present on a cross-project move." - }, - "folders": { - "type": "object" - } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" } - } - }, - "TestRunResponse": { - "description": "Successfully returned a test run under a\n`data` envelope, keyed `test_run` WITH an underscore. Used by the v1 run reads and by clone.\nNOTE the write operations on this entity (create / edit / assign / close / delete) return\n`data.testrun` with NO underscore instead, and the v2 run endpoints have no `data` envelope\nat all.\n", - "schema": { + } + }, + "issues": { + "type": "array", + "description": "The run's linked defects. CONDITIONAL \u2014 present but EMPTY when the run has nothing linked; the key is absent entirely when the run payload carries no issues association at all. An absent key therefore means 'not reported', not 'no defects'. Per-entry fields have their own conditions below.", + "items": { "type": "object", "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_run": { - "$schema": "TestRun" - } - } - } + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string", + "description": "The ticket's id in the tracker, e.g. XYZ-1 \u2014 this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL \u2014 omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } } - } - }, - "FolderCopyResponse": { - "description": "Folder copy operation initiated successfully", - "schema": { - "type": "object", - "required": [ - "success", - "async", - "message" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Whether operation is running asynchronously", - "example": true - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Tracking ID for async operation (present when async=true)", - "example": "6a206593-865c-41ec-9459-d94bce72c1b9" - }, - "message": { - "type": "string", - "example": "Folder copy operation queued successfully" - }, - "folder_id": { - "type": "integer", - "description": "New folder ID (present when async=false)", - "example": 4 - } + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" } - } - }, - "FilterCreateResponse": { - "description": "Successfully created a new filter", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Filter__CreateFilter" - }, - "success": { - "type": "boolean" - } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" } - } - }, - "CreateReportResponse": { - "description": "Successfully created scheduled report", - "schema": { - "$schema": "CreateReportResponse" - } - }, - "RootFolderDetailsResponse": { - "description": "Response containing only root folder details including test case counts, and links.", - "schema": { - "type": "object", - "properties": { - "info": { - "$schema": "PageInfo" - }, - "folder_hierarchy": { - "$schema": "FolderHierarchy" - }, - "folders": { - "type": "array", - "items": { - "$schema": "Folder__ListRootFolders" - } - }, - "self": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - } - } - } - } - }, - "success": { - "type": "boolean" - } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" + } + } + }, + "test_cases": { + "type": "array", + "description": "The run's test cases, present only on the single-run read; absent on create and clone. The rows use the same element schema as the standalone case listing, deliberately, so a correction to one cannot drift from the other \u2014 but a few values differ by caller and say so on the field: steps are never included here, for instance.", + "items": { + "$schema": "TestRunTestCaseV2" + } + } + } + } + } + } + }, + "TestRunWriteV2Response": { + "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape \u2014 the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" } - } - }, - "SharedStepsPostResponse": { - "description": "Response containing details of a shared step.", - "schema": { - "type": "object", - "required": [ - "data", - "success" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "test_case_count": { - "type": "integer", - "description": "Number of test cases using this shared step" - }, - "step_count": { - "type": "integer", - "description": "Number of steps in this shared step" - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "step_count", - "test_case_count", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" } - } - }, - "TestResultCreateResponse": { - "description": "Successful response for creating a test result", - "schema": { + } + }, + "issues": { + "type": "array", + "description": "The run's linked defects. CONDITIONAL, and usually absent on a write reply: this value is echoed from the update result rather than re-read, and on the newer run backend the write reply carries no linked-defect list at all. Never infer from its absence that the write dropped the links \u2014 read the run back to confirm what is linked.", + "items": { "type": "object", - "required": [ - "data" - ], "properties": { - "data": { - "type": "object", - "required": [ - "success", - "test-result", - "unique_issues" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test-result": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "author": { - "$schema": "User__CreateTestResultForTestCase" - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-25T20:02:05.000Z" - }, - "description": { - "type": "string" - }, - "error_description": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "example": null, - "nullable": true - }, - "failure_type": { - "type": "string", - "example": null, - "nullable": true - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "type": "string", - "example": "passed" - }, - "result_status": { - "$schema": "ResultStatus__CreateTestResultForTestCase" - }, - "configuration_id": { - "type": "integer", - "description": "ID of the configuration this test result is associated with", - "example": 16, - "nullable": true - }, - "time_elapsed": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "size": { - "type": "string" - } - } - } - }, - "created_by_imported": { - "type": "boolean", - "nullable": true - }, - "issues": { - "type": "array", - "example": [], - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" - }, - "custom_fields": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473/custom-fields" - } - } - }, - "custom_fields": { - "type": "object" - }, - "updated_by": { - "type": "string", - "nullable": true - } - } - }, - "unique_issues": { - "type": "array", - "items": { - "type": "object" - } - } - } - } + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string", + "description": "The ticket's id in the tracker, e.g. XYZ-1 \u2014 this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL \u2014 omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } } - } - }, - "FilterDeleteResponse": { - "description": "Successfully deleted the filter", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" } - } - }, - "DeleteScheduleResponse": { - "description": "Successfully deleted scheduled report(s)", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "array", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "The total number of scheduled reports in the project." - }, - "total_report_count": { - "type": "integer", - "description": "The total number of reports in the project." - }, - "info": { - "$schema": "PageInfo" - } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" } - } - }, - "DownloadReportResponse": { - "description": "Successfully initiated report download", - "schema": { - "type": "object", - "required": [ - "channel", - "success" - ], - "properties": { - "channel": { - "type": "string", - "description": "Unique channel identifier for streaming or polling download status.", - "example": "53bd342e-b4d8-4003-bb3d-d448a2e8780d" - }, - "success": { - "type": "boolean", - "example": true - } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" } + } } - }, - "DashboardExportInitiatedResponse": { - "description": "Dashboard export successfully initiated", - "schema": { - "type": "object", - "required": [ - "success", - "export_id" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "export_id": { - "type": "integer", - "description": "Export record ID for tracking progress", - "example": 12345 - }, - "message": { - "type": "string", - "description": "Optional status message", - "example": "Export queued successfully" - } - } + } + } + } + } + }, + "TestPlanV2Detail": { + "type": "object", + "description": "A test plan or sub-plan as the single-plan read returns it: everything the plan shape carries, plus an embedded preview of the plan's runs and the relative API links that go with it. Only the single-plan read produces these two; a create, an update or a listing never does.", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "description": { + "type": "string", + "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." + }, + "active_state": { + "type": "string", + "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", + "enum": [ + "active", + "completed" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." + }, + "test_runs_count": { + "type": "object", + "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "test_runs": { + "type": "array", + "description": "A preview of at most the first 30 runs in this plan, each with identifier and name alone. Absent - not empty - when the run fetch failed; check test_runs_count before concluding the plan has no runs. Only the single-plan read carries it.", + "items": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "example": "TR-164" + }, + "name": { + "type": "string" } - }, - "ActiveTestRunsInfo": { - "description": "An OBJECT with a `data` array — not a bare top-level array.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "One entry per result status across the project's active test runs. When more than six statuses exist only the top six by count are kept and the remainder are collapsed into a single 'Other' entry that has links but NO result_status.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Status display name, or the literal 'Other' for the collapsed bucket." - }, - "y": { - "type": "integer", - "description": "Number of results in this status." - }, - "result_status": { - "type": "object", - "description": "Absent on the collapsed 'Other' entry.", - "properties": { - "id": { - "type": "integer" - }, - "field_value": { - "type": "string" - }, - "internal_name": { - "type": "string" - }, - "field_name": { - "type": "string" - }, - "colour": { - "type": "string" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "links": { - "type": "object", - "description": "Present on the collapsed 'Other' entry; carries a UI filter URL covering the statuses that were merged in.", - "properties": { - "filter": { - "type": "string" - } - } - } - } - } - }, - "empty_data": { - "type": "boolean", - "nullable": true - }, - "filter_details": { - "type": "object", - "description": "Merged in only when filter_details=true and q was present." - } - } + } + } + }, + "sub_plans_count": { + "type": "integer", + "nullable": true, + "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." + }, + "parent_plan_id": { + "type": "string", + "description": "The parent's TP-NNN identifier, not its numeric id. Set only when this plan is a sub-plan; on a top-level plan the key is absent from the payload entirely and is never returned as null.", + "example": "TP-10000" + }, + "start_date": { + "type": "string", + "nullable": true, + "format": "date-time", + "example": "2026-01-01T00:00:00Z" + }, + "end_date": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "string", + "description": "PR-NNN identifier of the owning project, not a number.", + "example": "PR-2" + }, + "links": { + "type": "object", + "description": "Relative API paths for this plan. Present only when the run preview was fetched successfully - these appear and disappear together with test_runs. Only the single-plan read carries them.", + "properties": { + "self": { + "type": "string", + "description": "Path to read this plan.", + "example": "/api/v2/projects/PR-2/test-plans/TP-12" + }, + "test_runs": { + "type": "string", + "description": "Path to page through the runs in this plan.", + "example": "/api/v2/projects/PR-2/test-plans/TP-12/test-runs" + } + } + }, + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + }, + "sub_test_plans": { + "type": "string", + "description": "Address of this plan's children listing. Added only when the plan has no parent, so a sub-plan never carries it. Unlike self this is an API address, not a web-app page." + } + } + } + } + }, + "V2TestRunStepResultDetail": { + "type": "object", + "description": "One per-step result written by a v2 result submission that carried step_results, returned as an entry of `test-run-step-results`. One entry per step result written, in the order the steps were sent.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the result row written for this step. Carry it forward for a follow-up update; it is the id domain the server expects back." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true, + "description": "This step's own notes, not the case-level description." + }, + "status": { + "type": "string", + "example": "passed", + "description": "This step's own status, as the LOWERCASE internal token ('passed'/'failed'/'blocked') - not the display-case name a case-level result echoes back. The display-case form of the same status is inside test_run_test_case_step_details as field_value, and the status rolled up for the case as a whole is the response's overall-result-status." + }, + "issues": { + "type": "array", + "description": "Issues linked to this step result, as plain KEY STRINGS, deduplicated - the same shape they are sent in, not objects.", + "items": { + "type": "string", + "example": "JIRA-12" + } + }, + "urls": { + "type": "object", + "description": "Web link for this submission.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's ROW IN THE RUN - its final path segment is the run row's id, not the id of the step result just written." + } + } + }, + "test_run_test_case_step_details": { + "type": "object", + "description": "The step this result belongs to and how it was answered. Present on every entry here - each entry IS a step result - and never on the case-level `test-result`, which only exists when no step results were submitted. Alongside the step result's own ids and timestamps it carries the status twice (`status` as the lowercase internal token, `field_value` as the display-case name) with its `colour`, and the step itself: `step` (the instruction), `result` (the expected result) and `order` (its position in the case).", + "properties": { + "id": { + "type": "integer", + "description": "Id of the step-result record." + }, + "step_mapping_id": { + "type": "integer", + "description": "Id of the step within the case that this result answers." + }, + "status": { + "type": "string", + "example": "passed", + "description": "Lowercase internal token for this step's status." + }, + "field_value": { + "type": "string", + "example": "Passed", + "description": "Display-case name of the same status." + }, + "status_id": { + "type": "integer", + "description": "Id of the configured status value." + }, + "colour": { + "type": "string", + "nullable": true, + "description": "Colour configured for that status." + }, + "step": { + "type": "string", + "nullable": true, + "description": "The step instruction, as rich text." + }, + "result": { + "type": "string", + "nullable": true, + "description": "The expected result for the step, as rich text." + }, + "order": { + "type": "integer", + "nullable": true, + "description": "Position of the step in the case." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "TestCaseFieldValue": { + "type": "object", + "description": "One resolved field value on a test case \u2014 the shared descriptor behind case_type, priority, status and automation_state. Identical across the detail, list, folder-list, search, search-v2 and run-mapping routes.", + "properties": { + "id": { + "type": "integer" + }, + "internal_name": { + "type": "string", + "description": "Machine name, e.g. `acceptance`, `low`, `not_automated`." + }, + "colour": { + "type": "string", + "nullable": true + }, + "value_category": { + "type": "string", + "nullable": true, + "description": "Coarse grouping for the value, e.g. `manual` / `automated` on automation_state. Null on most priority, status and case_type values." + }, + "name": { + "type": "string", + "description": "Display label." + } + } + }, + "V1TestRunTestCaseRow": { + "type": "object", + "description": "One test case AS MAPPED INTO A v1 TEST RUN \u2014 a case-plus-configuration row, not the project-level record. A case mapped to several configurations appears once per configuration. The row is the shared test-case record (see TestCase) plus the execution fields below; it does NOT carry attachments, estimated_duration_seconds, template_name or test_case_dataset, and it DOES carry step_count.\n\nNOT EVERY WORKSPACE RETURNS EVERY FIELD DECLARED HERE. `execution_eligible` and the `review_status`/`reviewers` pair are gated by a per-workspace feature flag and are mutually exclusive in practice: a workspace returns one side or the other, never both. The declaration is the union, so treat each of those three as absent unless you have seen it on the workspace you are actually reading. The remaining fields are returned everywhere.", + "allOf": [ + { + "$schema": "TestCaseCore" + } + ], + "properties": { + "mapping_id": { + "type": "integer", + "description": "Id of THIS case-x-configuration mapping inside the run. NOT stable: it shifts to the latest result's id after a write, so re-read it before each bulk operation rather than caching it." + }, + "test_case_id": { + "type": "integer", + "description": "Integer id of the underlying project-level test case. `id` on this row is the same case id; `mapping_id` is what identifies the row." + }, + "test_run_id": { + "type": "integer", + "description": "Integer id of the run this mapping belongs to." + }, + "configuration": { + "type": "object", + "nullable": true, + "description": "The configuration this row is mapped to.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "browser": { + "type": "string", + "nullable": true + }, + "browser_version": { + "type": "string", + "nullable": true + }, + "os": { + "type": "string", + "nullable": true + }, + "os_version": { + "type": "string", + "nullable": true + }, + "device": { + "type": "string", + "nullable": true + } + } + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS row, e.g. `untested`.", + "example": "untested" + }, + "result_status": { + "$schema": "ResultStatusFieldValue", + "nullable": true + }, + "result_notes": { + "type": "string", + "nullable": true, + "description": "Notes recorded with the latest result." + }, + "result_custom_fields": { + "type": "array", + "description": "Custom field values recorded with the latest result; `[]` when none.", + "items": { + "type": "object" + } + }, + "defects": { + "type": "array", + "description": "Defects linked to the latest result; `[]` when none.", + "items": { + "type": "object" + } + }, + "dataset_values": { + "type": "array", + "description": "Dataset values bound to this mapping; `[]` when none.", + "items": { + "type": "object" + } + }, + "last_executed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Null until the row has been executed." + }, + "last_executed_by": { + "type": "integer", + "nullable": true, + "description": "Null until the row has been executed." + }, + "planned_execution_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "test_assignee": { + "type": "integer", + "nullable": true, + "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`." + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "description": "True when the underlying case has been removed but the mapping row is still returned." + }, + "project_name": { + "type": "string", + "nullable": true, + "description": "Populated only for cases mapped in from another project; null otherwise." + }, + "test_case_folders_path": { + "type": "array", + "description": "Breadcrumb from root to the case's folder.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + } + } + } + }, + "execution_eligible": { + "type": "boolean", + "description": "Whether this mapping may be executed. Gated by a per-workspace feature flag: a workspace returning this field does not return `review_status`/`reviewers` on these rows, and vice versa. Treat as absent unless seen." + }, + "step_count": { + "type": "integer", + "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) \u2014 count `test_case_steps` there instead." + }, + "comments_count": { + "type": "integer" + } + } + }, + "TestRunSelectionCaseRow": { + "type": "object", + "description": "The raw test-case record as returned by the run-selection endpoint. This is NOT the shared TestCase serializer: `automation_status` is a plain string here rather than the {name,value} object, the folder key is `folder_id` rather than `test_case_folder_id`, `type` replaces the `case_type` descriptor, and assignee / priority / status / tags / custom_fields / steps are not returned at all. Do not plan against TestCase fields on this route.\n", + "properties": { + "id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "example": "TC-838569" + }, + "third_party_identifier": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "description": "Case type as a bare string, e.g. `manual`. The descriptor object is not returned here.", + "example": "manual" + }, + "folder_structure": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "nullable": true + }, + "source_entity_id": { + "type": "integer", + "nullable": true + }, + "owner": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id, not the TM user id used by `assignee.id` elsewhere." + }, + "folder_id": { + "type": "integer", + "description": "The case's folder. Called `test_case_folder_id` on every other test-case route." + }, + "group_id": { + "type": "integer" + }, + "template": { + "type": "string", + "example": "test_case_text" + }, + "template_id": { + "type": "integer", + "nullable": true + }, + "import_id": { + "type": "integer", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "updated_by": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id." + }, + "created_by": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id." + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "automation_status": { + "type": "string", + "description": "A PLAIN STRING on this route, e.g. `not_automated` \u2014 elsewhere `automation_status` is a {name, value} object.", + "example": "not_automated" + }, + "automation_id": { + "type": "integer", + "nullable": true + }, + "archived_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Non-null when the case is archived." + }, + "trashed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Non-null when the case is in the bin." + }, + "is_shared": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "nullable": true + }, + "is_project_level": { + "type": "boolean" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "review_sent_date": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "integer", + "nullable": true + } + } + }, + "TestCaseCore": { + "type": "object", + "description": "Fields returned by EVERY test-case row on every route, including the run-mapping rows. Routes add to this; nothing here is route-conditional.\n", + "properties": { + "assignee": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "creator_details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updator_details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "case_type": { + "$schema": "TestCaseFieldValue", + "nullable": true + }, + "priority": { + "$schema": "TestCaseFieldValue" + }, + "status": { + "$schema": "TestCaseFieldValue", + "nullable": true + }, + "case_type_imported": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "estimate": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "folder": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "history_count": { + "type": "integer", + "nullable": true + }, + "owner": { + "type": "integer", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "owner_imported": { + "type": "string" + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "template": { + "type": "string" + }, + "test_case_folder_id": { + "type": "integer" + }, + "test_case_steps": { + "$schema": "TestCaseStep" + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "metadata": { + "type": "object", + "properties": { + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "profileId": { + "type": "string", + "nullable": true, + "description": "Low-code profile id. Present under `metadata.lcnc_build_map` but NOT on the top-level `lcnc_build_map`, which carries only resource_path and status." + } + } + }, + "ai_prompt": { + "type": "object", + "properties": { + "status": { + "type": "boolean" + } } - }, - "AutomationStats": { - "description": "Response containing the automation statistics for the test cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "automated_coverage": { - "type": "number", - "format": "float" - }, - "automated_test_cases": { - "type": "integer" - }, - "total_test_cases": { - "type": "integer" - }, - "manual_test_cases": { - "type": "integer" - }, - "empty_data": { - "type": "boolean" - } - } + } + } + }, + "automation_state": { + "$schema": "TestCaseFieldValue", + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement." + }, + "is_shared": { + "type": "boolean", + "description": "True when the case is a shared case rather than one owned outright by its folder." + }, + "template_id": { + "type": "integer", + "nullable": true, + "description": "Id of the template backing this case. Null for cases on the plain-text template." + }, + "template_step_type": { + "type": "string", + "description": "Step type the template implies \u2014 `test_case_steps` or `test_case_text`. Mirrors `template`.", + "example": "test_case_steps" + }, + "test_run_results_count": { + "type": "integer", + "nullable": true, + "description": "Count of results recorded for this case across runs. Null on list routes. Known to disagree with the actual number of results \u2014 do not rely on it; page the run-results endpoint instead." + }, + "test_run_results_issues_count": { + "type": "integer", + "nullable": true, + "description": "Count of issues raised against those results. Null on list routes; carries the same reliability caveat as test_run_results_count." + } + } + }, + "V1TestCaseListRow": { + "type": "object", + "description": "A test-case row as returned by the LIST routes. Adds `step_count`, which the single-case detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "step_count": { + "type": "integer", + "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) \u2014 count `test_case_steps` there instead." + } + } + }, + "V1TestCaseFolderListRow": { + "type": "object", + "description": "The folder listing's row: a list row that also carries `duplicates_tc_count`.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "duplicates_tc_count": { + "type": "integer", + "nullable": true, + "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListFolderTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListFolderTestCases" + } + } + }, + "V1TestCaseSearchRow": { + "type": "object", + "description": "A search result row: a list row that also carries `attachments`.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object" + } + }, + "case_type": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "priority": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "status": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "automation_state": { + "$schema": "SearchFieldValue", + "nullable": true + } + } + }, + "V1TestCaseRecordRow": { + "type": "object", + "description": "The single-case record as returned by the integer-id read. Carries `attachments` and `comments_count`, and NOT `step_count` \u2014 count `test_case_steps` instead.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object" + } + }, + "comments_count": { + "type": "integer" + } + } + }, + "V1TestCaseDetailRow": { + "type": "object", + "description": "The slide-over detail row: the single-case record plus the resolved template and dedupe extras. Does not carry `step_count`.\n", + "allOf": [ + { + "$schema": "V1TestCaseRecordRow" + } + ], + "properties": { + "folder": { + "type": "object", + "nullable": true, + "description": "DETAIL ROUTES ONLY (get_test_case_detail). Observed null in every response seen." + }, + "testcase_template": { + "type": "object", + "description": "DETAIL ROUTES ONLY. The resolved template layout \u2014 a large nested object." + }, + "duplicates_tc_count": { + "type": "integer", + "nullable": true, + "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." + }, + "folder_path": { + "type": "string", + "description": "DETAIL ROUTE ONLY, and only when `include=folder_path` is sent." + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "priority": { + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "test_case_steps": { + "$schema": "TestCaseStep__GetTestCaseDetail" + } + } + }, + "V1TestRunDetailRow": { + "type": "object", + "description": "A single v1 test run as returned by the detail route: the shared run record plus its linked issues, which the list and search rows do not carry.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "issues": { + "type": "array", + "description": "Linked issues. Returned by the single-run detail route; not present on the list or search rows.", + "items": { + "type": "object" + } + }, + "test_result_issues": { + "type": "string", + "nullable": true, + "description": "Issues raised against this run's results. Not returned by every workspace \u2014 treat as absent unless seen." + }, + "observability_id": { + "type": "integer", + "example": 17574995 + }, + "created_by": { + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", + "$schema": "TestRunUserRef__GetTestRunByIntegerId" + }, + "updated_by": { + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", + "$schema": "TestRunUserRef__GetTestRunByIntegerId" + } + } + }, + "V1TestRunListRow": { + "type": "object", + "description": "A v1 test run as returned by the LIST and SEARCH routes: the shared run record plus `build_meta`, which the single-run detail route does not carry.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_meta": { + "type": "object", + "nullable": true, + "description": "Automation build this run is linked to \u2014 {id, name, creationType, buildNormalisedName, \u2026}. Populated for runs created from an automation build." + } + } + }, + "ExploratorySessionCore": { + "type": "object", + "description": "Fields returned for an exploratory session on BOTH the list and the single-session read.\n", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the exploratory session.", + "example": 123 + }, + "identifier": { + "type": "string", + "description": "ES-NNN form. NOTE: the single-session read takes the INTEGER `id` in its path \u2014 passing this identifier returns 404.", + "example": "ES-295" + }, + "title": { + "type": "string", + "description": "Title of the exploratory session.", + "example": "Checkout flow exploration" + }, + "session_state": { + "type": "string", + "description": "Current lifecycle state of the session.", + "enum": [ + "active", + "closed" + ], + "example": "active" + }, + "timebox_duration": { + "type": "integer", + "description": "Planned duration of the session in minutes.", + "example": 60, + "nullable": true + }, + "assignee": { + "$schema": "ExploratorySessionUserRef" + }, + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "tags": { + "type": "array", + "description": "Tags associated with the session.", + "example": [ + "regression", + "payment" + ], + "items": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the session was created.", + "example": "2026-01-16T10:30:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the session was last updated.", + "example": "2026-01-16T11:00:00Z" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "test_plan_id": { + "type": "integer", + "nullable": true + } + } + }, + "ExploratorySessionListRow": { + "type": "object", + "description": "An exploratory session as returned by the LIST route: the shared core plus `summary` and the resolved `test_plan`. It does NOT carry charter, description, durations, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations \u2014 read a single session for those.\n", + "allOf": [ + { + "$schema": "ExploratorySessionCore" + } + ], + "properties": { + "summary": { + "type": "string", + "nullable": true, + "description": "Returned on the LIST route; absent from the single-session read." + }, + "test_plan": { + "type": "object", + "nullable": true, + "description": "Resolved plan. Returned on the LIST route; absent from the single-session read." + } + } + }, + "UserCore": { + "type": "object", + "description": "The four fields every expanded user reference carries, on every serializer.\n", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + } + } + }, + "TestRunUserRef": { + "type": "object", + "description": "The user reference embedded in a test run's created_by / updated_by. Carries group_id and browserstack_user_id but NOT reports_and_notification_enabled.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + } + } + }, + "DatasetUserRef": { + "type": "object", + "description": "The user reference embedded in a dataset's created_by. Carries `role`, which no other user serializer returns, and omits browserstack_user_id / group_id.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "role": { + "type": "string", + "description": "The user's role on the dataset's project, e.g. `owner`.", + "example": "owner" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "ProjectsPageInfo": { + "type": "object", + "description": "The projects listing's pagination envelope: PageInfo plus `starred_count`, which no other paginated read returns.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "starred_count": { + "type": "integer", + "example": 0 + } + } + }, + "FilterDetailsFolderNode": { + "type": "object", + "description": "A folder as returned inside `filter_details.folders` \u2014 the shared folder record plus the tree/selection fields that only this projection carries.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "folder_tree": { + "type": "object" + }, + "only_child_selected_parent_ids": { + "type": "string", + "nullable": true + }, + "only_selected_parent_ids": { + "type": "integer", + "example": 764138 + }, + "partial_selected_parent_ids": { + "type": "string", + "nullable": true + }, + "select_all_parent_ids": { + "type": "integer", + "example": 764093 + }, + "folders": { + "type": "object", + "properties": { + "ancestors": { + "type": "string", + "nullable": true + }, + "folder_path": { + "type": "string", + "example": "/764093/" + }, + "id": { + "type": "integer", + "example": 764093 + }, + "name": { + "type": "string", + "example": "__readonly__" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "selected_all": { + "type": "boolean", + "example": true + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + } + } + } + } + }, + "V1TestRunCasesRunRow": { + "type": "object", + "description": "The run header returned alongside a run's test cases. Adds the build and sharing fields that the run list and single-run detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" + }, + "build_serial_id": { + "type": "integer", + "example": 1 + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true + } + } + }, + "SearchFieldValue": { + "type": "object", + "description": "The field-value descriptor as returned by the two SEARCH routes, which additionally embed the per-project binding rows.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "array", + "description": "Per-project binding rows for this value. Returned ONLY by the two search routes (search_project_entities, search_project_entities_by_filter); absent from the detail, list, folder-list and run-mapping routes.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "custom_values_id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "dataset_id": { + "type": "integer", + "nullable": true } - }, - "TestRunsListResponse": { - "description": "Successfully retrieved test runs", - "schema": { + } + } + } + } + }, + "ResultStatusFieldValue": { + "type": "object", + "description": "The field-value descriptor as returned for a RESULT status. This serializer names the label `field_value`; every other consumer names it `name`. Both are present and they agree.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_value": { + "type": "string", + "example": "Passed" + } + } + }, + "PathFolder": { + "type": "object", + "description": "A folder as it appears in a breadcrumb list (`path_folders`, `destination_path_folders`). The shared folder record plus the row's own audit and lineage fields, which the folder LISTING routes do not return.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13482" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:31:46.000Z" + } + } + }, + "V1TestRunWriteRow": { + "type": "object", + "description": "The run as echoed by a run WRITE and by the run-cases read: the shared run record plus the build and sharing fields the run list and single-run detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" + }, + "build_serial_id": { + "type": "integer", + "example": 1 + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true + } + } + }, + "V1TestCaseWriteRow": { + "type": "object", + "description": "The test-case row as echoed by a WRITE and by global search: the project-level record plus the extras those responses attach.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + } + } + }, + "FilterWriteRow": { + "type": "object", + "description": "A filter as echoed by create/update: the shared filter record plus the ownership and lifecycle fields the filter LISTING does not return.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "TestRunWriteResponse__EditTestRun": { + "type": "object", + "description": "TestRunWriteResponse as returned by `edit_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17577900 + }, + "assignee": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_runs": { - "type": "array", - "items": { - "$schema": "V1TestRunListRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 5 - }, - "closed": { - "type": "integer", - "example": 2 - } - } - } + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } } - } - }, - "ClosedTestRunsInfo": { - "description": "Closed test runs analytics data by month", - "schema": { + }, + "created_by": { "type": "object", "properties": { - "data": { - "type": "array", - "example": [ - { - "month": "Apr", - "count": 3 - }, - { - "month": "May", - "count": 0 - } - ], - "items": { - "type": "object", - "properties": {} - } - }, - "empty_data": { - "type": "boolean" - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } } - } - }, - "ClosedTestRunsSplit": { - "description": "Closed test run results split by month and status", - "schema": { + }, + "overall_progress": { "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "integer" - } - }, - "result_status": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "field_value": { - "type": "string" - }, - "internal_name": { - "type": "string" - }, - "field_name": { - "type": "string" - }, - "colour": { - "type": "string" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - } - } - } - }, - "date_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "FilterDetailResponse": { - "description": "Successfully retrieved filter details", - "schema": { + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "updated_by": { "type": "object", "properties": { - "data": { - "$schema": "Filter" - }, - "success": { - "type": "boolean" - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } } + }, + "issues": { + "type": "string", + "nullable": true + } } - }, - "RemoveFolderSummaryResponse": { - "description": "A summary of entities that would be deleted when removing a folder", - "schema": { + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "TestRunWriteResponse__AssignTestRunOwner": { + "type": "object", + "description": "TestRunWriteResponse as returned by `assign_test_run_owner`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee": { "type": "object", - "required": [ - "success", - "has_other_product_entity", - "deleted_entity_count" - ], "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful" - }, - "has_other_product_entity": { - "type": "boolean", - "description": "Indicates if the folder contains entities from other products" - }, - "deleted_entity_count": { - "type": "object", - "required": [ - "active_test_case_count", - "archived_test_case_count", - "sub_folders" - ], - "properties": { - "test_recordings_count": { - "type": "integer", - "description": "Number of test recordings that would be deleted" - }, - "active_test_case_count": { - "type": "integer", - "description": "Number of active test cases that would be deleted" - }, - "archived_test_case_count": { - "type": "integer", - "description": "Number of archived test cases that would be deleted" - }, - "sub_folders": { - "type": "integer", - "description": "Number of sub-folders that would be deleted" - } - } - } - } - } - }, - "UsersV2Response": { - "description": "Successfully retrieved users list", - "schema": { - "$schema": "UsersV2Response" - } - }, - "IssuesCountInfo": { - "description": "Response containing the issue count trend for each month.", - "schema": { + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "label", - "count" - ], - "properties": {} - } - }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "CustomFieldsV2Response": { - "description": "Successful response with paginated custom fields list", - "schema": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17577900 + }, + "overall_progress": { "type": "object", - "required": [ - "success", - "custom_fields", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the custom field", - "example": "cf-123" - }, - "field_type": { - "type": "string", - "description": "Type of the custom field", - "enum": [ - "text", - "textarea", - "dropdown", - "multiselect", - "number", - "date", - "boolean", - "url" - ], - "example": "dropdown" - }, - "field_name": { - "type": "string", - "description": "Name of the custom field", - "example": "Priority Level" - }, - "entity_type": { - "type": "string", - "description": "Entity type this field belongs to", - "example": "TestCase" - }, - "is_required": { - "type": "boolean", - "description": "Whether the field is required", - "example": false - }, - "default_value": { - "description": "Default value for the field" - }, - "place_holder_text": { - "type": "string", - "description": "Hint text shown in the empty field. Omitted entirely when the field has no hint text. This is the key to read: it is filled from the field's hint under either of the two names the stored record may use.", - "example": "Select priority level" - }, - "system_name": { - "type": "string", - "description": "System name for the field" - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be bulk edited" - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used in filters" - }, - "linked_projects_count": { - "type": "integer", - "description": "Number of projects this field is linked to", - "example": 5 - }, - "group_id": { - "type": "string", - "description": "Group ID this field belongs to" - }, - "placeholder": { - "type": "string", - "description": "The same hint text as place_holder_text, under a second name. Present only when the stored field carries its hint under this name - which is the common case, so both keys usually appear with identical values, but neither is guaranteed. Read place_holder_text and ignore this one; it exists for older clients." - } - } - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "ProjectsPaginatedResponse": { - "description": "Successfully retrieved paginated projects", - "schema": { - "$schema": "ProjectsPaginatedResponse" - } - }, - "ProjectMinifyResponse": { - "description": "Successfully retrieved minified projects list", - "schema": { - "$schema": "ProjectMinifyResponse" - } - }, - "GetReportDetailResponse": { - "description": "Successfully fetched report details", - "schema": { + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { "type": "object", - "required": [ - "data", - "report_filters", - "dynamic_filters", - "report_data", - "project" - ], - "properties": { - "data": { - "$schema": "ReportDetail__GetReportDetail" - }, - "report_filters": { - "$schema": "ReportFiltersDetail" - }, - "dynamic_filters": { - "$schema": "DynamicFilters" - }, - "report_data": { - "$schema": "ReportData__GetReportDetail" - }, - "project": { - "$schema": "ReportProjectDetail" - }, - "filters": { - "type": "object" - }, - "is_dynamic": { - "type": "boolean", - "example": false - } - } - } - }, - "ReportSummaryResponse": { - "description": "Successful response for report details", - "schema": { + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "run_state": { + "type": "string", + "example": "new_run" + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "example": 4 + }, + "test_plans": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:33.000Z" + }, + "updated_by": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "issues_by_priority": { - "type": "string", - "nullable": true - }, - "issues_by_status": { - "type": "string", - "nullable": true - }, - "issues_data": { - "type": "string", - "nullable": true - }, - "test_cases_status": { - "type": "string", - "nullable": true - }, - "test_run_data": { - "type": "object" - }, - "test_run_status": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "string", - "nullable": true - }, - "user_result_stats": { - "type": "string", - "nullable": true - } - }, - "required": [ - "success" - ] + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "uuid": { + "type": "integer", + "example": 17577900 + } } - }, - "GetReportTestCases": { - "description": "Successfully fetched report testcases details", - "schema": { + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "success": { + "type": "boolean", + "example": true + } + } + } + } + }, + "Folder__BulkCopyTestCases": { + "type": "object", + "description": "Folder as returned by `bulk_copy_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13482" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:31:46.000Z" + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765811 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 6 + }, + "total_cases_count": { + "type": "integer", + "example": 35 + } + } + }, + "TestCase__BulkCopyTestCases": { + "type": "object", + "description": "TestCase as returned by `bulk_copy_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 + } + } + }, + "TestCaseFieldValue__BulkCopyTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_copy_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "TestCase__BulkReplaceTestCaseFields": { + "type": "object", + "description": "TestCase as returned by `bulk_replace_test_case_fields`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 + } + } + }, + "TestCaseFieldValue__BulkReplaceTestCaseFields": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_replace_test_case_fields`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "Folder__BulkMoveTestCases": { + "type": "object", + "description": "Folder as returned by `bulk_move_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13482" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:31:46.000Z" + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765811 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 6 + }, + "total_cases_count": { + "type": "integer", + "example": 35 + } + } + }, + "TestCase__BulkMoveTestCases": { + "type": "object", + "description": "TestCase as returned by `bulk_move_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 + } + } + }, + "TestCaseFieldValue__BulkMoveTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_move_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "ReportDetail__CreateReportByIntegerId": { + "type": "object", + "description": "ReportDetail as returned by `create_report_by_integer_id`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:49:30.804Z" + }, + "custom_date_range": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "example": "MCP live probe of create_report_by_integer_id capability" + }, + "frequency": { + "type": "string", + "nullable": true + }, + "frequency_details": { + "type": "string", + "nullable": true + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 10411 + }, + "identifier": { + "type": "string", + "example": "SC-510" + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + }, + "is_dynamic": { + "type": "boolean", + "example": false + }, + "next_run_at": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "report_creation_mode": { + "type": "string", + "example": "custom_report" + }, + "report_timeframe": { + "type": "string", + "example": "last_one_week" + }, + "report_type": { + "type": "string", + "example": "Test Run Summary" + }, + "slack_notification": { + "type": "string", + "nullable": true + }, + "slack_notification_supported": { + "type": "boolean", + "example": true + }, + "title": { + "type": "string", + "example": "__mcp-probe-report-20260921" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:49:30.804Z" + } + } + }, + "Project__CreateReportByIntegerId": { + "type": "object", + "description": "Project as returned by `create_report_by_integer_id`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Project" + } + ], + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-17T08:01:58.000Z" + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "example": "probe edit_project 2026-09-17T10:47:41Z" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "V1TestCaseCreateResponse__CreateTestCaseByIntegerId": { + "type": "object", + "description": "V1TestCaseCreateResponse as returned by `create_test_case_by_integer_id`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "V1TestCaseCreateResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "cases_count": { + "type": "integer", + "example": 47 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { "type": "object", - "required": [ - "success", - "selection_data" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "selection_data": { - "$schema": "SelectionData" - }, - "selection": { - "type": "object", - "description": "The primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).", - "example": { - "select_all": false, - "folders": { - "827": { - "test_case_select_all": false, - "selected_test_cases": [ - 1515 - ], - "deselected_test_cases": [] - } - }, - "unique_test_case_count": 1 - } - }, - "shared_selection": { - "type": "object", - "description": "Per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"\": }). Present only for cross-project Test Case Activity reports; `{}` otherwise.", - "example": { - "10209": { - "select_all": true, - "folders": {}, - "unique_test_case_count": 2 - } - } - } - } - } - }, - "SharedStepsIDResponse": { - "description": "Response containing details of a specific shared step by ID.", - "schema": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } + } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 48 + } + } + }, + "path_folders": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13472" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "cases_count": { + "type": "integer", + "example": 47 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 48 + } + } + }, + "test_case": { + "type": "object", + "properties": { + "comments_count": { + "type": "integer", + "example": 0 + }, + "assignee": { "type": "object", - "required": [ - "data", - "success" - ], "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } } - } - }, - "SharedStepsGetResponse": { - "description": "Response containing a list of shared steps with pagination info.", - "schema": { + }, + "automation_state": { "type": "object", - "required": [ - "data", - "info", - "success" - ], "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "test_case_count": { - "type": "integer", - "description": "Number of test cases using this shared step" - }, - "step_count": { - "type": "integer", - "description": "Number of steps in this shared step" - }, - "tags": { - "type": "string", - "nullable": true - } - }, - "required": [ - "created_by", - "id", - "step_count", - "test_case_count", - "title", - "updated_by" - ] - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "description": "Total number of shared steps" - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true - } - }, - "required": [ - "count", - "next", - "page", - "page_size", - "prev" - ] - }, - "pre_fetched_shared_step": { - "type": "object", - "description": "Pre-fetched details for the first shared step (when pre_fetch=true)", - "nullable": true, - "required": [ - "shared_step_details", - "page_info" - ], - "properties": { - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - }, - "page_info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "description": "Total number of step details" - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true - } - }, - "required": [ - "count", - "next", - "page", - "page_size", - "prev" - ] - } - } - }, - "success": { - "type": "boolean" - } + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 2509329 + }, + "internal_name": { + "type": "string", + "example": "not_automated" + }, + "name": { + "type": "string", + "example": "Not Automated" + }, + "value_category": { + "type": "string", + "example": "manual" + } } - } - }, - "TestCaseCountTrend": { - "description": "The response contains the trend data for each test case category over the months.", - "schema": { + }, + "automation_status": { "type": "object", - "required": [ - "data", - "empty_data", - "order" - ], "properties": { - "data": { - "type": "object", - "description": "KEYED BY CASE TYPE NAME, not a record with fixed fields — e.g. {\"Acceptance\": {…}, \"API\": {…}}. The keys are project data and change as case types are added or renamed, so they are deliberately not enumerated. Each value carries that type's counts plus a `graphData` map keyed by month abbreviation." - }, - "empty_data": { - "type": "boolean" - }, - "order": { - "type": "array", - "items": { - "type": "string" - } - } + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } } - } - }, - "HistoryDiffResponse": { - "description": "Response containing the diff of test case steps between two histories.", - "schema": { + }, + "case_type": { "type": "object", "properties": { - "success": { - "type": "boolean" - }, - "diff": { - "type": "object", - "required": [ - "test_case_steps" - ], - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "old": { - "type": "array", - "items": { - "$schema": "TestCaseDiffStep" - } - }, - "new": { - "type": "array", - "items": { - "$schema": "TestCaseDiffStep" - } - } - } - }, - "status": { - "type": "object", - "properties": { - "old": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "colour": { - "type": "string" - } - } - }, - "new": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - } - } - } - } - }, - "links": { - "type": "object", - "example": { - "99": "/api/v1/projects/10000/test-cases/1/histories/99", - "100": "/api/v1/projects/10000/test-cases/1/histories/100" - } - } - } - } - }, - "TestCaseTypeSplit": { - "description": "Test case count split by case type", - "schema": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318626 + }, + "internal_name": { + "type": "string", + "example": "other" + }, + "name": { + "type": "string", + "example": "Other" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "case_type_imported": { + "type": "string" + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:42.487Z" + }, + "creator_details": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "y": { - "type": "integer" - }, - "value": { - "type": "integer" - }, - "field": { - "type": "string" - } - } - } - }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "TestResultCustomFieldsResponse": { - "description": "Successfully retrieved custom fields for test results", - "schema": { - "$schema": "TestResultCustomFieldsResponse" - } - }, - "TestResultListResponse": { - "description": "List of test results", - "schema": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "estimate": { + "type": "string", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1986203 + }, + "identifier": { + "type": "string", + "example": "TC-54559" + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { "type": "object", - "required": [ - "success", - "test-results", - "info", - "unique_issues" - ], "properties": { - "success": { - "type": "boolean" - }, - "test-results": { - "type": "array", - "items": { - "$schema": "TestResult__ListTestResultsForTestCaseByIntegerId" - } - }, - "info": { - "$schema": "PageInfo__ListTestResultsForTestCaseByIntegerId" - }, - "unique_issues": { - "type": "array", - "example": [], - "items": { - "type": "string" - } - } - } - } - }, - "TestRunFormFieldsResponse": { - "description": "Successfully retrieved test result form fields", - "schema": { - "$schema": "TestRunFormFieldsResponse" - } - }, - "TestRunEditSelectionResponse": { - "description": "A test run's stored case selection and editable fields, as the full edit form reads them before an edit.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "selection": { - "type": "object", - "description": "The run's stored case selection: {select_all, unique_test_case_count, folders}, where `folders` maps a folder id (string) to {test_case_select_all, selected_test_cases, deselected_test_cases}. Can be {}. Not kept in step with the add-only and remove calls." - }, - "shared_selection": { - "type": "object", - "nullable": true, - "description": "Cases shared in from other projects, keyed by project; null when the run has none." - }, - "test_run": { - "$schema": "TestRun" - }, - "dynamic_filter": { - "type": "object", - "nullable": true, - "description": "The dynamic-run filter rule; null for a static run." - }, - "filters": { - "type": "object", - "nullable": true, - "description": "Same content as dynamic_filter, under its alias." - }, - "trtc_configuration_mappings": { - "type": "array", - "description": "Per-configuration case mappings; a run without configurations returns one default entry with configuration_id null and select_all true.", - "items": { - "type": "object" - } - } - } - } - } + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } } - }, - "TestRunsSelectionResponse": { - "description": "The test CASES a selection resolves to (this endpoint returns cases, not runs).\n`selected` is present only when show='all', and lists the ids of the returned rows that also\nmatch the `q` filter.\n", - "schema": { + }, + "links": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunSelectionCaseRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "selected": { - "type": "array", - "description": "Test case ids, among those returned, that also match the `q` filter. Only rendered when show='all' and at least one row matched.", - "items": { - "type": "integer" - } - } + "detail": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203/detail" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203" + } } - } - }, - "FilterListResponse": { - "description": "Successfully retrieved list of filters", - "schema": { + }, + "metadata": { "type": "object", "properties": { - "data": { - "type": "array", - "description": "Array of saved views, each carrying only id, name and is_project_level", - "items": { - "$schema": "FilterListRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "success": { - "type": "boolean" + "ai_prompt": { + "type": "object", + "properties": { + "status": { + "type": "boolean", + "example": false + } } - } - } - }, - "FolderDetailsResponse": { - "description": "Response containing folder details including subfolders, counts, and links.", - "schema": { - "type": "object", - "properties": { - "folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "self": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_id": { - "type": "integer" - }, - "notes": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - }, - "contents": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - } - } - } - } - }, - "parent_id": { - "type": "integer" - }, - "ancestors": { - "type": "object", + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "profileId": { + "type": "string", "nullable": true - }, - "test_cases": { - "type": "object", + }, + "resource_path": { + "type": "string", "nullable": true - }, - "info": { - "$schema": "PageInfo" - }, - "success": { - "type": "boolean" - } - } - } - }, - "TagsSearchResponse": { - "description": "Tags successfully retrieved", - "schema": { - "type": "object", - "required": [ - "tags", - "tag_objects", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "Simple array of tag names (for backward compatibility)", - "example": [ - "AI Generated", - "AI Generated Details" - ], - "items": { - "type": "string" - } - }, - "tag_objects": { - "type": "array", - "description": "Detailed tag objects with metadata", - "items": { - "$schema": "Tag" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "ProjectUsersSearchResponse": { - "description": "Project users successfully retrieved", - "schema": { + }, + "status": { + "type": "string", + "nullable": true + } + } + } + } + }, + "name": { + "type": "string", + "example": "__mcp-probe-createcase-20260921" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority": { "type": "object", - "required": [ - "users", - "info" - ], "properties": { - "users": { - "type": "array", - "description": "List of users with access to the project", - "items": { - "$schema": "ProjectUserDetail" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "FilterUpdateResponse": { - "description": "Successfully updated the filter", - "schema": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318618 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "status": { "type": "object", "properties": { - "data": { - "$schema": "Filter__UpdateFilter" - }, - "success": { - "type": "boolean" - } - } - } - }, - "UpdateReportResponse": { - "description": "Successfully updated scheduled report", - "schema": { - "$schema": "ReportUpdateResponse" - } - }, - "GenericAttachmentsResponse": { - "description": "Attachments successfully uploaded", - "schema": { + "colour": { + "type": "string", + "example": "active" + }, + "id": { + "type": "integer", + "example": 318632 + }, + "internal_name": { + "type": "string", + "example": "active" + }, + "name": { + "type": "string", + "example": "Active" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "mcp-probe" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_name": { + "type": "string", + "example": "Test Case Steps" + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_case_dataset": { + "type": "object" + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_steps": { "type": "object", - "required": [ - "generic_attachment" - ], "properties": { - "generic_attachment": { - "type": "array", - "description": "List of uploaded attachments with metadata", - "items": { - "$schema": "GenericAttachment" - } - } - } - } - }, - "TestRunTestCasesV2Response": { - "description": "Paginated test cases of a test run. `info` carries the paging counts and reflects any filters applied.", - "schema": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:42.508Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "id": { + "type": "integer", + "example": 12373418 + }, + "order": { + "type": "integer", + "example": 1 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_case_id": { + "type": "integer", + "example": 1986203 + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:42.508Z" + } + } + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "nullable": true + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + } + } + }, + "V1TestCaseCreateResponse__CreateTestCases": { + "type": "object", + "description": "V1TestCaseCreateResponse as returned by `create_test_cases`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "V1TestCaseCreateResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "cases_count": { + "type": "integer", + "example": 48 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunTestCaseV2" - } - } - } - } - }, - "V2TestRunResultSubmitResponse": { - "description": "Result submission accepted. An ordinary single submission returns the written result under `test-result`; a submission carrying step results returns `test-run-step-results` plus `overall-result-status` and NO `test-result`. The bulk form returns only `success: true` when everything was accepted, or `success: false` with `status: \"PARTIAL\"` and per-case `results` when some entries were rejected — still HTTP 200.", - "schema": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } + } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 49 + } + } + }, + "path_folders": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13472" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "cases_count": { + "type": "integer", + "example": 48 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 49 + } + } + }, + "test_case": { + "type": "object", + "properties": { + "comments_count": { + "type": "integer", + "example": 0 + }, + "assignee": { "type": "object", "properties": { - "success": { - "type": "boolean", - "description": "false on a PARTIAL bulk response even though the status is 200." - }, - "status": { - "type": "string", - "example": "PARTIAL", - "description": "Present only when some bulk entries were rejected and some were accepted." - }, - "message": { - "type": "string", - "description": "Present only alongside rejected bulk entries; names the unconfigured statuses and/or the count of cases blocked by the review-status gate." - }, - "test-result": { - "$schema": "V2TestRunResultDetail", - "description": "Single form only, and only when no step results were submitted." - }, - "test-run-step-results": { - "type": "array", - "description": "Single form, step submissions only — one entry per step result written. Mutually exclusive with test-result.", - "items": { - "$schema": "V2TestRunStepResultDetail" - } - }, - "overall-result-status": { - "type": "string", - "description": "Status rolled up from the submitted step results. Accompanies test-run-step-results." - }, - "results": { - "type": "array", - "description": "Bulk form, present only when at least one entry was rejected.", - "items": { - "$schema": "V2BulkResultOutcome" - } - }, - "attachments": { - "type": "array", - "description": "Attachments bound on a multipart single submission. This is the bind result, not the stored attachment record — it does not carry content type, size or checksum.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Attachment id, usable with the attachment endpoints." - }, - "name": { - "type": "string", - "description": "Filename of the multipart part that was bound." - }, - "url": { - "type": "string", - "description": "Time-limited download url." - }, - "url_expires_at": { - "type": "string", - "format": "date-time", - "description": "When url stops working." - }, - "test_result_id": { - "type": "integer" - } - } - } - }, - "attachment_errors": { - "type": "array", - "description": "Present only when at least one file failed to bind. The result itself was still written.", - "items": { - "type": "object", - "properties": { - "filename": { - "type": "string" - }, - "code": { - "type": "string", - "values": [ - "ATTACHMENT_BIND_INCOMPLETE", - "ATTACHMENT_BIND_FAILED" - ], - "description": "ATTACHMENT_BIND_INCOMPLETE means the file WAS stored but its id could not be read back; re-read the result's attachments to recover it. ATTACHMENT_BIND_FAILED means the file was discarded." - }, - "message": { - "type": "string" - } - } - } - }, - "dropped_attachments": { - "type": "array", - "description": "Bulk form only. Files that were uploaded but belonged to an entry that got rejected, plus inline rich-text references that could not be resolved. Two entry shapes appear in the same array.", - "items": { - "type": "object", - "properties": { - "test_case_id": { - "type": "string", - "description": "Rejected-entry form." - }, - "filenames": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Rejected-entry form." - }, - "reason": { - "type": "string", - "example": "status_validation_failed", - "description": "Rejected-entry form." - }, - "filename": { - "type": "string", - "description": "Inline-reference form." - }, - "code": { - "type": "string", - "example": "INLINE_ATTACHMENT_DROPPED", - "description": "Inline-reference form." - } - } - } - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } } - } - }, - "V2BulkResultStatusAcceptedResponse": { - "description": "The bulk status change was queued. It says nothing about which cases were changed or whether the work succeeded.", - "schema": { + }, + "automation_state": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - } + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 2509329 + }, + "internal_name": { + "type": "string", + "example": "not_automated" + }, + "name": { + "type": "string", + "example": "Not Automated" + }, + "value_category": { + "type": "string", + "example": "manual" + } } - } - }, - "V2TestResultListResponse": { - "description": "Recorded results for one test case in one test run, newest executions included as separate entries. Note the key is `test_results` with an underscore, unlike the v1 list's `test-results`.", - "schema": { + }, + "automation_status": { "type": "object", - "required": [ - "success", - "test_results", - "info" - ], "properties": { - "success": { - "type": "boolean" - }, - "test_results": { - "type": "array", - "items": { - "$schema": "V2TestResultListItem" - } - }, - "info": { - "$schema": "PageInfo" - } + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } } - } - }, - "TemplateListResponse": { - "description": "A page of test-case templates with pagination metadata.", - "schema": { + }, + "case_type": { "type": "object", - "required": [ - "success", - "templates" - ], "properties": { - "success": { - "type": "boolean" - }, - "templates": { - "type": "array", - "items": { - "$schema": "Template" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TemplateDetailResponse": { - "description": "A single test-case template with its full field layout.", - "schema": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318626 + }, + "internal_name": { + "type": "string", + "example": "other" + }, + "name": { + "type": "string", + "example": "Other" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "case_type_imported": { + "type": "string" + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:43.059Z" + }, + "creator_details": { "type": "object", - "required": [ - "success", - "template" - ], "properties": { - "success": { - "type": "boolean" - }, - "template": { - "$schema": "TemplateWithFields" - } - } - } - }, - "TemplateWriteAckResponse": { - "description": "Bare acknowledgement of a template write. Carries no template body, so re-read the template to confirm the result.", - "schema": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "estimate": { + "type": "string", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1986204 + }, + "identifier": { + "type": "string", + "example": "TC-54560" + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { "type": "object", - "required": [ - "success" - ], "properties": { - "success": { - "type": "boolean", - "description": "True on success. A free-plan refusal also arrives with HTTP 200 but sets this false and adds a message, so check this flag rather than the status code." - } + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } } - } - }, - "V2HistoryListResponse": { - "description": "A page of test case revisions, newest first.", - "schema": { + }, + "links": { "type": "object", "properties": { - "success": { - "type": "boolean" - }, - "history": { - "type": "array", - "description": "Revision records for the test case, newest first. Note the key is singular `history`, unlike the v1 listing's `histories`.", - "items": { - "$schema": "V2History" - } - }, - "info": { - "$schema": "PageInfo" - } + "detail": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204/detail" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204" + } } - } - }, - "TestPlanV2Response": { - "description": "A single test plan (or sub-plan read through the plan route). No data wrapper — the plan sits at the top level under `test_plan`.", - "schema": { + }, + "metadata": { "type": "object", "properties": { - "success": { + "ai_prompt": { + "type": "object", + "properties": { + "status": { "type": "boolean", - "example": true - }, - "test_plan": { - "$schema": "TestPlanV2" + "example": false + } } - } - } - }, - "SubTestPlanV2Response": { - "description": "A sub-plan returned by the parent-scoped route. The plan sits under `sub_test_plan`, NOT `test_plan`, and its identifier is STP-NNN.", - "schema": { + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "profileId": { + "type": "string", + "nullable": true + }, + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + } + } + }, + "name": { + "type": "string", + "example": "__mcp-probe-createcases-20260921-1" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "sub_test_plan": { - "$schema": "TestPlanV2__CreateSubTestPlan" - } - } - } - }, - "TestPlanV2ListResponse": { - "description": "A page of test plans. Rows omit the embedded test_runs preview and links that the single-plan read adds.", - "schema": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318618 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "status": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_plans": { - "type": "array", - "items": { - "$schema": "TestPlanV2__ListTestPlans" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TestRunsListV2Response": { - "description": "A page of v2 test runs — the shape returned by both the project-wide run listing and a plan's linked-run listing, which genuinely do share one serializer (confirmed in the backend). The rows are a SUMMARY projection, not the single-run read: they never carry issues, issue_tracker or test_cases. One row field differs by route — test_plan is present on the project-wide listing and absent from the plan-scoped one. success is hard-coded true even when the upstream run fetch failed; in that case test_runs and info are null and the HTTP status carries the failure.", - "schema": { + "colour": { + "type": "string", + "example": "active" + }, + "id": { + "type": "integer", + "example": 318632 + }, + "internal_name": { + "type": "string", + "example": "active" + }, + "name": { + "type": "string", + "example": "Active" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "mcp-probe" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_name": { + "type": "string", + "example": "Test Case Steps" + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_case_dataset": { + "type": "object" + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_steps": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_runs": { - "type": "array", - "nullable": true, - "items": { - "$schema": "TestRunSummaryV2" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "CustomFieldV2DetailResponse": { - "description": "A single custom field after a create or edit. The options, assigned_projects, applies_to_all_projects and link_to_future_projects keys are replayed from the request rather than read back from storage, and are omitted when the request did not carry them.", - "schema": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:43.080Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "id": { + "type": "integer", + "example": 12373419 + }, + "order": { + "type": "integer", + "example": 1 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_case_id": { + "type": "integer", + "example": 1986204 + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:43.080Z" + } + } + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "nullable": true + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + } + } + }, + "TestRunWriteResponse__CreateTestRunByIntegerId": { + "type": "object", + "description": "TestRunWriteResponse as returned by `create_test_run_by_integer_id`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_field": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric field id — the value the single-field read and the field edit take in their path.", - "example": 4821 - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Stored type. Always the stored value, never the one sent on an edit." - }, - "field_name": { - "type": "string", - "description": "Stored label, already trimmed." - }, - "entity_type": { - "type": "string", - "example": "TestCase", - "description": "Always TestCase on this surface." - }, - "is_required": { - "type": "boolean", - "description": "Whether a test case must fill the field." - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored default. Null after a create, because a default cannot be set at create time. On a boolean field it is stored as \"1\" or \"0\"." - }, - "place_holder_text": { - "type": "string", - "description": "Hint text. Absent when the field has none." - }, - "options": { - "type": "array", - "description": "Present only when the field actually has options — a free-text type returns no key at all rather than an empty list.", - "items": { - "type": "object", - "properties": { - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - } - } - } - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Echo of the request flag, defaulting to false — not a statement about the field's stored project links." - }, - "assigned_projects": { - "type": "array", - "description": "Present only when the field is linked to specific projects. A field that applies to all projects, or one created without any, returns no key rather than an empty list.", - "items": { - "type": "string", - "example": "PR-12345" - } - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Echo of the request flag, defaulting to false." - }, - "system_name": { - "type": "string", - "description": "The stable internal name for a system-provided field. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "placeholder": { - "type": "string", - "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "group_id": { - "type": "integer", - "description": "Workspace the definition belongs to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "linked_projects_count": { - "type": "integer", - "description": "How many projects the field is linked to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - } - } - } - }, - "required": [ - "success", - "custom_field" - ] - } - }, - "CustomFieldV2WithDatasetsResponse": { - "description": "A single custom field read by id: stored metadata plus references to the datasets that hold its option values. Carries no option values and no real project configuration.", - "schema": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17579088 + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "run_state": { + "type": "string", + "example": "new_run" + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "example": 3 + }, + "test_plans": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:43.000Z" + }, + "updated_by": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_field": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 4821 - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ] - }, - "field_name": { - "type": "string" - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored default; \"1\" / \"0\" on a boolean field." - }, - "place_holder_text": { - "type": "string", - "description": "Absent when the field has none." - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Always false on this read — a placeholder, not the field's real project scope." - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Always false on this read — a placeholder, not the field's real future-project setting." - }, - "dataset_ids": { - "type": "array", - "description": "Ids of the datasets holding this field's option values and project links. Empty for a field type that has no options.", - "items": { - "type": "integer" - } - }, - "datasets_url": { - "type": "string", - "description": "Ready-made link to this field's dataset collection." - }, - "dataset_urls": { - "type": "array", - "description": "Ready-made link per dataset, in the same order as dataset_ids.", - "items": { - "type": "string" - } - }, - "system_name": { - "type": "string", - "description": "The stable internal name for a system-provided field. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "placeholder": { - "type": "string", - "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "group_id": { - "type": "integer", - "description": "Workspace the definition belongs to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "linked_projects_count": { - "type": "integer", - "description": "How many projects the field is linked to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." - } - } - } - }, - "required": [ - "success", - "custom_field" - ] + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "uuid": { + "type": "integer", + "example": 17579088 + } } - }, - "V2ReportListResponse": { - "description": "Paginated list of a project's report configurations, plus group/project totals for the filtered set.", - "schema": { + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "success": { + "type": "boolean", + "example": true + } + } + } + } + }, + "TestCase__EditTestCase": { + "type": "object", + "description": "TestCase as returned by `edit_test_case`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "test_case_steps": { + "$schema": "TestCaseStep__EditTestCase" + } + } + }, + "UsersV2Response__ListGroupUsers": { + "type": "object", + "description": "UsersV2Response as returned by `list_group_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "UsersV2Response" + } + ], + "properties": { + "info": { + "$schema": "PageInfo__ListGroupUsers" + } + } + }, + "PageInfo__ListGroupUsers": { + "type": "object", + "description": "PageInfo as returned by `list_group_users`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "count": { + "type": "integer", + "example": 10 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "V1TestPlanDetail__GetTestPlanByIntegerId": { + "type": "object", + "description": "V1TestPlanDetail as returned by `get_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "active_state": { + "type": "string", + "example": "active" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-18T10:40:45.000Z" + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "example": "2026-10-18T00:00:00.000Z" + }, + "id": { + "type": "integer", + "example": 48300 + }, + "issues": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "example": "2026-09-18T00:00:00.000Z" + }, + "sub_plans_count": { + "type": "integer", + "example": 0 + }, + "tags": { + "type": "string", + "example": "disposable" + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T10:40:45.000Z" + } + } + } + } + }, + "PageInfo__GetTestResultsCustomFields": { + "type": "object", + "description": "PageInfo as returned by `get_test_results_custom_fields`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "TestRunResponse__ListTestRunCases": { + "type": "object", + "description": "TestRunResponse as returned by `get_test_run_detail`. Composes TestRunResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "test_run": { + "type": "object", + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" + }, + "build_serial_id": { + "type": "integer", + "example": 1 + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "created_by": { "type": "object", "properties": { - "success": { - "type": "boolean" - }, - "reports": { - "type": "array", - "items": { - "$schema": "V2ReportDetail" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata: page, next, prev, count, page_size.", - "properties": { - "page": { - "type": "integer" - }, - "next": { - "type": "integer", - "nullable": true - }, - "prev": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer", - "description": "Total matching rows, not rows on this page." - }, - "page_size": { - "type": "integer" - } - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "Scheduled reports within the filtered set, across all pages." - }, - "total_report_count": { - "type": "integer", - "description": "All reports within the filtered set, across all pages." - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } } - } - }, - "V2ReportDetailResponse": { - "description": "A single report configuration, returned by the v2 report create (201) and single-report read (200). Carries no report data.", - "schema": { + }, + "overall_progress": { "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$schema": "V2ReportDetail" - } - } - } - }, - "AttachmentListV2Response": { - "description": "A page of attachments on one test case, test result or test plan.", - "schema": { + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "updated_by": { "type": "object", - "required": [ - "success", - "attachments" - ], "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentListItem" - } - }, - "info": { - "$schema": "PageInfo" - } - } + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "dynamic_filter": { + "type": "string", + "nullable": true + }, + "filters": { + "type": "string", + "nullable": true + }, + "issues": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17575382 + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "trtc_folder_progress_enabled": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "Filter__UpdateFilter": { + "type": "object", + "description": "Filter as returned by `update_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions", + "properties": { + "customFields": { + "type": "object" + }, + "priority": { + "type": "string", + "example": "P1" + } + } + } + } + }, + "V1TestPlanDetail__UpdateTestPlanByIntegerId": { + "type": "object", + "description": "V1TestPlanDetail as returned by `update_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "active_state": { + "type": "string", + "example": "active" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-21T13:44:07.000Z" + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 48319 + }, + "issues": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer", + "example": 0 + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T05:11:26.000Z" + } + } + } + } + }, + "TestPlanV2Response__CreateTestPlan": { + "type": "object", + "description": "TestPlanV2Response as returned by `create_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestPlanV2Response" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "urls": { + "type": "object", + "properties": { + "sub_test_plans": { + "type": "string" + }, + "self": { + "type": "string", + "example": "https://test-management.browserstack.com/projects/1234/test-plans/49442" + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + } + } + } + } + }, + "V1TestCaseListRow__ListTestCases": { + "type": "object", + "description": "V1TestCaseListRow as returned by `list_test_cases`. Composes V1TestCaseListRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListTestCases" + } + } + }, + "TestCaseFieldValue__ListTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `list_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "TestCase__SearchAllProjects": { + "type": "object", + "description": "TestCase as returned by `search_all_projects`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "display_meta": { + "type": "object", + "properties": { + "created_by": { + "type": "string", + "example": "6013" + }, + "description": { + "type": "string", + "example": "-" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 2522 + }, + "name": { + "type": "string", + "example": "ing" + } + } + }, + "template": { + "type": "string", + "example": "test_case_text" + }, + "template_id": { + "type": "string", + "example": "1164" + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 415798 + }, + "name": { + "type": "string", + "example": "Reports Sanity Test Temp" + } + } + } + } + }, + "user_id": { + "type": "integer", + "example": 6013 + } + } + }, + "TestPlanV2__ListTestPlans": { + "type": "object", + "description": "TestPlanV2 as returned by `list_test_plans`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestPlanV2" + } + ], + "properties": { + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + }, + "sub_test_plans": { + "type": "string" + } + } + } + } + }, + "ResultStatus__SearchTestResults": { + "type": "object", + "description": "ResultStatus as returned by `search_test_results`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "valueDetails": { + "type": "string", + "nullable": true + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "TestPlanV2Response__UpdateTestPlan": { + "type": "object", + "description": "TestPlanV2Response as returned by `update_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestPlanV2Response" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "urls": { + "type": "object", + "properties": { + "sub_test_plans": { + "type": "string" + }, + "self": { + "type": "string", + "example": "https://test-management.browserstack.com/projects/1234/test-plans/48283" + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 1 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + } + } + } + } + }, + "PathFolder__CreateBulkTestCases": { + "type": "object", + "description": "PathFolder as returned by `create_bulk_test_cases`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PathFolder" + } + ], + "properties": { + "cases_count": { + "type": "integer", + "example": 51 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 13 + }, + "total_cases_count": { + "type": "integer", + "example": 52 + } + } + }, + "TestCase__CreateBulkTestCases": { + "type": "object", + "description": "TestCase as returned by `create_bulk_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "folder": { + "type": "object", + "properties": { + "cases_count": { + "type": "integer", + "example": 51 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } } - }, - "TestResultSearchResponse": { - "description": "Account-wide test result search page. Always HTTP 200 with success:true — an upstream search failure is swallowed and shows up as an empty test_results array with info null, not as an error status.", - "schema": { - "type": "object", - "required": [ - "success", - "test_results", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true, - "description": "Always true on this endpoint, including when the search itself failed. Do not use it as a success signal." - }, - "test_results": { - "type": "array", - "description": "Matching results, at most 30 per page (10 if per_page was sent at all). Empty both when nothing matched and when the search errored.", - "items": { - "$schema": "TestResultSearchItem" - } - }, - "info": { - "$schema": "PageInfo", - "nullable": true, - "description": "Paging metadata from the search service. NULL when the search failed — that, not the status code, is how you detect the failure." - } - } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 13 + }, + "total_cases_count": { + "type": "integer", + "example": 52 + } + } + }, + "test_case_steps": { + "$schema": "TestCaseStep__CreateBulkTestCases" + } + } + }, + "TestCaseStep__CreateBulkTestCases": { + "type": "object", + "description": "TestCaseStep as returned by `create_bulk_test_cases`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "hashed_id": { + "type": "string", + "example": "dcf9955bb4c3de3da47a04212b67ee4281dbcfa5" + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "TestResult__CreateStepResult": { + "type": "object", + "description": "TestResult as returned by `create_step_result`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestResult" + } + ], + "properties": { + "author": { + "$schema": "User__CreateStepResult" + }, + "result_status": { + "$schema": "ResultStatus__CreateStepResult" + }, + "test_run_step_result": { + "type": "object", + "nullable": true, + "properties": { + "btcer_id": { + "type": "integer", + "example": 2150428297 + }, + "colour": { + "type": "string", + "example": "#059669" + }, + "field_value": { + "type": "string", + "example": "Passed" + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "App opens successfully" + }, + "status_id": { + "type": "integer", + "example": 318610 + }, + "step": { + "type": "string", + "example": "Open the app" + }, + "step_mapping_id": { + "type": "integer", + "example": 12372581 + }, + "test_data": { + "type": "string", + "nullable": true + } + } + } + } + }, + "User__CreateStepResult": { + "type": "object", + "description": "User as returned by `create_step_result`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__CreateStepResult": { + "type": "object", + "description": "ResultStatus as returned by `create_step_result`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "User__CreateTestResultForTestCase": { + "type": "object", + "description": "User as returned by `create_test_result_for_test_case`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__CreateTestResultForTestCase": { + "type": "object", + "description": "ResultStatus as returned by `create_test_result_for_test_case`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "TestCaseStep__EditTestCase": { + "type": "object", + "description": "TestCaseStep as returned by `edit_test_case`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "TestRunsListResponse__ListClosedTestRuns": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_closed_test_runs`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17578623" + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "updated_by": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 60 + }, + "closed": { + "type": "integer", + "example": 1 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + }, + "Dataset__GetDataset": { + "type": "object", + "description": "Dataset as returned by `get_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__GetDataset" + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } + } + }, + "DatasetUserRef__GetDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `get_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__ListEntityFilterDetails": { + "type": "object", + "description": "ResultStatus as returned by `list_entity_filter_details`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "entity_type": { + "type": "string", + "example": "TestCase" + } + } + }, + "User__ListEntityFilterDetails": { + "type": "object", + "description": "User as returned by `list_entity_filter_details`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "ReportData__GetReportDetail": { + "type": "object", + "description": "ReportData as returned by `get_report_detail`. Composes ReportData and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportData" + } + ], + "properties": { + "time_tracking_enabled": { + "type": "boolean", + "example": false + }, + "test_cases_status": { + "type": "array", + "items": { + "type": "object", + "required": [ + "count", + "result_status" + ], + "properties": { + "count": { + "type": "integer" + }, + "result_status": { + "$schema": "ResultStatus__GetReportDetail" } - }, - "CommentsListResponse": { - "description": "A page of comments on one run/case cell, newest first, fixed 30 per page. info.count is the total across pages; a page past the end repeats the last page rather than returning an empty array.", - "schema": { - "type": "object", - "required": [ - "comments", - "success", - "info" - ], - "properties": { - "comments": { - "type": "array", - "items": { - "$schema": "Comment" - } - }, - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo" - } - } + } + } + }, + "test_run_status": { + "type": "object", + "properties": { + "Closed": { + "type": "integer", + "example": 1 + }, + "Done": { + "type": "integer", + "example": 0 + }, + "In_Progress": { + "type": "integer", + "example": 0 + }, + "New": { + "type": "integer", + "example": 59 + }, + "Rejected": { + "type": "integer", + "example": 0 + }, + "Under_Review": { + "type": "integer", + "example": 1 + } + } + }, + "time_tracking": { + "type": "object", + "properties": { + "cases_in_scope": { + "type": "integer", + "example": 166 + }, + "cases_with_elapsed": { + "type": "integer", + "example": 0 + }, + "total_elapsed_seconds": { + "type": "integer", + "example": 0 + } + } + }, + "user_result_stats": { + "type": "array", + "items": { + "type": "object", + "required": [ + "user_id", + "test_results", + "user_name" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "test_results": { + "type": "integer" + }, + "user_name": { + "type": "string" + }, + "local_user_id": { + "type": "integer", + "example": 3741 + }, + "tc_count": { + "type": "integer", + "example": 2 } - }, - "CommentResponse": { - "description": "The comment just posted, wrapped as data.comment. HTTP 200, not 201. Both inner keys are always present - the handler emits them as one literal with no branches.", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "success", - "comment" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "comment": { - "$schema": "Comment" - } - } - } - } + } + } + } + } + }, + "ResultStatus__GetReportDetail": { + "type": "object", + "description": "ResultStatus as returned by `get_report_detail`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + } + } + }, + "Folder__ListRootFolders": { + "type": "object", + "description": "Folder as returned by `list_root_folders`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764093" + } + } + } + } + }, + "SharedStepsIDResponse__GetSharedStep": { + "type": "object", + "description": "SharedStepsIDResponse as returned by `get_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "SharedStepsIDResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "page_info": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 1 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 150 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "created_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } } - }, - "TestRunConfigurationProgressResponse": { - "description": "Execution progress of a test run by result status. TWO shapes: when configuration_id was sent the body is {success, overall_progress_by_status_id} and `configurations`/`info` are absent; when it was not, the body is {success, configurations:[...], info} and the top-level `overall_progress_by_status_id` is absent. `info` is the backing service's paging block passed through unchanged (its exact fields are not fixed by this service).", - "schema": { + }, + "shared_step_details": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "probe result" + }, + "step": { + "type": "string", + "example": "probe step" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } + } + }, + "tags": { + "type": "string", + "nullable": true + } + } + } + } + }, + "V1TestCaseRecordRow__GetTestCaseByIntegerId": { + "type": "object", + "description": "V1TestCaseRecordRow as returned by `get_test_case_by_integer_id`. Composes V1TestCaseRecordRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseRecordRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "priority": { + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "download_url": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "TestCaseFieldValue__GetTestCaseByIntegerId": { + "type": "object", + "description": "TestCaseFieldValue as returned by `get_test_case_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "TestCaseFieldValue__GetTestCaseDetail": { + "type": "object", + "description": "TestCaseFieldValue as returned by `get_test_case_detail`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "TestCaseStep__GetTestCaseDetail": { + "type": "object", + "description": "TestCaseStep as returned by `get_test_case_detail`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "hashed_id": { + "type": "string", + "nullable": true + }, + "scenario": { + "type": "string", + "nullable": true + }, + "sharedStep": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "test_data": { + "type": "string", + "nullable": true + }, + "test_recording_id": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + } + } + }, + "History__ListTestCaseHistories": { + "type": "object", + "description": "History as returned by `list_test_case_histories`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "History" + } + ], + "properties": { + "user": { + "$schema": "User__ListTestCaseHistories" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + }, + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "new": { "type": "object", - "required": [ - "success" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "configurations": { - "type": "array", - "description": "Only in the all-configurations form. One page of the run's configurations, each with its progress attached.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 101 - }, - "name": { - "type": "string", - "example": "Chrome on Windows" - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } - } - } - }, - "info": { - "type": "object", - "nullable": true, - "description": "Only in the all-configurations form. Paging metadata from the backing service, passed through as-is." - } + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } } - } - }, - "BinnedTestCasesListResponse": { - "description": "A page of binned test cases. Paging lives under `metadata` (total/page/page_size/total_pages), not the usual `info` envelope. Field names follow the backing service's client model; confirm against a live call if a field is missing.", - "schema": { + }, + "old": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "BinnedTestCase" - } - }, - "metadata": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_pages": { - "type": "integer" - } - } - } + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } } + } } - }, - "TestCaseReviewOutcomeResponse": { - "description": "Outcome of a bulk review submission or verdict. Under 100 cases: the updated cases inline (testCases) — verdicts with a comment also carry `comment`. Over 100: {success, async:true, unique_id} and NO testCases; the change lands in the background.", - "schema": { + } + } + } + } + }, + "User__ListTestCaseHistories": { + "type": "object", + "description": "User as returned by `list_test_case_histories`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "History__GetTestCaseHistory": { + "type": "object", + "description": "History as returned by `get_test_case_history`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "History" + } + ], + "properties": { + "user": { + "$schema": "User__GetTestCaseHistory" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + }, + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "new": { "type": "object", - "required": [ - "success" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testCases": { - "type": "array", - "items": { - "$schema": "TestCase" - }, - "description": "Updated cases (serialized test case, including review_status and reviewers). Absent on the async path." - }, - "comment": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Verdict only, when a comment was sent: the created comment objects {id, comment, review_status, user{id, full_name, email}, created_at ...}." - }, - "async": { - "type": "boolean", - "description": "true when the selection exceeded 100 and was queued." - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Identifier of the queued background operation (async path only)." - } + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } } - } - }, - "ReauthRequired": { - "description": "Part 11 e-signature required: the reauth_token was missing, expired (5 min) or already used. Re-authenticate and resend with a fresh token.", - "schema": { + }, + "old": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error_code": { - "type": "string", - "example": "reauth_required" - } + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } } + } } - }, - "LcncCreateWrapperResponse": { - "description": "Wrapper around the automation service's reply. `status` is the upstream HTTP code and `body` is the upstream body as a JSON-encoded STRING to parse (except the 'already in progress' short-circuit, where body is an object {message, title} and status is 'ok'). HTTP 200 outside does not mean success inside — inspect status and body.", - "schema": { + } + } + } + } + }, + "User__GetTestCaseHistory": { + "type": "object", + "description": "User as returned by `get_test_case_history`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId": { + "type": "object", + "description": "V1TestRunTestCaseRow as returned by `list_test_run_test_cases_by_integer_id`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestRunTestCaseRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId" + } + } + }, + "TestCaseFieldValue__ListTestRunTestCasesByIntegerId": { + "type": "object", + "description": "TestCaseFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId": { + "type": "object", + "description": "ResultStatusFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatusFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "TestRunsListResponse__ListTestPlanTestRunsByIntegerId": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_test_plan_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17574995" + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "example": "MCP+capability+probe+fixture+run+%28link_entities_to_jira_issue%29" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string" + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "object", + "properties": { + "test_plan_error": { + "type": "string", + "example": "PLAN_LINKED" + } + } + }, + "observability_url": { + "type": "string" + }, + "updated_by": { + "type": "string", + "nullable": true + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 1 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + }, + "TestRunUserRef__GetTestRunByIntegerId": { + "type": "object", + "description": "TestRunUserRef as returned by `get_test_run_by_integer_id`. Composes TestRunUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "TestRunsListResponse__ListTestRunsByIntegerId": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17579092" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + }, + "updated_by": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "example": "__mcp-probe-createrun-20260921-260922110512" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 60 + }, + "closed": { + "type": "integer", + "example": 1 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "integer", + "example": 2 + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + }, + "DatasetUserRef__ListDatasets": { + "type": "object", + "description": "DatasetUserRef as returned by `list_datasets`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "TestCaseFieldValue__ListFolderTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `list_folder_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "Folder__MoveFolderByIntegerId": { + "type": "object", + "description": "Folder as returned by `move_folder_by_integer_id`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/765883" + } + } + }, + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765883 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "copy-of-__mcp-probe-subfolder-20260921" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + }, + "total_cases_count": { + "type": "integer", + "example": 2 + } + } + }, + "FolderWriteResponse__RenameFolderByIntegerId": { + "type": "object", + "description": "FolderWriteResponse as returned by `rename_folder_by_integer_id`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "FolderWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "links": { "type": "object", - "required": [ - "status", - "body" - ], "properties": { - "status": { - "type": "integer", - "example": 200, - "description": "Upstream HTTP status (an integer), or the string 'ok' on the already-in-progress short-circuit." - }, - "body": { - "type": "string", - "description": "Upstream body as a JSON string; typically {status:'submitted'|'failed', message, test_id?, test_version_id?, reason?} on the bulk-pipeline path." - } - } + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/765811" + } + } + }, + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765811 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 16 + }, + "total_cases_count": { + "type": "integer", + "example": 169 + } + } + } + } + } + } + }, + "V1TestCaseSearchRow__SearchProjectEntities": { + "type": "object", + "description": "V1TestCaseSearchRow as returned by `search_project_entities`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseSearchRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "case_type": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "priority": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "status": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "id": { + "type": "integer", + "example": 1102619 + }, + "key": { + "type": "string", + "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" + }, + "name": { + "type": "string", + "example": "probe.txt" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" } - }, - "BddExportValidationResponse": { - "description": "Verdict of the BDD export pre-check; the same shape is returned on pass (200, success:true) and on every failure (400, success:false) — read message.", - "schema": { - "type": "object", - "required": [ - "success", - "message" - ], - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string", - "example": "Validation successful. All test cases belong to the same feature and background." - } - } + } + } + } + } + }, + "SearchFieldValue__SearchProjectEntities": { + "type": "object", + "description": "SearchFieldValue as returned by `search_project_entities`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SearchFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "V1TestCaseSearchRow__SearchProjectEntitiesByFilter": { + "type": "object", + "description": "V1TestCaseSearchRow as returned by `search_project_entities_by_filter`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseSearchRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "case_type": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "priority": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "status": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "id": { + "type": "integer", + "example": 1102619 + }, + "key": { + "type": "string", + "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" + }, + "name": { + "type": "string", + "example": "probe.txt" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" } - }, - "TestRunV2Response": { - "description": "The test run at the top level under `test_run`, with no `data` envelope — the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", - "schema": { + } + } + } + } + }, + "SearchFieldValue__SearchProjectEntitiesByFilter": { + "type": "object", + "description": "SearchFieldValue as returned by `search_project_entities_by_filter`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SearchFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "Dataset__UpdateDataset": { + "type": "object", + "description": "Dataset as returned by `update_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__UpdateDataset" + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } + } + }, + "DatasetUserRef__UpdateDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `update_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "FormFieldDescriptor": { + "type": "object", + "description": "One form field on the run-creation form: its selectable values plus the link and count that describe the value set.\n", + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/test-case/status" + } + } + }, + "total_count": { + "type": "integer", + "example": 8 + }, + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "is_default": { + "type": "integer", + "example": 0 + }, + "name": { + "type": "string", + "example": "Passed" + }, + "value": { + "type": "integer", + "example": 318610 + }, + "value_category": { + "type": "string", + "example": "completed" + }, + "internal_name": { + "type": "string", + "example": "passed" + } + } + } + } + }, + "TestRunV2Response__ListTestRun": { + "type": "object", + "description": "TestRunV2Response as returned by `get_test_run`. Composes TestRunV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunV2Response" + } + ], + "properties": { + "test_run": { + "type": "object", + "properties": { + "test_cases": { + "type": "object", + "properties": { + "case_type": { + "type": "string", + "example": "Other" + }, + "configuration_id": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "example": "user@example.com" + }, + "dataset": { + "type": "string", + "nullable": true + }, + "execution_id": { + "type": "string", + "example": "ab5c1e3c40fc614f2020a9ba4dd1a8e4c075bba8fe0336a4f30646030b6f6d43" + }, + "folder_path": { + "type": "integer", + "example": 764138 + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "last_updated_at": { + "type": "string", + "example": "2026-09-18T12:53:07.732Z" + }, + "latest_result_id": { + "type": "integer", + "example": 2150413868 + }, + "latest_status": { + "type": "string", + "example": "passed" + }, + "planned_execution_date": { + "type": "string", + "nullable": true + }, + "priority": { + "type": "string", + "example": "High" + }, + "source_project_id": { + "type": "string", + "example": "PR-2005" + }, + "status": { + "type": "string", + "example": "Active" + }, + "step_count": { + "type": "integer", + "example": 1 + }, + "updated_by": { + "type": "string", + "example": "user@example.com" + }, + "urls": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_run": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "CONDITIONAL — the key is absent, not empty, when the run payload carries no issues association.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string" - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` — the same value under a tracker-specific name. Prefer `issue_id`." - }, - "issue_type": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's — use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" - } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" - } - } - } - } - } + "self": { + "type": "string" + } } + } } - }, - "TestRunWriteV2Response": { - "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape — the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", - "schema": { + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } + } + } + } + }, + "Project__ListProjects": { + "type": "object", + "description": "Project as returned by `list_projects`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Project" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "example": "/api/v1/projects/1234/repository" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234" + }, + "test_plans": { + "type": "string", + "example": "/api/v1/projects/1234/test-plans" + }, + "test_runs": { + "type": "string", + "example": "/api/v1/projects/1234/test-runs" + }, + "users": { + "type": "string", + "example": "/api/v1/projects/1234/users" + } + } + }, + "duplicates_tc_count": { + "type": "integer", + "example": 0 + }, + "jira_mapped": { + "type": "boolean", + "example": false + }, + "normalisedName": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "projectVisibilityBanner": { + "type": "string", + "nullable": true + }, + "starred": { + "type": "boolean", + "example": false + }, + "test_cases_count": { + "type": "integer", + "example": 272 + }, + "test_plans_count": { + "type": "integer", + "example": 75 + }, + "test_runs_count": { + "type": "integer", + "example": 99 + }, + "thProjectId": { + "type": "integer", + "example": 1234 + } + } + }, + "TestPlanV2__CreateSubTestPlan": { + "type": "object", + "description": "TestPlanV2 as returned by `create_sub_test_plan`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestPlanV2" + } + ], + "properties": { + "parent_plan_id": { + "type": "string", + "example": "TP-1616" + } + } + }, + "V1ProjectDetail__GetProjectByIntegerId": { + "type": "object", + "description": "V1ProjectDetail as returned by `get_project_by_integer_id`. Composes V1ProjectDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1ProjectDetail" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "project": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "PR-NNN identifier \u2014 the v2 {project_id}. (Integer -> PR-NNN translation output.)", + "example": "PR-1" + }, + "id": { + "type": "integer", + "description": "Integer project id \u2014 the value passed in the path. Same as thProjectId.", + "example": 11046 + }, + "thProjectId": { + "type": "integer", + "example": 11046 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "description": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "nullable": true + }, + "test_runs_count": { + "type": "integer", + "nullable": true + }, + "test_plans_count": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "starred": { + "type": "boolean" + }, + "jira_mapped": { + "type": "boolean" + }, + "links": { + "type": "object" + }, + "import_id": { + "type": "integer", + "nullable": true, + "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." + }, + "normalisedName": { + "type": "string", + "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." + }, + "projectVisibilityBanner": { "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "CONDITIONAL — the key is absent, not empty, when the run payload carries no issues association.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string" - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` — the same value under a tracker-specific name. Prefer `issue_id`." - }, - "issue_type": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's — use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" - } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" - } - } - } - } - } - } + "nullable": true, + "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." + }, + "permissions": { + "type": "string", + "example": "project_write" + }, + "user_role": { + "type": "string", + "example": "Admin" + } + } + } + } + } + } + }, + "UsersV2Response__ListProjectUsers": { + "type": "object", + "description": "UsersV2Response as returned by `list_project_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "UsersV2Response" + } + ], + "properties": { + "users": { + "type": "array", + "items": { + "$schema": "User__ListProjectUsers" + } + } + } + }, + "User__ListProjectUsers": { + "type": "object", + "description": "User as returned by `list_project_users`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "ReportDetail__GetReportDetail": { + "type": "object", + "description": "ReportDetail as returned by `get_report_detail`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { + "is_dynamic": { + "type": "boolean", + "example": false + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + }, + "PathFolder__MoveFolderByIntegerId": { + "type": "object", + "description": "PathFolder as returned by `move_folder_by_integer_id`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PathFolder" + } + ], + "properties": { + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765883 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "copy-of-__mcp-probe-subfolder-20260921" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + }, + "total_cases_count": { + "type": "integer", + "example": 2 + } + } + }, + "TagsSearchResponse__SearchGroupTags": { + "type": "object", + "description": "TagsSearchResponse as returned by `search_group_tags`. Composes TagsSearchResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TagsSearchResponseBody" + } + ], + "properties": { + "tags": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 541125 + } + } + }, + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 5 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + }, + "ExploratorySessionLog__UpdateExploratorySessionLog": { + "type": "object", + "description": "ExploratorySessionLog as returned by `update_exploratory_session_log`. Composes ExploratorySessionLog and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ExploratorySessionLog" + } + ], + "properties": { + "attachment_relations": { + "type": "string", + "nullable": true + } + } + }, + "ReportDetail__UpdateReport": { + "type": "object", + "description": "ReportDetail as returned by `update_report`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { + "is_dynamic": { + "type": "boolean", + "example": false + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + }, + "SharedStepsIDResponse__UpdateSharedStep": { + "type": "object", + "description": "SharedStepsIDResponse as returned by `update_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SharedStepsIDResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "step_count": { + "type": "integer", + "example": 1 + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_case_count": { + "type": "integer", + "example": 0 + }, + "created_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } } - }, - "CustomFieldAdminWriteResponse": { - "description": "A custom field definition after a create or edit on the workspace settings surface. The field is echoed under `data` exactly as storage returned it — no options, no datasets and no project links, so confirm those with a separate read.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "The stored field. Take `id` — it is what every other custom-field call puts in its path.", - "properties": { - "id": { - "type": "integer", - "example": 4821 - }, - "field_name": { - "type": "string", - "description": "Stored label, already whitespace-trimmed." - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Canonical spelling, not the legacy field_* form the project form-field reads return." - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored as \"1\" / \"0\" on a boolean field; null on every other field type, because this surface forces it to null for non-boolean types." - }, - "place_holder_text": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "is_bulk_editable": { - "type": "boolean", - "example": true - }, - "is_filterable": { - "type": "boolean", - "example": true - }, - "parent_custom_field_id": { - "type": "string", - "nullable": true - }, - "system_name": { - "type": "string", - "example": "__mcp-probe-cfdef-20260921-renamed" - } - } - } - } + }, + "shared_step_details": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "example": "user=probe" + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } } - }, - "CustomFieldAdminDetailResponse": { - "description": "One custom field definition read by id, with its datasets and their linked projects. Carries no option values and no entity_type.", - "schema": { + } + } + } + } + }, + "FilterListRow": { + "type": "object", + "description": "One saved view as list_filters returns it: its id, name and scope only.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_project_level": { + "type": "boolean" + } + } + }, + "Filter__CreateFilter": { + "type": "object", + "description": "Filter as returned by `create_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions", + "properties": { + "priority": { + "type": "string", + "example": "P1" + } + } + } + } + }, + "Dataset__CreateDataset": { + "type": "object", + "description": "Dataset as returned by `create_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__CreateDataset" + } + } + }, + "DatasetUserRef__CreateDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `create_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "FolderWriteResponse__CreateRootFolder": { + "type": "object", + "description": "FolderWriteResponse as returned by `create_root_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "FolderWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "integer", + "example": 2522 + }, + "root": { + "type": "boolean", + "example": true + }, + "updated_by": { + "type": "integer", + "example": 2522 + }, + "links": { "type": "object", - "required": [ - "success", - "data", - "info" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the datasets list; page size is fixed at 30." - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "field_name": { - "type": "string" - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ] - }, - "system_name": { - "type": "string", - "nullable": true, - "description": "Internal name; null for user-defined fields." - }, - "is_required": { - "type": "boolean" - }, - "place_holder_text": { - "type": "string", - "nullable": true, - "description": "Read from the upstream `placeholder` attribute and renamed here." - }, - "is_bulk_editable": { - "type": "boolean" - }, - "is_filterable": { - "type": "boolean" - }, - "default_value": { - "description": "Converted to a real boolean when field_type is `boolean`; returned as stored otherwise." - }, - "type": { - "type": "string", - "example": "Custom", - "description": "Always the literal \"Custom\" on this read." - }, - "levels": { - "type": "array", - "description": "Present only for a nested_dropdown field.", - "items": { - "type": "object" - } - }, - "datasets": { - "type": "array", - "description": "Empty array when the field has none. Options are NOT included — read them from the dataset options list.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Dataset id — what the dataset, option and project-mapping calls take in their paths." - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer" - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Derived per request by comparing linked_project_count against the workspace project count; not a stored flag." - }, - "linked_projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "example": "PR-1" - }, - "name": { - "type": "string" - } - } - } - } - } - } - } - } - } + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/766097" + } } + } } - }, - "WorkspaceFieldsListResponse": { - "description": "A page of the workspace's fields, system and custom together. Page size is fixed at 10; system fields are injected on page 1 only.", - "schema": { + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-17T08:01:58.000Z" + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "example": "probe edit_project 2026-09-17T10:47:41Z" + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "FolderWriteResponse__CreateSubFolder": { + "type": "object", + "description": "FolderWriteResponse as returned by `create_sub_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "FolderWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "integer", + "example": 6013 + }, + "root": { + "type": "boolean", + "example": false + }, + "updated_by": { + "type": "integer", + "example": 6013 + }, + "links": { "type": "object", - "required": [ - "success", - "data", - "info" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "type": "object", - "description": "Paging metadata. On this endpoint it is {count, page, page_size, total_pages} — there is no prev/next.", - "properties": { - "count": { - "type": "integer", - "description": "Total matching fields; includes the matched system fields when field_source is `all`." - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_pages": { - "type": "integer" - }, - "next": { - "type": "integer", - "example": 2 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric field id — absent on the injected system-field rows, which are identified by system_name instead." - }, - "field_name": { - "type": "string" - }, - "field_type": { - "type": "string", - "description": "Canonical spelling. Injected system-field rows always report `dropdown`." - }, - "entity_type": { - "type": "string" - }, - "system_name": { - "type": "string", - "nullable": true, - "description": "Internal name, e.g. priority / case_type / status / automation_state on system rows." - }, - "place_holder_text": { - "type": "string", - "nullable": true - }, - "linked_projects_count": { - "type": "integer" - }, - "dataset_count": { - "type": "integer", - "description": "Number of datasets behind this field; 0 on system rows." - }, - "is_system_field": { - "type": "boolean" - }, - "type": { - "type": "string", - "enum": [ - "System", - "Custom" - ] - }, - "field_values": { - "type": "array", - "description": "Populated for system rows on page 1; always an empty array for custom fields on this listing.", - "items": { - "type": "object" - } - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Derived by comparing linked_projects_count against the workspace project count; always true on system rows." - } - } - } - } + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/766098" + } } + } } - }, - "CustomFieldAdminDatasetResponse": { - "description": "One dataset of a custom field — its project links and, when the read asked for them, its option values. There is no paging metadata even though the linked-projects list is paged.", - "schema": { + } + } + } + } + }, + "PageInfo__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "PageInfo as returned by `list_test_results_for_test_case_by_integer_id`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "truncated": { + "type": "boolean", + "example": false + } + } + }, + "TestResult__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "TestResult as returned by `list_test_results_for_test_case_by_integer_id`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestResult" + } + ], + "properties": { + "author": { + "$schema": "User__ListTestResultsForTestCaseByIntegerId" + }, + "result_status": { + "$schema": "ResultStatus__ListTestResultsForTestCaseByIntegerId" + }, + "updated_by": { + "type": "string", + "nullable": true, + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + } + } + } + } + }, + "User__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "User as returned by `list_test_results_for_test_case_by_integer_id`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "ResultStatus as returned by `list_test_results_for_test_case_by_integer_id`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "TestCase__ReorderTestCasesByFolder": { + "type": "object", + "description": "TestCase as returned by `reorder_test_cases_by_folder`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" + }, + "priority": { + "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" + } + } + }, + "TestCaseFieldValue__ReorderTestCasesByFolder": { + "type": "object", + "description": "TestCaseFieldValue as returned by `reorder_test_cases_by_folder`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "TestRunWriteResponse__CloseTestRun": { + "type": "object", + "description": "TestRunWriteResponse as returned by `close_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "import_id": { + "type": "string", + "nullable": true + } + } + }, + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17579204 + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "created_by": { "type": "object", - "required": [ - "success", - "data" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Dataset id — what the option and project-mapping calls take in their paths." - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer", - "description": "Total linked projects, not the number in this page of `linked_projects`." - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Added by this layer by comparing linked_project_count against the workspace project count. Absent when linked_project_count is not set." - }, - "linked_projects": { - "type": "array", - "description": "One page of the linked projects (30 per page, selected by `p`).", - "items": { - "type": "object" - } - }, - "options": { - "type": "array", - "description": "Present only when the request asked for options (fetch_options), and always present on the project-mapping update response. Absent otherwise — which is indistinguishable from a dataset with no options.", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "example": "2026-09-11T06:50:12.000Z" - }, - "custom_field_id": { - "type": "integer", - "example": 105915 - }, - "datasetProjects": { - "type": "object" - }, - "future_projects_applicable": { - "type": "integer", - "example": 0 - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - } + "email": { + "type": "string", + "example": "user@example.com" + }, + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } } - } - }, - "CustomFieldDatasetProjectMappingUpdateResponse": { - "description": "Result of relinking a dataset's projects. Two different shapes: synchronously the re-read dataset under `data`; above 300 affected projects a bare async acknowledgement with no dataset at all.", - "schema": { + }, + "updated_by": { "type": "object", - "required": [ - "success" - ], "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Present and true only when the change was handed to a background job (more than 300 projects affected). When it is true there is no `data`, and the path ids were never validated." - }, - "unique_id": { - "type": "string", - "description": "Job handle for the async case only. There is no endpoint on this surface to poll it — verify by re-reading the dataset." - }, - "data": { - "type": "object", - "description": "The dataset re-read after the change, with its options included. Absent on the async path.", - "properties": { - "id": { - "type": "integer" - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer" - }, - "linked_to_all_projects": { - "type": "boolean" - }, - "linked_projects": { - "type": "array", - "items": { - "type": "object" - } - }, - "options": { - "type": "array", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "example": "2026-09-21T13:34:12.000Z" - }, - "custom_field_id": { - "type": "integer", - "example": 106131 - }, - "datasetProjects": { - "type": "object" - }, - "future_projects_applicable": { - "type": "integer", - "example": 1 - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - } + "email": { + "type": "string", + "example": "user@example.com" + }, + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } + } + } + } + } + } + }, + "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun": { + "type": "object", + "description": "V1TestRunTestCaseRow as returned by `bulk_set_test_case_assignee_in_run`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestRunTestCaseRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "test_assignee": { + "type": "integer", + "nullable": true, + "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun": { + "type": "object", + "description": "ResultStatusFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatusFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "DuplicateGroup": { + "type": "object", + "description": "One group of test cases the dedupe model judged duplicates. Returned by the duplicates listing; the single-duplicate read returns the same group under `duplicate`.\n", + "properties": { + "id": { + "type": "integer", + "description": "The duplicate GROUP id \u2014 this is the `duplicate_id` the single-duplicate read and the write actions take." + }, + "duplicate_ids": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Ids of every pair in this group; usually `[id]`." + }, + "ai_score": { + "type": "number", + "description": "Similarity score from the dedupe model, 0-100." + }, + "avg_score": { + "type": "number", + "description": "Mean score across the group." + }, + "reason": { + "type": "string", + "description": "Model-written explanation of why the cases were judged duplicates." + }, + "duplicate_type": { + "type": "string", + "description": "How they matched, e.g. `semantic`." + }, + "case_count": { + "type": "integer", + "description": "Number of cases in the group." + }, + "folder_count": { + "type": "integer", + "description": "Number of distinct folders they span." + }, + "owner_count": { + "type": "integer", + "description": "Number of distinct owners." + }, + "suggested_base_id": { + "type": "integer", + "description": "The case the model suggests keeping." + }, + "test_cases": { + "type": "array", + "items": { + "type": "object" + }, + "description": "The cases in this group; each carries its own id and identifier." + } + } + }, + "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans": { + "type": "object", + "description": "V1TestPlanBulkResponse as returned by `bulk_restore_archived_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanBulkResponse" + } + ], + "properties": { + "count": { + "type": "integer" + }, + "plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "sub_plans_count": { + "type": "integer" + } + } + } + }, + "success": { + "type": "boolean" + } + } + }, + "V1TestPlanBulkResponse__BulkArchiveTestPlans": { + "type": "object", + "description": "V1TestPlanBulkResponse as returned by `bulk_archive_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanBulkResponse" + } + ], + "properties": { + "count": { + "type": "integer" + }, + "plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "sub_plans_count": { + "type": "integer" + } + } + } + }, + "success": { + "type": "boolean" + } + } + }, + "V1TestPlanDetail__CreateTestPlanByIntegerId": { + "type": "object", + "description": "V1TestPlanDetail as returned by `create_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], + "properties": { + "success": { + "type": "boolean" + }, + "test_plan": { + "type": "object", + "properties": { + "active_state": { + "type": "string" + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object" + } + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } + } + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "owner": { + "type": "integer" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "review_settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sub_plan_mode": { + "type": "string" + } + } + }, + "review_status": { + "type": "string" + }, + "reviewers": { + "type": "array", + "items": { + "type": "object" + } + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "test_plan_progress": { + "type": "object" + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "string" + } + } + } + } + }, + "SharedComponentDetail": { + "type": "object", + "description": "The shared field as echoed by a CREATE or UPDATE: the listed fields plus its collection and its actual rows. The LISTING returns neither - it gives `step_count` instead of the rows, and does not carry `folder_id` at all.\n", + "allOf": [ + { + "$schema": "SharedComponent" + } + ], + "properties": { + "folder_id": { + "type": "integer", + "nullable": true, + "description": "Collection it is filed under; null for the Unassigned root." + }, + "shared_step_details": { + "type": "array", + "description": "The ordered rows. Returned by the create and update echoes; the LISTING omits it and gives `step_count` instead.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string" + }, + "result": { + "type": "string", + "nullable": true + }, + "test_data": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer" + } + } + } + } + } + }, + "FolderWriteResponseBody": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "Folder" + } + } + } + }, + "description": "Response body of `FolderWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "SharedStepsIDResponseBody": { + "type": "object", + "required": [ + "data", + "success" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + }, + "description": "Response body of `SharedStepsIDResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "TagsSearchResponseBody": { + "type": "object", + "required": [ + "tags", + "tag_objects", + "info" + ], + "properties": { + "tags": { + "type": "array", + "description": "Simple array of tag names (for backward compatibility)", + "example": [ + "AI Generated", + "AI Generated Details" + ], + "items": { + "type": "string" + } + }, + "tag_objects": { + "type": "array", + "description": "Detailed tag objects with metadata", + "items": { + "$schema": "Tag" + } + }, + "info": { + "$schema": "PageInfo" + } + }, + "description": "Response body of `TagsSearchResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "TestRunWriteResponseBody": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "$schema": "TestRun" + }, + "project": { + "$schema": "Project" + }, + "error": { + "type": "string", + "description": "Only present when success is false; the backing service's message." + } + } + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", + "items": { + "type": "integer" + } + } + }, + "description": "Response body of `TestRunWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + } + }, + "responses": { + "TestRunWriteResponse": { + "description": "Successfully returned the test run after a WRITE\n(create / edit / assign / close / delete). NOTE the envelope key is `data.testrun` with NO\nunderscore on these operations, unlike clone and the plain reads, which use `data.test_run`.\n`skipped_test_case_ids` is a SIBLING of `data`, not inside it, and lists cases the review gate\nrefused to add. Client errors from the backing service replace the payload with\n{data: {success: false, error: }} at the 4xx status.\n", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "$schema": "TestRun" + }, + "project": { + "$schema": "Project" + }, + "error": { + "type": "string", + "description": "Only present when success is false; the backing service's message." + } + } + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", + "items": { + "type": "integer" + } + } + } + } + }, + "BadRequest": { + "description": "Bad request due to invalid parameters.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Unauthorized": { + "description": "Unauthorized access. Authentication is required.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "InternalServerError": { + "description": "Internal server error.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BulkArchiveResponse": { + "description": "Test cases archived successfully", + "schema": { + "type": "object", + "required": [ + "success", + "folders" + ], + "properties": { + "success": { + "type": "boolean" + }, + "folders": { + "type": "object" + } + } + } + }, + "Forbidden": { + "description": "Forbidden access. You don't have permission.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "UnprocessableEntity": { + "description": "The request was well-formed but contains semantic errors.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BulkCopyTestCasesResponse": { + "description": "Test cases copied successfully", + "schema": { + "type": "object", + "required": [ + "success", + "test_cases", + "info", + "cases_count", + "source_path_folders", + "destination_path_folders" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkCopyTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object" + }, + "source_path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "Folder__BulkCopyTestCases" + } + }, + "folders": { + "type": "object" + } + } + } + }, + "BulkDeleteTestCasesResponse": { + "description": "Response after bulk deleting test cases", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object" + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + } + } + } + }, + "BulkMoveTestCasesResponse": { + "description": "CORRECTION of an existing key (only bulk_move_test_cases uses it). A synchronous bulk move renders the refreshed source listing, not {success, folders}. Above the async threshold the same 200 carries {success, async:true, unique_id} and none of these keys.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkMoveTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object", + "description": "Case counts keyed by folder id, for the source and destination folders." + }, + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "Folder__BulkMoveTestCases" + } + }, + "source_path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Only present when the request scoped a source folder." + }, + "moved_cases_count": { + "type": "integer", + "description": "Only present on a cross-project move." + }, + "folders": { + "type": "object" + } + } + } + }, + "TestRunResponse": { + "description": "Successfully returned a test run under a\n`data` envelope, keyed `test_run` WITH an underscore. Used by the v1 run reads and by clone.\nNOTE the write operations on this entity (create / edit / assign / close / delete) return\n`data.testrun` with NO underscore instead, and the v2 run endpoints have no `data` envelope\nat all.\n", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_run": { + "$schema": "TestRun" + } + } + } + } + } + }, + "FolderCopyResponse": { + "description": "Folder copy operation initiated successfully", + "schema": { + "type": "object", + "required": [ + "success", + "async", + "message" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "description": "Whether operation is running asynchronously", + "example": true + }, + "unique_id": { + "type": "string", + "format": "uuid", + "description": "Tracking ID for async operation (present when async=true)", + "example": "6a206593-865c-41ec-9459-d94bce72c1b9" + }, + "message": { + "type": "string", + "example": "Folder copy operation queued successfully" + }, + "folder_id": { + "type": "integer", + "description": "New folder ID (present when async=false)", + "example": 4 + } + } + } + }, + "FilterCreateResponse": { + "description": "Successfully created a new filter", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "Filter__CreateFilter" + }, + "success": { + "type": "boolean" + } + } + } + }, + "CreateReportResponse": { + "description": "Successfully created scheduled report", + "schema": { + "$schema": "CreateReportResponse" + } + }, + "RootFolderDetailsResponse": { + "description": "Response containing only root folder details including test case counts, and links.", + "schema": { + "type": "object", + "properties": { + "info": { + "$schema": "PageInfo" + }, + "folder_hierarchy": { + "$schema": "FolderHierarchy" + }, + "folders": { + "type": "array", + "items": { + "$schema": "Folder__ListRootFolders" + } + }, + "self": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" } + } } + } }, - "CustomFieldAdminDatasetOptionsResponse": { - "description": "A page of a dataset's option values. When any option has a parent the list is returned as a tree and children move under their parent's `children` key rather than appearing at the top level.", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the options; page size is fixed at 30." + "success": { + "type": "boolean" + } + } + } + }, + "SharedStepsPostResponse": { + "description": "Response containing details of a shared step.", + "schema": { + "type": "object", + "required": [ + "data", + "success" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "test_case_count": { + "type": "integer", + "description": "Number of test cases using this shared step" + }, + "step_count": { + "type": "integer", + "description": "Number of steps in this shared step" + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "step_count", + "test_case_count", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + } + } + }, + "TestResultCreateResponse": { + "description": "Successful response for creating a test result", + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "test-result", + "unique_issues" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test-result": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "author": { + "$schema": "User__CreateTestResultForTestCase" + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-25T20:02:05.000Z" + }, + "description": { + "type": "string" + }, + "error_description": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "example": null, + "nullable": true + }, + "failure_type": { + "type": "string", + "example": null, + "nullable": true + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "type": "string", + "example": "passed" + }, + "result_status": { + "$schema": "ResultStatus__CreateTestResultForTestCase" + }, + "configuration_id": { + "type": "integer", + "description": "ID of the configuration this test result is associated with", + "example": 16, + "nullable": true + }, + "time_elapsed": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "size": { + "type": "string" + } + } + } + }, + "created_by_imported": { + "type": "boolean", + "nullable": true + }, + "issues": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" }, - "data": { - "type": "array", - "description": "Empty array when the dataset has no options.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Option id — what the option update and delete calls take in their paths." - }, - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - }, - "colour": { - "type": "string", - "nullable": true - }, - "parent_option_id": { - "type": "integer", - "nullable": true, - "description": "Set only on nested_dropdown children." - }, - "children": { - "type": "array", - "description": "Present only when the page was rebuilt as a tree and this option has children.", - "items": { - "type": "object" - } - } - } - } + "custom_fields": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473/custom-fields" } + } + }, + "custom_fields": { + "type": "object" + }, + "updated_by": { + "type": "string", + "nullable": true } + } + }, + "unique_issues": { + "type": "array", + "items": { + "type": "object" + } } - }, - "CustomFieldAdminOptionResponse": { - "description": "One option value after a create or rename. Always the five serialised keys — keys the request did not set come back null rather than absent.", - "schema": { + } + } + } + } + }, + "FilterDeleteResponse": { + "description": "Successfully deleted the filter", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "DeleteScheduleResponse": { + "description": "Successfully deleted scheduled report(s)", + "schema": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "The total number of scheduled reports in the project." + }, + "total_report_count": { + "type": "integer", + "description": "The total number of reports in the project." + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "DownloadReportResponse": { + "description": "Successfully initiated report download", + "schema": { + "type": "object", + "required": [ + "channel", + "success" + ], + "properties": { + "channel": { + "type": "string", + "description": "Unique channel identifier for streaming or polling download status.", + "example": "53bd342e-b4d8-4003-bb3d-d448a2e8780d" + }, + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "DashboardExportInitiatedResponse": { + "description": "Dashboard export successfully initiated", + "schema": { + "type": "object", + "required": [ + "success", + "export_id" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "export_id": { + "type": "integer", + "description": "Export record ID for tracking progress", + "example": 12345 + }, + "message": { + "type": "string", + "description": "Optional status message", + "example": "Export queued successfully" + } + } + } + }, + "ActiveTestRunsInfo": { + "description": "An OBJECT with a `data` array \u2014 not a bare top-level array.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "One entry per result status across the project's active test runs. When more than six statuses exist only the top six by count are kept and the remainder are collapsed into a single 'Other' entry that has links but NO result_status.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Status display name, or the literal 'Other' for the collapsed bucket." + }, + "y": { + "type": "integer", + "description": "Number of results in this status." + }, + "result_status": { "type": "object", - "required": [ - "success", - "data" - ], + "description": "Absent on the collapsed 'Other' entry.", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Option id — carry this forward; the update and delete calls take it in their paths." - }, - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - }, - "colour": { - "type": "string", - "nullable": true - }, - "parent_option_id": { - "type": "integer", - "nullable": true, - "description": "Null unless the option was created under a nested_dropdown parent. The update call cannot change it." - } - } - } + "id": { + "type": "integer" + }, + "field_value": { + "type": "string" + }, + "internal_name": { + "type": "string" + }, + "field_name": { + "type": "string" + }, + "colour": { + "type": "string" + }, + "value_category": { + "type": "string", + "nullable": true + } } - } - }, - "CustomFieldAdminAckResponse": { - "description": "Bare success acknowledgement returned by the custom-field delete calls. Nothing about what was removed is echoed back.", - "schema": { + }, + "links": { "type": "object", - "required": [ - "success" - ], + "description": "Present on the collapsed 'Other' entry; carries a UI filter URL covering the statuses that were merged in.", "properties": { - "success": { - "type": "boolean", - "example": true - } + "filter": { + "type": "string" + } } + } } + } }, - "ProjectFormFieldsV2Response": { - "description": "One project's test-case form: its custom fields, the pick lists behind the standard dropdowns, and the built-in system fields. Three top-level lists, no data envelope and no paging.", - "schema": { - "type": "object", - "required": [ - "success", - "custom_fields", - "default_fields", - "system_fields" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_fields": { - "type": "array", - "description": "Every TestCase custom field this project shows, unpaginated. Same row shape as the test-result custom fields listing; rows on a nested_dropdown field additionally carry parent_custom_field_id. `field_type` is the legacy field_* spelling. Empty when the path carried no project.", - "items": { - "$schema": "ResultCustomField" - } - }, - "default_fields": { - "type": "object", - "description": "Pick lists for the standard test-case dropdowns, keyed by field name (priority, case_type, status, …), each {values, total_count}. `automation_status` is assembled in this layer rather than read from storage." - }, - "system_fields": { - "type": "array", - "description": "Built-in fields available on the test-case template, with id, field_name, field_type, system_name and whether they can be removed from a template.", - "items": { - "type": "object" - } - } - } - } + "empty_data": { + "type": "boolean", + "nullable": true }, - "CustomFieldValuesResponse": { - "description": "The values one custom field offers in one project. The payload sits under a DYNAMIC key — the field id exactly as it was spelled in the request path — so there is no fixed property name to read.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "description": "Paging metadata from the backing service. Null when the project id in the path was not numeric.", - "nullable": true, - "properties": { - "count": { - "type": "integer", - "example": 0 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "": { - "type": "object", - "description": "Dynamic key: the field id as sent in the path, e.g. \"4821\". Not a literal property name.", - "properties": { - "field_type": { - "type": "string", - "nullable": true, - "enum": [ - "field_string", - "field_text", - "field_int", - "field_date", - "field_url", - "field_user", - "field_boolean", - "field_checkbox", - "field_dropdown", - "field_multi_dropdown" - ], - "description": "LEGACY spelling, not the canonical vocabulary the admin write calls take. Null for a nested_dropdown field, which has no legacy spelling, and null when the project id was not numeric." - }, - "values": { - "type": "array", - "nullable": true, - "description": "The allowed values. Converted to real booleans for a boolean field. Null when the project id in the path was not numeric.", - "items": { - "type": "object" - } - } - } - }, - "105915": { - "type": "object" - } - } + "filter_details": { + "type": "object", + "description": "Merged in only when filter_details=true and q was present." + } + } + } + }, + "AutomationStats": { + "description": "Response containing the automation statistics for the test cases.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "automated_coverage": { + "type": "number", + "format": "float" + }, + "automated_test_cases": { + "type": "integer" + }, + "total_test_cases": { + "type": "integer" + }, + "manual_test_cases": { + "type": "integer" + }, + "empty_data": { + "type": "boolean" + } + } + } + }, + "TestRunsListResponse": { + "description": "Successfully retrieved test runs", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_runs": { + "type": "array", + "items": { + "$schema": "V1TestRunListRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 5 + }, + "closed": { + "type": "integer", + "example": 2 } - }, - "ExploratorySessionListV1Response": { - "description": "Paged list of exploratory sessions. Top level, no data envelope.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_sessions": { - "type": "array", - "items": { - "$schema": "ExploratorySessionListRow" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "count": { - "type": "integer", - "description": "Total rows matching the query — this, not the top-level `count`, is the number to paginate against.", - "example": 150 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - }, - "count": { - "type": "object", - "nullable": true, - "description": "Per-state session totals for the project — NOT the number of rows returned or matched.", - "properties": { - "active": { - "type": "integer", - "example": 12 - }, - "closed": { - "type": "integer", - "example": 138 - } - } - } - } + } + } + } + } + }, + "ClosedTestRunsInfo": { + "description": "Closed test runs analytics data by month", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "example": [ + { + "month": "Apr", + "count": 3 + }, + { + "month": "May", + "count": 0 } + ], + "items": { + "type": "object", + "properties": {} + } }, - "ExploratorySessionLogListV1Response": { - "description": "Paged list of log entries for one exploratory session. Top level, no data envelope.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_logs": { - "type": "array", - "items": { - "$schema": "ExploratorySessionLog" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 50 - }, - "count": { - "type": "integer", - "example": 24 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - }, - "status_counts": { - "type": "object", - "description": "Intended as whole-session per-status log totals, unfiltered by q[status]. Currently always rendered as an empty object because the upstream response object does not expose the field — do not depend on it.", - "additionalProperties": { - "type": "integer" - }, - "example": {} - } + "empty_data": { + "type": "boolean" + } + } + } + }, + "ClosedTestRunsSplit": { + "description": "Closed test run results split by month and status", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "integer" } - } - }, - "ExploratorySessionDefectsV1Response": { - "description": "Paged list of issue links raised on an exploratory session and on its log entries. Top level, no data envelope.", - "schema": { + }, + "result_status": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "defects": { - "type": "array", - "description": "Defect links raised on this session's LOG ENTRIES. Session-level `issues` do NOT appear here.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "description": "Identifier in the external tracker.", - "example": "PROBE-2" - }, - "issue_type": { - "type": "string", - "description": "Tracker type.", - "example": "jira" - }, - "jira_id": { - "type": "string", - "nullable": true, - "description": "Repeat of issue_id for jira links." - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "count": { - "type": "integer", - "example": 5 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - } + "id": { + "type": "integer" + }, + "field_value": { + "type": "string" + }, + "internal_name": { + "type": "string" + }, + "field_name": { + "type": "string" + }, + "colour": { + "type": "string" + }, + "value_category": { + "type": "string", + "nullable": true + } } + } } + } }, - "ExploratorySessionCloneSyncResponse": { - "description": "Clone completed inline (source session had fewer than 50 logs). Status 201.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "example": false, - "description": "false on this branch — the clone and all its logs already exist." - }, - "unique_id": { - "type": "string", - "nullable": true, - "example": null, - "description": "Always null on the synchronous branch." - }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } + "date_list": { + "type": "array", + "items": { + "type": "string" + } }, - "ExploratorySessionCloneAsyncResponse": { - "description": "Clone accepted and the log copy handed to a background job (source session had 50 or more logs). Status 202. The new session's id is NOT returned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "example": true, - "description": "true on this branch — the session shell exists but its logs are still being copied." - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Realtime broadcast channel name for the completion event. It is not a job id and no endpoint reports the job's status — to confirm the clone, re-read the sessions listing.", - "example": "8f1c2b30-6d1a-4a1e-9c1e-2a7b3f0d5e44" - } - } + "empty_data": { + "type": "boolean" + } + } + } + }, + "FilterDetailResponse": { + "description": "Successfully retrieved filter details", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "Filter" + }, + "success": { + "type": "boolean" + } + } + } + }, + "RemoveFolderSummaryResponse": { + "description": "A summary of entities that would be deleted when removing a folder", + "schema": { + "type": "object", + "required": [ + "success", + "has_other_product_entity", + "deleted_entity_count" + ], + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful" + }, + "has_other_product_entity": { + "type": "boolean", + "description": "Indicates if the folder contains entities from other products" + }, + "deleted_entity_count": { + "type": "object", + "required": [ + "active_test_case_count", + "archived_test_case_count", + "sub_folders" + ], + "properties": { + "test_recordings_count": { + "type": "integer", + "description": "Number of test recordings that would be deleted" + }, + "active_test_case_count": { + "type": "integer", + "description": "Number of active test cases that would be deleted" + }, + "archived_test_case_count": { + "type": "integer", + "description": "Number of archived test cases that would be deleted" + }, + "sub_folders": { + "type": "integer", + "description": "Number of sub-folders that would be deleted" } + } + } + } + } + }, + "UsersV2Response": { + "description": "Successfully retrieved users list", + "schema": { + "$schema": "UsersV2Response" + } + }, + "IssuesCountInfo": { + "description": "Response containing the issue count trend for each month.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "count" + ], + "properties": {} + } }, - "SharedStepDeleteResponse": { - "description": "Shared step deleted. Status is 200 with this body — NOT 204 with an empty body.", - "schema": { - "type": "object", - "required": [ - "success" + "empty_data": { + "type": "boolean" + } + } + } + }, + "CustomFieldsV2Response": { + "description": "Successful response with paginated custom fields list", + "schema": { + "type": "object", + "required": [ + "success", + "custom_fields", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the custom field", + "example": "cf-123" + }, + "field_type": { + "type": "string", + "description": "Type of the custom field", + "enum": [ + "text", + "textarea", + "dropdown", + "multiselect", + "number", + "date", + "boolean", + "url" ], - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } + "example": "dropdown" + }, + "field_name": { + "type": "string", + "description": "Name of the custom field", + "example": "Priority Level" + }, + "entity_type": { + "type": "string", + "description": "Entity type this field belongs to", + "example": "TestCase" + }, + "is_required": { + "type": "boolean", + "description": "Whether the field is required", + "example": false + }, + "default_value": { + "description": "Default value for the field" + }, + "place_holder_text": { + "type": "string", + "description": "Hint text shown in the empty field. Omitted entirely when the field has no hint text. This is the key to read: it is filled from the field's hint under either of the two names the stored record may use.", + "example": "Select priority level" + }, + "system_name": { + "type": "string", + "description": "System name for the field" + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be bulk edited" + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used in filters" + }, + "linked_projects_count": { + "type": "integer", + "description": "Number of projects this field is linked to", + "example": 5 + }, + "group_id": { + "type": "string", + "description": "Group ID this field belongs to" + }, + "placeholder": { + "type": "string", + "description": "The same hint text as place_holder_text, under a second name. Present only when the stored field carries its hint under this name - which is the common case, so both keys usually appear with identical values, but neither is guaranteed. Read place_holder_text and ignore this one; it exists for older clients." + } + } + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "ProjectsPaginatedResponse": { + "description": "Successfully retrieved paginated projects", + "schema": { + "$schema": "ProjectsPaginatedResponse" + } + }, + "ProjectMinifyResponse": { + "description": "Successfully retrieved minified projects list", + "schema": { + "$schema": "ProjectMinifyResponse" + } + }, + "GetReportDetailResponse": { + "description": "Successfully fetched report details", + "schema": { + "type": "object", + "required": [ + "data", + "report_filters", + "dynamic_filters", + "report_data", + "project" + ], + "properties": { + "data": { + "$schema": "ReportDetail__GetReportDetail" + }, + "report_filters": { + "$schema": "ReportFiltersDetail" + }, + "dynamic_filters": { + "$schema": "DynamicFilters" + }, + "report_data": { + "$schema": "ReportData__GetReportDetail" }, - "TestCaseTagListResponse": { - "description": "The tags on one test case, as a BARE JSON ARRAY at the top level — there is no object envelope, no `data` and no `success`. Items are tag name strings for projects served from the test-hub backend. For legacy Rails-backed projects the same action renders tag records, whose items are objects {id, name, taggings_count}; treat a non-string item as that shape.", - "schema": { - "type": "array", - "items": { - "type": "string", - "example": "regression" - } - } + "project": { + "$schema": "ReportProjectDetail" }, - "ConfigurationCreateResponse": { - "description": "Configuration created. The record is under `configuration` (not `data`), beside a `success` flag. It is the backing service's raw configuration object, not the serialized listing row, so the os/browser/device breakdown may be absent here even though the listing carries it.", - "schema": { - "type": "object", - "required": [ - "success", - "configuration" + "filters": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + } + } + } + }, + "ReportSummaryResponse": { + "description": "Successful response for report details", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "issues_by_priority": { + "type": "string", + "nullable": true + }, + "issues_by_status": { + "type": "string", + "nullable": true + }, + "issues_data": { + "type": "string", + "nullable": true + }, + "test_cases_status": { + "type": "string", + "nullable": true + }, + "test_run_data": { + "type": "object" + }, + "test_run_status": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "string", + "nullable": true + }, + "user_result_stats": { + "type": "string", + "nullable": true + } + }, + "required": [ + "success" + ] + } + }, + "GetReportTestCases": { + "description": "Successfully fetched report testcases details", + "schema": { + "type": "object", + "required": [ + "success", + "selection_data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "selection_data": { + "$schema": "SelectionData" + }, + "selection": { + "type": "object", + "description": "The primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).", + "example": { + "select_all": false, + "folders": { + "827": { + "test_case_select_all": false, + "selected_test_cases": [ + 1515 ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "configuration": { - "$schema": "Configuration" - } + "deselected_test_cases": [] + } + }, + "unique_test_case_count": 1 + } + }, + "shared_selection": { + "type": "object", + "description": "Per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"\": }). Present only for cross-project Test Case Activity reports; `{}` otherwise.", + "example": { + "10209": { + "select_all": true, + "folders": {}, + "unique_test_case_count": 2 + } + } + } + } + } + }, + "SharedStepsIDResponse": { + "description": "Response containing details of a specific shared step by ID.", + "schema": { + "type": "object", + "required": [ + "data", + "success" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" } - } - }, - "FolderWriteResponse": { - "description": "Single-folder write acknowledgement used by the v1 folder create and rename actions. The folder is nested under `data` — there is no top-level `folder` and no top-level `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "folder": { - "$schema": "Folder" - } - } - } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" } - } - }, - "UserWorkloadDrilldownResponse": { - "description": "Single-user workload breakdown, returned verbatim from the analytics service with no envelope and no success flag.", - "schema": { + } + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + } + } + }, + "SharedStepsGetResponse": { + "description": "Response containing a list of shared steps with pagination info.", + "schema": { + "type": "object", + "required": [ + "data", + "info", + "success" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { "type": "object", + "required": [ + "id", + "full_name" + ], "properties": { - "tcs": { - "type": "array", - "description": "Test cases this user executed in the report's window, with per-case execution counts.", - "items": { - "type": "object" - } - }, - "trs": { - "type": "array", - "description": "Test runs this user participated in over the report's window.", - "items": { - "type": "object" - } - }, - "days": { - "type": "array", - "description": "Day-by-day activity timeline for this user over the report's window.", - "items": { - "type": "object" - } - } + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } } - } - }, - "TestCaseAttachmentDeleteResponse": { - "description": "Acknowledgement for a v1 test-case attachment delete. The payload is wrapped in `data` — unlike the attachment list and upload on the same path, which return their payload at the top level.", - "schema": { + }, + "updated_by": { "type": "object", "required": [ - "data" + "id", + "full_name" ], "properties": { - "data": { - "type": "object", - "description": "Envelope. Reading a top-level `attachments` finds nothing on this action.", - "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "description": "The attachment record that was removed. Its stored file is purged, not just unlinked.", - "$schema": "AttachmentResponse" - } - } - } + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "test_case_count": { + "type": "integer", + "description": "Number of test cases using this shared step" + }, + "step_count": { + "type": "integer", + "description": "Number of steps in this shared step" + }, + "tags": { + "type": "string", + "nullable": true + } + }, + "required": [ + "created_by", + "id", + "step_count", + "test_case_count", + "title", + "updated_by" + ] + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "description": "Total number of shared steps" + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true + } + }, + "required": [ + "count", + "next", + "page", + "page_size", + "prev" + ] + }, + "pre_fetched_shared_step": { + "type": "object", + "description": "Pre-fetched details for the first shared step (when pre_fetch=true)", + "nullable": true, + "required": [ + "shared_step_details", + "page_info" + ], + "properties": { + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + }, + "page_info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "description": "Total number of step details" + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true } + }, + "required": [ + "count", + "next", + "page", + "page_size", + "prev" + ] } + } }, - "BulkRetrieveTestCasesResponse": { - "description": "Bulk un-archive acknowledgement: folder case counts after the restore, not the restored test cases. Above the async threshold the same 200 instead carries {success, async:true, unique_id}.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "description": "false with an `error` string and still HTTP 200 when the workspace is not on a paid plan." - }, - "folders": { - "type": "object", - "description": "Per-folder case counts after the restore." - }, - "error": { - "type": "string", - "description": "Present only on the plan-gated 200 refusal." - } + "success": { + "type": "boolean" + } + } + } + }, + "TestCaseCountTrend": { + "description": "The response contains the trend data for each test case category over the months.", + "schema": { + "type": "object", + "required": [ + "data", + "empty_data", + "order" + ], + "properties": { + "data": { + "type": "object", + "description": "KEYED BY CASE TYPE NAME, not a record with fixed fields \u2014 e.g. {\"Acceptance\": {\u2026}, \"API\": {\u2026}}. The keys are project data and change as case types are added or renamed, so they are deliberately not enumerated. Each value carries that type's counts plus a `graphData` map keyed by month abbreviation." + }, + "empty_data": { + "type": "boolean" + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "HistoryDiffResponse": { + "description": "Response containing the diff of test case steps between two histories.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "diff": { + "type": "object", + "required": [ + "test_case_steps" + ], + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "old": { + "type": "array", + "items": { + "$schema": "TestCaseDiffStep" + } + }, + "new": { + "type": "array", + "items": { + "$schema": "TestCaseDiffStep" + } + } + } + }, + "status": { + "type": "object", + "properties": { + "old": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "colour": { + "type": "string" + } + } + }, + "new": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + } + }, + "links": { + "type": "object", + "example": { + "99": "/api/v1/projects/10000/test-cases/1/histories/99", + "100": "/api/v1/projects/10000/test-cases/1/histories/100" + } + } + } + } + }, + "TestCaseTypeSplit": { + "description": "Test case count split by case type", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "y": { + "type": "integer" + }, + "value": { + "type": "integer" + }, + "field": { + "type": "string" + } + } + } + }, + "empty_data": { + "type": "boolean" + } + } + } + }, + "TestResultCustomFieldsResponse": { + "description": "Successfully retrieved custom fields for test results", + "schema": { + "$schema": "TestResultCustomFieldsResponse" + } + }, + "TestResultListResponse": { + "description": "List of test results", + "schema": { + "type": "object", + "required": [ + "success", + "test-results", + "info", + "unique_issues" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test-results": { + "type": "array", + "items": { + "$schema": "TestResult__ListTestResultsForTestCaseByIntegerId" + } + }, + "info": { + "$schema": "PageInfo__ListTestResultsForTestCaseByIntegerId" + }, + "unique_issues": { + "type": "array", + "example": [], + "items": { + "type": "string" + } + } + } + } + }, + "TestRunFormFieldsResponse": { + "description": "Successfully retrieved test result form fields", + "schema": { + "$schema": "TestRunFormFieldsResponse" + } + }, + "TestRunEditSelectionResponse": { + "description": "A test run's stored case selection and editable fields, as the full edit form reads them before an edit.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "selection": { + "type": "object", + "description": "The run's stored case selection: {select_all, unique_test_case_count, folders}, where `folders` maps a folder id (string) to {test_case_select_all, selected_test_cases, deselected_test_cases}. Can be {}. Not kept in step with the add-only and remove calls." + }, + "shared_selection": { + "type": "object", + "nullable": true, + "description": "Cases shared in from other projects, keyed by project; null when the run has none." + }, + "test_run": { + "$schema": "TestRun" + }, + "dynamic_filter": { + "type": "object", + "nullable": true, + "description": "The dynamic-run filter rule; null for a static run." + }, + "filters": { + "type": "object", + "nullable": true, + "description": "Same content as dynamic_filter, under its alias." + }, + "trtc_configuration_mappings": { + "type": "array", + "description": "Per-configuration case mappings; a run without configurations returns one default entry with configuration_id null and select_all true.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "TestRunsSelectionResponse": { + "description": "The test CASES a selection resolves to (this endpoint returns cases, not runs).\n`selected` is present only when show='all', and lists the ids of the returned rows that also\nmatch the `q` filter.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunSelectionCaseRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "selected": { + "type": "array", + "description": "Test case ids, among those returned, that also match the `q` filter. Only rendered when show='all' and at least one row matched.", + "items": { + "type": "integer" + } + } + } + } + }, + "FilterListResponse": { + "description": "Successfully retrieved list of filters", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "Array of saved views, each carrying only id, name and is_project_level", + "items": { + "$schema": "FilterListRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "success": { + "type": "boolean" + } + } + } + }, + "FolderDetailsResponse": { + "description": "Response containing folder details including subfolders, counts, and links.", + "schema": { + "type": "object", + "properties": { + "folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "self": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "notes": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + }, + "contents": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" } + } } + } }, - "TestRunCasesBulkEditResponse": { - "description": "Synchronous bulk edit of a run's cases: the refreshed run listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no listing.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunCaseBulkEditRow" - }, - "description": "The run's per-case rows (mapping id + latest result), not bare test cases." - }, - "folder": { - "$schema": "Folder", - "description": "Only present when the request scoped a folder_id." - }, - "ancestors": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Only present on the folder-scoped response." - }, - "skipped_test_case_ids": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Cases the edit could not be applied to." - } - } - } + "parent_id": { + "type": "integer" }, - "V1TestCaseCreateResponse": { - "description": "v1 single test-case create. Everything is inside `data`, including `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "TestCase", - "description": "The created case. `id` is the integer id, `identifier` the TC-NNN form, `estimate` always null." - }, - "folder": { - "$schema": "Folder", - "description": "The destination folder, with its case counts already incremented for this create." - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "identifier": { - "type": "string" - } - } - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Root-to-folder breadcrumb for the new case." - }, - "request_trace_id": { - "type": "string", - "description": "Trace id for the AI enrichment that continues after the response; null when no enrichment was queued." - } - } - } - } - } + "ancestors": { + "type": "object", + "nullable": true }, - "V1TestCaseWriteResponse": { - "description": "v1 full test-case edit (POST .../edit). The updated case sits under `data`, alongside `success` — there is no top-level success key.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "TestCase__EditTestCase" - } - } - } - } - } + "test_cases": { + "type": "object", + "nullable": true }, - "TestCaseWriteAckResponse": { - "description": "Bare acknowledgement — `{success: true}` and nothing else. The updated record is NOT returned; re-read the case to see the new values.", - "schema": { - "type": "object", - "required": [ - "success" + "info": { + "$schema": "PageInfo" + }, + "success": { + "type": "boolean" + } + } + } + }, + "TagsSearchResponse": { + "description": "Tags successfully retrieved", + "schema": { + "type": "object", + "required": [ + "tags", + "tag_objects", + "info" + ], + "properties": { + "tags": { + "type": "array", + "description": "Simple array of tag names (for backward compatibility)", + "example": [ + "AI Generated", + "AI Generated Details" + ], + "items": { + "type": "string" + } + }, + "tag_objects": { + "type": "array", + "description": "Detailed tag objects with metadata", + "items": { + "$schema": "Tag" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "ProjectUsersSearchResponse": { + "description": "Project users successfully retrieved", + "schema": { + "type": "object", + "required": [ + "users", + "info" + ], + "properties": { + "users": { + "type": "array", + "description": "List of users with access to the project", + "items": { + "$schema": "ProjectUserDetail" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "FilterUpdateResponse": { + "description": "Successfully updated the filter", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "Filter__UpdateFilter" + }, + "success": { + "type": "boolean" + } + } + } + }, + "UpdateReportResponse": { + "description": "Successfully updated scheduled report", + "schema": { + "$schema": "ReportUpdateResponse" + } + }, + "GenericAttachmentsResponse": { + "description": "Attachments successfully uploaded", + "schema": { + "type": "object", + "required": [ + "generic_attachment" + ], + "properties": { + "generic_attachment": { + "type": "array", + "description": "List of uploaded attachments with metadata", + "items": { + "$schema": "GenericAttachment" + } + } + } + } + }, + "TestRunTestCasesV2Response": { + "description": "Paginated test cases of a test run. `info` carries the paging counts and reflects any filters applied.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunTestCaseV2" + } + } + } + } + }, + "V2TestRunResultSubmitResponse": { + "description": "Result submission accepted. An ordinary single submission returns the written result under `test-result`; a submission carrying step results returns `test-run-step-results` plus `overall-result-status` and NO `test-result`. The bulk form returns only `success: true` when everything was accepted, or `success: false` with `status: \"PARTIAL\"` and per-case `results` when some entries were rejected \u2014 still HTTP 200.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "false on a PARTIAL bulk response even though the status is 200." + }, + "status": { + "type": "string", + "example": "PARTIAL", + "description": "Present only when some bulk entries were rejected and some were accepted." + }, + "message": { + "type": "string", + "description": "Present only alongside rejected bulk entries; names the unconfigured statuses and/or the count of cases blocked by the review-status gate." + }, + "test-result": { + "$schema": "V2TestRunResultDetail", + "description": "Single form only, and only when no step results were submitted." + }, + "test-run-step-results": { + "type": "array", + "description": "Single form, step submissions only \u2014 one entry per step result written. Mutually exclusive with test-result.", + "items": { + "$schema": "V2TestRunStepResultDetail" + } + }, + "overall-result-status": { + "type": "string", + "description": "Status rolled up from the submitted step results. Accompanies test-run-step-results." + }, + "results": { + "type": "array", + "description": "Bulk form, present only when at least one entry was rejected.", + "items": { + "$schema": "V2BulkResultOutcome" + } + }, + "attachments": { + "type": "array", + "description": "Attachments bound on a multipart single submission. This is the bind result, not the stored attachment record \u2014 it does not carry content type, size or checksum.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Attachment id, usable with the attachment endpoints." + }, + "name": { + "type": "string", + "description": "Filename of the multipart part that was bound." + }, + "url": { + "type": "string", + "description": "Time-limited download url." + }, + "url_expires_at": { + "type": "string", + "format": "date-time", + "description": "When url stops working." + }, + "test_result_id": { + "type": "integer" + } + } + } + }, + "attachment_errors": { + "type": "array", + "description": "Present only when at least one file failed to bind. The result itself was still written.", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "code": { + "type": "string", + "values": [ + "ATTACHMENT_BIND_INCOMPLETE", + "ATTACHMENT_BIND_FAILED" ], - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } + "description": "ATTACHMENT_BIND_INCOMPLETE means the file WAS stored but its id could not be read back; re-read the result's attachments to recover it. ATTACHMENT_BIND_FAILED means the file was discarded." + }, + "message": { + "type": "string" + } + } + } + }, + "dropped_attachments": { + "type": "array", + "description": "Bulk form only. Files that were uploaded but belonged to an entry that got rejected, plus inline rich-text references that could not be resolved. Two entry shapes appear in the same array.", + "items": { + "type": "object", + "properties": { + "test_case_id": { + "type": "string", + "description": "Rejected-entry form." + }, + "filenames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Rejected-entry form." + }, + "reason": { + "type": "string", + "example": "status_validation_failed", + "description": "Rejected-entry form." + }, + "filename": { + "type": "string", + "description": "Inline-reference form." + }, + "code": { + "type": "string", + "example": "INLINE_ATTACHMENT_DROPPED", + "description": "Inline-reference form." + } + } + } + } + } + } + }, + "V2BulkResultStatusAcceptedResponse": { + "description": "The bulk status change was queued. It says nothing about which cases were changed or whether the work succeeded.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "V2TestResultListResponse": { + "description": "Recorded results for one test case in one test run, newest executions included as separate entries. Note the key is `test_results` with an underscore, unlike the v1 list's `test-results`.", + "schema": { + "type": "object", + "required": [ + "success", + "test_results", + "info" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test_results": { + "type": "array", + "items": { + "$schema": "V2TestResultListItem" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "TemplateListResponse": { + "description": "A page of test-case templates with pagination metadata.", + "schema": { + "type": "object", + "required": [ + "success", + "templates" + ], + "properties": { + "success": { + "type": "boolean" + }, + "templates": { + "type": "array", + "items": { + "$schema": "Template" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "TemplateDetailResponse": { + "description": "A single test-case template with its full field layout.", + "schema": { + "type": "object", + "required": [ + "success", + "template" + ], + "properties": { + "success": { + "type": "boolean" }, - "V1TestCaseDetailResponse": { - "description": "v1 test-case detail (slide-over). Everything is inside `data`, including `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "V1TestCaseDetailRow", - "description": "The case's full slide-over record. `success` sits INSIDE `data` beside it." - }, - "is_archived": { - "type": "boolean" - }, - "linked_issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "pagy": { - "type": "object", - "description": "Always an empty object on this path." - } - } - } - } - } + "template": { + "$schema": "TemplateWithFields" + } + } + } + }, + "TemplateWriteAckResponse": { + "description": "Bare acknowledgement of a template write. Carries no template body, so re-read the template to confirm the result.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "description": "True on success. A free-plan refusal also arrives with HTTP 200 but sets this false and adds a message, so check this flag rather than the status code." + } + } + } + }, + "V2HistoryListResponse": { + "description": "A page of test case revisions, newest first.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "history": { + "type": "array", + "description": "Revision records for the test case, newest first. Note the key is singular `history`, unlike the v1 listing's `histories`.", + "items": { + "$schema": "V2History" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "TestPlanV2Response": { + "description": "A single test plan (or sub-plan read through the plan route). No data wrapper \u2014 the plan sits at the top level under `test_plan`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "ArchivedTestCasesListResponse": { - "description": "Archived test-case listing. Plan-gated: on a non-paid workspace the same 200 carries {success:false, error} and no test cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase" - }, - "description": "Archived cases, each with its original folder path." - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object", - "description": "Single-entry map keyed by the requested folder id — null on this route, since it takes no folder." - }, - "error": { - "type": "string", - "description": "Present only on the plan-gated 200 refusal." - } - } - } + "test_plan": { + "$schema": "TestPlanV2" + } + } + } + }, + "SubTestPlanV2Response": { + "description": "A sub-plan returned by the parent-scoped route. The plan sits under `sub_test_plan`, NOT `test_plan`, and its identifier is STP-NNN.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "sub_test_plan": { + "$schema": "TestPlanV2__CreateSubTestPlan" + } + } + } + }, + "TestPlanV2ListResponse": { + "description": "A page of test plans. Rows omit the embedded test_runs preview and links that the single-plan read adds.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plans": { + "type": "array", + "items": { + "$schema": "TestPlanV2__ListTestPlans" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "TestRunsListV2Response": { + "description": "A page of v2 test runs \u2014 the shape returned by both the project-wide run listing and a plan's linked-run listing, which genuinely do share one serializer (confirmed in the backend). The rows are a SUMMARY projection, not the single-run read: they never carry issues, issue_tracker or test_cases. One row field differs by route \u2014 test_plan is present on the project-wide listing and absent from the plan-scoped one. success is hard-coded true even when the upstream run fetch failed; in that case test_runs and info are null and the HTTP status carries the failure.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_runs": { + "type": "array", + "nullable": true, + "items": { + "$schema": "TestRunSummaryV2" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "CustomFieldV2DetailResponse": { + "description": "A single custom field after a create or edit. The options, assigned_projects, applies_to_all_projects and link_to_future_projects keys are replayed from the request rather than read back from storage, and are omitted when the request did not carry them.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "SystemFieldValuesResponse": { - "description": "Option list for one system field. The options hang off a key NAMED AFTER the field you requested (priority / status / case_type / automation_state / defects), so the path is .values.", - "schema": { + "custom_field": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric field id \u2014 the value the single-field read and the field edit take in their path.", + "example": 4821 + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Stored type. Always the stored value, never the one sent on an edit." + }, + "field_name": { + "type": "string", + "description": "Stored label, already trimmed." + }, + "entity_type": { + "type": "string", + "example": "TestCase", + "description": "Always TestCase on this surface." + }, + "is_required": { + "type": "boolean", + "description": "Whether a test case must fill the field." + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored default. Null after a create, because a default cannot be set at create time. On a boolean field it is stored as \"1\" or \"0\"." + }, + "place_holder_text": { + "type": "string", + "description": "Hint text. Absent when the field has none." + }, + "options": { + "type": "array", + "description": "Present only when the field actually has options \u2014 a free-text type returns no key at all rather than an empty list.", + "items": { "type": "object", "properties": { - "success": { - "type": "boolean" - }, - "": { - "type": "object", - "description": "Key is the field_name from the path.", - "properties": { - "values": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "integer", - "description": "The numeric option id — this is what the write paths take." - }, - "name": { - "type": "string", - "description": "The project's display name for the option." - } - } - } - } - } - }, - "info": { - "$schema": "PageInfo" - }, - "status": { - "type": "object", - "properties": { - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "is_default": { - "type": "integer", - "example": 1 - } - } - } - } - } + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + } } + } + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Echo of the request flag, defaulting to false \u2014 not a statement about the field's stored project links." + }, + "assigned_projects": { + "type": "array", + "description": "Present only when the field is linked to specific projects. A field that applies to all projects, or one created without any, returns no key rather than an empty list.", + "items": { + "type": "string", + "example": "PR-12345" + } + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Echo of the request flag, defaulting to false." + }, + "system_name": { + "type": "string", + "description": "The stable internal name for a system-provided field. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "group_id": { + "type": "integer", + "description": "Workspace the definition belongs to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "linked_projects_count": { + "type": "integer", + "description": "How many projects the field is linked to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." } + } + } + }, + "required": [ + "success", + "custom_field" + ] + } + }, + "CustomFieldV2WithDatasetsResponse": { + "description": "A single custom field read by id: stored metadata plus references to the datasets that hold its option values. Carries no option values and no real project configuration.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "TestCaseFolderListResponse": { - "description": "Folder-scoped test-case listing. `folders` appears only when the request asked for subfolders.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "V1TestCaseFolderListRow" - } - }, - "info": { - "$schema": "PageInfo", - "description": "page_size is the server-clamped value, not necessarily the per_page you sent." - }, - "folders": { - "$schema": "TestCaseConsolidatedResultFolders" - } - } + "custom_field": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 4821 + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ] + }, + "field_name": { + "type": "string" + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored default; \"1\" / \"0\" on a boolean field." + }, + "place_holder_text": { + "type": "string", + "description": "Absent when the field has none." + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Always false on this read \u2014 a placeholder, not the field's real project scope." + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Always false on this read \u2014 a placeholder, not the field's real future-project setting." + }, + "dataset_ids": { + "type": "array", + "description": "Ids of the datasets holding this field's option values and project links. Empty for a field type that has no options.", + "items": { + "type": "integer" + } + }, + "datasets_url": { + "type": "string", + "description": "Ready-made link to this field's dataset collection." + }, + "dataset_urls": { + "type": "array", + "description": "Ready-made link per dataset, in the same order as dataset_ids.", + "items": { + "type": "string" + } + }, + "system_name": { + "type": "string", + "description": "The stable internal name for a system-provided field. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "group_id": { + "type": "integer", + "description": "Workspace the definition belongs to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "linked_projects_count": { + "type": "integer", + "description": "How many projects the field is linked to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." } - }, - "LegacyTestCaseSearchResponse": { - "description": "BARE JSON ARRAY of rows from the deprecated local test-case table — no success, no info, no test_cases wrapper. Empty for any project whose data lives in the current backend.", - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Legacy table row: id, name, identifier, project_id, group_id, template, steps and the other legacy columns. Not the serialised TestCase the rest of this surface returns." - } + } + } + }, + "required": [ + "success", + "custom_field" + ] + } + }, + "V2ReportListResponse": { + "description": "Paginated list of a project's report configurations, plus group/project totals for the filtered set.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "reports": { + "type": "array", + "items": { + "$schema": "V2ReportDetail" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata: page, next, prev, count, page_size.", + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer", + "nullable": true + }, + "prev": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer", + "description": "Total matching rows, not rows on this page." + }, + "page_size": { + "type": "integer" } + } }, - "TestPlanCloneAcceptedResponse": { - "description": "A clone was QUEUED, not completed. The new plan does not exist yet and nothing identifying it is returned — no id, no identifier, no name echo. `unique_id` is a correlation id for the background job only; there is no job-status endpoint for it in this profile. Poll the project's plan listing for the name you sent to find the result.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "unique_id": { - "type": "string", - "description": "Correlation id of the clone job (32-character hex). Not a plan id.", - "example": "9f1c2ab34de5678901234567890abcde" - } - } - } + "scheduled_report_count": { + "type": "integer", + "description": "Scheduled reports within the filtered set, across all pages." }, - "V1TestPlanListResponse": { - "description": "A page of v1 test plans. Rows use the same serializer as the single-plan read. `count` is a project-wide {active, completed} rollup, NOT a page total — page with `info`. `filter_details` appears only when filter_details=true was sent together with an owner or custom-field filter. In id-picker mode (filter_mapping=true) each row is reduced to {id, identifier} only.\n", - "schema": { + "total_report_count": { + "type": "integer", + "description": "All reports within the filtered set, across all pages." + } + } + } + }, + "V2ReportDetailResponse": { + "description": "A single report configuration, returned by the v2 report create (201) and single-report read (200). Carries no report data.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$schema": "V2ReportDetail" + } + } + } + }, + "AttachmentListV2Response": { + "description": "A page of attachments on one test case, test result or test plan.", + "schema": { + "type": "object", + "required": [ + "success", + "attachments" + ], + "properties": { + "success": { + "type": "boolean" + }, + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentListItem" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "TestResultSearchResponse": { + "description": "Account-wide test result search page. Always HTTP 200 with success:true \u2014 an upstream search failure is swallowed and shows up as an empty test_results array with info null, not as an error status.", + "schema": { + "type": "object", + "required": [ + "success", + "test_results", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true, + "description": "Always true on this endpoint, including when the search itself failed. Do not use it as a success signal." + }, + "test_results": { + "type": "array", + "description": "Matching results, at most 30 per page (10 if per_page was sent at all). Empty both when nothing matched and when the search errored.", + "items": { + "$schema": "TestResultSearchItem" + } + }, + "info": { + "$schema": "PageInfo", + "nullable": true, + "description": "Paging metadata from the search service. NULL when the search failed \u2014 that, not the status code, is how you detect the failure." + } + } + } + }, + "CommentsListResponse": { + "description": "A page of comments on one run/case cell, newest first, fixed 30 per page. info.count is the total across pages; a page past the end repeats the last page rather than returning an empty array.", + "schema": { + "type": "object", + "required": [ + "comments", + "success", + "info" + ], + "properties": { + "comments": { + "type": "array", + "items": { + "$schema": "Comment" + } + }, + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "CommentResponse": { + "description": "The comment just posted, wrapped as data.comment. HTTP 200, not 201. Both inner keys are always present - the handler emits them as one literal with no branches.", + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "comment" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "comment": { + "$schema": "Comment" + } + } + } + } + } + }, + "TestRunConfigurationProgressResponse": { + "description": "Execution progress of a test run by result status. TWO shapes: when configuration_id was sent the body is {success, overall_progress_by_status_id} and `configurations`/`info` are absent; when it was not, the body is {success, configurations:[...], info} and the top-level `overall_progress_by_status_id` is absent. `info` is the backing service's paging block passed through unchanged (its exact fields are not fixed by this service).", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "configurations": { + "type": "array", + "description": "Only in the all-configurations form. One page of the run's configurations, each with its progress attached.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 101 + }, + "name": { + "type": "string", + "example": "Chrome on Windows" + }, + "overall_progress_by_status_id": { "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Integer plan id — the value every v1 plan route takes in its path." - }, - "identifier": { - "type": "string", - "description": "TP-NNN (STP-NNN for a sub-plan). Display form; not accepted in a v1 path.", - "example": "TP-12" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "plan_status": { - "type": "string", - "description": "new / started / completed." - }, - "active_state": { - "type": "string", - "description": "'completed' when plan_status is completed, else 'active'." - }, - "record_status": { - "type": "string", - "description": "'archived' for an archived plan." - }, - "start_date": { - "type": "string" - }, - "end_date": { - "type": "string" - }, - "owner": { - "type": "integer", - "description": "Owner as a BrowserStack user id; `assignee` carries the resolved user object." - }, - "assignee": { - "type": "object" - }, - "created_by": { - "type": "object" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "custom_fields": { - "type": "array", - "description": "Empty ARRAY (not an object) when the plan has no custom-field values." - }, - "parent_plan_id": { - "type": "integer", - "description": "Null on a top-level plan; set on a sub-plan." - }, - "sub_plans_count": { - "type": "integer" - }, - "test_runs_count": { - "type": "object", - "description": "{active, closed} for runs linked directly to this plan." - }, - "total_test_runs_count": { - "type": "object", - "description": "{active, closed} including the runs of its sub-plans." - }, - "test_plan_progress_v2": { - "type": "object", - "description": "Result rollup keyed by status id. test_plan_progress is always {} and carries nothing." - }, - "project_id": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "links": { - "type": "object" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - } - } - } - }, - "info": { - "$schema": "PageInfo" - }, - "count": { - "type": "object", - "description": "Project-wide plan rollup, not a page total.", - "properties": { - "active": { - "type": "integer", - "description": "Plans whose status is not completed." - }, - "completed": { - "type": "integer" - } - } - }, - "filter_details": { - "type": "object", - "description": "Present only when filter_details=true was sent. Resolves the owner and custom-field filters back into displayable values.", - "properties": { - "owner": { - "type": "array", - "items": { - "type": "object" - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - } + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } } + } }, - "V1ArchivedTestPlanListResponse": { - "description": "A page of ARCHIVED v1 test plans. Rows are serialized exactly like the active listing (so record_status is 'archived'), but there is no `count` rollup on this response and archived sub-plans are never included.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "active_state": { - "type": "string" - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "owner": { - "type": "integer" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "review_settings": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "sub_plan_mode": { - "type": "string" - } - } - }, - "review_status": { - "type": "string" - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "test_plan_progress": { - "type": "object" - }, - "test_plan_progress_v2": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "string" - } - } - } - }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "prev": { - "type": "string", - "nullable": true - } - } - } - } + "info": { + "type": "object", + "nullable": true, + "description": "Only in the all-configurations form. Paging metadata from the backing service, passed through as-is." + } + } + } + }, + "BinnedTestCasesListResponse": { + "description": "A page of binned test cases. Paging lives under `metadata` (total/page/page_size/total_pages), not the usual `info` envelope. Field names follow the backing service's client model; confirm against a live call if a field is missing.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "BinnedTestCase" + } + }, + "metadata": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_pages": { + "type": "integer" } + } + } + } + } + }, + "TestCaseReviewOutcomeResponse": { + "description": "Outcome of a bulk review submission or verdict. Under 100 cases: the updated cases inline (testCases) \u2014 verdicts with a comment also carry `comment`. Over 100: {success, async:true, unique_id} and NO testCases; the change lands in the background.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testCases": { + "type": "array", + "items": { + "$schema": "TestCase" + }, + "description": "Updated cases (serialized test case, including review_status and reviewers). Absent on the async path." + }, + "comment": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Verdict only, when a comment was sent: the created comment objects {id, comment, review_status, user{id, full_name, email}, created_at ...}." + }, + "async": { + "type": "boolean", + "description": "true when the selection exceeded 100 and was queued." }, - "BulkAssignTestCasesResponse": { - "description": "Result of a bulk assign on a test run. TWO shapes, decided by the size of the selection.\nUnder the async threshold the write is applied inline and the body is the run's test-case\nlisting re-read afterwards: {success, info, test_cases} (plus `folder` when a folder_id was\nsent, and a top-level skipped_test_case_ids when the review gate excluded cases). At or above\nthe threshold the write is queued and the body is {success, async: true, unique_id} with NO\ntest_cases. Branch on `async`.\n", - "schema": { + "unique_id": { + "type": "string", + "format": "uuid", + "description": "Identifier of the queued background operation (async path only)." + } + } + } + }, + "ReauthRequired": { + "description": "Part 11 e-signature required: the reauth_token was missing, expired (5 min) or already used. Re-authenticate and resend with a fresh token.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error_code": { + "type": "string", + "example": "reauth_required" + } + } + } + }, + "LcncCreateWrapperResponse": { + "description": "Wrapper around the automation service's reply. `status` is the upstream HTTP code and `body` is the upstream body as a JSON-encoded STRING to parse (except the 'already in progress' short-circuit, where body is an object {message, title} and status is 'ok'). HTTP 200 outside does not mean success inside \u2014 inspect status and body.", + "schema": { + "type": "object", + "required": [ + "status", + "body" + ], + "properties": { + "status": { + "type": "integer", + "example": 200, + "description": "Upstream HTTP status (an integer), or the string 'ok' on the already-in-progress short-circuit." + }, + "body": { + "type": "string", + "description": "Upstream body as a JSON string; typically {status:'submitted'|'failed', message, test_id?, test_version_id?, reason?} on the bulk-pipeline path." + } + } + } + }, + "BddExportValidationResponse": { + "description": "Verdict of the BDD export pre-check; the same shape is returned on pass (200, success:true) and on every failure (400, success:false) \u2014 read message.", + "schema": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string", + "example": "Validation successful. All test cases belong to the same feature and background." + } + } + } + }, + "TestRunV2Response": { + "description": "The test run at the top level under `test_run`, with no `data` envelope \u2014 the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_run": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "issues": { + "type": "array", + "description": "CONDITIONAL \u2014 the key is absent, not empty, when the run payload carries no issues association.", + "items": { "type": "object", "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Present and true only when the assignment was queued rather than applied inline." - }, - "unique_id": { - "type": "string", - "description": "Handle for the queued bulk operation. Only present on the async shape." - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "description": "One entry per case x configuration mapping, each carrying both `id` (test case) and `mapping_id` (the run mapping). Absent on the async shape.", - "items": { - "$schema": "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun" - } - }, - "folder": { - "type": "object", - "description": "Only present when a folder_id was sent; the folder's status breakdown.", - "nullable": true - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Cases the review gate excluded. Top level, a sibling of test_cases.", - "items": { - "type": "integer" - } - } + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string" + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name. Prefer `issue_id`." + }, + "issue_type": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's \u2014 use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" + } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" } - } - }, - "ConfigurationGroupListResponse": { - "description": "Successfully retrieved the account's configuration groups", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "configuration_groups": { - "type": "array", - "items": { - "$schema": "ConfigurationGroup" - } - }, - "info": { - "$schema": "PageInfo" - } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" } + } } - }, - "UsersListResponse": { - "description": "All active users in the caller's account plus a reduced record for the caller.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "users": { - "type": "array", - "description": "Every active user in the account, ordered by full_name; not filtered to the project.", - "items": { - "$schema": "User" - } - }, - "myself": { - "$schema": "MyselfUser" - } + } + } + } + } + }, + "TestRunWriteV2Response": { + "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape \u2014 the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" } - } - }, - "CommentCreateResponse": { - "description": "The comment just posted, wrapped in a data envelope.", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "success", - "comment" - ], - "properties": { - "success": { - "type": "boolean" - }, - "comment": { - "$schema": "Comment" - } - } - } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" } - } - }, - "ExploratorySessionFormFieldsResponse": { - "description": "The extra fields configured for exploratory sessions in this project. The list of built-in fields is always empty on this path. Both lists can legitimately be empty on a valid project.", - "schema": { + } + }, + "issues": { + "type": "array", + "description": "CONDITIONAL \u2014 the key is absent, not empty, when the run payload carries no issues association.", + "items": { "type": "object", - "required": [ - "success", - "custom_fields", - "default_fields" - ], "properties": { - "success": { - "type": "boolean" - }, - "default_fields": { - "type": "array", - "description": "Always empty on this path - built-in session details are not described here. The key is always present.", - "items": { - "type": "object" - } - }, - "custom_fields": { - "type": "array", - "description": "One entry per configured field. Empty when the project has no extra session fields.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The field's identifier, used when referring to this field elsewhere." - }, - "field_name": { - "type": "string", - "description": "The field's internal name." - }, - "field_user_name": { - "type": "string", - "description": "The name your team sees in the product; match a person's plain-words request against this." - }, - "field_type": { - "type": "string", - "description": "The kind of input, given in the product's prefixed form - for example a single-line string, a text area, a date, a dropdown, a multi-dropdown, a checkbox, a boolean, a URL, an integer or a user picker.", - "example": "field_dropdown" - }, - "entity_type": { - "type": "string", - "description": "The record kind this field belongs to." - }, - "is_required": { - "type": "boolean", - "description": "True when a session cannot be saved without this field. Always present, defaulting to false." - }, - "optional": { - "type": "boolean", - "description": "The exact inverse of is_required; both are always returned." - }, - "option_values": { - "type": "array", - "description": "The choices a dropdown offers. Returned only when the request asked for the configured choices AND the field has any - and the choices variant of this request is currently answered with a not-found, so in practice this is not reachable today.", - "items": { - "type": "object" - } - }, - "field_values": { - "type": "array", - "description": "Current values recorded for the field. Same conditions and same practical caveat as option_values.", - "items": { - "type": "object" - } - }, - "default_value": { - "type": "string", - "description": "Pre-filled value, if the field has one. Always present and may be null. For a yes/no field this comes back as a true or false rather than as a number.", - "nullable": true - }, - "placeholder": { - "type": "string", - "description": "Hint text shown in the empty field. Omitted entirely when no hint text is configured." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "True when the field can be set on many records at once." - }, - "is_filterable": { - "type": "boolean", - "description": "True when the field can be used to narrow a list." - }, - "parent_custom_field_id": { - "type": "integer", - "description": "Present only for a dependent field; names the field whose value decides this one's choices. Omitted otherwise." - }, - "links": { - "type": "object", - "description": "A single self link. For most fields it points at where that field's own choices can be read; for a user-picker field it points at the project's user list instead." - }, - "sort_options": { - "type": "object", - "description": "A pre-built sort token for this field. Present only when the field's stored type is sortable (integer, string, date, URL, boolean or dropdown) and the workspace has custom-field sorting enabled, so most responses omit it entirely.", - "properties": { - "value": { - "type": "string", - "description": "Opaque sort key combining the field id and its stored type." - } - } - } - } - } - } + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string" + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name. Prefer `issue_id`." + }, + "issue_type": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's \u2014 use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" } - } - }, - "SharedComponentsGetResponse": { - "description": "A page of a project's shared fields with page details, optionally carrying the first field's steps.", - "schema": { - "type": "object", - "required": [ - "data", - "info", - "success" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "SharedComponent" - } - }, - "info": { - "$schema": "PageInfo" - }, - "pre_fetched_shared_step": { - "type": "object", - "nullable": true, - "description": "Only present when the pre-fetch option was used on page 1; null otherwise.", - "properties": { - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - }, - "page_info": { - "$schema": "PageInfo" - } - } - }, - "success": { - "type": "boolean" - } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" } - } - }, - "CommentListResponse": { - "description": "A page of comments on an item, newest first, with page details.", - "schema": { - "type": "object", - "required": [ - "comments", - "success", - "info" - ], - "properties": { - "comments": { - "type": "array", - "items": { - "$schema": "Comment" - } - }, - "success": { - "type": "boolean" - }, - "info": { - "$schema": "PageInfo" - } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" } + } } - }, - "FolderAttachmentsResponse": { - "description": "The files attached to the folder itself.", - "schema": { - "type": "object", - "required": [ - "success", - "attachments" - ], - "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "type": "array", - "description": "One entry per file on the folder, empty when the folder has none. Alongside the fields below each entry also carries 'size', a ready-formatted version of byte_size for display.", - "items": { - "$schema": "Attachment" - } - } - } + } + } + } + } + }, + "CustomFieldAdminWriteResponse": { + "description": "A custom field definition after a create or edit on the workspace settings surface. The field is echoed under `data` exactly as storage returned it \u2014 no options, no datasets and no project links, so confirm those with a separate read.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "description": "The stored field. Take `id` \u2014 it is what every other custom-field call puts in its path.", + "properties": { + "id": { + "type": "integer", + "example": 4821 + }, + "field_name": { + "type": "string", + "description": "Stored label, already whitespace-trimmed." + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Canonical spelling, not the legacy field_* form the project form-field reads return." + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored as \"1\" / \"0\" on a boolean field; null on every other field type, because this surface forces it to null for non-boolean types." + }, + "place_holder_text": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "is_bulk_editable": { + "type": "boolean", + "example": true + }, + "is_filterable": { + "type": "boolean", + "example": true + }, + "parent_custom_field_id": { + "type": "string", + "nullable": true + }, + "system_name": { + "type": "string", + "example": "__mcp-probe-cfdef-20260921-renamed" } - }, - "V2TestCaseResponse": { - "description": "v2 public-API single-case envelope: {data: {success, test_case}}. Note the case is under `data`, uses `title` (not name) and has no integer id.", - "schema": { + } + } + } + } + }, + "CustomFieldAdminDetailResponse": { + "description": "One custom field definition read by id, with its datasets and their linked projects. Carries no option values and no entity_type.", + "schema": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the datasets list; page size is fixed at 30." + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "field_name": { + "type": "string" + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ] + }, + "system_name": { + "type": "string", + "nullable": true, + "description": "Internal name; null for user-defined fields." + }, + "is_required": { + "type": "boolean" + }, + "place_holder_text": { + "type": "string", + "nullable": true, + "description": "Read from the upstream `placeholder` attribute and renamed here." + }, + "is_bulk_editable": { + "type": "boolean" + }, + "is_filterable": { + "type": "boolean" + }, + "default_value": { + "description": "Converted to a real boolean when field_type is `boolean`; returned as stored otherwise." + }, + "type": { + "type": "string", + "example": "Custom", + "description": "Always the literal \"Custom\" on this read." + }, + "levels": { + "type": "array", + "description": "Present only for a nested_dropdown field.", + "items": { + "type": "object" + } + }, + "datasets": { + "type": "array", + "description": "Empty array when the field has none. Options are NOT included \u2014 read them from the dataset options list.", + "items": { "type": "object", "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_case": { - "$schema": "V2TestCaseDetail" - } + "id": { + "type": "integer", + "description": "Dataset id \u2014 what the dataset, option and project-mapping calls take in their paths." + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer" + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Derived per request by comparing linked_project_count against the workspace project count; not a stored flag." + }, + "linked_projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "example": "PR-1" + }, + "name": { + "type": "string" } + } } + } } + } } - }, - "GlobalSearchResponse": { - "description": "Account-wide search results. There is one top-level bucket per entity type you asked for - `test_case`, `test_run`, `test_plan`, `project`, `shared_step`, `report` - and a bucket appears ONLY if you named that type in the request; a type you did not ask for has no bucket at all, while a type that simply had no matches comes back as a present bucket with an empty `data` and a zero count. Alongside the buckets sits `project_details`, a lookup for naming the project each hit belongs to. When results are combined, the per-entity buckets are replaced entirely by a single sorted `data` list plus one `info` block.", - "schema": { - "type": "object", - "properties": { - "test_case": { - "type": "object", - "description": "Test-case matches. The only bucket that also carries a folder lookup.", - "properties": { - "data": { - "type": "array", - "description": "Matching test cases, in the same serialized shape the project-scoped case search returns, plus two search-only extras per row: `display_meta` (what the query matched - title, description, steps, preconditions, folder path - with the owner and project resolved to names) and `user_id` (the case's creator). On the typeahead fast path the rows are lean stubs - id, identifier, name, project, owner - and none of the serialized detail is filled in.", - "items": { - "$schema": "TestCase__SearchAllProjects" - } - }, - "info": { - "$schema": "PageInfo" - }, - "folders": { - "type": "object", - "description": "Folder-hierarchy lookup for these test-case hits, keyed by folder id, so each row's folder can be shown as a path without another call. Test cases are the only entity type that gets this." - } - } - }, - "test_run": { - "type": "object", - "description": "Test-run matches.", - "properties": { - "data": { - "type": "array", - "description": "Matching test runs, in the same serialized shape the project-scoped run search and the run listings return, plus a search-only `user_id` (the run's creator). Lean stubs on the typeahead fast path.", - "items": { - "$schema": "TestRun" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "test_plan": { - "type": "object", - "description": "Test-plan matches.", - "properties": { - "data": { - "type": "array", - "description": "Matching test plans, in the same serialized shape a project's plan listing returns - numeric `id` AND `identifier` (TP-NNN, or STP-NNN for a sub-plan), name, description, plan_status and the derived active_state, owner and assignee, tags, start and end dates, the {active, closed} run counts both direct and including sub-plans, parent_plan_id and sub_plans_count. This is the numeric-id plan shape, NOT the identifier-only shape the v2 plan surface returns. Each row also carries a search-only `display_meta` (with the parent plan resolved) and `user_id` (the plan's creator). Lean stubs on the typeahead fast path.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "project": { - "$schema": "GlobalSearchEntityResults", - "description": "Project matches. Rows are search-index records, not the project read shape: expect the project's id, name, identifier, description, the owning group, created and updated times and a relevance score - and NOT the counts, links or access fields a project read returns. An owner filter does not narrow this bucket." - }, - "shared_step": { - "$schema": "GlobalSearchEntityResults", - "description": "Shared-step matches. Rows are search-index records carrying the step's id, title and owning project - leaner than a shared-step read, and with no step bodies. An owner filter does not narrow this bucket." - }, - "report": { - "type": "object", - "description": "Report matches. Absent on the typeahead fast path even when reports were requested. Unlike the other entity types, this bucket ignores the requested page size and always pages at 30, and it is not a search-index bucket - it is a direct lookup over the account's saved and scheduled reports, matched on report type, title and, when no owner filter is set, owner name.", - "properties": { - "data": { - "type": "array", - "description": "Matching reports, in the same serialized shape a project's saved-report listing returns.", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "How many of the matches are scheduled reports." - }, - "total_report_count": { - "type": "integer", - "description": "How many reports matched in total, before paging." - }, - "info": { - "$schema": "PageInfo", - "description": "Pagination for the report bucket. Always returned. Its page size is always 30, whatever page size was requested." - } - } - }, - "project_details": { - "type": "array", - "description": "One entry per project referenced by any hit in any bucket, so a row's project can be named without another call - match a row's project id against these. Entries are project records as stored, with id and name being the parts to rely on; this is a lookup, not the project read shape. Always present on an uncombined search, as an empty list when nothing matched; on a combined one it is included only when non-empty.", - "items": { - "type": "object" - } - }, - "data": { - "type": "array", - "description": "Only when results are combined: all types merged, newest first, each row carrying an entity_type. Capped at 5 rows.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo", - "description": "Only when results are combined. Its count is the true total across all types, but its page size is the per-type page sizes added together rather than a real page size - three types searched at 30 each reports 90." - }, - "test_case_folders": { - "type": "object", - "description": "Only when results are combined and the merged list contains test-case rows: the folder lookup for those rows." - }, - "filter_details": { - "type": "object", - "description": "Only when explicitly requested with the exact lowercase word true: the filters that were applied, with ids resolved to names. Absent if resolution failed - the search itself still succeeds." - } - } + } + } + } + } + }, + "WorkspaceFieldsListResponse": { + "description": "A page of the workspace's fields, system and custom together. Page size is fixed at 10; system fields are injected on page 1 only.", + "schema": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "type": "object", + "description": "Paging metadata. On this endpoint it is {count, page, page_size, total_pages} \u2014 there is no prev/next.", + "properties": { + "count": { + "type": "integer", + "description": "Total matching fields; includes the matched system fields when field_source is `all`." + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + }, + "next": { + "type": "integer", + "example": 2 + }, + "prev": { + "type": "string", + "nullable": true } + } }, - "FolderV2WriteResponse": { - "description": "Public (v2) envelope returned after creating, renaming or moving a folder: the affected folder in its public shape.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "folder": { - "$schema": "FolderV2" - } + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric field id \u2014 absent on the injected system-field rows, which are identified by system_name instead." + }, + "field_name": { + "type": "string" + }, + "field_type": { + "type": "string", + "description": "Canonical spelling. Injected system-field rows always report `dropdown`." + }, + "entity_type": { + "type": "string" + }, + "system_name": { + "type": "string", + "nullable": true, + "description": "Internal name, e.g. priority / case_type / status / automation_state on system rows." + }, + "place_holder_text": { + "type": "string", + "nullable": true + }, + "linked_projects_count": { + "type": "integer" + }, + "dataset_count": { + "type": "integer", + "description": "Number of datasets behind this field; 0 on system rows." + }, + "is_system_field": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "System", + "Custom" + ] + }, + "field_values": { + "type": "array", + "description": "Populated for system rows on page 1; always an empty array for custom fields on this listing.", + "items": { + "type": "object" } + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Derived by comparing linked_projects_count against the workspace project count; always true on system rows." + } } - }, - "TestPlanV2DetailResponse": { - "description": "A single test plan (or sub-plan read through the plan route). No data wrapper - the plan sits at the top level under `test_plan`, and on this route alone it can carry the embedded run preview and the relative API links.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_plan": { - "$schema": "TestPlanV2Detail" - } - } + } + } + } + } + }, + "CustomFieldAdminDatasetResponse": { + "description": "One dataset of a custom field \u2014 its project links and, when the read asked for them, its option values. There is no paging metadata even though the linked-projects list is paged.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Dataset id \u2014 what the option and project-mapping calls take in their paths." + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer", + "description": "Total linked projects, not the number in this page of `linked_projects`." + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Added by this layer by comparing linked_project_count against the workspace project count. Absent when linked_project_count is not set." + }, + "linked_projects": { + "type": "array", + "description": "One page of the linked projects (30 per page, selected by `p`).", + "items": { + "type": "object" + } + }, + "options": { + "type": "array", + "description": "Present only when the request asked for options (fetch_options), and always present on the project-mapping update response. Absent otherwise \u2014 which is indistinguishable from a dataset with no options.", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "example": "2026-09-11T06:50:12.000Z" + }, + "custom_field_id": { + "type": "integer", + "example": 105915 + }, + "datasetProjects": { + "type": "object" + }, + "future_projects_applicable": { + "type": "integer", + "example": 0 + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "record_status": { + "type": "string", + "example": "active" } - }, - "SharedComponentWriteResponse": { - "description": "The saved shared field, echoed back under `data`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "$schema": "SharedComponentDetail" - } + } + } + } + } + }, + "CustomFieldDatasetProjectMappingUpdateResponse": { + "description": "Result of relinking a dataset's projects. Two different shapes: synchronously the re-read dataset under `data`; above 300 affected projects a bare async acknowledgement with no dataset at all.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "description": "Present and true only when the change was handed to a background job (more than 300 projects affected). When it is true there is no `data`, and the path ids were never validated." + }, + "unique_id": { + "type": "string", + "description": "Job handle for the async case only. There is no endpoint on this surface to poll it \u2014 verify by re-reading the dataset." + }, + "data": { + "type": "object", + "description": "The dataset re-read after the change, with its options included. Absent on the async path.", + "properties": { + "id": { + "type": "integer" + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer" + }, + "linked_to_all_projects": { + "type": "boolean" + }, + "linked_projects": { + "type": "array", + "items": { + "type": "object" + } + }, + "options": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "example": "2026-09-21T13:34:12.000Z" + }, + "custom_field_id": { + "type": "integer", + "example": 106131 + }, + "datasetProjects": { + "type": "object" + }, + "future_projects_applicable": { + "type": "integer", + "example": 1 + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + } + } + } + }, + "CustomFieldAdminDatasetOptionsResponse": { + "description": "A page of a dataset's option values. When any option has a parent the list is returned as a tree and children move under their parent's `children` key rather than appearing at the top level.", + "schema": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the options; page size is fixed at 30." + }, + "data": { + "type": "array", + "description": "Empty array when the dataset has no options.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Option id \u2014 what the option update and delete calls take in their paths." + }, + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "colour": { + "type": "string", + "nullable": true + }, + "parent_option_id": { + "type": "integer", + "nullable": true, + "description": "Set only on nested_dropdown children." + }, + "children": { + "type": "array", + "description": "Present only when the page was rebuilt as a tree and this option has children.", + "items": { + "type": "object" } + } + } + } + } + } + } + }, + "CustomFieldAdminOptionResponse": { + "description": "One option value after a create or rename. Always the five serialised keys \u2014 keys the request did not set come back null rather than absent.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Option id \u2014 carry this forward; the update and delete calls take it in their paths." + }, + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "colour": { + "type": "string", + "nullable": true + }, + "parent_option_id": { + "type": "integer", + "nullable": true, + "description": "Null unless the option was created under a nested_dropdown parent. The update call cannot change it." + } + } + } + } + } + }, + "CustomFieldAdminAckResponse": { + "description": "Bare success acknowledgement returned by the custom-field delete calls. Nothing about what was removed is echoed back.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "ProjectFormFieldsV2Response": { + "description": "One project's test-case form: its custom fields, the pick lists behind the standard dropdowns, and the built-in system fields. Three top-level lists, no data envelope and no paging.", + "schema": { + "type": "object", + "required": [ + "success", + "custom_fields", + "default_fields", + "system_fields" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "custom_fields": { + "type": "array", + "description": "Every TestCase custom field this project shows, unpaginated. Same row shape as the test-result custom fields listing; rows on a nested_dropdown field additionally carry parent_custom_field_id. `field_type` is the legacy field_* spelling. Empty when the path carried no project.", + "items": { + "$schema": "ResultCustomField" + } + }, + "default_fields": { + "type": "object", + "description": "Pick lists for the standard test-case dropdowns, keyed by field name (priority, case_type, status, \u2026), each {values, total_count}. `automation_status` is assembled in this layer rather than read from storage." + }, + "system_fields": { + "type": "array", + "description": "Built-in fields available on the test-case template, with id, field_name, field_type, system_name and whether they can be removed from a template.", + "items": { + "type": "object" + } + } + } + } + }, + "CustomFieldValuesResponse": { + "description": "The values one custom field offers in one project. The payload sits under a DYNAMIC key \u2014 the field id exactly as it was spelled in the request path \u2014 so there is no fixed property name to read.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "description": "Paging metadata from the backing service. Null when the project id in the path was not numeric.", + "nullable": true, + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "": { + "type": "object", + "description": "Dynamic key: the field id as sent in the path, e.g. \"4821\". Not a literal property name.", + "properties": { + "field_type": { + "type": "string", + "nullable": true, + "enum": [ + "field_string", + "field_text", + "field_int", + "field_date", + "field_url", + "field_user", + "field_boolean", + "field_checkbox", + "field_dropdown", + "field_multi_dropdown" + ], + "description": "LEGACY spelling, not the canonical vocabulary the admin write calls take. Null for a nested_dropdown field, which has no legacy spelling, and null when the project id was not numeric." + }, + "values": { + "type": "array", + "nullable": true, + "description": "The allowed values. Converted to real booleans for a boolean field. Null when the project id in the path was not numeric.", + "items": { + "type": "object" + } + } + } + }, + "105915": { + "type": "object" + } + } + } + }, + "ExploratorySessionListV1Response": { + "description": "Paged list of exploratory sessions. Top level, no data envelope.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_sessions": { + "type": "array", + "items": { + "$schema": "ExploratorySessionListRow" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "count": { + "type": "integer", + "description": "Total rows matching the query \u2014 this, not the top-level `count`, is the number to paginate against.", + "example": 150 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } + } + }, + "count": { + "type": "object", + "nullable": true, + "description": "Per-state session totals for the project \u2014 NOT the number of rows returned or matched.", + "properties": { + "active": { + "type": "integer", + "example": 12 + }, + "closed": { + "type": "integer", + "example": 138 + } + } + } + } + } + }, + "ExploratorySessionLogListV1Response": { + "description": "Paged list of log entries for one exploratory session. Top level, no data envelope.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_logs": { + "type": "array", + "items": { + "$schema": "ExploratorySessionLog" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "count": { + "type": "integer", + "example": 24 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } + } + }, + "status_counts": { + "type": "object", + "description": "Intended as whole-session per-status log totals, unfiltered by q[status]. Currently always rendered as an empty object because the upstream response object does not expose the field \u2014 do not depend on it.", + "additionalProperties": { + "type": "integer" + }, + "example": {} + } + } + } + }, + "ExploratorySessionDefectsV1Response": { + "description": "Paged list of issue links raised on an exploratory session and on its log entries. Top level, no data envelope.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "defects": { + "type": "array", + "description": "Defect links raised on this session's LOG ENTRIES. Session-level `issues` do NOT appear here.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "description": "Identifier in the external tracker.", + "example": "PROBE-2" + }, + "issue_type": { + "type": "string", + "description": "Tracker type.", + "example": "jira" + }, + "jira_id": { + "type": "string", + "nullable": true, + "description": "Repeat of issue_id for jira links." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "count": { + "type": "integer", + "example": 5 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true } + } } - }, - "paging": { - "POST /api/v1/projects/{project_id}/test-cases/bulk-archive": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-copy": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-delete": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-edit": { - "page": "p" - }, - "PATCH /api/v1/projects/{project_id}/test-cases": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-move": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-retrieve": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets/{uuid}/test-cases": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets/variables": { - "page": "p" - }, - "GET /api/v1/projects/basic": { - "page": "p", - "size": "count", - "max": 300 - }, - "GET /api/v1/projects/minify": { - "page": "p", - "size": "count", - "max": 300 - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}/{section_name}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/folders": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/shared-steps/{shared_step_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/shared-steps": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-case/{field_name}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-cases": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-results/custom-fields": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-plans/archived_test_plans": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/ai-duplicates": { - "page": "page" - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/filter": { - "page": "page" - }, - "GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases": { - "page": "p", - "size": "per_page", - "max": 100 + } + } + }, + "ExploratorySessionCloneSyncResponse": { + "description": "Clone completed inline (source session had fewer than 50 logs). Status 201.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "example": false, + "description": "false on this branch \u2014 the clone and all its logs already exist." + }, + "unique_id": { + "type": "string", + "nullable": true, + "example": null, + "description": "Always null on the synchronous branch." }, - "GET /api/v1/projects": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/schedules": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-case/tags-v2": { - "page": "p" - }, - "GET /api/v1/admin-v2/settings/templates": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-plans": { - "page": "p" - }, - "GET /api/v1/admin-v2/settings/fields": { - "page": "p" - }, - "PATCH /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder": { - "page": "page" - }, - "POST /api/v1/projects/{project_id}/ai-duplicates/search-v2": { - "page": "page" - }, - "GET /api/v1/group/{entity_type}/tags": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/{entity}/search": { - "page": "p", - "size": "page_size", - "max": 100 - }, - "POST /api/v1/projects/{project_id}/{entity}/search-v2": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/users/search": { - "page": "p", - "size": "page_size", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-case/tags/search": { - "page": "p", - "size": "page_size", - "max": 100 + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "ExploratorySessionCloneAsyncResponse": { + "description": "Clone accepted and the log copy handed to a background job (source session had 50 or more logs). Status 202. The new session's id is NOT returned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "example": true, + "description": "true on this branch \u2014 the session shell exists but its logs are still being copied." + }, + "unique_id": { + "type": "string", + "format": "uuid", + "description": "Realtime broadcast channel name for the completion event. It is not a job id and no endpoint reports the job's status \u2014 to confirm the clone, re-read the sessions listing.", + "example": "8f1c2b30-6d1a-4a1e-9c1e-2a7b3f0d5e44" + } + } + } + }, + "SharedStepDeleteResponse": { + "description": "Shared step deleted. Status is 200 with this body \u2014 NOT 204 with an empty body.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "TestCaseTagListResponse": { + "description": "The tags on one test case, as a BARE JSON ARRAY at the top level \u2014 there is no object envelope, no `data` and no `success`. Items are tag name strings for projects served from the test-hub backend. For legacy Rails-backed projects the same action renders tag records, whose items are objects {id, name, taggings_count}; treat a non-string item as that shape.", + "schema": { + "type": "array", + "items": { + "type": "string", + "example": "regression" + } + } + }, + "ConfigurationCreateResponse": { + "description": "Configuration created. The record is under `configuration` (not `data`), beside a `success` flag. It is the backing service's raw configuration object, not the serialized listing row, so the os/browser/device breakdown may be absent here even though the listing carries it.", + "schema": { + "type": "object", + "required": [ + "success", + "configuration" + ], + "properties": { + "success": { + "type": "boolean", + "example": true }, - "POST /api/v1/projects/{project_id}/reports/{report_id}/drilldown": { - "page": "p", - "size": "per_page", - "max": 100 + "configuration": { + "$schema": "Configuration" } - }, - "entities": { - "activity": { - "entity": "activity", - "title": "Activity feed (audit trail)", - "description": "Group-wide audit trail of who changed what, on which entity, and when.", - "aliases": [ - "activity", - "activities", - "activity feed", - "audit log", - "audit trail", - "history feed", - "who changed" - ], - "id_convention": "integer", - "parents": [], - "relations": [ - { - "entity": "project", - "via": "scopes events" - }, - { - "entity": "user", - "via": "actor of an event" - }, - { - "entity": "test_case", - "via": "subject of an event" - }, - { - "entity": "test_run", - "via": "subject of an event" - } - ], - "capabilities": [ - "list_activity_events" - ], - "key_facts": [ - "READ-ONLY by design. Activity events are emitted by the system as a side effect of other writes — there is no create, update, or delete, and there never will be. If asked to edit or remove an audit entry, say it is immutable.", - "HARD 15-DAY WINDOW. The server clamps every query to the last 15 days no matter what you ask for. For anything older, say the history is not retrievable through the API and point to the Test Management UI — do NOT report 'no activity' for an older period, which would be a false negative.", - "CURSOR pagination, not page numbers. Follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no p param and no info.count, so you CANNOT state a total number of events or jump to a page.", - "Not project-scoped in the path — it is group-wide. Pass project_id as a QUERY param to narrow it to one project.", - "starred_only=true restricts to the caller's starred projects. When they have none the response is {events: [], no_starred_projects: true} — that means 'no starred projects', NOT 'no activity'. Check for that flag before reporting emptiness.", - "The response carries a projects map (project id → metadata) so you can resolve the project an event refers to without extra reads.", - "Gated by a group feature flag. If the call is refused, report that the activity feed isn't enabled for the group rather than that there is no activity." - ] + } + } + }, + "FolderWriteResponse": { + "description": "Single-folder write acknowledgement used by the v1 folder create and rename actions. The folder is nested under `data` \u2014 there is no top-level `folder` and no top-level `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "Folder" + } + } + } + } + } + }, + "UserWorkloadDrilldownResponse": { + "description": "Single-user workload breakdown, returned verbatim from the analytics service with no envelope and no success flag.", + "schema": { + "type": "object", + "properties": { + "tcs": { + "type": "array", + "description": "Test cases this user executed in the report's window, with per-case execution counts.", + "items": { + "type": "object" + } }, - "attachment": { - "entity": "attachment", - "title": "Attachments", - "description": "A file hanging off a parent test case or test result.", - "aliases": [ - "attachment", - "file", - "attachments", - "blob", - "upload", - "evidence", - "screenshot", - "blob id" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "attached to" - }, - { - "entity": "result", - "via": "attached to" - } - ], - "capabilities": [ - "delete_test_case_attachment", - "list_test_case_attachments", - "list_entity_attachments", - "upload_ai_attachments", - "upload_generic_attachments", - "link_attachments_to_test_case" - ], - "key_facts": [ - "ai_uploads uploads a file as AI CONTEXT (restricted types, smaller cap) for seeding case content — see create-test-case.", - "Each blob/attachment row carries an integer id and a url (the file's location) — read the file from that url; there is no dedicated download endpoint.", - "A file is attached in TWO steps: upload it to the project to get a numeric blob id, then link that id to the test case, result or step. The link calls take ids, never file content or filenames.", - "A blob can be linked ONCE. Attaching one that is already referenced elsewhere fails the whole request; upload a separate copy for each place the file should appear.", - "Putting a NEW file into the product cannot be done through this interface at all — an agent can link an already-uploaded file, remove one, and read attachment metadata, but the upload itself has to happen in the product UI.", - "Removing an attachment purges the stored file, not just the link. There is no recycle bin for attachments and no undo.", - "The view and download URLs handed back on upload are pre-signed and expire after about 30 minutes — fetch the file promptly or re-read the attachment to get a fresh one.", - "The older per-test-case attachment reads return metadata only (id, filename, type, size) with no URL; the newer entity-attachment listing is the one that hands back a readable URL per file.", - "A file cannot be attached to a test case through this surface at all. The call that does it works, but it takes the file itself as a form upload, which this transport cannot send — the same reason the upload capabilities here accept no file and quietly do nothing. Attaching is done in the product, not here.", - "An empty attachment list is not evidence that nothing is attached; it is the expected result whenever the attaching was done elsewhere." - ] + "trs": { + "type": "array", + "description": "Test runs this user participated in over the report's window.", + "items": { + "type": "object" + } }, - "configuration": { - "entity": "configuration", - "title": "Configurations", - "description": "An operating system, browser or device combination that a test run executes against.", - "aliases": [ - "config", - "configuration", - "environment", - "browser", - "os", - "device", - "configs", - "configurations", - "platform" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "case status tracked against" - } - ], - "capabilities": [ - "create_configuration", - "list_configurations" - ], - "key_facts": [ - "Configurations live at workspace level, not per project: one created while working in a project appears in every project in the workspace, and the project listing returns the same workspace-wide set everywhere.", - "Only a configuration's name can be set when creating one. The operating system, browser and device breakdown that reads return cannot be supplied through the API.", - "Test runs reference a configuration by its integer id, never by name or by the configuration object.", - "Configurations are gated on the test-run permissions rather than the test-case ones, and there is no way to delete or rename one through this profile — cleanup is a UI operation." - ] + "days": { + "type": "array", + "description": "Day-by-day activity timeline for this user over the report's window.", + "items": { + "type": "object" + } + } + } + } + }, + "TestCaseAttachmentDeleteResponse": { + "description": "Acknowledgement for a v1 test-case attachment delete. The payload is wrapped in `data` \u2014 unlike the attachment list and upload on the same path, which return their payload at the top level.", + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "description": "Envelope. Reading a top-level `attachments` finds nothing on this action.", + "properties": { + "success": { + "type": "boolean" + }, + "attachments": { + "description": "The attachment record that was removed. Its stored file is purged, not just unlinked.", + "$schema": "AttachmentResponse" + } + } + } + } + } + }, + "BulkRetrieveTestCasesResponse": { + "description": "Bulk un-archive acknowledgement: folder case counts after the restore, not the restored test cases. Above the async threshold the same 200 instead carries {success, async:true, unique_id}.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "description": "false with an `error` string and still HTTP 200 when the workspace is not on a paid plan." + }, + "folders": { + "type": "object", + "description": "Per-folder case counts after the restore." + }, + "error": { + "type": "string", + "description": "Present only on the plan-gated 200 refusal." + } + } + } + }, + "TestRunCasesBulkEditResponse": { + "description": "Synchronous bulk edit of a run's cases: the refreshed run listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no listing.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunCaseBulkEditRow" + }, + "description": "The run's per-case rows (mapping id + latest result), not bare test cases." }, - "custom_field": { - "entity": "custom_field", - "title": "Custom fields & system fields", - "description": "A user-defined field on test cases, results or plans, alongside the built-in system fields.", - "aliases": [ - "custom field", - "custom fields", - "field", - "system field", - "form field", - "dropdown", - "picklist", - "option", - "option value", - "dataset", - "field definition", - "field values", - "extra field", - "user defined field" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "extends" + "folder": { + "$schema": "Folder", + "description": "Only present when the request scoped a folder_id." + }, + "ancestors": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Only present on the folder-scoped response." + }, + "skipped_test_case_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Cases the edit could not be applied to." + } + } + } + }, + "V1TestCaseCreateResponse": { + "description": "v1 single test-case create. Everything is inside `data`, including `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "TestCase", + "description": "The created case. `id` is the integer id, `identifier` the TC-NNN form, `estimate` always null." + }, + "folder": { + "$schema": "Folder", + "description": "The destination folder, with its case counts already incremented for this create." + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer" }, - { - "entity": "result", - "via": "extends" - } - ], - "capabilities": [ - "create_custom_field_dataset_option", - "create_custom_field_dataset", - "create_custom_field_definition", - "create_custom_field", - "delete_custom_field_dataset_option", - "delete_custom_field_dataset", - "delete_custom_field_definition", - "get_custom_field_dataset", - "get_custom_field_definition", - "get_custom_field", - "get_custom_field_values", - "get_project_form_fields", - "get_test_results_custom_fields", - "list_custom_field_dataset_options", - "list_custom_fields", - "list_workspace_fields", - "update_custom_field_dataset_option", - "update_custom_field_dataset_project_mapping", - "update_custom_field_definition", - "update_custom_field" - ], - "key_facts": [ - "A field applies to ONE entity type (TestCase | TestResult | TestPlan) — pick the matching entity_type; project scope is via the integer project id in the path.", - "A definition write is workspace-wide, not project-scoped: it affects every project the field's datasets link to. Say which projects are affected BEFORE writing.", - "field_name IS editable via .../update; field_type is SILENTLY immutable — sending a new type returns 200 and changes nothing. Never report a type change as done; a real one means delete-and-recreate, which destroys every stored value.", - "Update is a full REPLACE: field_name + field_type + is_required must all be present (missing is_required = 400, missing field_name = 500). Read the field first and resend the whole set.", - "The project-mapping endpoint uses link_projects / unlink_projects — NOT the linked_projects of the create bodies. The wrong spelling is silently dropped: 200, nothing changed.", - "Three operations destroy stored data across every linked project: deleting a field, deleting an option, and unlinking a project. There is no bin. Count and name the affected projects before asking to proceed.", - "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Only values are writable here, through the test-case create/update body's custom_fields.", - "A field applies to ONE entity type (TestCase | TestResult | TestPlan | ExploratorySession | TestCaseStep) and cannot be moved later. TestCaseStep fields need the group's step-custom-fields feature, and cannot be url or nested_dropdown.", - "A field DEFINITION is workspace-wide; a DATASET is what makes it visible in a project, by pairing an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, exists but appears nowhere.", - "A project may belong to only one dataset per field. Linking a project that a sibling dataset already holds is rejected — read the field's existing datasets before linking.", - "Editing a field definition is a full REPLACE: the label, the mandatory flag and the type must all be resent, and a placeholder or boolean default you leave out is cleared. Read the field first and send back everything you want to keep.", - "Three operations destroy stored values across every linked project and cannot be undone: deleting a field, deleting an option, and unlinking a project from a dataset. There is no bin. Count and name the affected projects before asking to proceed.", - "Renaming an option keeps its id, and stored values point at the id — so a rename loses nothing and is the safe way to correct a wrong label. Deleting and recreating is not.", - "Field types have two spellings. Definitions are written with the canonical names (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown); the project form-field and field-value reads return the legacy field_* forms (field_dropdown, field_string, …). Translate between them, and expect null where a nested_dropdown has no legacy name.", - "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Nothing here writes values — those are set when creating or editing the test case, result or plan itself.", - "A default value can only ever be set on a boolean field. On every other type the admin write calls force it to null, so a default cannot be created or preserved there." - ] - }, - "dataset": { - "entity": "dataset", - "title": "Dataset", - "description": "A named table of variables and rows that drives data-driven execution of a test case.", - "aliases": [ - "datasets", - "data-driven-data", - "dds", - "data driven testing", - "data-driven test data", - "test data table", - "dataset variables", - "dataset columns", - "parameterised test data", - "csv test data" - ], - "id_convention": "uuid", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "drives" + "name": { + "type": "string" }, - { - "entity": "variable", - "via": "has columns" + "identifier": { + "type": "string" } - ], - "capabilities": [ - "create_dataset", - "delete_dataset", - "get_dataset", - "list_dataset_linked_test_cases", - "get_dataset_variables", - "import_dataset_csv", - "list_datasets", - "update_dataset" - ], - "key_facts": [ - "Team Pro plan gated: without it EVERY dataset endpoint returns 422 \"Feature not available\". A 422 here means NOT-ENTITLED, not a bad payload — say so and stop, don't retry with a different body.", - "Datasets keep NO version history and there is no restore: an update replaces the whole table of columns and rows, and the previous contents are unrecoverable. Read a dataset before writing to it.", - "What you send is not what gets stored. Blank rows and columns that are empty in every row are dropped, cell values are trimmed, rows that end up identical are collapsed to one, and row numbers are reassigned from 1. Always read the stored result back.", - "Column names are restricted to letters, digits and underscores, must start with a letter, and must be unique within a dataset (compared case-insensitively).", - "Default size limits are 40 columns and 100 rows per dataset; groups on the raised-limit flag get 100 columns and 200 rows.", - "Deleting a dataset is permanent and is not blocked by test cases still bound to it — check what it drives first." - ] - }, - "duplicate": { - "entity": "duplicate", - "title": "Duplicate recommendation (AI dedupe)", - "description": "A machine-suggested pair of near-identical test cases, for a human to review and merge.", - "aliases": [ - "duplicate", - "duplicates", - "dedupe", - "deduplication", - "duplicate recommendation", - "ai duplicates", - "redundant test cases", - "duplicate test cases", - "near-duplicate", - "merge duplicates", - "duplicate review", - "duplicate suggestions", - "dedupe queue", - "similar test cases" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "links the pair it believes are duplicates" + } + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Root-to-folder breadcrumb for the new case." + }, + "request_trace_id": { + "type": "string", + "description": "Trace id for the AI enrichment that continues after the response; null when no enrichment was queued." + } + } + } + } + } + }, + "V1TestCaseWriteResponse": { + "description": "v1 full test-case edit (POST .../edit). The updated case sits under `data`, alongside `success` \u2014 there is no top-level success key.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "TestCase__EditTestCase" + } + } + } + } + } + }, + "TestCaseWriteAckResponse": { + "description": "Bare acknowledgement \u2014 `{success: true}` and nothing else. The updated record is NOT returned; re-read the case to see the new values.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "V1TestCaseDetailResponse": { + "description": "v1 test-case detail (slide-over). Everything is inside `data`, including `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "V1TestCaseDetailRow", + "description": "The case's full slide-over record. `success` sits INSIDE `data` beside it." + }, + "is_archived": { + "type": "boolean" + }, + "linked_issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "pagy": { + "type": "object", + "description": "Always an empty object on this path." + } + } + } + } + } + }, + "ArchivedTestCasesListResponse": { + "description": "Archived test-case listing. Plan-gated: on a non-paid workspace the same 200 carries {success:false, error} and no test cases.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase" + }, + "description": "Archived cases, each with its original folder path." + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object", + "description": "Single-entry map keyed by the requested folder id \u2014 null on this route, since it takes no folder." + }, + "error": { + "type": "string", + "description": "Present only on the plan-gated 200 refusal." + } + } + } + }, + "SystemFieldValuesResponse": { + "description": "Option list for one system field. The options hang off a key NAMED AFTER the field you requested (priority / status / case_type / automation_state / defects), so the path is .values.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "": { + "type": "object", + "description": "Key is the field_name from the path.", + "properties": { + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "description": "The numeric option id \u2014 this is what the write paths take." + }, + "name": { + "type": "string", + "description": "The project's display name for the option." + } } - ], - "capabilities": [ - "discard_duplicate", - "get_dedupe_status", - "get_duplicate_source_test_case", - "get_duplicate", - "list_duplicates", - "resolve_duplicate", - "search_duplicates" - ], - "key_facts": [ - "There is NO create. Duplicate suggestions are produced by the dedupe pipeline; a caller cannot author one.", - "Only status=suggested can be read or resolved; already-merged/archived/discarded suggestions return 404. A 404 here usually means 'already handled', not 'never existed'.", - "source_tc works ONLY for status=merged, and the snapshot may legitimately be absent (404 with error 'Source test case snapshot not available'). Treat that as 'not retained', not as a retryable error.", - "This list family pages with `page`, not the `p` used elsewhere in the profile.", - "Dedupe REVIEW is exposed here; TCG test-case GENERATION is not. Don't offer to generate cases.", - "Duplicate review can be paused entirely while the dedupe data is migrated — resolving or discarding then fails with a conflict and an explicit 'try again shortly' message. Nothing was changed; it is safe to retry later.", - "Resolving a duplicate requires the same permission as removing a test case, while discarding one needs only edit permission. A reviewer who can dismiss suggestions may still be unable to merge them.", - "Merging archives the source test case and tags it 'Archived as Duplicate', posts a comment on the surviving case, and stores a frozen snapshot of the source. Archived cases are recoverable; the comment and the status change are not undone by recovering one.", - "The review queue is served ten pairs at a time, ordered by model confidence rather than age, and the page size cannot be increased — the oldest suggestion is not reachable by asking for more rows.", - "A pair whose test case can no longer be resolved is dropped from the queue, so the reported total can exceed the pairs actually returned." - ] + } + } + } }, - "exploratory_session": { - "entity": "exploratory_session", - "title": "Exploratory session", - "description": "An unscripted, charter-driven testing session and the observations recorded during it.", - "aliases": [ - "session", - "exploratory", - "et-session", - "exploratory testing", - "charter", - "session log", - "session note", - "bug bash" - ], - "id_convention": "integer", - "parents": [ - "project", - "folder" - ], - "relations": [ - { - "entity": "exploratory_log", - "via": "records" - }, - { - "entity": "issue", - "via": "links defects" - }, - { - "entity": "configuration", - "via": "runs against" - }, - { - "entity": "test_case", - "via": "notes become" - } - ], - "capabilities": [ - "clone_exploratory_session", - "close_exploratory_session", - "create_exploratory_session", - "create_exploratory_session_log", - "delete_exploratory_session", - "delete_exploratory_session_log", - "get_exploratory_session", - "list_exploratory_session_defects", - "list_exploratory_session_logs", - "list_exploratory_sessions", - "update_exploratory_session", - "update_exploratory_session_log" - ], - "key_facts": [ - "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1).", - "Logs hold rich-text HTML sanitised on write (off-project anchor/image links stripped); defects are the issues linked to the session.", - "Clone runs async when the session has many logs → re-fetch to confirm (see async-jobs).", - "No convert endpoint: turn session notes into test cases via the create-test-case flow.", - "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1). The display identifier (ES-NNN) is never accepted as an input.", - "A log entry belongs to exactly one session — both the session id and the log id are needed to change or remove a log, and a log id is not addressable under a different session.", - "Logs hold rich-text HTML sanitised on write (anchors and images pointing outside the project or group are stripped); screenshots are attached by uploading the image first and referencing its id inside the HTML, never by sending bytes.", - "Session-level 'defects' are the issue-tracker links recorded during the session — both links on the session and links made on individual logs; each row says which it is.", - "Closing a session is one-way: nothing accepts a state change and there is no reopen, so a closed session only leaves the default listing, it never comes back to it.", - "Neither sessions nor logs keep version history, and neither has a recycle bin — a deletion or an overwritten note cannot be recovered.", - "Listing sessions shows only ACTIVE ones unless the closed state is asked for explicitly.", - "Cloning a session with 50 or more logs is handled in the background and returns before the copy exists; re-read the session list to confirm. A clone does not carry over the charter or the folder.", - "Exploratory sessions are governed by the same permissions as test runs; deleting a closed session needs the closed-run delete permission specifically." - ] + "info": { + "$schema": "PageInfo" }, - "filter": { - "entity": "filter", - "title": "Saved filter view", - "description": "A saved, named set of filter conditions a user can reapply to a list view.", - "aliases": [ - "filter", - "filters", - "saved view", - "saved filter", - "view", - "saved search", - "smart filter", - "filter view" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "filters" + "status": { + "type": "object", + "properties": { + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "active" }, - { - "entity": "test_run", - "via": "filters" + "internal_name": { + "type": "string", + "example": "active" }, - { - "entity": "test_plan", - "via": "filters" + "is_default": { + "type": "integer", + "example": 1 } - ], - "capabilities": [ - "create_filter", - "delete_filter", - "get_filter", - "list_filters", - "update_filter" - ], - "key_facts": [ - "A saved filter view persists a reusable filter: { name, entity, filters, is_project_level }. entity = testcase | testplan | testrun | exploratory_session; filter_id is an integer.", - "is_project_level scopes the view to the whole project vs. just the creator.", - "A saved filter view has no version history and no bin — an overwritten name or condition set, and a deleted view, cannot be restored. Read one before changing it if the conditions matter.", - "Reading a single saved view can change it: conditions on custom fields that no longer exist are pruned from the stored filter as a side effect of the read. The listing does not do this.", - "Saving a view as project-level publishes it to everyone on the project, and anyone with project access can then edit or delete it — there is no owner-only protection." - ] - }, - "folder": { - "entity": "folder", - "title": "Folder", - "description": "A container organising test cases inside a project; folders nest to form a tree.", - "aliases": [ - "dir", - "directory", - "test case folder", - "repository folder", - "test suite", - "suite", - "module" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "folder", - "via": "nests under" - }, - { - "entity": "test_case", - "via": "organises" + } + } + } + } + } + } + }, + "TestCaseFolderListResponse": { + "description": "Folder-scoped test-case listing. `folders` appears only when the request asked for subfolders.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "V1TestCaseFolderListRow" + } + }, + "info": { + "$schema": "PageInfo", + "description": "page_size is the server-clamped value, not necessarily the per_page you sent." + }, + "folders": { + "$schema": "TestCaseConsolidatedResultFolders" + } + } + } + }, + "LegacyTestCaseSearchResponse": { + "description": "BARE JSON ARRAY of rows from the deprecated local test-case table \u2014 no success, no info, no test_cases wrapper. Empty for any project whose data lives in the current backend.", + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Legacy table row: id, name, identifier, project_id, group_id, template, steps and the other legacy columns. Not the serialised TestCase the rest of this surface returns." + } + } + }, + "TestPlanCloneAcceptedResponse": { + "description": "A clone was QUEUED, not completed. The new plan does not exist yet and nothing identifying it is returned \u2014 no id, no identifier, no name echo. `unique_id` is a correlation id for the background job only; there is no job-status endpoint for it in this profile. Poll the project's plan listing for the name you sent to find the result.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "unique_id": { + "type": "string", + "description": "Correlation id of the clone job (32-character hex). Not a plan id.", + "example": "9f1c2ab34de5678901234567890abcde" + } + } + } + }, + "V1TestPlanListResponse": { + "description": "A page of v1 test plans. Rows use the same serializer as the single-plan read. `count` is a project-wide {active, completed} rollup, NOT a page total \u2014 page with `info`. `filter_details` appears only when filter_details=true was sent together with an owner or custom-field filter. In id-picker mode (filter_mapping=true) each row is reduced to {id, identifier} only.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Integer plan id \u2014 the value every v1 plan route takes in its path." + }, + "identifier": { + "type": "string", + "description": "TP-NNN (STP-NNN for a sub-plan). Display form; not accepted in a v1 path.", + "example": "TP-12" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "plan_status": { + "type": "string", + "description": "new / started / completed." + }, + "active_state": { + "type": "string", + "description": "'completed' when plan_status is completed, else 'active'." + }, + "record_status": { + "type": "string", + "description": "'archived' for an archived plan." + }, + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "owner": { + "type": "integer", + "description": "Owner as a BrowserStack user id; `assignee` carries the resolved user object." + }, + "assignee": { + "type": "object" + }, + "created_by": { + "type": "object" + }, + "tags": { + "type": "array", + "items": { + "type": "string" } - ], - "capabilities": [ - "copy_folder", - "create_root_folder", - "create_sub_folder", - "delete_folder_and_contents", - "get_folder_remove_summary", - "get_folder_tree", - "list_root_folders", - "list_folder_contents", - "move_folder_by_integer_id", - "rename_folder_by_integer_id", - "reorder_folders", - "delete_folder_and_subtree" - ], - "key_facts": [ - "Folder ids are plain INTEGERS (e.g. 54) — never FL-… strings. Nesting is via parent_id (integer, null = root).", - "mv cross-project and copy run ASYNC ({ success, async, unique_id }) — confirm by re-reading the destination (see async-jobs).", - "Deleting a folder is recursive and takes every subfolder and every test case beneath it. Nothing in this product restores a deleted folder — only the test cases can come back, and only from the recycle bin when that feature is switched on for the group.", - "Folders keep no version history, so a rename or a notes edit cannot be rolled back to a previous value.", - "Folder names must be unique among their siblings. Creating or renaming into a clash fails unless auto-rename is asked for; copying a folder resolves the clash silently by prefixing the copy's name.", - "Folder notes are stored as sanitised HTML with signed image links injected on read, so the text that comes back is not byte-identical to the text that was sent.", - "Copying a folder, and moving one to a different project, are background jobs: the call returns a tracking id and nothing else, and the only way to confirm the outcome is to re-read the destination." - ] - }, - "project": { - "entity": "project", - "title": "Project", - "description": "The top-level container that owns every folder, test case, run, plan and report.", - "aliases": [ - "pr", - "workspace", - "project id", - "pr-id", - "test project" - ], - "id_convention": "PR-NNN", - "parents": [], - "relations": [ - { - "entity": "folder" - }, - { - "entity": "test_case" - }, - { - "entity": "test_run" - }, - { - "entity": "test_plan" - }, - { - "entity": "configuration", - "via": "scopes" - }, - { - "entity": "custom_field", - "via": "scopes" - }, - { - "entity": "user", - "via": "grants access to" + }, + "issues": { + "type": "array", + "items": { + "type": "object" } - ], - "capabilities": [ - "create_project", - "export_dashboard_analytics", - "get_active_test_runs_info", - "get_automation_stats", - "get_closed_test_runs_info", - "get_closed_test_runs_split", - "list_entity_filter_details", - "get_issues_count_info", - "get_project_by_integer_id", - "get_project_settings", - "list_project_users", - "get_projects_basic", - "get_projects_minify", - "get_test_case_count_trend", - "get_test_case_type_split", - "list_projects", - "search_project_entities", - "update_project_settings" - ], - "key_facts": [ - "Project ids come in two forms that are not interchangeable: an integer id and a PR-NNN identifier. Nearly every v1 project capability takes the integer; the v2 routes take PR-NNN. Read a project once to get both.", - "A project the caller cannot see is reported as 'not found' rather than 'forbidden', so a missing project and a permission gap look identical.", - "Projects are never deleted through the capabilities in this index — a project created by mistake can only be removed in the product UI.", - "Project settings are stored as free-form name/value pairs and keep no history: overwriting one loses the previous value, and a setting that was never written simply has no entry rather than a default.", - "Project-level analytics are aggregate counters only; none of the dashboard figures can be drilled down to the individual cases or runs behind them.", - "Listing projects and searching inside a project are different surfaces with different pagination: project listings cap at 300 per page, in-project search at 300 for test cases and 30 for test runs." - ] - }, - "report": { - "entity": "report", - "title": "Report", - "description": "A saved, optionally scheduled analytics view over a project's test data.", - "aliases": [ - "reports", - "scheduled-report", - "schedule", - "analytics-report", - "report-section", - "report-widget", - "report-drilldown", - "traceability-report", - "user-workload-report", - "exploratory-session-summary", - "report-export" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "summarises" - }, - { - "entity": "test_plan", - "via": "summarises" - }, - { - "entity": "test_case", - "via": "traces (traceability)" - }, - { - "entity": "user", - "via": "mailed to" + }, + "custom_fields": { + "type": "array", + "description": "Empty ARRAY (not an object) when the plan has no custom-field values." + }, + "parent_plan_id": { + "type": "integer", + "description": "Null on a top-level plan; set on a sub-plan." + }, + "sub_plans_count": { + "type": "integer" + }, + "test_runs_count": { + "type": "object", + "description": "{active, closed} for runs linked directly to this plan." + }, + "total_test_runs_count": { + "type": "object", + "description": "{active, closed} including the runs of its sub-plans." + }, + "test_plan_progress_v2": { + "type": "object", + "description": "Result rollup keyed by status id. test_plan_progress is always {} and carries nothing." + }, + "project_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "links": { + "type": "object" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + } + } + } + }, + "info": { + "$schema": "PageInfo" + }, + "count": { + "type": "object", + "description": "Project-wide plan rollup, not a page total.", + "properties": { + "active": { + "type": "integer", + "description": "Plans whose status is not completed." + }, + "completed": { + "type": "integer" + } + } + }, + "filter_details": { + "type": "object", + "description": "Present only when filter_details=true was sent. Resolves the owner and custom-field filters back into displayable values.", + "properties": { + "owner": { + "type": "array", + "items": { + "type": "object" + } + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "V1ArchivedTestPlanListResponse": { + "description": "A page of ARCHIVED v1 test plans. Rows are serialized exactly like the active listing (so record_status is 'archived'), but there is no `count` rollup on this response and archived sub-plans are never included.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "active_state": { + "type": "string" + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } } - ], - "capabilities": [ - "create_report_by_integer_id", - "create_report_by_integer_id", - "delete_report_schedule", - "download_report", - "get_exploratory_session_summary", - "get_report_attachment_url", - "get_report_detail", - "get_report_section", - "get_report_summary_by_type", - "get_report", - "get_selected_report_testcases", - "list_reports", - "list_schedules", - "send_report_email_now", - "update_report", - "get_user_workload_drilldown" - ], - "key_facts": [ - "report_id and schedule_id are INTEGERS; the parent project takes the INTEGER project id (v1).", - "report_type on create/update is a DISPLAY NAME and the set is CLOSED — exactly these nine: Test Run Summary, Test Run Detailed Report, Test Plan Summary, Requirement Traceability Report, Test Case Activity, Exploratory Session Summary, User Workload Report, Defects Summary, Defects Detailed Report. Never invent one and never substitute a neighbouring type: if the user asks for activity, it is 'Test Case Activity', not 'Test Run Summary'.", - "Defects Summary and Defects Detailed Report are accepted on create but have no data branch — they always read back report_data: null. For defect counts use Test Run Summary's issues_by_priority / issues_by_status sections instead.", - "null inside report_data means NOT COMPUTED, not 'no data'. A Test Case Activity report fills its headline counts on a bare read; a Test Run Summary leaves issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null until you name them in sections. Never conclude 'there is no defect or status data' from a null — request the section; an empty payload like {total: 0} is a real finding, null is not. And never answer a 400 on sections by retreating to a bare read.", - "*_drilldown sections return PAGINATED ROWS, not aggregates — never tally rows into a count; read the aggregate section or report_data.", - "frequency + frequency_details travel together or not at all; omit both for an unscheduled on-demand report. weekly needs day as a lowercase name, monthly needs day as an integer 1-28. There is no 'once'. report_timeframe: custom REQUIRES custom_date_range.", - "\"Understand a report\" = fetch the report + its section/widget data and summarise agent-side; the API returns structured data, not narrative.", - "There is a tenth report type, Test Plan Detailed Report, hidden behind a per-group feature flag — creating, reading or exporting one without the flag is refused as not permitted.", - "A report is found by its id within your group, not within the project you name: the project you address it through does not narrow the lookup, so an id belonging to a sibling project still resolves (and still deletes).", - "Reports are not versioned and there is no undo. An edit replaces the entire definition — every field you leave out is cleared, and filters you leave out are deleted — and a delete is permanent, silent, and reports success even for an id that never existed.", - "Whether a report is scheduled is inferred, never declared: it is scheduled exactly when both a cadence and its details are present. Drop either one on an edit and the report stops emailing. Recipients are only stored on a scheduled report.", - "A report can span up to 60 projects, but only for Test Run Summary, Test Plan Summary, Test Run Detailed, Test Plan Detailed, Test Case Activity, Requirement Traceability and User Workload — and User Workload stops at 5.", - "A report file cannot be downloaded through the API: the export call only hands back a job channel, so it is withheld. To get the file, mail it with send_report_email_now (confirm the recipient first — it sends immediately); to work with the figures, read them with get_report_summary_by_type or get_report_detail.", - "Listing reports is fixed at 30 per page and always newest-first; the page size cannot be changed and asking for a page past the end is an error rather than an empty page.", - "Requirement Traceability reports have a grain — test case (default) or test plan — fixed when the report is created and refused on any later edit." - ] - }, - "result": { - "entity": "result", - "title": "Result", - "description": "The recorded outcome of one test case in one run: its status, and who set it.", - "aliases": [ - "outcome", - "execution-result", - "test-result" - ], - "id_convention": "integer", - "parents": [ - "test_run", - "test_case" - ], - "relations": [ - { - "entity": "test_case", - "via": "references" - }, - { - "entity": "test_run", - "via": "references" + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "object" } - ], - "capabilities": [ - "bulk_delete_test_results", - "bulk_set_test_result_status", - "create_step_result", - "create_test_result_for_test_case", - "create_test_results_for_test_run", - "delete_test_result", - "list_test_results_for_test_case_by_integer_id", - "list_test_results_for_test_case", - "search_test_results", - "update_latest_test_result_for_test_case" - ], - "key_facts": [ - "Bodies are wrapped in test_result; set status by NAME (status) or id (status_id) — use a real configured status (see statuses-and-states).", - "bulk-delete SUCCEEDS PARTIALLY and still returns 200. Authorisation is per id (author OR project admin); ids the caller may not delete come back in `skipped` — and BOTH lists are nested under a top-level `result` key, with `skipped` holding OBJECTS ({id, reason}) not bare ids, so read result.deleted_ids / result.skipped. Not as an error. ALWAYS compare deleted_ids against what you asked for and report the skipped ids — treating 200 as 'all deleted' will misreport.", - "bulk-delete needs a non-empty result_ids array (400 otherwise) of at most 300 ids (422 beyond) — batch bigger sets yourself.", - "On the v2 surface a result's status is given by NAME (a status configured for the project, matched case-insensitively); the numeric status_id the v1 surface takes is not accepted there.", - "Logging a result APPENDS to a test case's result history in that run, so the previous outcome stays readable. A bulk status set REPLACES the case's current outcome in place instead — the overwritten status is not recoverable.", - "Bulk result writes are queued, not applied inline: a success response means accepted for processing. Per-case outcomes come back as ACCEPTED/FAILED, a partly-valid batch still answers 200 with success:false and status PARTIAL, and a bulk status set reports nothing per case at all — read the results back to confirm.", - "A project can restrict which review statuses are allowed to log an execution result; cases outside that set are rejected with a REVIEW_STATUS_GATE error rather than being logged.", - "Result attachments only attach on a multipart upload that carries the files alongside the JSON body; naming filenames in a plain JSON request is accepted and does nothing. Limits are 10 files per result, 100 per request and 50 MB per file, and a result description over 16 KB is truncated, not rejected.", - "Amending the latest result edits it in place and keeps no prior copy of the amended values; logging a new result instead preserves the earlier outcome as its own entry in the case's history for that run.", - "Deleting a result is permanent and silently changes the case's standing in the run: its outcome rolls back to the previous attempt, or to unexecuted if that was the only one.", - "Only the person who logged a result may delete it, unless they are a project admin — a mixed batch comes back partly done rather than refused.", - "Result attachments are always referenced by the numeric id of a previously uploaded file; naming a filename in the body does nothing." - ] - }, - "shared_step": { - "entity": "shared_step", - "title": "Shared step", - "description": "A reusable block of test steps embedded by reference, held in one flat untyped list per project.", - "aliases": [ - "shared step", - "shared steps", - "reusable step", - "step template", - "shared field", - "step block", - "shared component" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "reused by" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "owner": { + "type": "integer" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "review_settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sub_plan_mode": { + "type": "string" + } + } + }, + "review_status": { + "type": "string" + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "test_plan_progress": { + "type": "object" + }, + "test_plan_progress_v2": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } } - ], - "capabilities": [ - "create_shared_step", - "delete_shared_step", - "get_shared_step", - "get_shared_steps", - "update_shared_step" - ], - "key_facts": [ - "A shared step is embedded by MANY test cases, so editing or deleting it changes every one of them at once. That is the point of shared steps, but it also means a delete is a wide blast radius — say how it is used before changing it.", - "Reference a shared step from a test case's steps via shared_step_id in test_case_steps — see test-case-authoring.", - "Shared steps keep no version history, unlike test cases and test plans, so an edit to one cannot be rolled back and a delete cannot be undone — there is no bin for them.", - "Each row inside a shared step has its own id, and a test case's step holds both the shared step's id and that row's id. Rebuilding the rows instead of updating them in place breaks every reference into the block.", - "An edit replaces the whole step list rather than merging: read the block, change the array, send it back whole. Rows left out are removed.", - "The count of test cases embedding a block is returned on every read of it — that count is the blast radius of any edit or delete, and it is the number to check first." - ] - }, - "tag": { - "entity": "tag", - "title": "Tag", - "description": "A free-text label on a test case, run or plan, used to group and filter them.", - "aliases": [ - "tags", - "label", - "labels", - "tag", - "keyword", - "keywords" - ], - "id_convention": "name", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "labels" - }, - { - "entity": "test_run", - "via": "labels" - }, - { - "entity": "test_plan", - "via": "labels" + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } } - ], - "capabilities": [ - "bulk_replace_test_case_fields", - "get_test_case_tags", - "list_test_case_tags", - "merge_tags", - "search_group_tags", - "search_test_case_tags" - ], - "key_facts": [ - "Tags are referenced by NAME in filters and on cases; the merge endpoint takes integer source_id/target_id, so resolve ids via the tag reads first.", - "That body MUST carry custom_fields (send {} when not changing any) — omitting it returns 500 'undefined method each for nil', even though the server's validator lists it as optional.", - "tags/merge is admin-gated (tags_management flag) — may be unavailable for a group; degrade gracefully and say so.", - "TAG-OBJECT WRITES ARE NOT EXPOSED. Renaming or deleting a tag project-wide is deliberately out of this profile: the endpoints exist but sit behind the tags_management flag that most groups do not have, and tagging is always driven through the ENTITY. If asked to rename or delete a tag, say it is not available here and point to the Test Management UI.", - "Tags are referenced by NAME on entities and in filters. Tag IDs exist but are only returned by the workspace-wide tag search; the project-level tag reads give names only, so resolve ids there when an operation needs them.", - "Tag vocabularies are per entity kind — test case, test run, test plan and shared step tags are separate sets. A name existing in one says nothing about the others, and an operation must name which vocabulary it means.", - "Merging one tag into another is not reversible and is not confined to a project: the losing tag stops existing and everything that carried it is re-pointed at the surviving tag, across the whole workspace. Capture what carries a tag before merging it — nothing afterwards tells you what changed.", - "Tag management — merging and renaming — is restricted to account owners and admins. A caller without that role is refused.", - "Renaming or deleting a tag as an object is deliberately not exposed here — tagging is driven through the entity. If asked to rename or delete a tag, say it is not available and point to the Test Management UI." - ] + }, + "updated_at": { + "type": "string" + } + } + } }, - "template": { - "entity": "template", - "title": "Test case template", - "description": "A reusable field layout a test case is authored on — step or Gherkin style — setting which fields appear and in what order.", - "aliases": [ - "templates", - "test case template", - "case template", - "field template", - "test case layout", - "custom template", - "BDD template", - "Gherkin template", - "field layout" - ], - "id_convention": "integer, with no prefix. Discover ids through the template list call rather than guessing them.", - "parents": [], - "relations": [ - { - "entity": "test_case", - "via": "shapes the fields of" - }, - { - "entity": "project", - "via": "linked to" - }, - { - "entity": "custom_field", - "via": "arranges" + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "BulkAssignTestCasesResponse": { + "description": "Result of a bulk assign on a test run. TWO shapes, decided by the size of the selection.\nUnder the async threshold the write is applied inline and the body is the run's test-case\nlisting re-read afterwards: {success, info, test_cases} (plus `folder` when a folder_id was\nsent, and a top-level skipped_test_case_ids when the review gate excluded cases). At or above\nthe threshold the write is queued and the body is {success, async: true, unique_id} with NO\ntest_cases. Branch on `async`.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "description": "Present and true only when the assignment was queued rather than applied inline." + }, + "unique_id": { + "type": "string", + "description": "Handle for the queued bulk operation. Only present on the async shape." + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "description": "One entry per case x configuration mapping, each carrying both `id` (test case) and `mapping_id` (the run mapping). Absent on the async shape.", + "items": { + "$schema": "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun" + } + }, + "folder": { + "type": "object", + "description": "Only present when a folder_id was sent; the folder's status breakdown.", + "nullable": true + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Cases the review gate excluded. Top level, a sibling of test_cases.", + "items": { + "type": "integer" + } + } + } + } + }, + "ConfigurationGroupListResponse": { + "description": "Successfully retrieved the account's configuration groups", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "configuration_groups": { + "type": "array", + "items": { + "$schema": "ConfigurationGroup" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "UsersListResponse": { + "description": "All active users in the caller's account plus a reduced record for the caller.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "users": { + "type": "array", + "description": "Every active user in the account, ordered by full_name; not filtered to the project.", + "items": { + "$schema": "User" + } + }, + "myself": { + "$schema": "MyselfUser" + } + } + } + }, + "CommentCreateResponse": { + "description": "The comment just posted, wrapped in a data envelope.", + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "comment" + ], + "properties": { + "success": { + "type": "boolean" + }, + "comment": { + "$schema": "Comment" + } + } + } + } + } + }, + "ExploratorySessionFormFieldsResponse": { + "description": "The extra fields configured for exploratory sessions in this project. The list of built-in fields is always empty on this path. Both lists can legitimately be empty on a valid project.", + "schema": { + "type": "object", + "required": [ + "success", + "custom_fields", + "default_fields" + ], + "properties": { + "success": { + "type": "boolean" + }, + "default_fields": { + "type": "array", + "description": "Always empty on this path - built-in session details are not described here. The key is always present.", + "items": { + "type": "object" + } + }, + "custom_fields": { + "type": "array", + "description": "One entry per configured field. Empty when the project has no extra session fields.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The field's identifier, used when referring to this field elsewhere." + }, + "field_name": { + "type": "string", + "description": "The field's internal name." + }, + "field_user_name": { + "type": "string", + "description": "The name your team sees in the product; match a person's plain-words request against this." + }, + "field_type": { + "type": "string", + "description": "The kind of input, given in the product's prefixed form - for example a single-line string, a text area, a date, a dropdown, a multi-dropdown, a checkbox, a boolean, a URL, an integer or a user picker.", + "example": "field_dropdown" + }, + "entity_type": { + "type": "string", + "description": "The record kind this field belongs to." + }, + "is_required": { + "type": "boolean", + "description": "True when a session cannot be saved without this field. Always present, defaulting to false." + }, + "optional": { + "type": "boolean", + "description": "The exact inverse of is_required; both are always returned." + }, + "option_values": { + "type": "array", + "description": "The choices a dropdown offers. Returned only when the request asked for the configured choices AND the field has any - and the choices variant of this request is currently answered with a not-found, so in practice this is not reachable today.", + "items": { + "type": "object" } - ], - "capabilities": [ - "create_template", - "delete_template", - "get_template", - "list_templates", - "list_test_case_templates", - "update_template" - ], - "key_facts": [ - "Templates belong to the workspace, not to a project — one template can be linked to many projects and keeps the same id everywhere. Do not look for them inside a project.", - "Templates exist for test cases, test results, test plans and exploratory sessions, but only test-case templates can be created or edited here; the older listing can enumerate the other three types and returns test-case templates unless asked for another kind.", - "Template ids are plain integers with no prefix (not PR- or TR- style). Discover an id from the template list rather than guessing it.", - "Templates have no version history and no recycle bin: an edit overwrites the previous field layout and a deletion cannot be undone from Test Management.", - "Disabling a template is the reversible alternative to deleting it — a disabled template stops being offered for new test cases but keeps its fields and project links and can be re-enabled.", - "Changing a template's field layout changes the fields shown on every test case using it, across every linked project. Say how many projects are affected before writing.", - "Creating, editing and deleting templates are paid-plan features; on a free plan the call reports failure in the body while still answering 200, and reading templates stays available.", - "The Review & Approve system fields (reviewers and review status) are hidden from reads on this surface and re-added automatically whenever the property fields are rewritten, so they can be neither seen nor removed here.", - "A template only affects a project once it is linked to it; creating a template links it to nothing unless linkage is requested.", - "Failures arrive as a single human-readable message next to a false success flag rather than as a structured error code.", - "Only test-case and test-result templates can be listed through the older settings surface. Asking it for test-plan or exploratory-session templates fails outright rather than returning an empty list, and the kind must always be stated — there is no default.", - "Only test-case templates exist today; there are no templates for runs, plans or results, so every template's entity reads TestCase.", - "A template's step style is fixed at creation: classic step rows or Gherkin (feature, background, scenario). A case authored on a Gherkin template cannot take step rows and vice versa, so choose the template before writing steps.", - "Templates belong to the workspace but apply only in the projects they are linked to. Naming a template that is unknown to, or not visible from, the caller's workspace is refused outright rather than falling back to the default.", - "Exactly one template is the default; a case created without naming a template lands on it. System templates ship with the workspace and cannot be deleted.", - "Disabled templates still appear in the listing (enabled false) but should not be offered for new cases.", - "Creating, editing, cloning, linking and deleting templates are paid-plan operations and are not exposed through these capabilities; only listing is." - ] + }, + "field_values": { + "type": "array", + "description": "Current values recorded for the field. Same conditions and same practical caveat as option_values.", + "items": { + "type": "object" + } + }, + "default_value": { + "type": "string", + "description": "Pre-filled value, if the field has one. Always present and may be null. For a yes/no field this comes back as a true or false rather than as a number.", + "nullable": true + }, + "placeholder": { + "type": "string", + "description": "Hint text shown in the empty field. Omitted entirely when no hint text is configured." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "True when the field can be set on many records at once." + }, + "is_filterable": { + "type": "boolean", + "description": "True when the field can be used to narrow a list." + }, + "parent_custom_field_id": { + "type": "integer", + "description": "Present only for a dependent field; names the field whose value decides this one's choices. Omitted otherwise." + }, + "links": { + "type": "object", + "description": "A single self link. For most fields it points at where that field's own choices can be read; for a user-picker field it points at the project's user list instead." + }, + "sort_options": { + "type": "object", + "description": "A pre-built sort token for this field. Present only when the field's stored type is sortable (integer, string, date, URL, boolean or dropdown) and the workspace has custom-field sorting enabled, so most responses omit it entirely.", + "properties": { + "value": { + "type": "string", + "description": "Opaque sort key combining the field id and its stored type." + } + } + } + } + } + } + } + } + }, + "SharedComponentsGetResponse": { + "description": "A page of a project's shared fields with page details, optionally carrying the first field's steps.", + "schema": { + "type": "object", + "required": [ + "data", + "info", + "success" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "SharedComponent" + } + }, + "info": { + "$schema": "PageInfo" + }, + "pre_fetched_shared_step": { + "type": "object", + "nullable": true, + "description": "Only present when the pre-fetch option was used on page 1; null otherwise.", + "properties": { + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + }, + "page_info": { + "$schema": "PageInfo" + } + } }, + "success": { + "type": "boolean" + } + } + } + }, + "CommentListResponse": { + "description": "A page of comments on an item, newest first, with page details.", + "schema": { + "type": "object", + "required": [ + "comments", + "success", + "info" + ], + "properties": { + "comments": { + "type": "array", + "items": { + "$schema": "Comment" + } + }, + "success": { + "type": "boolean" + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "FolderAttachmentsResponse": { + "description": "The files attached to the folder itself.", + "schema": { + "type": "object", + "required": [ + "success", + "attachments" + ], + "properties": { + "success": { + "type": "boolean" + }, + "attachments": { + "type": "array", + "description": "One entry per file on the folder, empty when the folder has none. Alongside the fields below each entry also carries 'size', a ready-formatted version of byte_size for display.", + "items": { + "$schema": "Attachment" + } + } + } + } + }, + "V2TestCaseResponse": { + "description": "v2 public-API single-case envelope: {data: {success, test_case}}. Note the case is under `data`, uses `title` (not name) and has no integer id.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_case": { + "$schema": "V2TestCaseDetail" + } + } + } + } + } + }, + "GlobalSearchResponse": { + "description": "Account-wide search results. There is one top-level bucket per entity type you asked for - `test_case`, `test_run`, `test_plan`, `project`, `shared_step`, `report` - and a bucket appears ONLY if you named that type in the request; a type you did not ask for has no bucket at all, while a type that simply had no matches comes back as a present bucket with an empty `data` and a zero count. Alongside the buckets sits `project_details`, a lookup for naming the project each hit belongs to. When results are combined, the per-entity buckets are replaced entirely by a single sorted `data` list plus one `info` block.", + "schema": { + "type": "object", + "properties": { "test_case": { - "entity": "test_case", - "title": "Test case", - "description": "The unit of test logic: title, preconditions, steps and expected results.", - "aliases": [ - "tc", - "case", - "test case", - "testcase", - "test-case", - "tc-id", - "scenario" - ], - "id_convention": "TC-NNN", - "parents": [ - "folder", - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "executed in" - }, - { - "entity": "result", - "via": "produces" - }, - { - "entity": "custom_field", - "via": "extended by" - }, - { - "entity": "attachment", - "via": "has" - }, - { - "entity": "tag", - "via": "labelled by" - }, - { - "entity": "shared_step", - "via": "reuses" - } - ], - "capabilities": [ - "apply_test_case_review_verdict", - "bulk_archive_test_cases_by_project", - "bulk_copy_test_cases", - "bulk_delete_test_cases", - "bulk_replace_test_case_fields", - "bulk_edit_test_cases_in_test_run", - "bulk_move_test_cases", - "bulk_restore_archived_test_cases", - "count_binned_test_cases", - "create_bulk_test_cases", - "create_test_case_by_integer_id", - "create_test_cases", - "edit_test_case_partial", - "edit_test_case", - "generate_test_case_automation", - "list_archived_test_cases", - "get_system_field_values", - "get_test_case_by_integer_id", - "get_test_case_detail", - "list_test_cases", - "list_binned_test_cases", - "list_folder_test_cases", - "move_test_case", - "reorder_test_cases_by_folder", - "search_project_entities_by_filter", - "search_test_cases_by_folder", - "submit_test_cases_for_review", - "update_test_case", - "validate_bdd_export_selection" - ], - "key_facts": [ - "A filter is a (key, value) pair and each half fails silently: an unknown KEY is dropped (you get the whole project with success: true), and a wrong-form VALUE matches nothing (count: 0, also success: true). So resolve both — never guess either — and see pagination-and-filtering for the key table and each value's source.", - "A surprising count means check the FORM, not try another value: 0 on a value the user plainly meant = wrong form, so go resolve ids; filtered count == unfiltered count = the key was dropped. Never take either count from a per_page: 1 call — it returns count: 1 regardless of the real total.", - "required.fields ADDS joined columns; it does NOT slim a row, so naming fields never buys more rows per page. required.fields=[\"count\"] ALONE is the only value that changes the response.", - "The compact response has a DIFFERENT envelope: no `success` key ({test_cases, info, filter_details} only — absence of success is NOT failure), info.page_size echoes the server ceiling not your per_page, and filter_details is {} so confirm filter intent before the call.", - "READING ROWS to show a user: leave per_page at its default and walk p until info.next is null. p advances by per_page and not by what came back, so paging over an incomplete read never recovers the gap.", - "Paths take INTEGER ids (project + folder + case). The case's TC-NNN comes back as data.test_case.identifier on any read; folder ids are plain integers.", - "Every successful edit to a test case writes a new version, and an earlier version can be restored — so an edit is recoverable even though nothing offers an undo. Deleting the case is not.", - "That safety net is plan-gated: below an Enterprise or Team-Pro plan only the last 30 days of versions are visible, and restoring one needs a paid plan — so an older edit may not be recoverable in practice.", - "The newer v2 project surface addresses a test case by its TC-NNN identifier — or by its third-party identifier in workspaces that issue project-level ids — and does not accept the internal integer id; the older v1 surface is the reverse.", - "Custom fields on a test case are addressed by their display NAME on the v2 surface and by numeric field id on the v1 surface; a name the project does not define is skipped without any error, so a typo reads back as a successful call that changed nothing.", - "Priority, status and case type are per-project configurable values, not a fixed vocabulary: send the project's own display value (or its numeric id) and resolve the set from the project's system field values before writing.", - "Priority, status, case type and automation state are per-project configurable option sets. The single-case write paths accept either the numeric option id or the project's display name, but every BULK write requires the numeric id — a display name there is stored as a broken id with no error. Resolve the ids from the project's system field values first.", - "`status` is the exception on every path, single and bulk alike: it is only ever taken as a numeric option id, never resolved from a display name.", - "A case's steps are only ever read from `test_case_steps`. A key called `steps` is legacy, is parsed as a JSON-encoded STRING, and turns a real JSON array into an empty step list — wiping the case's steps while still reporting success.", - "`estimate` is a dead field: it is accepted on the write paths and then never stored, and every read serialises it as null. The estimate that actually persists is `estimated_duration_seconds`, in seconds, and it is not settable through any bulk edit.", - "Bulk operations switch to a background job above a size threshold that differs per operation — around 30 for a copy, 100 for an edit or move, 300 for a delete or restore. Past it the call returns an acknowledgement with a unique id instead of the result, so anything that reads the response body as the outcome is wrong for large selections.", - "Two bulk operations cannot touch the same cases at once: the second is refused with a 'TestCases in progress with another operation' error. That is a retry, not bad input.", - "Archiving and un-archiving test cases, and listing the archive, are gated on a paid plan — and the refusal comes back as a SUCCESSFUL response carrying success:false, not as an error status.", - "Whether deleting a case is recoverable depends on the workspace: where the Bin is enabled the case is moved there and can be restored, otherwise the delete is permanent. Either way the case's results and schedule links do not come back.", - "Creating a case from a template is not automatic: when neither a template name nor a step type is sent, the write paths default to the plain-text template, which stores no steps at all and still reports success." - ] + "type": "object", + "description": "Test-case matches. The only bucket that also carries a folder lookup.", + "properties": { + "data": { + "type": "array", + "description": "Matching test cases, in the same serialized shape the project-scoped case search returns, plus two search-only extras per row: `display_meta` (what the query matched - title, description, steps, preconditions, folder path - with the owner and project resolved to names) and `user_id` (the case's creator). On the typeahead fast path the rows are lean stubs - id, identifier, name, project, owner - and none of the serialized detail is filled in.", + "items": { + "$schema": "TestCase__SearchAllProjects" + } + }, + "info": { + "$schema": "PageInfo" + }, + "folders": { + "type": "object", + "description": "Folder-hierarchy lookup for these test-case hits, keyed by folder id, so each row's folder can be shown as a path without another call. Test cases are the only entity type that gets this." + } + } + }, + "test_run": { + "type": "object", + "description": "Test-run matches.", + "properties": { + "data": { + "type": "array", + "description": "Matching test runs, in the same serialized shape the project-scoped run search and the run listings return, plus a search-only `user_id` (the run's creator). Lean stubs on the typeahead fast path.", + "items": { + "$schema": "TestRun" + } + }, + "info": { + "$schema": "PageInfo" + } + } }, "test_plan": { - "entity": "test_plan", - "title": "Test plan (and sub-plan)", - "description": "A container grouping several test runs, typically for one release or milestone.", - "aliases": [ - "tp", - "plan", - "milestone", - "sub test plan", - "subplan", - "stp", - "release plan", - "test cycle", - "sub-plan", - "child plan", - "parent plan" - ], - "id_convention": "TP-NNN (sub-plan STP-NNN)", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "groups" - }, - { - "entity": "test_plan", - "via": "parent/child via parent_plan_id" - } - ], - "capabilities": [ - "bulk_archive_test_plans", - "bulk_restore_archived_test_plans", - "clone_test_plan", - "create_test_plan_by_integer_id", - "delete_test_plan", - "get_archived_test_plan_count", - "get_test_plan_by_integer_id", - "get_test_plan_execution_trend", - "get_test_plan_linked_sessions_chart_data", - "list_test_plan_test_runs_by_integer_id", - "list_archived_test_plans", - "list_test_plans_by_integer_id", - "unlink_test_runs_from_test_plan", - "update_test_plan_by_integer_id", - "list_test_plans", - "create_test_plan", - "get_test_plan", - "create_sub_test_plan", - "list_test_plan_test_runs", - "update_test_plan" - ], - "key_facts": [ - "Paths take the INTEGER plan id. The prefixed identifier (TP-NNN / STP-NNN) is for display; never put it in a v1 path.", - "The read detail has NO data wrapper: the plan sits under a top-level test_plan key, and test_plan.identifier is the TP-NNN / STP-NNN.", - "Sub-plans are HIDDEN by default in the list. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", - "name IS REQUIRED ON EVERY PLAN UPDATE, including one that only touches test_runs. Omit it and the call is rejected with 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is — the server's edit branch is gated on name being present. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt. For example, {test_plan: {test_runs: [198]}} is a 400, and the same call with name added succeeds.", - "Run ids in test_runs are the INTEGER uuid from a run read — NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream service error). Take uuid, never id.", - "Linking a run MOVES it: a run belongs to at most one plan, so linking silently unlinks it from the plan that held it, with nothing in the 200 to say so. Read the runs' current membership first and tell the user which plans will lose runs.", - "test_runs accepts an id array OR a bulk-selection object {select_all, de_selected_ids, search_query}. select_all means 'the newest active runs', NOT 'everything matching' — and `filters` is accepted but SILENTLY IGNORED, so a filtered select_all links the whole recent set. Narrow with search_query (name/identifier substring) or send explicit ids.", - "bulk-archive / bulk-retrieve go ASYNC above the bulk threshold and return {success, async: true, unique_id} — the plans are NOT changed yet. Re-read to confirm; see async-jobs.", - "execution-trend is scoped by EXACTLY ONE of test_plan_id / test_run_id. Both → 400 'Provide either test_plan_id or test_run_id, not both'; neither → 400 'test_plan_id or test_run_id is required'.", - "execution-trend and linked-sessions-chart-data return chart DATA. There is no insights-widget object to create, update, or delete in this profile.", - "A plan's status only moves forward, one step at a time: new -> started -> completed. Once completed the plan is FROZEN — even a rename is refused with 'Cannot update completed test plan' — so complete it last.", - "Plans keep no version history. An overwritten name, description or date is gone for good; there is no prior revision to restore, unlike a test case.", - "The same plan is addressed two different ways depending on which generation of the API you are on: the newer surface takes the prefixed identifier (TP-NNN / STP-NNN), the older one the numeric id. Carry whichever the capability you picked declares.", - "On the newer surface a run's plan membership is changed from the RUN, never from the plan: the plan's own edit ignores a list of runs entirely (the older surface accepts one). Linking still moves the run out of whatever plan held it.", - "On the newer surface a sub-plan's parent is fixed when it is created — there is no re-parenting or promotion to a top-level plan there, and the plan's owner is always whoever created it.", - "Creating a plan takes a name and nothing else; a name is mandatory on create, on clone, and on almost every edit. A status supplied at creation time is accepted and then ignored — a new plan always starts in the `new` state.", - "A status change and a content edit cannot travel together: when a request moves the plan's status, everything else it carries (name, description, tags, issues, owner) is silently dropped. Rename first, then move the status.", - "Starting a plan needs its start date in the same request and completing it needs its end date; the dates already stored on the plan are not used as a fallback.", - "Archiving is an optional feature for the account. When it is switched off, every archive, restore and archived read answers as though the address did not exist — that is a feature gap, not a missing plan.", - "Reading or restoring archived plans requires the same permission as DELETING a plan, so someone who can happily create and edit plans may still be refused the archived view.", - "Archived plans vanish from the normal plan listing entirely and have their own listing and their own count; archived sub-plans appear in neither.", - "An archived plan reads as missing unless the read explicitly opts in to archived records.", - "Cloning a plan is a background job: the reply confirms the job was queued and carries no id, name or identifier for the copy, and nothing except the runs and sub-plans is inherited — the new plan's description, dates, tags and owner all come from the clone request.", - "Owner is given as the person's test-management user id, not their BrowserStack id or email, and an id that does not resolve inside the account is discarded rather than rejected — leaving the plan with no owner.", - "Tags are all-or-nothing: if any entry in the list is not a plain string the entire tag list is dropped without an error." - ] + "type": "object", + "description": "Test-plan matches.", + "properties": { + "data": { + "type": "array", + "description": "Matching test plans, in the same serialized shape a project's plan listing returns - numeric `id` AND `identifier` (TP-NNN, or STP-NNN for a sub-plan), name, description, plan_status and the derived active_state, owner and assignee, tags, start and end dates, the {active, closed} run counts both direct and including sub-plans, parent_plan_id and sub_plans_count. This is the numeric-id plan shape, NOT the identifier-only shape the v2 plan surface returns. Each row also carries a search-only `display_meta` (with the parent plan resolved) and `user_id` (the plan's creator). Lean stubs on the typeahead fast path.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" + } + } }, - "test_run": { - "entity": "test_run", - "title": "Test run", - "description": "One execution cycle over a fixed set of test cases.", - "aliases": [ - "tr", - "run", - "execution", - "test cycle", - "cycle", - "test execution" - ], - "id_convention": "TR-NNN", - "parents": [ - "test_plan", - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "includes" - }, - { - "entity": "result", - "via": "produces" - }, - { - "entity": "configuration", - "via": "runs against" - }, - { - "entity": "test_plan", - "via": "grouped by" - } - ], - "capabilities": [ - "get_test_run_edit_selection", - "assign_test_run_owner", - "assign_test_run_test_cases", - "bulk_set_test_case_assignee_in_run", - "bulk_update_test_run_test_cases", - "clone_test_run_by_integer_id", - "clone_test_run_by_integer_id", - "close_test_run", - "count_run_selection", - "create_test_run_by_integer_id", - "create_test_run", - "delete_test_run", - "edit_test_run", - "export_test_run_csv", - "list_closed_test_runs", - "list_test_run_test_cases_by_integer_id", - "get_test_run_by_integer_id", - "get_test_run_detail", - "get_test_run_progress", - "list_test_run_test_cases", - "get_test_run", - "get_test_runs_form_fields", - "list_test_runs_selection", - "list_test_runs_by_integer_id", - "list_test_runs", - "replace_test_run", - "update_test_run" - ], - "key_facts": [ - "Only test_case_ids / selection / shared_selection put cases in a run. `filters` and `dynamic_filter` are a forward-sync rule, NOT a selection: a create carrying only `filters` returns 200 with ZERO cases, and sets is_dynamic: true even though you did not ask.", - "Dynamic runs match on nine fields only: priority, status, category, owner, automation_status, automation_state, folder, tags, issues. created_at/updated_at are stored and echoed on read but NEVER matched, so a 'last 7 days' dynamic run drops the date window and over-collects forever. Create runs static unless the user explicitly asks for sync.", - "test_run.run_state is MANDATORY on create and edit (new_run | in_progress | under_review | rejected | done | closed). It is validated before the selection, so a bare 400 'invalid data' means a bad test_run field — NOT a bad selection shape.", - "A run's `id` FIELD is the TR-NNN STRING, not the integer — on the project list, a plan's linked-run list and the single-run detail alike. The integer lives in `uuid` ({id: 'TR-90', uuid: 198}). Paths tolerate either form, which hides this; bodies and UI paths do not. Carrying `id` into a test plan's test_runs array sends a string that reaches the backing service uncoerced and 502s there. Take `uuid` whenever a run id leaves a read.", - "Full lifecycle is exposed: create, edit, assign, bulk-assign cases, bulk-edit results, clone, close, delete.", - "A test run keeps NO version history and has no restore: unlike a test case, an edited name, cleared tag list or removed case cannot be recovered. Confirm before overwriting or removing.", - "Removing a case from a run deletes the results recorded against it unless the caller explicitly asks to preserve them — the default is to discard.", - "Changing which cases a run covers is always queued: the call returns an acceptance immediately and the run still shows the old case list for a moment. Re-read the run's cases to confirm, and do not retry on the acceptance.", - "Every way of editing a run only ever ADDS cases to it. Taking cases out is a separate operation, and only that one can shrink a run.", - "The newer API generation identifies a run and its project only by their prefixed ids (TR-14, PR-1) and rejects the plain integers the older generation still tolerates.", - "Listing a project's runs hides closed ones by default. Unless the caller asks to include closed runs, or filters on a lifecycle state, a run count or 'all our runs' answer silently omits every closed run and still looks complete.", - "Filtering a run listing by owner fails open: an email that matches nobody in the group is dropped from the filter rather than refused, so a misspelled name returns a wider list that reads as a correct answer. Filtering by plan or by date fails closed and errors instead.", - "Creating a run does not populate it synchronously. The run exists as soon as the call succeeds, but its test cases are attached by background work, so an immediate read of the run's cases can come back empty or short. Re-read rather than retrying the create, which makes a second run.", - "A run's name is optional at creation: leave it out and the run is named 'Test Run' plus today's date, which is how projects end up with several identically named runs.", - "Marking a run as an automation run is not something a caller can do at creation — the flag is accepted and discarded, but it still decides which permission is demanded, so asking for it can be refused outright.", - "Rows in a run are per case-and-configuration pair, not per test case: a case covered by three configurations is three rows with the same case id and three different mapping ids. Every bulk operation on a run addresses those mapping ids, and passing the test case's own id instead returns success and changes nothing.", - "Cloning a run copies NO test cases unless the caller explicitly asks for them. A clone requested without a case selection succeeds and produces an empty run, and the clone always starts in the new-run state with no configurations, owner or sync rule carried over.", - "Bulk changes inside a run switch from immediate to queued above 300 selected rows, and the queued form returns an acknowledgement with no data. The selected rows are locked while either form runs, so a second bulk operation touching any of them is refused as contention rather than queued behind it.", - "Deleting a finished run needs a different permission from deleting one still in progress, so a role that can clear up working runs can be refused on completed ones.", - "Discovering how many cases a selection really covers is a distinct read: it multiplies the cases by the configurations, so the number it returns is routinely far larger than the number of cases chosen.", - "Editing a run requires resending its lifecycle state on every call, including edits that only change the name. A rejected edit that reports nothing more than 'invalid data' is almost always that missing state, not a bad case selection." - ] + "project": { + "$schema": "GlobalSearchEntityResults", + "description": "Project matches. Rows are search-index records, not the project read shape: expect the project's id, name, identifier, description, the owning group, created and updated times and a relevance score - and NOT the counts, links or access fields a project read returns. An owner filter does not narrow this bucket." }, - "user": { - "entity": "user", - "title": "User", - "description": "A member with access to a project: who you assign a case to, or set as an owner.", - "aliases": [ - "users", - "member", - "assignee", - "owner", - "user", - "members", - "people", - "teammate" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "member of" - }, - { - "entity": "test_run", - "via": "assigned to" - }, - { - "entity": "test_case", - "via": "owns" - } - ], - "capabilities": [ - "list_group_users", - "search_project_users" - ], - "key_facts": [ - "WRITE BODIES take the integer: test_case.owner, test_run.owner/assignee_id — send 14.", - "An unrecognised q key is dropped, not rejected — the server silently omits that condition and returns an unfiltered set that looks like a successful filter. Use the documented key names verbatim.", - "Every person has two ids: a Test Management user id and a BrowserStack account id. Owner and assignee fields take the Test Management id; several listing filters take the BrowserStack one instead. Sending the wrong one usually matches nobody rather than failing, so check which the operation wants.", - "Both user reads here are scoped to the whole workspace, not to a project — neither can answer 'who has access to this project', and one of them says so in its path while doing the opposite.", - "Name search matches the full name only; there is no partial-email search. An exact-email lookup exists and returns at most one person.", - "Only active users are listed, so someone who has left the workspace cannot be resolved even when their name is still on a case or run.", - "An unrecognised search key is dropped rather than rejected — the condition silently disappears and an unfiltered set comes back looking like a successful filter. Use the documented key names verbatim." - ] + "shared_step": { + "$schema": "GlobalSearchEntityResults", + "description": "Shared-step matches. Rows are search-index records carrying the step's id, title and owning project - leaner than a shared-step read, and with no step bodies. An owner filter does not narrow this bucket." }, - "version": { - "entity": "version", - "title": "Test case version", - "description": "A point-in-time snapshot of a test case, for viewing or restoring an earlier revision.", - "aliases": [ - "history", - "histories", - "revision", - "version-history" - ], - "id_convention": "integer", - "parents": [ - "test_case", - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "versions" - }, - { - "entity": "user", - "via": "changed by" - } - ], - "capabilities": [ - "list_test_case_histories", - "get_test_case_history_diff", - "get_test_case_history", - "restore_history", - "get_test_case_histories" - ], - "key_facts": [ - "Each record carries source (create/update), modified_fields, a modified map (per-field old/new), user and created_at.", - "diff (compare two versions) and restore require a PAID plan; listing + single-version reads work otherwise. Degrade gracefully.", - "A plain-language diff summary is agent-side — read the field deltas and describe them; the API returns structured data, not prose.", - "A revision is addressed two different ways depending on the generation of the read: the older listing hands back a numeric history record id, the newer one identifies a revision only by its position label V1, V2, … with V1 the oldest revision still visible.", - "Without an Enterprise or Team Pro plan only the last 30 days of a test case's revisions are visible; older ones are absent with no flag saying so, and position labels count only what is visible, so they renumber as old revisions age out.", - "In the newer payload the editor arrives as updated_by, an email string that is null when the user cannot be resolved in this group, instead of the nested user object the older records carry.", - "A revision listing populates the per-field old/new detail only for the FIRST record on each page; every other record names the changed fields but not their values, so summarising a page of changes costs one extra read per revision.", - "Comparing two revisions only looks at a fixed set of fields (name, description, preconditions, automation status, type, status, priority, owner, assignee, folder, tags, defects, custom fields, steps, attachments, reviewers, review status). A change to anything else is invisible in a comparison.", - "Restoring is a full overwrite of the snapshotted fields, not a merge: a custom field that is empty in the snapshot is cleared, steps added since are removed, and attachments added since are unlinked. Say what will be lost before restoring.", - "A restore is applied as an ordinary edit, so the pre-restore state stays in the revision trail and can itself be restored — that is the only way back, and there is no undo action." - ] + "report": { + "type": "object", + "description": "Report matches. Absent on the typeahead fast path even when reports were requested. Unlike the other entity types, this bucket ignores the requested page size and always pages at 30, and it is not a search-index bucket - it is a direct lookup over the account's saved and scheduled reports, matched on report type, title and, when no owner filter is set, owner name.", + "properties": { + "data": { + "type": "array", + "description": "Matching reports, in the same serialized shape a project's saved-report listing returns.", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "How many of the matches are scheduled reports." + }, + "total_report_count": { + "type": "integer", + "description": "How many reports matched in total, before paging." + }, + "info": { + "$schema": "PageInfo", + "description": "Pagination for the report bucket. Always returned. Its page size is always 30, whatever page size was requested." + } + } }, - "comment": { - "entity": "comment", - "title": "Comment", - "description": "A rich-text note a team member posts on a test case, on its execution in a test run, or on a test plan, to discuss it.", - "aliases": [ - "comments", - "note", - "notes", - "remark", - "feedback", - "discussion", - "thread", - "review comment", - "review feedback", - "mention", - "@mention", - "annotation", - "message" - ], - "id_convention": "Plain integer, assigned on creation and returned as `id` on the posted comment. Existing comment ids are not guessable and must be discovered through the comment listing.", - "parents": [ - "test_run", - "test_case", - "test_plan" - ], - "relations": [ - { - "entity": "test_case", - "via": "posted on" - }, - { - "entity": "test_run", - "via": "scoped to the case's execution in" - }, - { - "entity": "user", - "via": "authored by / mentions" - } - ], - "capabilities": [ - "list_test_run_test_case_comments", - "create_test_run_test_case_comment" - ], - "key_facts": [ - "A comment can hang off a test case in its folder, off a test case's execution inside a specific test run, or off a test plan. Only the test-run variant is published in this pass; folder-scoped test-case comments and test-plan comments exist in the product but are not exposed here.", - "Comments are a paid-plan feature. On a free plan every comment call comes back HTTP 200 with success:false and the message 'This Feature is only for paid customers' — the body, not the status, carries the refusal.", - "Reading and posting comments both need the test-case VIEW permission; no separate comment or write permission exists, so anyone who can see the case can comment on it.", - "Comments cannot be posted on automation test runs (Automate/Observability-backed), and not on an archived test case; reading an archived case's comments still works.", - "Mentioning a colleague — via the mention span markup carrying data-lexical-mention, data-mention (their display name) and data-id (their BrowserStack user id) — notifies them immediately, and the run owner is alerted on every new comment. A mention of someone outside the group fails the whole post; a span missing data-mention is accepted but shows as plain text rather than a tag.", - "Display names are not unique within an account — two people can share one — so resolve the person to mention by email, not name, and take their BrowserStack user id from that row.", - "The body is HTML rich text. Whitespace-only or tag-only content is refused; an inline image counts as content. Stored HTML can differ from what was sent (links normalised, mentions rewritten).", - "Threads are per run/case/configuration cell: a case mapped to three configurations in a run has three separate comment threads, chosen by the mapping id.", - "Listings come newest first, 30 to a batch, and the size cannot be changed. Asking beyond the end returns the last batch again rather than an empty one, so stop when the next-batch marker is null.", - "Posting a comment is recoverable: the author can edit it or delete it afterwards, and deletion hides the comment rather than destroying it. Neither edit nor delete is exposed in this pass.", - "On each returned comment `edited` is true once it has been changed after posting; the author object's `id` is the internal TM user id (not the BrowserStack user id) and is null for imported comments, which show the original author's name instead.", - "Mentioning a colleague in a comment notifies them straight away, and the owner of the item commented on is notified of every new comment. Both are real messages that leave the product and cannot be recalled.", - "A person can only be mentioned if they belong to the same account; naming anyone else is refused rather than quietly ignored.", - "Only the person who wrote a comment can edit or delete it. Editing replaces the earlier text and keeps no previous version, and a deleted comment disappears from every listing with no self-serve way to restore it.", - "Comments are stored as rich text, so formatting, links and images survive, and a mention is stored as markup that displays as the person's name.", - "New comments cannot be added to an archived test case, although the comments already on it stay readable.", - "Comments are flat - each one sits on the item itself, and there is no way to reply beneath another comment.", - "Commenting is a paid-plan feature; on a plan without it the answer reports the feature as unavailable instead of failing." - ] + "project_details": { + "type": "array", + "description": "One entry per project referenced by any hit in any bucket, so a row's project can be named without another call - match a row's project id against these. Entries are project records as stored, with id and name being the parts to rely on; this is a lookup, not the project read shape. Always present on an uncombined search, as an empty list when nothing matched; on a combined one it is included only when non-empty.", + "items": { + "type": "object" + } }, - "issue": { - "entity": "issue", - "title": "Linked issue", - "description": "A ticket in an external tracker such as Jira that test cases and runs are linked to.", - "aliases": [ - "requirement", - "ticket", - "jira issue", - "bug", - "work item" - ], - "id_convention": "The external tracker's own ticket key, as a string — for Jira an uppercase project key, a hyphen and digits, e.g. PROJ-123; other trackers use their own form. It is never a Test Management integer id and cannot be minted here: take it from the tracker itself, or read it from the linked-ticket list carried on a test case or test run. Test Management does not validate that the key exists, so a wrong key links silently.", - "parents": [], - "relations": [ - { - "entity": "test_case", - "via": "linked to" - }, - { - "entity": "test_run", - "via": "linked to" - }, - { - "entity": "project", - "via": "scoped by" - } - ], - "capabilities": [ - "link_entities_to_jira_issue", - "get_test_case_linked_issues", - "get_linked_test_case_selection", - "unlink_test_case", - "unlink_test_run" - ], - "key_facts": [ - "A linked issue is not stored by Test Management — only the link is. Unlinking removes the association and leaves the test case, the test run and the tracker ticket untouched, so every unlink here is reversible by linking again.", - "Linking and unlinking never write anything back into the external tracker; the ticket is not commented on, transitioned or modified.", - "One ticket can carry many test cases and many test runs, and one test case can be linked to several tickets, including tickets in different trackers.", - "The tracker a link belongs to is part of its identity: the same ticket key recorded under two different trackers is two independent links.", - "Unlinking identifies the link by the test case or test run plus the tracker, not by the ticket, so a case linked to several tickets in one tracker cannot be unlinked from a chosen one.", - "Test Management also has a separate Requirements feature of its own; a linked issue always lives in an external tracker and is a different thing, even though customers call both 'requirements'.", - "Ticket keys are never checked against the tracker, so a mistyped key produces a link to a ticket that does not exist and reports success." - ] + "data": { + "type": "array", + "description": "Only when results are combined: all types merged, newest first, each row carrying an entity_type. Capped at 5 rows.", + "items": { + "type": "object" + } }, - "shared_field": { - "entity": "shared_field", - "title": "Shared field", - "description": "The foldered, typed form of a reusable content block: the same step rows plus a field type, a folder and tags.", - "aliases": [ - "shared component", - "shared field", - "reusable field", - "collection" - ], - "id_convention": "Plain integer, not guessable; discover ids by listing a project's shared fields. A shared step id comes from a different set and will not resolve as a shared field.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "belongs to" - }, - { - "entity": "test_case", - "via": "embedded by" - }, - { - "entity": "folder", - "via": "organised into collections" - }, - { - "entity": "tag", - "via": "labelled with" - } - ], - "capabilities": [ - "list_shared_components" - ], - "key_facts": [ - "A shared field is not a shared step. They are separate objects with separate listings and separate ids, and they are easy to confuse because both are reusable content - establish which one the user means before acting on an id.", - "The API calls this object a shared component while the product calls it a shared field; they are the same thing under two names.", - "Every shared field has a type - step, background or precondition - and the type decides where a test case can embed it.", - "Shared fields are organised into collections, which nest. A field placed in no collection sits in the Unassigned root.", - "A shared field is embedded by many test cases at once, so editing or deleting one changes every case that uses it; the usage count on the field is the measure of that blast radius.", - "Distinct from a shared step and stored separately: creating one does not make the other, and each is listed only by its own read.", - "Only the listing is available here. Creating, editing, deleting, copying and reading one by id all exist in the product but are not offered on this surface, so a block seen here cannot be changed here." - ] + "info": { + "$schema": "PageInfo", + "description": "Only when results are combined. Its count is the true total across all types, but its page size is the per-type page sizes added together rather than a real page size - three types searched at 30 each reports 90." + }, + "test_case_folders": { + "type": "object", + "description": "Only when results are combined and the merged list contains test-case rows: the folder lookup for those rows." + }, + "filter_details": { + "type": "object", + "description": "Only when explicitly requested with the exact lowercase word true: the filters that were applied, with ids resolved to names. Absent if resolution failed - the search itself still succeeds." } - }, - "version": "1.37" - } + } + } + }, + "FolderV2WriteResponse": { + "description": "Public (v2) envelope returned after creating, renaming or moving a folder: the affected folder in its public shape.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "FolderV2" + } + } + } + }, + "TestPlanV2DetailResponse": { + "description": "A single test plan (or sub-plan read through the plan route). No data wrapper - the plan sits at the top level under `test_plan`, and on this route alone it can carry the embedded run preview and the relative API links.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plan": { + "$schema": "TestPlanV2Detail" + } + } + } + }, + "SharedComponentWriteResponse": { + "description": "The saved shared field, echoed back under `data`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$schema": "SharedComponentDetail" + } + } + } + } + }, + "paging": { + "POST /api/v1/projects/{project_id}/test-cases/bulk-archive": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-copy": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-delete": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-edit": { + "page": "p" + }, + "PATCH /api/v1/projects/{project_id}/test-cases": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-move": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-retrieve": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets/{uuid}/test-cases": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets/variables": { + "page": "p" + }, + "GET /api/v1/projects/basic": { + "page": "p", + "size": "count", + "max": 300 + }, + "GET /api/v1/projects/minify": { + "page": "p", + "size": "count", + "max": 300 + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}/{section_name}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/folders": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/shared-steps/{shared_step_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/shared-steps": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-case/{field_name}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-cases": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-results/custom-fields": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-plans/archived_test_plans": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/ai-duplicates": { + "page": "page" + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/filter": { + "page": "page" + }, + "GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/schedules": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-case/tags-v2": { + "page": "p" + }, + "GET /api/v1/admin-v2/settings/templates": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-plans": { + "page": "p" + }, + "GET /api/v1/admin-v2/settings/fields": { + "page": "p" + }, + "PATCH /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder": { + "page": "page" + }, + "POST /api/v1/projects/{project_id}/ai-duplicates/search-v2": { + "page": "page" + }, + "GET /api/v1/group/{entity_type}/tags": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/{entity}/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "POST /api/v1/projects/{project_id}/{entity}/search-v2": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/users/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-case/tags/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "POST /api/v1/projects/{project_id}/reports/{report_id}/drilldown": { + "page": "p", + "size": "per_page", + "max": 100 + } + }, + "entities": { + "activity": { + "entity": "activity", + "title": "Activity feed (audit trail)", + "description": "Group-wide audit trail of who changed what, on which entity, and when.", + "aliases": [ + "activity", + "activities", + "activity feed", + "audit log", + "audit trail", + "history feed", + "who changed" + ], + "id_convention": "integer", + "parents": [], + "relations": [ + { + "entity": "project", + "via": "scopes events" + }, + { + "entity": "user", + "via": "actor of an event" + }, + { + "entity": "test_case", + "via": "subject of an event" + }, + { + "entity": "test_run", + "via": "subject of an event" + } + ], + "capabilities": [ + "list_activity_events" + ], + "key_facts": [ + "READ-ONLY by design. Activity events are emitted by the system as a side effect of other writes \u2014 there is no create, update, or delete, and there never will be. If asked to edit or remove an audit entry, say it is immutable.", + "HARD 15-DAY WINDOW. The server clamps every query to the last 15 days no matter what you ask for. For anything older, say the history is not retrievable through the API and point to the Test Management UI \u2014 do NOT report 'no activity' for an older period, which would be a false negative.", + "CURSOR pagination, not page numbers. Follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no p param and no info.count, so you CANNOT state a total number of events or jump to a page.", + "Not project-scoped in the path \u2014 it is group-wide. Pass project_id as a QUERY param to narrow it to one project.", + "starred_only=true restricts to the caller's starred projects. When they have none the response is {events: [], no_starred_projects: true} \u2014 that means 'no starred projects', NOT 'no activity'. Check for that flag before reporting emptiness.", + "The response carries a projects map (project id \u2192 metadata) so you can resolve the project an event refers to without extra reads.", + "Gated by a group feature flag. If the call is refused, report that the activity feed isn't enabled for the group rather than that there is no activity." + ] + }, + "attachment": { + "entity": "attachment", + "title": "Attachments", + "description": "A file hanging off a parent test case or test result.", + "aliases": [ + "attachment", + "file", + "attachments", + "blob", + "upload", + "evidence", + "screenshot", + "blob id" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "attached to" + }, + { + "entity": "result", + "via": "attached to" + } + ], + "capabilities": [ + "delete_test_case_attachment", + "list_test_case_attachments", + "list_entity_attachments", + "upload_ai_attachments", + "upload_generic_attachments", + "link_attachments_to_test_case" + ], + "key_facts": [ + "ai_uploads uploads a file as AI CONTEXT (restricted types, smaller cap) for seeding case content \u2014 see create-test-case.", + "Each blob/attachment row carries an integer id and a url (the file's location) \u2014 read the file from that url; there is no dedicated download endpoint.", + "A file is attached in TWO steps: upload it to the project to get a numeric blob id, then link that id to the test case, result or step. The link calls take ids, never file content or filenames.", + "A blob can be linked ONCE. Attaching one that is already referenced elsewhere fails the whole request; upload a separate copy for each place the file should appear.", + "Putting a NEW file into the product cannot be done through this interface at all \u2014 an agent can link an already-uploaded file, remove one, and read attachment metadata, but the upload itself has to happen in the product UI.", + "Removing an attachment purges the stored file, not just the link. There is no recycle bin for attachments and no undo.", + "The view and download URLs handed back on upload are pre-signed and expire after about 30 minutes \u2014 fetch the file promptly or re-read the attachment to get a fresh one.", + "The older per-test-case attachment reads return metadata only (id, filename, type, size) with no URL; the newer entity-attachment listing is the one that hands back a readable URL per file.", + "A file cannot be attached to a test case through this surface at all. The call that does it works, but it takes the file itself as a form upload, which this transport cannot send \u2014 the same reason the upload capabilities here accept no file and quietly do nothing. Attaching is done in the product, not here.", + "An empty attachment list is not evidence that nothing is attached; it is the expected result whenever the attaching was done elsewhere." + ] + }, + "configuration": { + "entity": "configuration", + "title": "Configurations", + "description": "An operating system, browser or device combination that a test run executes against.", + "aliases": [ + "config", + "configuration", + "environment", + "browser", + "os", + "device", + "configs", + "configurations", + "platform" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "case status tracked against" + } + ], + "capabilities": [ + "create_configuration", + "list_configurations" + ], + "key_facts": [ + "Configurations live at workspace level, not per project: one created while working in a project appears in every project in the workspace, and the project listing returns the same workspace-wide set everywhere.", + "Only a configuration's name can be set when creating one. The operating system, browser and device breakdown that reads return cannot be supplied through the API.", + "Test runs reference a configuration by its integer id, never by name or by the configuration object.", + "Configurations are gated on the test-run permissions rather than the test-case ones, and there is no way to delete or rename one through this profile \u2014 cleanup is a UI operation." + ] + }, + "custom_field": { + "entity": "custom_field", + "title": "Custom fields & system fields", + "description": "A user-defined field on test cases, results or plans, alongside the built-in system fields.", + "aliases": [ + "custom field", + "custom fields", + "field", + "system field", + "form field", + "dropdown", + "picklist", + "option", + "option value", + "dataset", + "field definition", + "field values", + "extra field", + "user defined field" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "extends" + }, + { + "entity": "result", + "via": "extends" + } + ], + "capabilities": [ + "create_custom_field_dataset_option", + "create_custom_field_dataset", + "create_custom_field_definition", + "create_custom_field", + "delete_custom_field_dataset_option", + "delete_custom_field_dataset", + "delete_custom_field_definition", + "get_custom_field_dataset", + "get_custom_field_definition", + "get_custom_field", + "get_custom_field_values", + "get_project_form_fields", + "get_test_results_custom_fields", + "list_custom_field_dataset_options", + "list_custom_fields", + "list_workspace_fields", + "update_custom_field_dataset_option", + "update_custom_field_dataset_project_mapping", + "update_custom_field_definition", + "update_custom_field" + ], + "key_facts": [ + "A field applies to ONE entity type (TestCase | TestResult | TestPlan) \u2014 pick the matching entity_type; project scope is via the integer project id in the path.", + "A definition write is workspace-wide, not project-scoped: it affects every project the field's datasets link to. Say which projects are affected BEFORE writing.", + "field_name IS editable via .../update; field_type is SILENTLY immutable \u2014 sending a new type returns 200 and changes nothing. Never report a type change as done; a real one means delete-and-recreate, which destroys every stored value.", + "Update is a full REPLACE: field_name + field_type + is_required must all be present (missing is_required = 400, missing field_name = 500). Read the field first and resend the whole set.", + "The project-mapping endpoint uses link_projects / unlink_projects \u2014 NOT the linked_projects of the create bodies. The wrong spelling is silently dropped: 200, nothing changed.", + "Three operations destroy stored data across every linked project: deleting a field, deleting an option, and unlinking a project. There is no bin. Count and name the affected projects before asking to proceed.", + "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Only values are writable here, through the test-case create/update body's custom_fields.", + "A field applies to ONE entity type (TestCase | TestResult | TestPlan | ExploratorySession | TestCaseStep) and cannot be moved later. TestCaseStep fields need the group's step-custom-fields feature, and cannot be url or nested_dropdown.", + "A field DEFINITION is workspace-wide; a DATASET is what makes it visible in a project, by pairing an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, exists but appears nowhere.", + "A project may belong to only one dataset per field. Linking a project that a sibling dataset already holds is rejected \u2014 read the field's existing datasets before linking.", + "Editing a field definition is a full REPLACE: the label, the mandatory flag and the type must all be resent, and a placeholder or boolean default you leave out is cleared. Read the field first and send back everything you want to keep.", + "Three operations destroy stored values across every linked project and cannot be undone: deleting a field, deleting an option, and unlinking a project from a dataset. There is no bin. Count and name the affected projects before asking to proceed.", + "Renaming an option keeps its id, and stored values point at the id \u2014 so a rename loses nothing and is the safe way to correct a wrong label. Deleting and recreating is not.", + "Field types have two spellings. Definitions are written with the canonical names (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown); the project form-field and field-value reads return the legacy field_* forms (field_dropdown, field_string, \u2026). Translate between them, and expect null where a nested_dropdown has no legacy name.", + "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Nothing here writes values \u2014 those are set when creating or editing the test case, result or plan itself.", + "A default value can only ever be set on a boolean field. On every other type the admin write calls force it to null, so a default cannot be created or preserved there." + ] + }, + "dataset": { + "entity": "dataset", + "title": "Dataset", + "description": "A named table of variables and rows that drives data-driven execution of a test case.", + "aliases": [ + "datasets", + "data-driven-data", + "dds", + "data driven testing", + "data-driven test data", + "test data table", + "dataset variables", + "dataset columns", + "parameterised test data", + "csv test data" + ], + "id_convention": "uuid", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "drives" + }, + { + "entity": "variable", + "via": "has columns" + } + ], + "capabilities": [ + "create_dataset", + "delete_dataset", + "get_dataset", + "list_dataset_linked_test_cases", + "get_dataset_variables", + "import_dataset_csv", + "list_datasets", + "update_dataset" + ], + "key_facts": [ + "Team Pro plan gated: without it EVERY dataset endpoint returns 422 \"Feature not available\". A 422 here means NOT-ENTITLED, not a bad payload \u2014 say so and stop, don't retry with a different body.", + "Datasets keep NO version history and there is no restore: an update replaces the whole table of columns and rows, and the previous contents are unrecoverable. Read a dataset before writing to it.", + "What you send is not what gets stored. Blank rows and columns that are empty in every row are dropped, cell values are trimmed, rows that end up identical are collapsed to one, and row numbers are reassigned from 1. Always read the stored result back.", + "Column names are restricted to letters, digits and underscores, must start with a letter, and must be unique within a dataset (compared case-insensitively).", + "Default size limits are 40 columns and 100 rows per dataset; groups on the raised-limit flag get 100 columns and 200 rows.", + "Deleting a dataset is permanent and is not blocked by test cases still bound to it \u2014 check what it drives first." + ] + }, + "duplicate": { + "entity": "duplicate", + "title": "Duplicate recommendation (AI dedupe)", + "description": "A machine-suggested pair of near-identical test cases, for a human to review and merge.", + "aliases": [ + "duplicate", + "duplicates", + "dedupe", + "deduplication", + "duplicate recommendation", + "ai duplicates", + "redundant test cases", + "duplicate test cases", + "near-duplicate", + "merge duplicates", + "duplicate review", + "duplicate suggestions", + "dedupe queue", + "similar test cases" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "links the pair it believes are duplicates" + } + ], + "capabilities": [ + "discard_duplicate", + "get_dedupe_status", + "get_duplicate_source_test_case", + "get_duplicate", + "list_duplicates", + "resolve_duplicate", + "search_duplicates" + ], + "key_facts": [ + "There is NO create. Duplicate suggestions are produced by the dedupe pipeline; a caller cannot author one.", + "Only status=suggested can be read or resolved; already-merged/archived/discarded suggestions return 404. A 404 here usually means 'already handled', not 'never existed'.", + "source_tc works ONLY for status=merged, and the snapshot may legitimately be absent (404 with error 'Source test case snapshot not available'). Treat that as 'not retained', not as a retryable error.", + "This list family pages with `page`, not the `p` used elsewhere in the profile.", + "Dedupe REVIEW is exposed here; TCG test-case GENERATION is not. Don't offer to generate cases.", + "Duplicate review can be paused entirely while the dedupe data is migrated \u2014 resolving or discarding then fails with a conflict and an explicit 'try again shortly' message. Nothing was changed; it is safe to retry later.", + "Resolving a duplicate requires the same permission as removing a test case, while discarding one needs only edit permission. A reviewer who can dismiss suggestions may still be unable to merge them.", + "Merging archives the source test case and tags it 'Archived as Duplicate', posts a comment on the surviving case, and stores a frozen snapshot of the source. Archived cases are recoverable; the comment and the status change are not undone by recovering one.", + "The review queue is served ten pairs at a time, ordered by model confidence rather than age, and the page size cannot be increased \u2014 the oldest suggestion is not reachable by asking for more rows.", + "A pair whose test case can no longer be resolved is dropped from the queue, so the reported total can exceed the pairs actually returned." + ] + }, + "exploratory_session": { + "entity": "exploratory_session", + "title": "Exploratory session", + "description": "An unscripted, charter-driven testing session and the observations recorded during it.", + "aliases": [ + "session", + "exploratory", + "et-session", + "exploratory testing", + "charter", + "session log", + "session note", + "bug bash" + ], + "id_convention": "integer", + "parents": [ + "project", + "folder" + ], + "relations": [ + { + "entity": "exploratory_log", + "via": "records" + }, + { + "entity": "issue", + "via": "links defects" + }, + { + "entity": "configuration", + "via": "runs against" + }, + { + "entity": "test_case", + "via": "notes become" + } + ], + "capabilities": [ + "clone_exploratory_session", + "close_exploratory_session", + "create_exploratory_session", + "create_exploratory_session_log", + "delete_exploratory_session", + "delete_exploratory_session_log", + "get_exploratory_session", + "list_exploratory_session_defects", + "list_exploratory_session_logs", + "list_exploratory_sessions", + "update_exploratory_session", + "update_exploratory_session_log" + ], + "key_facts": [ + "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1).", + "Logs hold rich-text HTML sanitised on write (off-project anchor/image links stripped); defects are the issues linked to the session.", + "Clone runs async when the session has many logs \u2192 re-fetch to confirm (see async-jobs).", + "No convert endpoint: turn session notes into test cases via the create-test-case flow.", + "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1). The display identifier (ES-NNN) is never accepted as an input.", + "A log entry belongs to exactly one session \u2014 both the session id and the log id are needed to change or remove a log, and a log id is not addressable under a different session.", + "Logs hold rich-text HTML sanitised on write (anchors and images pointing outside the project or group are stripped); screenshots are attached by uploading the image first and referencing its id inside the HTML, never by sending bytes.", + "Session-level 'defects' are the issue-tracker links recorded during the session \u2014 both links on the session and links made on individual logs; each row says which it is.", + "Closing a session is one-way: nothing accepts a state change and there is no reopen, so a closed session only leaves the default listing, it never comes back to it.", + "Neither sessions nor logs keep version history, and neither has a recycle bin \u2014 a deletion or an overwritten note cannot be recovered.", + "Listing sessions shows only ACTIVE ones unless the closed state is asked for explicitly.", + "Cloning a session with 50 or more logs is handled in the background and returns before the copy exists; re-read the session list to confirm. A clone does not carry over the charter or the folder.", + "Exploratory sessions are governed by the same permissions as test runs; deleting a closed session needs the closed-run delete permission specifically." + ] + }, + "filter": { + "entity": "filter", + "title": "Saved filter view", + "description": "A saved, named set of filter conditions a user can reapply to a list view.", + "aliases": [ + "filter", + "filters", + "saved view", + "saved filter", + "view", + "saved search", + "smart filter", + "filter view" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "filters" + }, + { + "entity": "test_run", + "via": "filters" + }, + { + "entity": "test_plan", + "via": "filters" + } + ], + "capabilities": [ + "create_filter", + "delete_filter", + "get_filter", + "list_filters", + "update_filter" + ], + "key_facts": [ + "A saved filter view persists a reusable filter: { name, entity, filters, is_project_level }. entity = testcase | testplan | testrun | exploratory_session; filter_id is an integer.", + "is_project_level scopes the view to the whole project vs. just the creator.", + "A saved filter view has no version history and no bin \u2014 an overwritten name or condition set, and a deleted view, cannot be restored. Read one before changing it if the conditions matter.", + "Reading a single saved view can change it: conditions on custom fields that no longer exist are pruned from the stored filter as a side effect of the read. The listing does not do this.", + "Saving a view as project-level publishes it to everyone on the project, and anyone with project access can then edit or delete it \u2014 there is no owner-only protection." + ] + }, + "folder": { + "entity": "folder", + "title": "Folder", + "description": "A container organising test cases inside a project; folders nest to form a tree.", + "aliases": [ + "dir", + "directory", + "test case folder", + "repository folder", + "test suite", + "suite", + "module" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "folder", + "via": "nests under" + }, + { + "entity": "test_case", + "via": "organises" + } + ], + "capabilities": [ + "copy_folder", + "create_root_folder", + "create_sub_folder", + "delete_folder_and_contents", + "get_folder_remove_summary", + "get_folder_tree", + "list_root_folders", + "list_folder_contents", + "move_folder_by_integer_id", + "rename_folder_by_integer_id", + "reorder_folders", + "delete_folder_and_subtree" + ], + "key_facts": [ + "Folder ids are plain INTEGERS (e.g. 54) \u2014 never FL-\u2026 strings. Nesting is via parent_id (integer, null = root).", + "mv cross-project and copy run ASYNC ({ success, async, unique_id }) \u2014 confirm by re-reading the destination (see async-jobs).", + "Deleting a folder is recursive and takes every subfolder and every test case beneath it. Nothing in this product restores a deleted folder \u2014 only the test cases can come back, and only from the recycle bin when that feature is switched on for the group.", + "Folders keep no version history, so a rename or a notes edit cannot be rolled back to a previous value.", + "Folder names must be unique among their siblings. Creating or renaming into a clash fails unless auto-rename is asked for; copying a folder resolves the clash silently by prefixing the copy's name.", + "Folder notes are stored as sanitised HTML with signed image links injected on read, so the text that comes back is not byte-identical to the text that was sent.", + "Copying a folder, and moving one to a different project, are background jobs: the call returns a tracking id and nothing else, and the only way to confirm the outcome is to re-read the destination." + ] + }, + "project": { + "entity": "project", + "title": "Project", + "description": "The top-level container that owns every folder, test case, run, plan and report.", + "aliases": [ + "pr", + "workspace", + "project id", + "pr-id", + "test project" + ], + "id_convention": "PR-NNN", + "parents": [], + "relations": [ + { + "entity": "folder" + }, + { + "entity": "test_case" + }, + { + "entity": "test_run" + }, + { + "entity": "test_plan" + }, + { + "entity": "configuration", + "via": "scopes" + }, + { + "entity": "custom_field", + "via": "scopes" + }, + { + "entity": "user", + "via": "grants access to" + } + ], + "capabilities": [ + "create_project", + "export_dashboard_analytics", + "get_active_test_runs_info", + "get_automation_stats", + "get_closed_test_runs_info", + "get_closed_test_runs_split", + "list_entity_filter_details", + "get_issues_count_info", + "get_project_by_integer_id", + "get_project_settings", + "list_project_users", + "get_projects_basic", + "get_projects_minify", + "get_test_case_count_trend", + "get_test_case_type_split", + "list_projects", + "search_project_entities", + "update_project_settings" + ], + "key_facts": [ + "Project ids come in two forms that are not interchangeable: an integer id and a PR-NNN identifier. Nearly every v1 project capability takes the integer; the v2 routes take PR-NNN. Read a project once to get both.", + "A project the caller cannot see is reported as 'not found' rather than 'forbidden', so a missing project and a permission gap look identical.", + "Projects are never deleted through the capabilities in this index \u2014 a project created by mistake can only be removed in the product UI.", + "Project settings are stored as free-form name/value pairs and keep no history: overwriting one loses the previous value, and a setting that was never written simply has no entry rather than a default.", + "Project-level analytics are aggregate counters only; none of the dashboard figures can be drilled down to the individual cases or runs behind them.", + "Listing projects and searching inside a project are different surfaces with different pagination: project listings cap at 300 per page, in-project search at 300 for test cases and 30 for test runs." + ] + }, + "report": { + "entity": "report", + "title": "Report", + "description": "A saved, optionally scheduled analytics view over a project's test data.", + "aliases": [ + "reports", + "scheduled-report", + "schedule", + "analytics-report", + "report-section", + "report-widget", + "report-drilldown", + "traceability-report", + "user-workload-report", + "exploratory-session-summary", + "report-export" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "summarises" + }, + { + "entity": "test_plan", + "via": "summarises" + }, + { + "entity": "test_case", + "via": "traces (traceability)" + }, + { + "entity": "user", + "via": "mailed to" + } + ], + "capabilities": [ + "create_report_by_integer_id", + "create_report_by_integer_id", + "delete_report_schedule", + "download_report", + "get_exploratory_session_summary", + "get_report_attachment_url", + "get_report_detail", + "get_report_section", + "get_report_summary_by_type", + "get_report", + "get_selected_report_testcases", + "list_reports", + "list_schedules", + "send_report_email_now", + "update_report", + "get_user_workload_drilldown" + ], + "key_facts": [ + "report_id and schedule_id are INTEGERS; the parent project takes the INTEGER project id (v1).", + "report_type on create/update is a DISPLAY NAME and the set is CLOSED \u2014 exactly these nine: Test Run Summary, Test Run Detailed Report, Test Plan Summary, Requirement Traceability Report, Test Case Activity, Exploratory Session Summary, User Workload Report, Defects Summary, Defects Detailed Report. Never invent one and never substitute a neighbouring type: if the user asks for activity, it is 'Test Case Activity', not 'Test Run Summary'.", + "Defects Summary and Defects Detailed Report are accepted on create but have no data branch \u2014 they always read back report_data: null. For defect counts use Test Run Summary's issues_by_priority / issues_by_status sections instead.", + "null inside report_data means NOT COMPUTED, not 'no data'. A Test Case Activity report fills its headline counts on a bare read; a Test Run Summary leaves issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null until you name them in sections. Never conclude 'there is no defect or status data' from a null \u2014 request the section; an empty payload like {total: 0} is a real finding, null is not. And never answer a 400 on sections by retreating to a bare read.", + "*_drilldown sections return PAGINATED ROWS, not aggregates \u2014 never tally rows into a count; read the aggregate section or report_data.", + "frequency + frequency_details travel together or not at all; omit both for an unscheduled on-demand report. weekly needs day as a lowercase name, monthly needs day as an integer 1-28. There is no 'once'. report_timeframe: custom REQUIRES custom_date_range.", + "\"Understand a report\" = fetch the report + its section/widget data and summarise agent-side; the API returns structured data, not narrative.", + "There is a tenth report type, Test Plan Detailed Report, hidden behind a per-group feature flag \u2014 creating, reading or exporting one without the flag is refused as not permitted.", + "A report is found by its id within your group, not within the project you name: the project you address it through does not narrow the lookup, so an id belonging to a sibling project still resolves (and still deletes).", + "Reports are not versioned and there is no undo. An edit replaces the entire definition \u2014 every field you leave out is cleared, and filters you leave out are deleted \u2014 and a delete is permanent, silent, and reports success even for an id that never existed.", + "Whether a report is scheduled is inferred, never declared: it is scheduled exactly when both a cadence and its details are present. Drop either one on an edit and the report stops emailing. Recipients are only stored on a scheduled report.", + "A report can span up to 60 projects, but only for Test Run Summary, Test Plan Summary, Test Run Detailed, Test Plan Detailed, Test Case Activity, Requirement Traceability and User Workload \u2014 and User Workload stops at 5.", + "A report file cannot be downloaded through the API: the export call only hands back a job channel, so it is withheld. To get the file, mail it with send_report_email_now (confirm the recipient first \u2014 it sends immediately); to work with the figures, read them with get_report_summary_by_type or get_report_detail.", + "Listing reports is fixed at 30 per page and always newest-first; the page size cannot be changed and asking for a page past the end is an error rather than an empty page.", + "Requirement Traceability reports have a grain \u2014 test case (default) or test plan \u2014 fixed when the report is created and refused on any later edit." + ] + }, + "result": { + "entity": "result", + "title": "Result", + "description": "The recorded outcome of one test case in one run: its status, and who set it.", + "aliases": [ + "outcome", + "execution-result", + "test-result" + ], + "id_convention": "integer", + "parents": [ + "test_run", + "test_case" + ], + "relations": [ + { + "entity": "test_case", + "via": "references" + }, + { + "entity": "test_run", + "via": "references" + } + ], + "capabilities": [ + "bulk_delete_test_results", + "bulk_set_test_result_status", + "create_step_result", + "create_test_result_for_test_case", + "create_test_results_for_test_run", + "delete_test_result", + "list_test_results_for_test_case_by_integer_id", + "list_test_results_for_test_case", + "search_test_results", + "update_latest_test_result_for_test_case" + ], + "key_facts": [ + "Bodies are wrapped in test_result; set status by NAME (status) or id (status_id) \u2014 use a real configured status (see statuses-and-states).", + "bulk-delete SUCCEEDS PARTIALLY and still returns 200. Authorisation is per id (author OR project admin); ids the caller may not delete come back in `skipped` \u2014 and BOTH lists are nested under a top-level `result` key, with `skipped` holding OBJECTS ({id, reason}) not bare ids, so read result.deleted_ids / result.skipped. Not as an error. ALWAYS compare deleted_ids against what you asked for and report the skipped ids \u2014 treating 200 as 'all deleted' will misreport.", + "bulk-delete needs a non-empty result_ids array (400 otherwise) of at most 300 ids (422 beyond) \u2014 batch bigger sets yourself.", + "On the v2 surface a result's status is given by NAME (a status configured for the project, matched case-insensitively); the numeric status_id the v1 surface takes is not accepted there.", + "Logging a result APPENDS to a test case's result history in that run, so the previous outcome stays readable. A bulk status set REPLACES the case's current outcome in place instead \u2014 the overwritten status is not recoverable.", + "Bulk result writes are queued, not applied inline: a success response means accepted for processing. Per-case outcomes come back as ACCEPTED/FAILED, a partly-valid batch still answers 200 with success:false and status PARTIAL, and a bulk status set reports nothing per case at all \u2014 read the results back to confirm.", + "A project can restrict which review statuses are allowed to log an execution result; cases outside that set are rejected with a REVIEW_STATUS_GATE error rather than being logged.", + "Result attachments only attach on a multipart upload that carries the files alongside the JSON body; naming filenames in a plain JSON request is accepted and does nothing. Limits are 10 files per result, 100 per request and 50 MB per file, and a result description over 16 KB is truncated, not rejected.", + "Amending the latest result edits it in place and keeps no prior copy of the amended values; logging a new result instead preserves the earlier outcome as its own entry in the case's history for that run.", + "Deleting a result is permanent and silently changes the case's standing in the run: its outcome rolls back to the previous attempt, or to unexecuted if that was the only one.", + "Only the person who logged a result may delete it, unless they are a project admin \u2014 a mixed batch comes back partly done rather than refused.", + "Result attachments are always referenced by the numeric id of a previously uploaded file; naming a filename in the body does nothing." + ] + }, + "shared_step": { + "entity": "shared_step", + "title": "Shared step", + "description": "A reusable block of test steps embedded by reference, held in one flat untyped list per project.", + "aliases": [ + "shared step", + "shared steps", + "reusable step", + "step template", + "shared field", + "step block", + "shared component" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "reused by" + } + ], + "capabilities": [ + "create_shared_step", + "delete_shared_step", + "get_shared_step", + "get_shared_steps", + "update_shared_step" + ], + "key_facts": [ + "A shared step is embedded by MANY test cases, so editing or deleting it changes every one of them at once. That is the point of shared steps, but it also means a delete is a wide blast radius \u2014 say how it is used before changing it.", + "Reference a shared step from a test case's steps via shared_step_id in test_case_steps \u2014 see test-case-authoring.", + "Shared steps keep no version history, unlike test cases and test plans, so an edit to one cannot be rolled back and a delete cannot be undone \u2014 there is no bin for them.", + "Each row inside a shared step has its own id, and a test case's step holds both the shared step's id and that row's id. Rebuilding the rows instead of updating them in place breaks every reference into the block.", + "An edit replaces the whole step list rather than merging: read the block, change the array, send it back whole. Rows left out are removed.", + "The count of test cases embedding a block is returned on every read of it \u2014 that count is the blast radius of any edit or delete, and it is the number to check first." + ] + }, + "tag": { + "entity": "tag", + "title": "Tag", + "description": "A free-text label on a test case, run or plan, used to group and filter them.", + "aliases": [ + "tags", + "label", + "labels", + "tag", + "keyword", + "keywords" + ], + "id_convention": "name", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "labels" + }, + { + "entity": "test_run", + "via": "labels" + }, + { + "entity": "test_plan", + "via": "labels" + } + ], + "capabilities": [ + "bulk_replace_test_case_fields", + "get_test_case_tags", + "list_test_case_tags", + "merge_tags", + "search_group_tags", + "search_test_case_tags" + ], + "key_facts": [ + "Tags are referenced by NAME in filters and on cases; the merge endpoint takes integer source_id/target_id, so resolve ids via the tag reads first.", + "That body MUST carry custom_fields (send {} when not changing any) \u2014 omitting it returns 500 'undefined method each for nil', even though the server's validator lists it as optional.", + "tags/merge is admin-gated (tags_management flag) \u2014 may be unavailable for a group; degrade gracefully and say so.", + "TAG-OBJECT WRITES ARE NOT EXPOSED. Renaming or deleting a tag project-wide is deliberately out of this profile: the endpoints exist but sit behind the tags_management flag that most groups do not have, and tagging is always driven through the ENTITY. If asked to rename or delete a tag, say it is not available here and point to the Test Management UI.", + "Tags are referenced by NAME on entities and in filters. Tag IDs exist but are only returned by the workspace-wide tag search; the project-level tag reads give names only, so resolve ids there when an operation needs them.", + "Tag vocabularies are per entity kind \u2014 test case, test run, test plan and shared step tags are separate sets. A name existing in one says nothing about the others, and an operation must name which vocabulary it means.", + "Merging one tag into another is not reversible and is not confined to a project: the losing tag stops existing and everything that carried it is re-pointed at the surviving tag, across the whole workspace. Capture what carries a tag before merging it \u2014 nothing afterwards tells you what changed.", + "Tag management \u2014 merging and renaming \u2014 is restricted to account owners and admins. A caller without that role is refused.", + "Renaming or deleting a tag as an object is deliberately not exposed here \u2014 tagging is driven through the entity. If asked to rename or delete a tag, say it is not available and point to the Test Management UI." + ] + }, + "template": { + "entity": "template", + "title": "Test case template", + "description": "A reusable field layout a test case is authored on \u2014 step or Gherkin style \u2014 setting which fields appear and in what order.", + "aliases": [ + "templates", + "test case template", + "case template", + "field template", + "test case layout", + "custom template", + "BDD template", + "Gherkin template", + "field layout" + ], + "id_convention": "integer, with no prefix. Discover ids through the template list call rather than guessing them.", + "parents": [], + "relations": [ + { + "entity": "test_case", + "via": "shapes the fields of" + }, + { + "entity": "project", + "via": "linked to" + }, + { + "entity": "custom_field", + "via": "arranges" + } + ], + "capabilities": [ + "create_template", + "delete_template", + "get_template", + "list_templates", + "list_test_case_templates", + "update_template" + ], + "key_facts": [ + "Templates belong to the workspace, not to a project \u2014 one template can be linked to many projects and keeps the same id everywhere. Do not look for them inside a project.", + "Templates exist for test cases, test results, test plans and exploratory sessions, but only test-case templates can be created or edited here; the older listing can enumerate the other three types and returns test-case templates unless asked for another kind.", + "Template ids are plain integers with no prefix (not PR- or TR- style). Discover an id from the template list rather than guessing it.", + "Templates have no version history and no recycle bin: an edit overwrites the previous field layout and a deletion cannot be undone from Test Management.", + "Disabling a template is the reversible alternative to deleting it \u2014 a disabled template stops being offered for new test cases but keeps its fields and project links and can be re-enabled.", + "Changing a template's field layout changes the fields shown on every test case using it, across every linked project. Say how many projects are affected before writing.", + "Creating, editing and deleting templates are paid-plan features; on a free plan the call reports failure in the body while still answering 200, and reading templates stays available.", + "The Review & Approve system fields (reviewers and review status) are hidden from reads on this surface and re-added automatically whenever the property fields are rewritten, so they can be neither seen nor removed here.", + "A template only affects a project once it is linked to it; creating a template links it to nothing unless linkage is requested.", + "Failures arrive as a single human-readable message next to a false success flag rather than as a structured error code.", + "Only test-case and test-result templates can be listed through the older settings surface. Asking it for test-plan or exploratory-session templates fails outright rather than returning an empty list, and the kind must always be stated \u2014 there is no default.", + "Only test-case templates exist today; there are no templates for runs, plans or results, so every template's entity reads TestCase.", + "A template's step style is fixed at creation: classic step rows or Gherkin (feature, background, scenario). A case authored on a Gherkin template cannot take step rows and vice versa, so choose the template before writing steps.", + "Templates belong to the workspace but apply only in the projects they are linked to. Naming a template that is unknown to, or not visible from, the caller's workspace is refused outright rather than falling back to the default.", + "Exactly one template is the default; a case created without naming a template lands on it. System templates ship with the workspace and cannot be deleted.", + "Disabled templates still appear in the listing (enabled false) but should not be offered for new cases.", + "Creating, editing, cloning, linking and deleting templates are paid-plan operations and are not exposed through these capabilities; only listing is." + ] + }, + "test_case": { + "entity": "test_case", + "title": "Test case", + "description": "The unit of test logic: title, preconditions, steps and expected results.", + "aliases": [ + "tc", + "case", + "test case", + "testcase", + "test-case", + "tc-id", + "scenario" + ], + "id_convention": "TC-NNN", + "parents": [ + "folder", + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "executed in" + }, + { + "entity": "result", + "via": "produces" + }, + { + "entity": "custom_field", + "via": "extended by" + }, + { + "entity": "attachment", + "via": "has" + }, + { + "entity": "tag", + "via": "labelled by" + }, + { + "entity": "shared_step", + "via": "reuses" + } + ], + "capabilities": [ + "apply_test_case_review_verdict", + "bulk_archive_test_cases_by_project", + "bulk_copy_test_cases", + "bulk_delete_test_cases", + "bulk_replace_test_case_fields", + "bulk_edit_test_cases_in_test_run", + "bulk_move_test_cases", + "bulk_restore_archived_test_cases", + "count_binned_test_cases", + "create_bulk_test_cases", + "create_test_case_by_integer_id", + "create_test_cases", + "edit_test_case_partial", + "edit_test_case", + "generate_test_case_automation", + "list_archived_test_cases", + "get_system_field_values", + "get_test_case_by_integer_id", + "get_test_case_detail", + "list_test_cases", + "list_binned_test_cases", + "list_folder_test_cases", + "move_test_case", + "reorder_test_cases_by_folder", + "search_project_entities_by_filter", + "search_test_cases_by_folder", + "submit_test_cases_for_review", + "update_test_case", + "validate_bdd_export_selection" + ], + "key_facts": [ + "A filter is a (key, value) pair and each half fails silently: an unknown KEY is dropped (you get the whole project with success: true), and a wrong-form VALUE matches nothing (count: 0, also success: true). So resolve both \u2014 never guess either \u2014 and see pagination-and-filtering for the key table and each value's source.", + "A surprising count means check the FORM, not try another value: 0 on a value the user plainly meant = wrong form, so go resolve ids; filtered count == unfiltered count = the key was dropped. Never take either count from a per_page: 1 call \u2014 it returns count: 1 regardless of the real total.", + "required.fields ADDS joined columns; it does NOT slim a row, so naming fields never buys more rows per page. required.fields=[\"count\"] ALONE is the only value that changes the response.", + "The compact response has a DIFFERENT envelope: no `success` key ({test_cases, info, filter_details} only \u2014 absence of success is NOT failure), info.page_size echoes the server ceiling not your per_page, and filter_details is {} so confirm filter intent before the call.", + "READING ROWS to show a user: leave per_page at its default and walk p until info.next is null. p advances by per_page and not by what came back, so paging over an incomplete read never recovers the gap.", + "Paths take INTEGER ids (project + folder + case). The case's TC-NNN comes back as data.test_case.identifier on any read; folder ids are plain integers.", + "Every successful edit to a test case writes a new version, and an earlier version can be restored \u2014 so an edit is recoverable even though nothing offers an undo. Deleting the case is not.", + "That safety net is plan-gated: below an Enterprise or Team-Pro plan only the last 30 days of versions are visible, and restoring one needs a paid plan \u2014 so an older edit may not be recoverable in practice.", + "The newer v2 project surface addresses a test case by its TC-NNN identifier \u2014 or by its third-party identifier in workspaces that issue project-level ids \u2014 and does not accept the internal integer id; the older v1 surface is the reverse.", + "Custom fields on a test case are addressed by their display NAME on the v2 surface and by numeric field id on the v1 surface; a name the project does not define is skipped without any error, so a typo reads back as a successful call that changed nothing.", + "Priority, status and case type are per-project configurable values, not a fixed vocabulary: send the project's own display value (or its numeric id) and resolve the set from the project's system field values before writing.", + "Priority, status, case type and automation state are per-project configurable option sets. The single-case write paths accept either the numeric option id or the project's display name, but every BULK write requires the numeric id \u2014 a display name there is stored as a broken id with no error. Resolve the ids from the project's system field values first.", + "`status` is the exception on every path, single and bulk alike: it is only ever taken as a numeric option id, never resolved from a display name.", + "A case's steps are only ever read from `test_case_steps`. A key called `steps` is legacy, is parsed as a JSON-encoded STRING, and turns a real JSON array into an empty step list \u2014 wiping the case's steps while still reporting success.", + "`estimate` is a dead field: it is accepted on the write paths and then never stored, and every read serialises it as null. The estimate that actually persists is `estimated_duration_seconds`, in seconds, and it is not settable through any bulk edit.", + "Bulk operations switch to a background job above a size threshold that differs per operation \u2014 around 30 for a copy, 100 for an edit or move, 300 for a delete or restore. Past it the call returns an acknowledgement with a unique id instead of the result, so anything that reads the response body as the outcome is wrong for large selections.", + "Two bulk operations cannot touch the same cases at once: the second is refused with a 'TestCases in progress with another operation' error. That is a retry, not bad input.", + "Archiving and un-archiving test cases, and listing the archive, are gated on a paid plan \u2014 and the refusal comes back as a SUCCESSFUL response carrying success:false, not as an error status.", + "Whether deleting a case is recoverable depends on the workspace: where the Bin is enabled the case is moved there and can be restored, otherwise the delete is permanent. Either way the case's results and schedule links do not come back.", + "Creating a case from a template is not automatic: when neither a template name nor a step type is sent, the write paths default to the plain-text template, which stores no steps at all and still reports success." + ] + }, + "test_plan": { + "entity": "test_plan", + "title": "Test plan (and sub-plan)", + "description": "A container grouping several test runs, typically for one release or milestone.", + "aliases": [ + "tp", + "plan", + "milestone", + "sub test plan", + "subplan", + "stp", + "release plan", + "test cycle", + "sub-plan", + "child plan", + "parent plan" + ], + "id_convention": "TP-NNN (sub-plan STP-NNN)", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "groups" + }, + { + "entity": "test_plan", + "via": "parent/child via parent_plan_id" + } + ], + "capabilities": [ + "bulk_archive_test_plans", + "bulk_restore_archived_test_plans", + "clone_test_plan", + "create_test_plan_by_integer_id", + "delete_test_plan", + "get_archived_test_plan_count", + "get_test_plan_by_integer_id", + "get_test_plan_execution_trend", + "get_test_plan_linked_sessions_chart_data", + "list_test_plan_test_runs_by_integer_id", + "list_archived_test_plans", + "list_test_plans_by_integer_id", + "unlink_test_runs_from_test_plan", + "update_test_plan_by_integer_id", + "list_test_plans", + "create_test_plan", + "get_test_plan", + "create_sub_test_plan", + "list_test_plan_test_runs", + "update_test_plan" + ], + "key_facts": [ + "Paths take the INTEGER plan id. The prefixed identifier (TP-NNN / STP-NNN) is for display; never put it in a v1 path.", + "The read detail has NO data wrapper: the plan sits under a top-level test_plan key, and test_plan.identifier is the TP-NNN / STP-NNN.", + "Sub-plans are HIDDEN by default in the list. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", + "name IS REQUIRED ON EVERY PLAN UPDATE, including one that only touches test_runs. Omit it and the call is rejected with 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is \u2014 the server's edit branch is gated on name being present. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt. For example, {test_plan: {test_runs: [198]}} is a 400, and the same call with name added succeeds.", + "Run ids in test_runs are the INTEGER uuid from a run read \u2014 NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream service error). Take uuid, never id.", + "Linking a run MOVES it: a run belongs to at most one plan, so linking silently unlinks it from the plan that held it, with nothing in the 200 to say so. Read the runs' current membership first and tell the user which plans will lose runs.", + "test_runs accepts an id array OR a bulk-selection object {select_all, de_selected_ids, search_query}. select_all means 'the newest active runs', NOT 'everything matching' \u2014 and `filters` is accepted but SILENTLY IGNORED, so a filtered select_all links the whole recent set. Narrow with search_query (name/identifier substring) or send explicit ids.", + "bulk-archive / bulk-retrieve go ASYNC above the bulk threshold and return {success, async: true, unique_id} \u2014 the plans are NOT changed yet. Re-read to confirm; see async-jobs.", + "execution-trend is scoped by EXACTLY ONE of test_plan_id / test_run_id. Both \u2192 400 'Provide either test_plan_id or test_run_id, not both'; neither \u2192 400 'test_plan_id or test_run_id is required'.", + "execution-trend and linked-sessions-chart-data return chart DATA. There is no insights-widget object to create, update, or delete in this profile.", + "A plan's status only moves forward, one step at a time: new -> started -> completed. Once completed the plan is FROZEN \u2014 even a rename is refused with 'Cannot update completed test plan' \u2014 so complete it last.", + "Plans keep no version history. An overwritten name, description or date is gone for good; there is no prior revision to restore, unlike a test case.", + "The same plan is addressed two different ways depending on which generation of the API you are on: the newer surface takes the prefixed identifier (TP-NNN / STP-NNN), the older one the numeric id. Carry whichever the capability you picked declares.", + "On the newer surface a run's plan membership is changed from the RUN, never from the plan: the plan's own edit ignores a list of runs entirely (the older surface accepts one). Linking still moves the run out of whatever plan held it.", + "On the newer surface a sub-plan's parent is fixed when it is created \u2014 there is no re-parenting or promotion to a top-level plan there, and the plan's owner is always whoever created it.", + "Creating a plan takes a name and nothing else; a name is mandatory on create, on clone, and on almost every edit. A status supplied at creation time is accepted and then ignored \u2014 a new plan always starts in the `new` state.", + "A status change and a content edit cannot travel together: when a request moves the plan's status, everything else it carries (name, description, tags, issues, owner) is silently dropped. Rename first, then move the status.", + "Starting a plan needs its start date in the same request and completing it needs its end date; the dates already stored on the plan are not used as a fallback.", + "Archiving is an optional feature for the account. When it is switched off, every archive, restore and archived read answers as though the address did not exist \u2014 that is a feature gap, not a missing plan.", + "Reading or restoring archived plans requires the same permission as DELETING a plan, so someone who can happily create and edit plans may still be refused the archived view.", + "Archived plans vanish from the normal plan listing entirely and have their own listing and their own count; archived sub-plans appear in neither.", + "An archived plan reads as missing unless the read explicitly opts in to archived records.", + "Cloning a plan is a background job: the reply confirms the job was queued and carries no id, name or identifier for the copy, and nothing except the runs and sub-plans is inherited \u2014 the new plan's description, dates, tags and owner all come from the clone request.", + "Owner is given as the person's test-management user id, not their BrowserStack id or email, and an id that does not resolve inside the account is discarded rather than rejected \u2014 leaving the plan with no owner.", + "Tags are all-or-nothing: if any entry in the list is not a plain string the entire tag list is dropped without an error." + ] + }, + "test_run": { + "entity": "test_run", + "title": "Test run", + "description": "One execution cycle over a fixed set of test cases.", + "aliases": [ + "tr", + "run", + "execution", + "test cycle", + "cycle", + "test execution" + ], + "id_convention": "TR-NNN", + "parents": [ + "test_plan", + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "includes" + }, + { + "entity": "result", + "via": "produces" + }, + { + "entity": "configuration", + "via": "runs against" + }, + { + "entity": "test_plan", + "via": "grouped by" + } + ], + "capabilities": [ + "get_test_run_edit_selection", + "assign_test_run_owner", + "assign_test_run_test_cases", + "bulk_set_test_case_assignee_in_run", + "bulk_update_test_run_test_cases", + "clone_test_run_by_integer_id", + "clone_test_run_by_integer_id", + "close_test_run", + "count_run_selection", + "create_test_run_by_integer_id", + "create_test_run", + "delete_test_run", + "edit_test_run", + "export_test_run_csv", + "list_closed_test_runs", + "list_test_run_test_cases_by_integer_id", + "get_test_run_by_integer_id", + "get_test_run_detail", + "get_test_run_progress", + "list_test_run_test_cases", + "get_test_run", + "get_test_runs_form_fields", + "list_test_runs_selection", + "list_test_runs_by_integer_id", + "list_test_runs", + "replace_test_run", + "update_test_run" + ], + "key_facts": [ + "Only test_case_ids / selection / shared_selection put cases in a run. `filters` and `dynamic_filter` are a forward-sync rule, NOT a selection: a create carrying only `filters` returns 200 with ZERO cases, and sets is_dynamic: true even though you did not ask.", + "Dynamic runs match on nine fields only: priority, status, category, owner, automation_status, automation_state, folder, tags, issues. created_at/updated_at are stored and echoed on read but NEVER matched, so a 'last 7 days' dynamic run drops the date window and over-collects forever. Create runs static unless the user explicitly asks for sync.", + "test_run.run_state is MANDATORY on create and edit (new_run | in_progress | under_review | rejected | done | closed). It is validated before the selection, so a bare 400 'invalid data' means a bad test_run field \u2014 NOT a bad selection shape.", + "A run's `id` FIELD is the TR-NNN STRING, not the integer \u2014 on the project list, a plan's linked-run list and the single-run detail alike. The integer lives in `uuid` ({id: 'TR-90', uuid: 198}). Paths tolerate either form, which hides this; bodies and UI paths do not. Carrying `id` into a test plan's test_runs array sends a string that reaches the backing service uncoerced and 502s there. Take `uuid` whenever a run id leaves a read.", + "Full lifecycle is exposed: create, edit, assign, bulk-assign cases, bulk-edit results, clone, close, delete.", + "A test run keeps NO version history and has no restore: unlike a test case, an edited name, cleared tag list or removed case cannot be recovered. Confirm before overwriting or removing.", + "Removing a case from a run deletes the results recorded against it unless the caller explicitly asks to preserve them \u2014 the default is to discard.", + "Changing which cases a run covers is always queued: the call returns an acceptance immediately and the run still shows the old case list for a moment. Re-read the run's cases to confirm, and do not retry on the acceptance.", + "Every way of editing a run only ever ADDS cases to it. Taking cases out is a separate operation, and only that one can shrink a run.", + "The newer API generation identifies a run and its project only by their prefixed ids (TR-14, PR-1) and rejects the plain integers the older generation still tolerates.", + "Listing a project's runs hides closed ones by default. Unless the caller asks to include closed runs, or filters on a lifecycle state, a run count or 'all our runs' answer silently omits every closed run and still looks complete.", + "Filtering a run listing by owner fails open: an email that matches nobody in the group is dropped from the filter rather than refused, so a misspelled name returns a wider list that reads as a correct answer. Filtering by plan or by date fails closed and errors instead.", + "Creating a run does not populate it synchronously. The run exists as soon as the call succeeds, but its test cases are attached by background work, so an immediate read of the run's cases can come back empty or short. Re-read rather than retrying the create, which makes a second run.", + "A run's name is optional at creation: leave it out and the run is named 'Test Run' plus today's date, which is how projects end up with several identically named runs.", + "Marking a run as an automation run is not something a caller can do at creation \u2014 the flag is accepted and discarded, but it still decides which permission is demanded, so asking for it can be refused outright.", + "Rows in a run are per case-and-configuration pair, not per test case: a case covered by three configurations is three rows with the same case id and three different mapping ids. Every bulk operation on a run addresses those mapping ids, and passing the test case's own id instead returns success and changes nothing.", + "Cloning a run copies NO test cases unless the caller explicitly asks for them. A clone requested without a case selection succeeds and produces an empty run, and the clone always starts in the new-run state with no configurations, owner or sync rule carried over.", + "Bulk changes inside a run switch from immediate to queued above 300 selected rows, and the queued form returns an acknowledgement with no data. The selected rows are locked while either form runs, so a second bulk operation touching any of them is refused as contention rather than queued behind it.", + "Deleting a finished run needs a different permission from deleting one still in progress, so a role that can clear up working runs can be refused on completed ones.", + "Discovering how many cases a selection really covers is a distinct read: it multiplies the cases by the configurations, so the number it returns is routinely far larger than the number of cases chosen.", + "Editing a run requires resending its lifecycle state on every call, including edits that only change the name. A rejected edit that reports nothing more than 'invalid data' is almost always that missing state, not a bad case selection." + ] + }, + "user": { + "entity": "user", + "title": "User", + "description": "A member with access to a project: who you assign a case to, or set as an owner.", + "aliases": [ + "users", + "member", + "assignee", + "owner", + "user", + "members", + "people", + "teammate" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "member of" + }, + { + "entity": "test_run", + "via": "assigned to" + }, + { + "entity": "test_case", + "via": "owns" + } + ], + "capabilities": [ + "list_group_users", + "search_project_users" + ], + "key_facts": [ + "WRITE BODIES take the integer: test_case.owner, test_run.owner/assignee_id \u2014 send 14.", + "An unrecognised q key is dropped, not rejected \u2014 the server silently omits that condition and returns an unfiltered set that looks like a successful filter. Use the documented key names verbatim.", + "Every person has two ids: a Test Management user id and a BrowserStack account id. Owner and assignee fields take the Test Management id; several listing filters take the BrowserStack one instead. Sending the wrong one usually matches nobody rather than failing, so check which the operation wants.", + "Both user reads here are scoped to the whole workspace, not to a project \u2014 neither can answer 'who has access to this project', and one of them says so in its path while doing the opposite.", + "Name search matches the full name only; there is no partial-email search. An exact-email lookup exists and returns at most one person.", + "Only active users are listed, so someone who has left the workspace cannot be resolved even when their name is still on a case or run.", + "An unrecognised search key is dropped rather than rejected \u2014 the condition silently disappears and an unfiltered set comes back looking like a successful filter. Use the documented key names verbatim." + ] + }, + "version": { + "entity": "version", + "title": "Test case version", + "description": "A point-in-time snapshot of a test case, for viewing or restoring an earlier revision.", + "aliases": [ + "history", + "histories", + "revision", + "version-history" + ], + "id_convention": "integer", + "parents": [ + "test_case", + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "versions" + }, + { + "entity": "user", + "via": "changed by" + } + ], + "capabilities": [ + "list_test_case_histories", + "get_test_case_history_diff", + "get_test_case_history", + "restore_history", + "get_test_case_histories" + ], + "key_facts": [ + "Each record carries source (create/update), modified_fields, a modified map (per-field old/new), user and created_at.", + "diff (compare two versions) and restore require a PAID plan; listing + single-version reads work otherwise. Degrade gracefully.", + "A plain-language diff summary is agent-side \u2014 read the field deltas and describe them; the API returns structured data, not prose.", + "A revision is addressed two different ways depending on the generation of the read: the older listing hands back a numeric history record id, the newer one identifies a revision only by its position label V1, V2, \u2026 with V1 the oldest revision still visible.", + "Without an Enterprise or Team Pro plan only the last 30 days of a test case's revisions are visible; older ones are absent with no flag saying so, and position labels count only what is visible, so they renumber as old revisions age out.", + "In the newer payload the editor arrives as updated_by, an email string that is null when the user cannot be resolved in this group, instead of the nested user object the older records carry.", + "A revision listing populates the per-field old/new detail only for the FIRST record on each page; every other record names the changed fields but not their values, so summarising a page of changes costs one extra read per revision.", + "Comparing two revisions only looks at a fixed set of fields (name, description, preconditions, automation status, type, status, priority, owner, assignee, folder, tags, defects, custom fields, steps, attachments, reviewers, review status). A change to anything else is invisible in a comparison.", + "Restoring is a full overwrite of the snapshotted fields, not a merge: a custom field that is empty in the snapshot is cleared, steps added since are removed, and attachments added since are unlinked. Say what will be lost before restoring.", + "A restore is applied as an ordinary edit, so the pre-restore state stays in the revision trail and can itself be restored \u2014 that is the only way back, and there is no undo action." + ] + }, + "comment": { + "entity": "comment", + "title": "Comment", + "description": "A rich-text note a team member posts on a test case, on its execution in a test run, or on a test plan, to discuss it.", + "aliases": [ + "comments", + "note", + "notes", + "remark", + "feedback", + "discussion", + "thread", + "review comment", + "review feedback", + "mention", + "@mention", + "annotation", + "message" + ], + "id_convention": "Plain integer, assigned on creation and returned as `id` on the posted comment. Existing comment ids are not guessable and must be discovered through the comment listing.", + "parents": [ + "test_run", + "test_case", + "test_plan" + ], + "relations": [ + { + "entity": "test_case", + "via": "posted on" + }, + { + "entity": "test_run", + "via": "scoped to the case's execution in" + }, + { + "entity": "user", + "via": "authored by / mentions" + } + ], + "capabilities": [ + "list_test_run_test_case_comments", + "create_test_run_test_case_comment" + ], + "key_facts": [ + "A comment can hang off a test case in its folder, off a test case's execution inside a specific test run, or off a test plan. Only the test-run variant is published in this pass; folder-scoped test-case comments and test-plan comments exist in the product but are not exposed here.", + "Comments are a paid-plan feature. On a free plan every comment call comes back HTTP 200 with success:false and the message 'This Feature is only for paid customers' \u2014 the body, not the status, carries the refusal.", + "Reading and posting comments both need the test-case VIEW permission; no separate comment or write permission exists, so anyone who can see the case can comment on it.", + "Comments cannot be posted on automation test runs (Automate/Observability-backed), and not on an archived test case; reading an archived case's comments still works.", + "Mentioning a colleague \u2014 via the mention span markup carrying data-lexical-mention, data-mention (their display name) and data-id (their BrowserStack user id) \u2014 notifies them immediately, and the run owner is alerted on every new comment. A mention of someone outside the group fails the whole post; a span missing data-mention is accepted but shows as plain text rather than a tag.", + "Display names are not unique within an account \u2014 two people can share one \u2014 so resolve the person to mention by email, not name, and take their BrowserStack user id from that row.", + "The body is HTML rich text. Whitespace-only or tag-only content is refused; an inline image counts as content. Stored HTML can differ from what was sent (links normalised, mentions rewritten).", + "Threads are per run/case/configuration cell: a case mapped to three configurations in a run has three separate comment threads, chosen by the mapping id.", + "Listings come newest first, 30 to a batch, and the size cannot be changed. Asking beyond the end returns the last batch again rather than an empty one, so stop when the next-batch marker is null.", + "Posting a comment is recoverable: the author can edit it or delete it afterwards, and deletion hides the comment rather than destroying it. Neither edit nor delete is exposed in this pass.", + "On each returned comment `edited` is true once it has been changed after posting; the author object's `id` is the internal TM user id (not the BrowserStack user id) and is null for imported comments, which show the original author's name instead.", + "Mentioning a colleague in a comment notifies them straight away, and the owner of the item commented on is notified of every new comment. Both are real messages that leave the product and cannot be recalled.", + "A person can only be mentioned if they belong to the same account; naming anyone else is refused rather than quietly ignored.", + "Only the person who wrote a comment can edit or delete it. Editing replaces the earlier text and keeps no previous version, and a deleted comment disappears from every listing with no self-serve way to restore it.", + "Comments are stored as rich text, so formatting, links and images survive, and a mention is stored as markup that displays as the person's name.", + "New comments cannot be added to an archived test case, although the comments already on it stay readable.", + "Comments are flat - each one sits on the item itself, and there is no way to reply beneath another comment.", + "Commenting is a paid-plan feature; on a plan without it the answer reports the feature as unavailable instead of failing." + ] + }, + "issue": { + "entity": "issue", + "title": "Linked issue", + "description": "A ticket in an external tracker such as Jira that test cases and runs are linked to.", + "aliases": [ + "requirement", + "ticket", + "jira issue", + "bug", + "work item" + ], + "id_convention": "The external tracker's own ticket key, as a string \u2014 for Jira an uppercase project key, a hyphen and digits, e.g. PROJ-123; other trackers use their own form. It is never a Test Management integer id and cannot be minted here: take it from the tracker itself, or read it from the linked-ticket list carried on a test case or test run. Test Management does not validate that the key exists, so a wrong key links silently.", + "parents": [], + "relations": [ + { + "entity": "test_case", + "via": "linked to" + }, + { + "entity": "test_run", + "via": "linked to" + }, + { + "entity": "project", + "via": "scoped by" + } + ], + "capabilities": [ + "link_entities_to_jira_issue", + "get_test_case_linked_issues", + "get_linked_test_case_selection", + "unlink_test_case", + "unlink_test_run" + ], + "key_facts": [ + "A linked issue is not stored by Test Management \u2014 only the link is. Unlinking removes the association and leaves the test case, the test run and the tracker ticket untouched, so every unlink here is reversible by linking again.", + "Linking and unlinking never write anything back into the external tracker; the ticket is not commented on, transitioned or modified.", + "One ticket can carry many test cases and many test runs, and one test case can be linked to several tickets, including tickets in different trackers.", + "The tracker a link belongs to is part of its identity: the same ticket key recorded under two different trackers is two independent links.", + "Unlinking identifies the link by the test case or test run plus the tracker, not by the ticket, so a case linked to several tickets in one tracker cannot be unlinked from a chosen one.", + "Test Management also has a separate Requirements feature of its own; a linked issue always lives in an external tracker and is a different thing, even though customers call both 'requirements'.", + "Ticket keys are never checked against the tracker, so a mistyped key produces a link to a ticket that does not exist and reports success." + ] + }, + "shared_field": { + "entity": "shared_field", + "title": "Shared field", + "description": "The foldered, typed form of a reusable content block: the same step rows plus a field type, a folder and tags.", + "aliases": [ + "shared component", + "shared field", + "reusable field", + "collection" + ], + "id_convention": "Plain integer, not guessable; discover ids by listing a project's shared fields. A shared step id comes from a different set and will not resolve as a shared field.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "belongs to" + }, + { + "entity": "test_case", + "via": "embedded by" + }, + { + "entity": "folder", + "via": "organised into collections" + }, + { + "entity": "tag", + "via": "labelled with" + } + ], + "capabilities": [ + "list_shared_components" + ], + "key_facts": [ + "A shared field is not a shared step. They are separate objects with separate listings and separate ids, and they are easy to confuse because both are reusable content - establish which one the user means before acting on an id.", + "The API calls this object a shared component while the product calls it a shared field; they are the same thing under two names.", + "Every shared field has a type - step, background or precondition - and the type decides where a test case can embed it.", + "Shared fields are organised into collections, which nest. A field placed in no collection sits in the Unassigned root.", + "A shared field is embedded by many test cases at once, so editing or deleting one changes every case that uses it; the usage count on the field is the measure of that blast radius.", + "Distinct from a shared step and stored separately: creating one does not make the other, and each is listed only by its own read.", + "Only the listing is available here. Creating, editing, deleting, copying and reading one by id all exist in the product but are not offered on this surface, so a block seen here cannot be changed here." + ] + } + }, + "version": "1.37" + } } diff --git a/capability/tra.capability-index.json b/capability/tra.capability-index.json index ffbd4a76..220057af 100644 --- a/capability/tra.capability-index.json +++ b/capability/tra.capability-index.json @@ -1,31709 +1,31710 @@ { - "schema_version": 1, - "version": "1.7", - "build_id": "tra-v1.7-ai-failure-naming", - "tra": { - "routing_terms": [ - "flaky", - "flakiness", - "workspace", - "configuration", - "schedule", - "suite", - "result" - ], - "summary": "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability): ingest, investigate and report on automated test runs. A build groups the test runs of one CI/SDK execution; each run carries status, logs, errors, hooks and AI root-cause analysis. Everything is scoped to a project. Read builds and runs; filter and search them; manage dashboards, custom views and saved reports; configure quality gates, webhooks, notifications, CI triggers and integrations; and read stability, performance, flakiness and unique-error analytics. Tests are REPORTED here, not executed here - execution happens in your runner or another BrowserStack product and is pushed in via the SDK. Access-key auth reaches only the /ext/v1 external API; everything else needs a session or service credential. Each run records its result on the browser, device, OS and environment it ran on, under the app version and the build tags it was pushed with, and a failure can be linked to an issue. Saved reports can be scheduled. Destructive capabilities need explicit human approval.", - "auth": { - "type": "oauth2" + "schema_version": 1, + "version": "1.7", + "build_id": "tra-v1.7-ai-failure-naming", + "tra": { + "title": "Test Reporting & Analytics", + "routing_terms": [ + "flaky", + "flakiness", + "workspace", + "configuration", + "schedule", + "suite", + "result" + ], + "summary": "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability): ingest, investigate and report on automated test runs. A build groups the test runs of one CI/SDK execution; each run carries status, logs, errors, hooks and AI root-cause analysis. Everything is scoped to a project. Read builds and runs; filter and search them; manage dashboards, custom views and saved reports; configure quality gates, webhooks, notifications, CI triggers and integrations; and read stability, performance, flakiness and unique-error analytics. Tests are REPORTED here, not executed here - execution happens in your runner or another BrowserStack product and is pushed in via the SDK. Access-key auth reaches only the /ext/v1 external API; everything else needs a session or service credential. Each run records its result on the browser, device, OS and environment it ran on, under the app version and the build tags it was pushed with, and a failure can be linked to an issue. Saved reports can be scheduled. Destructive capabilities need explicit human approval.", + "auth": { + "type": "oauth2" + }, + "base_urls": [ + "https://api-observability.browserstack.com", + "https://api-observability-eu.browserstack.com", + "https://api-observability-in.browserstack.com" + ], + "probe_path": "/api/v1/access", + "capabilities": [ + { + "name": "list_latest_build_summaries", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/buildSummaries", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + } + ], + "intent": "Return full build summaries (status, run counts, duration, CI/host info, user, failure stats) for up to the 5 latest builds of one project in a time window - the per-project body of the daily summary email.", + "returns": [ + "uuid", + "id", + "name", + "status", + "statusStats", + "issueTypeAggregate" + ], + "guidance": [ + "The body is a top-level ARRAY of build summaries, not a wrapped object.", + "Build selection is server-side: it returns at most the latest 5 builds in the window (hardcoded). To fetch specific builds instead, use list_build_summaries_by_ids.", + "This is an internal/job-facing endpoint - the handler does no group scoping or role check, so do not rely on it to enforce project access." + ], + "responses": { + "200": { + "$response": "BuildSummaryListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_name_status_aggregation", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/buildSummaryAggs", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "default": 11, + "description": "Caps the number of distinct build names returned (default 11, i.e. top 10 + 1)." + } + ], + "intent": "For one project and time window, return a per-build-name breakdown of run counts by display status ('for each distinct build name, how many runs passed/failed') - used to pick the noteworthy builds for the summary.", + "returns": [ + "count", + "passed", + "failed", + "skipped", + "unknown" + ], + "guidance": [ + "The body is a top-level MAP keyed by build name, each value `{count, passed, failed, skipped, unknown}`. There is no wrapper.", + "Only parent builds are counted, and builds whose usage quota status is 'locked' are excluded, so totals can be lower than the raw run count.", + "`limit` caps the number of distinct build names (default 11); it does not cap runs within a name." + ], + "responses": { + "200": { + "$response": "BuildAggregateMapResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_summaries_by_ids", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/projectBuildSummaries", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "buildIds", + "type": "array", + "required": true, + "description": "The explicit build ids (integers) to summarize. Required." + } + ], + "intent": "Return full build summaries for an explicitly supplied list of build ids within one project - the follow-up call once you know which builds you want.", + "returns": [ + "uuid", + "id", + "name", + "status", + "statusStats", + "issueTypeAggregate" + ], + "guidance": [ + "The body is a top-level ARRAY of build summaries, same shape as list_latest_build_summaries.", + "The only difference from list_latest_build_summaries is that the CALLER chooses the builds by id here, instead of the server picking the latest 5 in a window.", + "Internal/job-facing endpoint - no group scoping or role check in the handler.", + "Result order does NOT follow the requested buildIds order - match rows by `id`." + ], + "responses": { + "200": { + "$response": "BuildSummaryListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_daily_summary_report", + "method": "GET", + "path": "/api/v1/daily-summary/projectBuildSummaries/v2", + "mode": "read", + "entity": "notification", + "query": [ + { + "name": "projectIds", + "type": "array", + "required": true, + "description": "Project ids (integers) to include in the report. Required." + }, + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + } + ], + "intent": "Build the one-shot payload for the daily/periodic summary email: for a set of projects and a time window, per-project build counts, top failed builds, failed-test summary and failure-category breakdown, with the top failed build best-effort enriched with AI RCA, self-healing and test-authoring summaries.", + "returns": [ + "filterQueries", + "projects" + ], + "guidance": [ + "Despite the '/v2' path this is NOT another version of list_build_summaries_by_ids: that returns a flat array of build summaries, this returns the aggregated OverallDailySummary object `{filterQueries, projects:[...]}` and does its own build selection across the projects.", + "Heavy call: several aggregations plus three best-effort enrichment passes. The AI RCA / self-healing / authoring summary fields on the top failed build are swallowed on error, so they may be silently absent - do not treat a missing enrichment field as a failure.", + "`filterQueries` is a fixed set of canned query strings the email UI uses to deep-link into filtered views; it is not per-project data.", + "Internal/job-facing endpoint - no role check in the handler." + ], + "responses": { + "200": { + "$response": "OverallDailySummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_dashboard_public_link_setting", + "method": "GET", + "path": "/api/v1/setting/publicLink/dashboard/{dashboardId}", + "mode": "read", + "entity": "public_link", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "format": "uuid", + "required": true, + "description": "The custom dashboard's UUID." + } + ], + "intent": "Check whether public 'anyone with the link' sharing is currently turned on for a custom dashboard.", + "returns": [ + "publicLink" + ], + "guidance": [ + "The whole body is just `{publicLink: }`. A dashboard that has never been shared reads `false` (the default), which means sharing is off, not that the dashboard is missing.", + "The dashboard lookup is permission-aware: a dashboard the caller cannot see comes back 404 DASHBOARD_NOT_FOUND rather than a setting." + ], + "responses": { + "200": { + "$response": "PublicLinkSettingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_public_link_setting", + "method": "GET", + "path": "/api/v1/setting/publicLink/project/{projectId}", + "mode": "read", + "entity": "public_link", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The TestHub project id (NOT the observability project id)." + } + ], + "intent": "Check whether public link sharing is on for a project's report pages such as build insights.", + "returns": [ + "publicLink" + ], + "guidance": [ + "The whole body is just `{publicLink: }`. When the project has no token row yet the value is `false` (the default) \u2014 sharing off, not an error.", + "`projectId` is resolved as a TestHub project id, not the observability project id; an id that does not resolve is 404 PROJECT_NOT_FOUND." + ], + "responses": { + "200": { + "$response": "PublicLinkSettingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_quality_profile_filter_values", + "method": "GET", + "path": "/api/v1/projects/{projectName}/settings/quality-gates/filters", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project's normalised name." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "BUILD_NAME", + "BUILD_TAGS", + "TEST_TAGS", + "FOLDERS", + "ALERTS", + "FAILURE_CATEGORIES" + ], + "description": "Which value source to return. Bound from a query POJO \u2014 there is no request body despite the object-shaped parameters." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Search prefix/substring to filter values. Max 100 chars." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Cursor for the next page. Max 100 chars. Echoed back in the response as `afterKey` when more values remain." + }, + { + "name": "isGlobalProfile", + "type": "boolean", + "description": "Group-wide (true) or this project (false). REQUIRED in practice: the ES-backed types unbox it without a null check, so omitted -> 500.", + "required": true + } + ], + "intent": "Typeahead/value source for the filter builder inside a quality profile: return matching build names, build tags, test tags, folder paths, failure categories or alerts so a caller can pick which builds a gate applies to.", + "returns": [ + "data", + "afterKey", + "query" + ], + "responses": { + "200": { + "$response": "QualityProfileFilterValues" + } }, - "base_urls": [ - "https://api-observability.browserstack.com", - "https://api-observability-eu.browserstack.com", - "https://api-observability-in.browserstack.com" + "guidance": [ + "Always send isGlobalProfile (false for a project profile): QGFilterESQuery does `if (!getIsGlobalProfile())` on a nullable Boolean, so omitting it makes BUILD_NAME/BUILD_TAGS/TEST_TAGS/FOLDERS return 500.", + "Page size is fixed PER TYPE, not caller-controlled: 10 for BUILD_NAME/BUILD_TAGS/TEST_TAGS/FAILURE_CATEGORIES/ALERTS, and 1000 for FOLDERS. There is no page-size parameter.", + "Paginate by passing the response's `afterKey` back as the next request's `afterKey`. afterKey is set even on the last page, so stop when a page returns fewer values than the page size (or none).", + "BUILD_NAME/BUILD_TAGS/TEST_TAGS values come from Elasticsearch aggregations over the project's builds/test runs; FAILURE_CATEGORIES and ALERTS come from the DB and honour `isGlobalProfile`." + ] + }, + { + "name": "get_project_quality_gate_settings", + "method": "GET", + "path": "/ext/v1/quality-gates/{project_name}/settings", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project's normalised name." + } ], - "probe_path": "/api/v1/access", - "capabilities": [ - { - "name": "list_latest_build_summaries", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/buildSummaries", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - } - ], - "intent": "Return full build summaries (status, run counts, duration, CI/host info, user, failure stats) for up to the 5 latest builds of one project in a time window - the per-project body of the daily summary email.", - "returns": [ - "uuid", - "id", - "name", - "status", - "statusStats", - "issueTypeAggregate" - ], - "guidance": [ - "The body is a top-level ARRAY of build summaries, not a wrapped object.", - "Build selection is server-side: it returns at most the latest 5 builds in the window (hardcoded). To fetch specific builds instead, use list_build_summaries_by_ids.", - "This is an internal/job-facing endpoint - the handler does no group scoping or role check, so do not rely on it to enforce project access." - ], - "responses": { - "200": { - "$response": "BuildSummaryListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_name_status_aggregation", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/buildSummaryAggs", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "default": 11, - "description": "Caps the number of distinct build names returned (default 11, i.e. top 10 + 1)." - } - ], - "intent": "For one project and time window, return a per-build-name breakdown of run counts by display status ('for each distinct build name, how many runs passed/failed') - used to pick the noteworthy builds for the summary.", - "returns": [ - "count", - "passed", - "failed", - "skipped", - "unknown" - ], - "guidance": [ - "The body is a top-level MAP keyed by build name, each value `{count, passed, failed, skipped, unknown}`. There is no wrapper.", - "Only parent builds are counted, and builds whose usage quota status is 'locked' are excluded, so totals can be lower than the raw run count.", - "`limit` caps the number of distinct build names (default 11); it does not cap runs within a name." - ], - "responses": { - "200": { - "$response": "BuildAggregateMapResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_summaries_by_ids", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/projectBuildSummaries", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "buildIds", - "type": "array", - "required": true, - "description": "The explicit build ids (integers) to summarize. Required." - } - ], - "intent": "Return full build summaries for an explicitly supplied list of build ids within one project - the follow-up call once you know which builds you want.", - "returns": [ - "uuid", - "id", - "name", - "status", - "statusStats", - "issueTypeAggregate" - ], - "guidance": [ - "The body is a top-level ARRAY of build summaries, same shape as list_latest_build_summaries.", - "The only difference from list_latest_build_summaries is that the CALLER chooses the builds by id here, instead of the server picking the latest 5 in a window.", - "Internal/job-facing endpoint - no group scoping or role check in the handler.", - "Result order does NOT follow the requested buildIds order - match rows by `id`." - ], - "responses": { - "200": { - "$response": "BuildSummaryListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_daily_summary_report", - "method": "GET", - "path": "/api/v1/daily-summary/projectBuildSummaries/v2", - "mode": "read", - "entity": "notification", - "query": [ - { - "name": "projectIds", - "type": "array", - "required": true, - "description": "Project ids (integers) to include in the report. Required." - }, - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - } - ], - "intent": "Build the one-shot payload for the daily/periodic summary email: for a set of projects and a time window, per-project build counts, top failed builds, failed-test summary and failure-category breakdown, with the top failed build best-effort enriched with AI RCA, self-healing and test-authoring summaries.", - "returns": [ - "filterQueries", - "projects" - ], - "guidance": [ - "Despite the '/v2' path this is NOT another version of list_build_summaries_by_ids: that returns a flat array of build summaries, this returns the aggregated OverallDailySummary object `{filterQueries, projects:[...]}` and does its own build selection across the projects.", - "Heavy call: several aggregations plus three best-effort enrichment passes. The AI RCA / self-healing / authoring summary fields on the top failed build are swallowed on error, so they may be silently absent - do not treat a missing enrichment field as a failure.", - "`filterQueries` is a fixed set of canned query strings the email UI uses to deep-link into filtered views; it is not per-project data.", - "Internal/job-facing endpoint - no role check in the handler." - ], - "responses": { - "200": { - "$response": "OverallDailySummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_dashboard_public_link_setting", - "method": "GET", - "path": "/api/v1/setting/publicLink/dashboard/{dashboardId}", - "mode": "read", - "entity": "public_link", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "format": "uuid", - "required": true, - "description": "The custom dashboard's UUID." - } - ], - "intent": "Check whether public 'anyone with the link' sharing is currently turned on for a custom dashboard.", - "returns": [ - "publicLink" - ], - "guidance": [ - "The whole body is just `{publicLink: }`. A dashboard that has never been shared reads `false` (the default), which means sharing is off, not that the dashboard is missing.", - "The dashboard lookup is permission-aware: a dashboard the caller cannot see comes back 404 DASHBOARD_NOT_FOUND rather than a setting." - ], - "responses": { - "200": { - "$response": "PublicLinkSettingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_public_link_setting", - "method": "GET", - "path": "/api/v1/setting/publicLink/project/{projectId}", - "mode": "read", - "entity": "public_link", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The TestHub project id (NOT the observability project id)." - } - ], - "intent": "Check whether public link sharing is on for a project's report pages such as build insights.", - "returns": [ - "publicLink" - ], - "guidance": [ - "The whole body is just `{publicLink: }`. When the project has no token row yet the value is `false` (the default) \u2014 sharing off, not an error.", - "`projectId` is resolved as a TestHub project id, not the observability project id; an id that does not resolve is 404 PROJECT_NOT_FOUND." - ], - "responses": { - "200": { - "$response": "PublicLinkSettingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_quality_profile_filter_values", - "method": "GET", - "path": "/api/v1/projects/{projectName}/settings/quality-gates/filters", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "BUILD_NAME", - "BUILD_TAGS", - "TEST_TAGS", - "FOLDERS", - "ALERTS", - "FAILURE_CATEGORIES" - ], - "description": "Which value source to return. Bound from a query POJO \u2014 there is no request body despite the object-shaped parameters." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Search prefix/substring to filter values. Max 100 chars." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Cursor for the next page. Max 100 chars. Echoed back in the response as `afterKey` when more values remain." - }, - { - "name": "isGlobalProfile", - "type": "boolean", - "description": "Group-wide (true) or this project (false). REQUIRED in practice: the ES-backed types unbox it without a null check, so omitted -> 500.", - "required": true - } - ], - "intent": "Typeahead/value source for the filter builder inside a quality profile: return matching build names, build tags, test tags, folder paths, failure categories or alerts so a caller can pick which builds a gate applies to.", - "returns": [ - "data", - "afterKey", - "query" - ], - "responses": { - "200": { - "$response": "QualityProfileFilterValues" - } - }, - "guidance": [ - "Always send isGlobalProfile (false for a project profile): QGFilterESQuery does `if (!getIsGlobalProfile())` on a nullable Boolean, so omitting it makes BUILD_NAME/BUILD_TAGS/TEST_TAGS/FOLDERS return 500.", - "Page size is fixed PER TYPE, not caller-controlled: 10 for BUILD_NAME/BUILD_TAGS/TEST_TAGS/FAILURE_CATEGORIES/ALERTS, and 1000 for FOLDERS. There is no page-size parameter.", - "Paginate by passing the response's `afterKey` back as the next request's `afterKey`. afterKey is set even on the last page, so stop when a page returns fewer values than the page size (or none).", - "BUILD_NAME/BUILD_TAGS/TEST_TAGS values come from Elasticsearch aggregations over the project's builds/test runs; FAILURE_CATEGORIES and ALERTS come from the DB and honour `isGlobalProfile`." - ] - }, - { - "name": "get_project_quality_gate_settings", - "method": "GET", - "path": "/ext/v1/quality-gates/{project_name}/settings", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "intent": "Return a project's quality-gate configuration over the public (access-key) API: whether gates are enabled, whether the gate overrides build status, and the profiles attached to the project. Use this from CI/integrations authenticated with a username+access-key.", - "returns": [ - "enabled", - "shouldOverrideBuildStatus", - "qualityProfiles" - ], - "responses": { - "200": { - "$response": "QualityGateSettings" - } - }, - "guidance": [ - "Same response as the in-product `get_project_quality_gate_settings`; this is the external surface that accepts a username+access-key (CI/integration).", - "The action name reads like a plain profile list but it also carries the project-level enabled / shouldOverrideBuildStatus settings.", - "Plan-gated: refused as feature-inaccessible when the plan lacks Quality Gates.", - "For service-account callers this enforces the quality-gate project permission (fallback modify_project_settings); ordinary user-key callers skip that check." - ] - }, - { - "name": "get_quality_profile", - "method": "GET", - "path": "/ext/v1/quality-gates/{project_name}/profiles/{profile_id}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project's normalised name." - }, - { - "name": "profile_id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Profile UUID. A non-UUID value 400s before the handler runs." - } - ], - "intent": "Fetch one quality-gate profile in full \u2014 rules, thresholds and build filters \u2014 over the public (access-key) API, with failure-category operands resolved to the project's display names.", - "returns": [ - "uuid", - "name", - "ruleStatus", - "rules", - "applicableBuilds", - "isGlobalProfile", - "hooksVisibility" - ], - "responses": { - "200": { - "$response": "QualityProfileDetail" - } - }, - "guidance": [ - "`displayWith` is populated only for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules. Null fields are omitted (JSON include NON_NULL).", - "The profile is loaded by UUID and is not itself re-checked against the named project.", - "Plan-gated (feature-inaccessible when the plan lacks Quality Gates).", - "External surface accepting a username+access-key; for service-account callers enforces the quality-gate project permission, user-key callers skip it." - ] - }, - { - "name": "get_build_quality_gate_result", - "method": "GET", - "path": "/ext/v1/quality-gates/{build_id}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "The build uuid (40-char hashed id, not an RFC-4122 UUID), despite the name `build_id`. Looked up by UUID alone, not scoped to the caller's group." - } - ], - "intent": "The CI-facing quality gate check: given a build UUID, return whether the build passed or failed its quality gate (or that it is still running), so a pipeline can decide to proceed or fail.", - "returns": [ - "build_uuid", - "build_url", - "status", - "quality_gate_result", - "quality_profiles" - ], - "responses": { - "200": { - "$response": "CiQualityGateResult" - } - }, - "guidance": [ - "While the build is unfinished (or gate analysis is still in process) it returns status 'running' with `quality_gate_result` set to a 'try again in a few minutes' message \u2014 CI callers must POLL, not treat the first response as final.", - "`status`/`quality_gate_result` resolve to 'passed', 'failed' or 'skipped' once evaluation completes; null fields are omitted (JSON include NON_NULL).", - "Errors: build-not-found for an unknown UUID; forbidden-operation for a locked build; profile-not-found when quality gates are disabled for the project OR no profile results exist for the build.", - "The build is looked up by UUID alone and NOT scoped to the caller's group, unlike the profile endpoints on this surface. For service-account callers it enforces the quality-gate project permission; ordinary user-key callers skip that check.", - "Triggers configured Quality-Gate category integrations as a side effect when a result is produced." - ] - }, - { - "name": "get_build_quality_gate_summary", - "method": "GET", - "path": "/api/v1/builds/{uuid}/v2/quality-gate", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - } - ], - "intent": "The readiness-aware quality-gate summary for a build: return the per-profile results, or instead a status message when the build is still pending, the auto-analyser is running, or an evaluation state is cached. This is the endpoint to poll after triggering a recalculation.", - "returns": [ - "qgResult", - "message", - "state" - ], - "responses": { - "200": { - "$response": "BuildQualityGateSummary" - } - }, - "guidance": [ - "The 200 is one of several shapes and a caller MUST branch on it: `qgResult` (the per-profile results list) when ready; otherwise `message` = 'Quality Gate result will be available after your build finishes' (build pending) or 'Quality Gate evaluation will begin soon' (auto-analyser running); or a bare `state` string read from cache. Only one of qgResult/message/state is present per response.", - "Poll this after a recalculate to watch the gate finish; the message/state responses mean 'not ready yet', not an error.", - "Behavioural counterpart of `list_build_quality_profile_results`: that one returns the results as a bare array with no readiness handling; this one wraps them and can report still-evaluating/pending states instead. (They are the v1/v2 pair on this route.)", - "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface.", - "Ignore the extracted 'Check project permission' comment associated with this route \u2014 it belongs to a neighbouring close-build method; there is no extra permission check here." - ] - }, - { - "name": "list_build_quality_profile_results", - "method": "GET", - "path": "/api/v1/builds/{uuid}/quality-gate", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - } - ], - "intent": "Return the per-quality-profile results for a build as a flat list: each profile with its name, uuid and per-status rule counts \u2014 the quality gate breakdown on the build page.", - "returns": [ - "uuid", - "name", - "status", - "statusStats" - ], - "responses": { - "200": { - "$response": "BuildQualityProfileResults" - } - }, - "guidance": [ - "The 200 body is a BARE JSON ARRAY of per-profile result objects (no wrapper, no `data`, no readiness field); each item is {uuid, name, status, statusStats:{passed,failed,skipped}}.", - "This returns the raw results with NO readiness handling: if the build is still evaluating you get an empty/partial list rather than a 'still evaluating' signal. Use `get_build_quality_gate_summary` (the v2 route) when you need to distinguish 'not ready' from 'no results'.", - "For a split build's child, results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." - ] - }, - { - "name": "get_build_quality_profile_result", - "method": "GET", - "path": "/api/v1/builds/{uuid}/quality-gate/profile/{profileUuid}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - }, - { - "name": "profileUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "UUID of the quality profile. A non-UUID value 400s before the handler runs." - } - ], - "intent": "Drill into a single quality profile's result for a build: the individual rules, their thresholds and rendered conditions, and each rule's pass/fail outcome \u2014 for the quality-gate detail drawer.", - "returns": [ - "rules", - "ruleStatus" - ], - "responses": { - "200": { - "$response": "BuildQualityProfileResultDetail" - } - }, - "guidance": [ - "The body is an object {rules, ruleStatus}, where each rule entry is {rule, ruleStatus, ruleResult} \u2014 the rule definition plus that rule's per-build status and result.", - "Failure-category rules have their `displayWith` resolved to the project's human display names; other rule types do not.", - "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." - ] - }, - { - "name": "get_flakiness_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/flakiness", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at the plan retention floor. Rejected with 400 only if it is negative or greater than upperBound." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now. Silently clamped down to the plan retention ceiling." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name. Omit for all builds." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's runs are counted; repeat the param for a set." - } - ], - "intent": "Get the flakiness card for a project's Testing Trends page - the percentage and count of flaky tests over the window plus the sparkline series, to track whether suite flakiness is improving.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ALWAYS pass lowerBound/upperBound: omitting them returns the full retention window (tens of thousands of characters) and blows the tool output limit. For a single build's flaky/new-failure/muted/re-run counts read the build row (historyAggregate) via list_project_builds_by_name or get_build_details instead; this card is per build NAME over a window, not per run.", - "Plan-feature-gated: if the flakiness widget is not in the caller's plan the call fails with 403 (FEATURE_INACCESSIBLE), NOT a 200 with empty data - do not treat that as 'no flaky tests'.", - "The window is clamped to plan retention silently, and the bucket interval is derived from the width of the window you asked for, so a wider range yields coarser buckets.", - "`insights.count` is the headline percentage as a STRING (one decimal), and `insights.meta` is a human sentence like 'N of M unique tests are flaky'; the per-bucket series is in `data`, each point's `y` being the flaky count for that bucket (null when no tests ran).", - "Both bounds are optional here; omit them to get the full retention window." - ] - }, - { - "name": "get_always_failing_tests_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/alwaysFailing", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor. 400 only if negative or greater than upperBound." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to the plan retention ceiling." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name. Omit for all builds." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the always-failing card for a project - the percentage and count of consistently broken (permanently red) tests over the window plus the sparkline series, to see whether they are growing.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-feature-gated: refused with 403 (FEATURE_INACCESSIBLE) when the always-failing widget is not in the caller's plan, rather than returning empty data.", - "Side effect: for non-superusers this records the Testing Trends view in the caller's recently-viewed list. It is harmless and does not change test data, but it means this read is not entirely free of writes.", - "Window is clamped to plan retention silently; bucket interval is auto-chosen from the width of the window. `insights.count` is a percentage string and `insights.meta` a summary sentence." - ] - }, - { - "name": "get_new_failures_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/newFailures", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the new-failures card for a project - the percentage and count of tests failing for the first time in the window plus the series, the 'what did we just break' regression signal.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-feature-gated: 403 (FEATURE_INACCESSIBLE) when the new-failures widget is not in the caller's plan; not a 200 with empty data.", - "Window clamped to plan retention silently; bucket interval auto-chosen from window width. `insights.count` is a percentage string; per-bucket new-failure counts are in `data[].y` (null for buckets with no runs)." - ] - }, - { - "name": "get_test_growth_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testGrowth", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the test-suite growth card for a project - how the number of unique test cases changed over the window, with the timeline series, i.e. whether the suite is actually growing.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the `testGrowth` path this counts DISTINCT TEST CASES (suite size), not test runs - use get_test_executions_trend for run volume.", - "Unlike flakiness / new-failures / always-failing this card is NOT feature-gated, so it works on any plan.", - "`insights` is null on this card (no headline metric); read the series in `data`, where each point's `y` is the distinct-test-case count for that bucket. Window clamped to plan retention; bucket interval auto from window width." - ] - }, - { - "name": "get_test_executions_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testExecutions", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the test-execution volume card for a project - how many tests ran over the window, bucketed over time, to correlate suite growth or CI changes with other trends.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Counts executions (how many tests ran), not distinct cases; for suite size use get_test_growth_trend.", - "`insights.count` here is the raw total execution count as a string (not a percentage), and `insights.metric`/`insights.meta` are null. Per-bucket counts are in `data[].y` (0 for empty buckets); `data[].t` is null on this card.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_failure_categories_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/failureCategories", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Break a project's failures down by failure category over time, so a team can see which classes of failure (defect types) are trending up and their share of the total.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FailureCategoriesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Response is `{data: [ {name, color, data:[{x,y}]} ]}` - one entry per defect/issue category, each with its own time series; `name` and `color` come from the group's defect-type mapping.", - "Within a category series, a point's `y` is null for a bucket in which no tests ran at all (as opposed to 0 failures) - distinguish 'no data' from 'no failures'.", - "Window clamped to plan retention; bucket granularity auto-chosen from the width of the window. A project with no failures in the window returns an empty `data` array, not an error." - ] - }, - { - "name": "get_cross_browser_coverage", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/cbt", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at epoch 0 rather than the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the cross-browser-testing coverage breakdown for a project - the window's executions aggregated by browser, OS and device and grouped into primary/secondary platform buckets with counts and percentages.", - "returns": [ - "data", - "drillDownData" - ], - "responses": { - "200": { - "$response": "CbtResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`data` holds the top-level (primary) platform groups; `drillDownData` holds each group's secondary breakdown, addressed by the primary group's key (`drillDownData[].id` matches `data[].drilldown`).", - "`percentage` fields are formatted STRINGS (two decimals), and `value` is the raw execution count. Primary points carry os/browser/device as null; those are set only on secondary (drill-down) points. Runs without platform info land in an 'Unknown' group with 'NA' secondaries.", - "A project with no runs in the window returns empty groups, not an error. Window clamped to plan retention." - ] - }, - { - "name": "get_build_frequency", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildFrequency", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds. REQUIRED on this card (unlike the others). Missing it is a 400." - }, - { - "name": "upperBound", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds. REQUIRED. lowerBound > upperBound, or a negative bound, is a 400." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns the run count for just this one build name; when absent, the count per distinct build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get how often builds ran over a period - run counts per build name (or for one build name) - so a team can see CI cadence and gaps.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildFrequencyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Unlike every other Testing Trends card, BOTH bounds are mandatory here and are validated as a range - omitting one, sending them reversed, or a negative value is a 400.", - "This card does NOT clamp the window to plan retention; the exact bounds you send are used against the relational build store, so counts can differ from the analytics-index cards.", - "Response is `{data: [ {id, buildName, runs} ]}`; `id` is just a positional index into the list, not a build id, and `runs` is the run count. There is no time series here - it is a per-build total for the window.", - "A `sourceFencingFlags` key exists on the model but is not populated on this path, so treat it as always absent." - ] - }, - { - "name": "list_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildNames", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's builds contribute names; repeat the param for a set." - } - ], - "intent": "List the distinct build names seen in a project within the plan's retention window - the source of the build-name values every other Testing Trends card filters by.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildNamesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes NO date range - it always scans the full plan-retention window, so build names from older builds simply disappear once they fall out of retention.", - "Response is wrapped as `{data: [ \"\", ... ]}` - a flat array of strings, not build objects.", - "An unknown project is a 404 (PROJECT_NOT_FOUND), unlike the analytics cards which resolve the project inside the aggregation." - ] - }, - { - "name": "list_stability_leaderboard", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the stability metric itself always uses the plan-retention window." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns just this one build's stability row (the 'All' aggregate row is then omitted)." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the stability leaderboard for a project's Testing Trends page - pass-rate / stability scoring per build name over the window, to rank the least reliable suites.", - "returns": [ - "data", - "pagingParam" - ], - "responses": { - "200": { - "$response": "StabilityLeaderboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The requested window is REPLACED by the plan-retention window for the stability computation, so an out-of-retention window silently returns retention-bounded scores; the bounds you send only affect which builds are listed on the page.", - "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false. Note the response key is `pagingParam` (SINGULAR), and `pagingParam.pageNumber` echoes back one-based (your pageNumber + 1).", - "On page 0 with no `buildName`, an extra project-wide row (`id:\"all\"`, buildName:\"All\") is prepended before the per-build rows; on later pages or with a build filter it is absent.", - "Each row's `percentage` is a formatted STRING and `chartData` is the per-bucket pass-rate series (entries may be null for empty buckets). For a full, higher-resolution series for one build use get_project_stability_chart / get_build_stability_chart." - ] - }, - { - "name": "get_project_stability_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the project-wide stability-over-time series for the Testing Trends stability chart - across all builds, or for one build selected by build name.", - "returns": [ - "median", - "data" - ], - "responses": { - "200": { - "$response": "StabilityChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the build-name-selected variant of the stability chart: it derives the series identifier from the `buildName` query param and falls back to the literal \"all\" when it is absent. To address a build by an identifier in the path instead, use get_build_stability_chart.", - "Response is `{median, data}`: `median` is the overall pass-rate percentage for the window (a number), and `data` is the per-bucket series where each point's `y` is a pass-rate percentage NUMBER (e.g. 55.5), null for empty buckets.", - "Window clamped to plan retention; bucket interval auto-chosen from the width of the window.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_build_stability_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/{identifier}/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - }, - { - "name": "identifier", - "type": "string", - "required": true, - "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the stability-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a stability leaderboard row is expanded.", - "returns": [ - "median", - "data" - ], - "responses": { - "200": { - "$response": "StabilityChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query instead, use get_project_stability_chart. Both return the identical `{median, data}` shape.", - "Window clamped to plan retention; bucket interval auto-chosen from window width. Point `y` values are pass-rate percentage strings, null for buckets with no runs." - ] - }, - { - "name": "list_performance_leaderboard", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the metric always uses the plan-retention window." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns just this one build's performance row." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the performance leaderboard for a project's Testing Trends page - per-build execution-duration metrics over the window, to find which suites are slowing down.", - "returns": [ - "data", - "pagingParam" - ], - "responses": { - "200": { - "$response": "PerformanceLeaderboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The requested window is REPLACED by the plan-retention window for the duration metrics; the bounds you send only affect which builds appear on the page. So a window outside retention silently returns retention-bounded numbers.", - "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false (note the response key is `pagingParam`, SINGULAR). Builds with zero matching executions in the window are dropped from the page, so a page can contain fewer than 8 rows even when hasNext is true.", - "Each row: `avgDuration` (number, avg execution duration), `testCount` (avg test-run count), `barChartData` (per-bucket average durations) and `lineChartData` (per-bucket test counts) - note barChartData is durations and lineChartData is counts. Per-row full series also available via get_project_performance_chart / get_build_performance_chart." - ] - }, - { - "name": "get_project_performance_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the project-wide duration-over-time series for the Testing Trends performance chart - across all builds, or for one build selected by build name.", - "returns": [ - "barData", - "lineData" - ], - "responses": { - "200": { - "$response": "PerformanceChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the build-name-selected variant: it derives the series identifier from the `buildName` query param, defaulting to \"all\" when absent. To address a build by an identifier in the path, use get_build_performance_chart.", - "Response is `{barData, lineData}`: `barData` is the per-bucket average duration series and `lineData` is the per-bucket test-count series. `lineData` points carry `pointRange: null` (only `x`/`y` are meaningful), whereas `barData` points carry the range.", - "Window clamped to plan retention; bucket interval auto-chosen from window width.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_build_performance_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/{identifier}/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - }, - { - "name": "identifier", - "type": "string", - "required": true, - "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the duration-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a performance leaderboard row is expanded.", - "returns": [ - "barData", - "lineData" - ], - "responses": { - "200": { - "$response": "PerformanceChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query, use get_project_performance_chart. Both return the identical `{barData, lineData}` shape.", - "Bucket interval auto-chosen from window width. `barData` is average duration per bucket, `lineData` is test count per bucket (lineData points carry pointRange:null and t:null).", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "list_recent_runs", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns", - "mode": "write", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 5 builds; there is no page-size param." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's runs are listed; repeat the param for a set." - } - ], - "intent": "List a project's most recent builds - one entry per build with its summary (name, number, status, counts) - the run picker / recent-runs list on the Testing Trends page.", - "returns": [ - "data", - "pagingParams", - "currentQGOverrideStatus" - ], - "responses": { - "200": { - "$response": "RecentRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SIDE EFFECT despite being a GET: this marks the project as subscribed for the calling user - a persisted user-preference write. Do not call it merely to peek at recent runs if you must avoid changing the user's subscriptions; there is no read-only variant on this route.", - "Takes NO date range; results are just the latest builds. Fixed page of 5 builds via the zero-based `pageNumber`; stop when `pagingParams.hasNext` is false. Here the response key is `pagingParams` (PLURAL) - unlike the leaderboards' singular `pagingParam`.", - "`data` is an array of full build summaries (not trend series), and `currentQGOverrideStatus` is the project's quality-gate override flag.", - "The subscription side effect is observable: when the caller has no email preference on the project, it creates their EMAIL notifier/preferences (daily summary ON by default), visible via list_project_notification_recipients type=EMAIL. Only fires once per user+project." - ] - }, - { - "name": "list_group_webhook_projects", - "method": "GET", - "path": "/api/v1/settings/webhooks/projects", - "mode": "read", - "entity": "webhook", - "intent": "List the group's TCM/TO projects for the webhook editor's project picker \u2014 a paginated, searchable, sortable list that marks the projects already linked to a given webhook.", - "returns": [ - "projectMapping" - ], - "query": [ - { - "name": "page", - "type": "integer", - "required": true, - "description": "Page number (required). Page size is fixed at 30 and is NOT caller-controllable \u2014 there is no perPage/pageSize parameter here." - }, - { - "name": "sortBy", - "type": "string", - "required": false, - "description": "Sort token for the project list." - }, - { - "name": "webhookId", - "type": "string", - "format": "uuid", - "required": false, - "description": "When supplied, each returned project's `isSelected` flags whether it is already linked to this rule; the rule must exist in the caller's group and product bucket." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Project name substring filter." - } - ], - "responses": { - "200": { - "$response": "WebhookProjectMappingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Subject to the controller-wide feature-entitlement check. When webhookId is passed, the rule must be in the caller's group/bucket or it is not found.", - "Page size is HARDCODED to 30 and the product source is HARDCODED to `TCM,TO` \u2014 neither can be changed by the caller; there is no perPage parameter. Walk pages via info.next.", - "Each project carries `isSelected`, true when it is already linked to the webhookId you passed \u2014 that is how the picker pre-checks linked projects.", - "Response is nested under projectMapping:{info, projects}; info here is the project-picker PaginationInfo (totalProjectsCount/linkedProjectsCount), distinct from the rule-list info block." - ] - }, - { - "name": "retrigger_rca_analysis", - "method": "POST", - "path": "/api/v1/ai/testRuns/{testRunId}/rcaContext/retrigger", - "mode": "write", - "entity": "ai_insight", - "intent": "'Improve analysis' \u2014 re-run the agentic AI RCA for a failed test with a user instruction and up to 3 previously presigned attachments, creating a new conversation turn. Returns the new message id (and trial quota used) so you can poll for the refreshed analysis.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "instruction", - "type": "string", - "required": false, - "description": "Free-text instruction for the improved analysis. Max 5000 characters." - }, - { - "name": "attachments", - "type": "array", - "required": false, - "description": "Up to 3 previously presigned attachments to include as context. Each item references a key returned by the RCA-context presign call. More than 3 is rejected.", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "description": "The key returned by the RCA-context presign call. Non-blank, max 512 chars." - }, - { - "name": "fileName", - "type": "string", - "required": true, - "description": "Non-blank, max 200 chars." - }, - { - "name": "contentType", - "type": "string", - "required": true, - "description": "Non-blank, max 100 chars." - }, - { - "name": "description", - "type": "string", - "required": false, - "description": "Optional note about the attachment. Max 500 chars." - } - ] - }, - { - "name": "usePreferences", - "type": "boolean", - "required": false, - "description": "Whether saved code-fix / analysis preferences should be applied to this run." - }, - { - "name": "webhook_url", - "type": "string", - "required": false, - "description": "Optional Atlas agent-callback URL. When present it rides the retrigger's metadata and the finished RCA is delivered to it. Max 2048 chars. Wire name is webhook_url.", - "json_path": "/webhook_url" - } - ], - "returns": [ - "messageId" - ], - "responses": { - "200": { - "$response": "RcaRetriggerResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: the response carries only messageId (and trialQuota.used for trial users) \u2014 the analysis itself arrives later when the AI service calls back. Poll the RCA conversation / that message id for the refreshed result.", - "trialQuota is present only for test-level trial users (their usage counter is incremented on success); it is omitted otherwise, so do not treat its absence as an error.", - "Attachments must first be presigned and uploaded via the RCA-context presign call \u2014 pass those returned s3Keys here; at most 3.", - "Only agentic RCAs on RCA-v2-eligible builds are accepted, the test run must be within the allowed age, and a run owned by the MCP-driven agent is refused.", - "There is no undo for a retrigger \u2014 it appends a new conversation turn; approve or discard the resulting turn separately." - ] - }, - { - "name": "get_build_accessibility_meta", - "method": "GET", - "path": "/api/v1/builds/{buildId}/a11y-meta", - "mode": "read", - "entity": "build", - "intent": "Get the build-level accessibility summary header \u2014 whether auto-scan was enabled, the severity breakdown (critical/serious/moderate/minor) and the total issue count \u2014 for the strip above a build's per-test accessibility listing.", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Build UUID. Can be silently overridden by the `run` query param \u2014 see guidance." - } - ], - "query": [ - { - "name": "run", - "type": "array", - "description": "Optional test-run ids (bound from `A11yFilterParams.run`). If supplied, the FIRST entry is treated as a test-run id and the build UUID is resolved from it, OVERRIDING the path `buildId`. Send this empty/absent to honour the path build.", - "fields": [ - { - "name": "run[]", - "type": "integer" - } - ] - }, - { - "name": "status", - "type": "string", - "description": "Accessibility status filter (bound from A11yFilterParams). Not used to compute the meta totals on this route." - }, - { - "name": "searchQuery", - "type": "string", - "description": "Search token (bound from A11yFilterParams). Not used to compute the meta totals on this route." - } - ], - "returns": [ - "totalIssue", - "severityBreakdown", - "autoEnabledBuild", - "autoEnabled", - "reportUrl" - ], - "guidance": [ - "This is the web/Automate accessibility shape. For App-Automate builds that have App Accessibility enabled for the group, the request is proxied to the App Accessibility service and returns a DIFFERENT body \u2014 do not assume the fields below when the build is an app build.", - "The `run` query param silently overrides the path `buildId`: if you pass a run id, the meta is for the build that run belongs to, not the build in the path.", - "The build must have finished (status FINISHED or TIMEOUT) and carry accessibility data; otherwise you get a classified accessibility error (build-not-completed / build-not-found / results-not-found), not an empty 200.", - "`autoEnabledBuild` and `autoEnabled` are the same value duplicated, read from the build's accessibility settings.auto_enable (the auto-scan config), so they can be false on a build whose SDK run did scan. `severityBreakdown` carries each severity the build summary reports (zero counts included), and `totalIssue` is their sum.", - "`reportUrl` is a weak deep link into the Accessibility dashboard: its path uses the literal placeholder segments Project/builds/Build/Number and only the thBuildId query is real. It is not an API endpoint.", - "The central-auth JWT (cookie or Bearer) is extracted and forwarded to the App-Accessibility proxy on the app path; a missing token there surfaces as an accessibility-service auth error." - ], - "responses": { - "200": { - "$response": "BuildAccessibilityMetaResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_similar_test_runs", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/analyzer-similar", - "mode": "read", - "entity": "build", - "intent": "For the auto-analyser / unique-errors view: given a failed test run (or a set of error-cluster ids) in a build, list the other test runs with a similar failure so you can judge blast radius and treat the cluster as one issue.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "mode", - "type": "string", - "required": true, - "description": "Scope of the similarity search. Required \u2014 a missing/invalid value is a 400.", - "values": [ - "CURRENT_BUILD", - "LAST_10_BUILDS", - "LAST_10_DAYS" - ] - }, - { - "name": "testRunId", - "type": "integer", - "description": "Numeric test-run id to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present." - }, - { - "name": "clusterIds", - "type": "array", - "description": "Error-cluster ids to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present.", - "fields": [ - { - "name": "clusterIds[]", - "type": "string" - } - ] - } - ], - "returns": [ - "similar", - "current", - "message" - ], - "guidance": [ - "Send EITHER `testRunId` OR `clusterIds` (or both) \u2014 if neither is provided the call is a 400 (\"Either clusterIds or TestRunId is required\").", - "403 (plan-feature-not-allowed) when the build is locked by plan fencing; this is a plan-fenced analyser feature.", - "`current` describes the reference run and `similar` the matched runs; each carries the run uuid plus project/build normalised names and build number so you can deep-link. `message` may explain why the list is empty.", - "Available on public-link-token auth (public dashboards), unlike most write routes." - ], - "responses": { - "200": { - "$response": "SimilarTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_always_failing_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/alwaysFailingStats", - "mode": "read", - "entity": "build", - "intent": "Get the 'always failing' tile for a build \u2014 how many of the build's tests have been failing consistently (out of the total), plus the tile's comment count \u2014 for the build insights page.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text filter over the build's tests (part of the standard build filter set). The whole FilterParamsV2 filter set is accepted here; it narrows which tests the metric counts." - }, - { - "name": "status", - "type": "array", - "description": "Test status filter (standard build filter set).", - "fields": [ - { - "name": "status[]", - "type": "string" - } - ] - }, - { - "name": "folders", - "type": "array", - "description": "Folder-path filter (standard build filter set).", - "fields": [ - { - "name": "folders[]", - "type": "string" - } - ] - }, - { - "name": "testTags", - "type": "array", - "description": "Tag filter (standard build filter set)." - }, - { - "name": "customParams", - "type": "object", - "description": "Any extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded. Unknown keys are not rejected." - } - ], - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "guidance": [ - "`count` is the always-failing test count and `meta` typically carries the total it is out of; `commentCount` is the number of comments on this tile.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "One of a family of near-identical build tiles (flaky, new-failure, muted, defect, stability) that share this shape and differ only in the metric computed \u2014 pick the endpoint for the metric you want rather than re-filtering this one.", - "This is the standard build filter set: the same query params work across the tiles." - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_ai_failure_summary", - "method": "GET", - "path": "/api/v1/builds/{id}/aiSummary", - "mode": "read", - "entity": "build", - "intent": "Get the AI-generated narrative of why a build failed \u2014 a summary paragraph plus the top failure themes (name, description, affected test count, failure %) and the top affected workflows.", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The NUMERIC internal build id \u2014 NOT the UUID used by most other build routes. A UUID here will not resolve." - } - ], - "returns": [ - "summary", - "topFailureThemes", - "topAffectedWorkflows", - "status" - ], - "guidance": [ - "Takes the numeric build id, so it will NOT accept the UUID the sibling build endpoints use.", - "Fails soft: any upstream error or missing theme data returns 200 with `status: \"NOT_AVAILABLE\"`, `summary: null` and empty theme/workflow lists \u2014 check `status`, do not treat it as an error.", - "`topFailureThemes` and `topAffectedWorkflows` are each capped at 5 items.", - "Ownership-checked: if the build's group does not match the caller it reads as build-not-found." - ], - "responses": { - "200": { - "$response": "BuildAISummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_attempt_run_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildAttemptRunStats", - "mode": "read", - "entity": "build", - "intent": "Break a build's test results down per re-run attempt \u2014 pass/fail/skipped/unknown and workflow-status counts for attempt 1, 2, 3\u2026 (including manual runs) \u2014 so you can see how results changed across retries.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "attempts", - "summary", - "count", - "commentCount" - ], - "guidance": [ - "`attempts` is one entry per attempt (label, attemptNumber, totalAttempts and status counts); `summary` is the aggregate across attempts.", - "The project's hooks visibility is resolved first and changes which test-run documents are counted \u2014 the same build can report different totals depending on hooks visibility.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID." - ], - "responses": { - "200": { - "$response": "BuildAttemptRunStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_history", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildHistory", - "mode": "read", - "entity": "build", - "intent": "Get the trend of the recent builds of the same build name leading up to this one (per-build pass/fail counts, keys, lock state) so the build page can show how this build compares with previous runs.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text filter (standard build filter set). The whole FilterParamsV2 filter set is accepted and narrows what the trend counts." - }, - { - "name": "status", - "type": "array", - "description": "Test status filter (standard build filter set).", - "fields": [ - { - "name": "status[]", - "type": "string" - } - ] - }, - { - "name": "customParams", - "type": "object", - "description": "Extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded; unknown keys are not rejected." - } - ], - "returns": [ - "data", - "keys", - "commentCount" - ], - "guidance": [ - "The window is hard-coded to the ~10 most recent builds of the same build name \u2014 there is no page size or count parameter to widen it.", - "`data` is a per-status series and `keys` the build labels/order along the x-axis; builds locked by plan fencing are flagged within the mapping.", - "403 (plan-feature-not-allowed) when the CURRENT build is plan-locked; build-not-found for an unknown UUID." - ], - "responses": { - "200": { - "$response": "BuildHistoryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_id_from_build_coordinates", - "method": "GET", - "path": "/api/v1/builds/getBuildIdFromBuildInfo", - "mode": "read", - "entity": "build", - "intent": "Resolve a human-readable build coordinate (project name + build name + build serial number, i.e. what appears in a build URL) into the internal build id/UUID, so deep links and CI references can be turned into an API-usable handle.", - "query": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name as it appears in the dashboard URL." - }, - { - "name": "buildNormalisedName", - "type": "string", - "required": true, - "description": "Build normalised name as it appears in the dashboard URL." - }, - { - "name": "buildSerialId", - "type": "integer", - "required": true, - "description": "Build serial number (the #N shown in the UI)." - } - ], - "returns": [ - "buildId" - ], - "guidance": [ - "All three query params are required.", - "When no build matches you get a bare HTTP 404 with an EMPTY body (not a structured error object) \u2014 handle the status code, there is nothing to parse.", - "The lookup is scoped to the caller's group, so a build in another group reads as not-found.", - "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse.", - "`buildSerialId` is the UI #N only. If the user's number does not match (404) it may be the CI run id (`buildRunId`): resolve that with search_entities (entity Build) or list_project_builds_by_name, which return both ids." - ], - "responses": { - "200": { - "$response": "BuildIdFromCoordinatesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No build matched the coordinates. Empty response body.", - "schema": { - "type": "object" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_coordinates_from_uuid", - "method": "GET", - "path": "/api/v1/builds/getBuildInfoFromUuid/{uuid}", - "mode": "read", - "entity": "build", - "intent": "The inverse of the coordinate lookup: given a build UUID, get the URL-safe coordinates for it (project & build normalised names, serial id, project id/name, finished/created timestamps) so a caller holding only a UUID can construct the build's dashboard URL.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "buildNormalisedName", - "buildSerialId", - "projectNormalisedName", - "userId", - "groupId", - "projectName", - "projectFinishedAt", - "projectCreatedAt", - "toProjectId", - "thProjectId" - ], - "guidance": [ - "The normalised names are URI-encoded with spaces turned into '+', so use them verbatim when building a URL \u2014 do not re-encode.", - "If the build's group does not match the caller the call is refused with a forbidden-operation error.", - "An unknown UUID returns a build-not-found error (note: an older description claimed an empty JSON object here; the current handler throws not-found \u2014 do not rely on an empty-object sentinel).", - "`toProjectId` is the Observability project id and `thProjectId` the TestHub project id \u2014 they are different id spaces." - ], - "responses": { - "200": { - "$response": "BuildCoordinatesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_rerun_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildRerunStats", - "mode": "read", - "entity": "build", - "intent": "Get the flat re-run summary for a build \u2014 a list of re-run legs with per-leg pass/fail/skipped/unknown counts and totals \u2014 for the re-run section of the build page.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "retryData", - "count", - "commentCount" - ], - "guidance": [ - "This is the flat/legacy shape, computed straight from the DB by build id + group id; `retryData` is a flat list of legs with no split-build grouping and there is no aggregate `summary`.", - "For split or parent/child builds, or when you need the aggregate summary, use `get_split_build_rerun_stats` instead \u2014 its response shape is different and NOT a drop-in swap.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "Hooks visibility for the project is resolved first and affects which runs are counted." - ], - "responses": { - "200": { - "$response": "BuildReRunResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_split_build_rerun_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildRerunStats/v2", - "mode": "read", - "entity": "build", - "intent": "Get the split-build-aware re-run summary for a build \u2014 re-run legs grouped (with per-split breakdowns) plus an overall aggregate summary \u2014 resolved against the whole build and the Observability project id so split/parent-child builds are handled correctly.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "retryData", - "count", - "commentCount", - "summary" - ], - "guidance": [ - "Use this for split or parent/child builds and project-scoped configuration \u2014 it resolves against the whole build object and the Observability project id.", - "The response shape differs from `get_build_rerun_stats`: `retryData` is a list of GROUPS (each with `splits` and its own `summary` and build metadata) and there is a top-level aggregate `summary`. It is NOT a drop-in swap for the flat version.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "Hooks visibility for the project is resolved first and affects which runs are counted." - ], - "responses": { - "200": { - "$response": "BuildReRunV2Response" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_test_filters", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/filters", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "The build's UUID. If it is a leg of a split build, the parent is resolved automatically and the filter set is aggregated over the parent plus every child leg." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text search echoed back into the resolved `applied` block; part of the currently-applied test filter, not a facet type-ahead." - }, - { - "name": "status", - "type": "array", - "description": "Currently-applied test status values, echoed back resolved. All the FilterParamsV2 test-listing fields are accepted here (status, priority, caseType, automationStatus, testTags, folders, owner, assignee, deviceList, osList, browserList, failureCategories, dateRange, isFlaky, isNewFailure, ...) and are returned normalised under `applied`." - }, - { - "name": "priority", - "type": "array", - "description": "Applied priority ids. When any of priority / caseType / automationStatus is present the response rewrites their labels in `applied` to match the static catalogue, so read labels from the response rather than assuming them." - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Test-management user ids; max 500 entries (enforced server-side)." - } - ], - "intent": "Get the filter panel for a build's TEST listing \u2014 the currently applied test filters resolved with labels, the static catalogue of every selectable test filter, hooks visibility, and which filters are pro-only for this workspace.", - "returns": [ - "applied", - "staticFilters", - "hooksVisibility", - "proFilters" - ], - "guidance": [ - "This is the filter set for the TESTS inside one build, not for the build listing \u2014 use get_build_list_filters / get_build_sidebar_filters for the builds-list facets.", - "`applied` is your own query echoed back in resolved form (ids turned into user/label objects); `staticFilters` is the full catalogue of selectable values keyed by filter name. Read available option values from `staticFilters`, not from `applied`.", - "`proFilters` lists the filter keys locked behind a paid plan; it is NON-EMPTY only for a freemium workspace (then it names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted) and empty otherwise. Treat a key that appears here as disabled, not missing.", - "`hooksVisibility` is one of all / failed / beforeFailed / none and tells you how before/after hook tests are surfaced for this project.", - "For a split build you may pass any leg's UUID; the parent is resolved and the static filters are unioned across all legs, so the result is the same whichever leg you name." - ], - "responses": { - "200": { - "$response": "BuildTestFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_list_filters", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/filters", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised-name slug. Resolved within the caller's group first, so a project in another group (or an unknown slug) returns 404." - } - ], - "query": [ - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Applied build-name selection, echoed back resolved. Any of the GetBuildFilterRqV2 build-list fields may be supplied as query params." - }, - { - "name": "users", - "type": "array", - "description": "Applied build-owner user ids (integers), echoed back as resolved user objects (id + name)." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team ids (integers), echoed back as resolved team objects." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values \u2014 branded SDK names and pass-through values like 'appium'/'selenium-4'. Sent as strings (not an enum), matched case-insensitively; use the exact value the static catalogue offers." - }, - { - "name": "statuses", - "type": "array", - "description": "Applied build display-status values." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag values (build-level tags)." - }, - { - "name": "branches", - "type": "array", - "description": "Applied git branch values." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end] \u2014 a size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names (distinct from buildTags); max 500 entries, and gated by a per-group flag \u2014 ignored downstream when the flag is off.", - "maxItems": 500 - }, - { - "name": "state", - "type": "string", - "description": "Build state scope; defaults to `active` when omitted.", - "default": "active" - } - ], - "intent": "Populate the build-listing filter bar for a project in one call \u2014 the static catalogue of selectable build filters (statuses, frameworks, build types, product sources, creation types) plus the caller's currently applied filter echoed back resolved.", - "returns": [ - "applied", - "staticFilters" - ], - "guidance": [ - "`staticFilters` carries the option lists: statusList, frameworkList, types, sources, creationType \u2014 each a list of {id, value, label} option objects. `applied` is your query resolved (user/team ids to names, date range, tags, branches).", - "This is the PER-PROJECT filter bar (scoped by the path project) \u2014 it returns the catalogue for that project. For a cross-project, filter-narrowed facet list use the sidebar capabilities instead.", - "On a demo-sandbox tenant this short-circuits before any project lookup and returns a hardcoded framework/static list, so the option set there is the global master list rather than what the project actually contains.", - "An unknown or foreign project slug is a 404 \u2014 the project is resolved inside the caller's group." - ], - "responses": { - "200": { - "$response": "BuildListFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_branch_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/branches", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the branch name. Optional \u2014 omit to get the leading matches." - } - ], - "intent": "Type-ahead options for the 'Branch' filter on a project's build listing \u2014 git branch names seen in the project's builds, as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Per-project, prefix-search only \u2014 it does not narrow by any other currently-applied filter. For branches present under a full applied filter across a project set, use list_sidebar_branch_filter_options.", - "Each option's `id`/`value` IS the branch string; send that value straight back as the branch filter selection." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_tag_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/tags", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the tag value. Optional." - } - ], - "intent": "Type-ahead options for the 'Build tag' filter on a project's build listing \u2014 matching build-tag values as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Per-project, prefix-search only \u2014 no other applied filter narrows the result. This handler always does a real project lookup (there is no demo-sandbox short-circuit here).", - "These are build-level tags. For test-execution tags, or for a tag list narrowed by an applied cross-project filter, use the sidebar tag facet." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_team_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/teams", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the team name. Optional." - } - ], - "intent": "Type-ahead options for the 'Team' filter on a project's build listing \u2014 BrowserStack teams that have builds in the project, as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Each option's `id` is the numeric team id (as a string) and `value`/`label` are the team name \u2014 send the id back as the team filter selection, not the name.", - "Per-project only; it is not narrowed by any other applied filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_user_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/users", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the user name. Optional." - } - ], - "intent": "Type-ahead options for the 'User' (build owner) filter on a project's build listing \u2014 matching users as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Each option's `id` is the numeric user id (as a string) and `value`/`label` are the user's name \u2014 send the id back as the user/owner filter selection.", - "Per-project only; it is not narrowed by any other applied filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_sidebar_filters", - "method": "POST", - "path": "/api/v2/builds/filters", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids the filter spans. When omitted the server falls back to a default project set for the group, so send it explicitly to scope the view." - }, - { - "name": "query", - "type": "string", - "description": "Type-ahead text for the sidebar sub-searches (users/tags/runGroups). NOT applied or echoed by this call - use `search` for the free-text build search." - }, - { - "name": "users", - "type": "array", - "description": "Applied build-owner user ids (integers), returned resolved to {id, name}." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team ids (integers), returned resolved to team objects." - }, - { - "name": "statuses", - "type": "array", - "description": "Applied build display-status values." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag values." - }, - { - "name": "branches", - "type": "array", - "description": "Applied git branch values." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values (branded SDK names and pass-through values like 'appium'/'selenium-4'); strings, matched case-insensitively." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end]; a size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - }, - { - "name": "assignees", - "type": "array", - "description": "Assignee tokens, each matching ^(\\$none|[0-9]+)$ \u2014 the literal '$none' selects unassigned, otherwise a numeric user id. A value that does not match the pattern is a 400.", - "pattern": "^(\\$none|[0-9]+)$" - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names; max 500 entries, gated by a per-group flag (ignored when off).", - "maxItems": 500 - }, - { - "name": "state", - "type": "string", - "description": "Build state scope; defaults to `active`.", - "default": "active" - }, - { - "name": "search", - "type": "string", - "description": "Free-text build search; echoed back as applied.search." - } - ], - "intent": "Hydrate the build-listing sidebar filter panel across a set of projects \u2014 the caller's applied selection resolved into display objects (user/team ids to names, date range, tags, branches, statuses, build names) alongside the static option catalogue.", - "returns": [ - "applied", - "staticFilters" - ], - "guidance": [ - "POST but purely read \u2014 the body is only a filter payload and nothing is written.", - "`applied` (a SidebarFilter: the resolved build filter plus the `projects` it was scoped to) echoes your selection resolved; `staticFilters` is the option catalogue (statusList, frameworkList, types, sources, creationType).", - "This is the GROUP-WIDE variant: the static framework list is the hardcoded master list, NOT the frameworks actually present in the selected projects. If you need the frameworks that really occur, treat frameworkList here as the full menu, not a presence list.", - "Scope with `projects`; if you leave it out you get the group's default project set rather than an error.", - "`applied` echoes users (resolved), teams, dateRange, statuses, buildTags (as `tags`), frameworks, branches, search and the boolean/enum toggles; `assignees` and `query` are NOT echoed, so do not read their absence as rejection." - ], - "responses": { - "200": { - "$response": "BuildSidebarFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_branch_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/branches", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive search text for the branch name. Optional." - }, - { - "name": "statuses", - "type": "array", - "description": "Part of the currently-applied build filter that narrows which builds are considered; the full GetBuildFilterRqV2 build-list field set is accepted (users, teams, buildTags, frameworks, dateRange, state, ...)." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end]; size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - } - ], - "intent": "Type-ahead options for the 'Branch' facet of the build-list sidebar \u2014 git branch names present in the builds matching the rest of the currently applied filter, optionally narrowed by a search string.", - "returns": [ - "data" - ], - "guidance": [ - "POST but read-only \u2014 the body is a filter, no state change.", - "Unlike the per-project branch list, this narrows to branches that actually occur under the whole applied filter across `projects`, and returns BARE STRINGS in `data` (not {id,value,label} objects).", - "Values come from an Elasticsearch terms aggregation capped at 100, so the list is a suggestion set, not a complete enumeration \u2014 narrow with `query` rather than expecting every branch back." - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_build_name_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/buildNames", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive search text for the build name. Optional." - }, - { - "name": "statuses", - "type": "array", - "description": "Part of the applied build filter that narrows which builds are considered; the full build-list field set (users, teams, buildTags, branches, frameworks, dateRange, state, ...) is accepted." - } - ], - "intent": "Type-ahead options for the 'Build name' facet of the build-list sidebar \u2014 the distinct build names present under the current filter/projects, optionally matched against a search string.", - "returns": [ - "data" - ], - "guidance": [ - "POST but read-only \u2014 body is a filter payload only.", - "Returns BARE STRINGS in `data`. Distinct from the shipped list_build_names capability, which is a different (paged) listing \u2014 this one is the sidebar facet's aggregation.", - "ES terms aggregation capped at 100 distinct names, so results are bounded, not exhaustive \u2014 use `query` to find a specific name." - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_run_group_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/runGroups", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids whose run groups to list. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive name search for the run group. Optional." - } - ], - "intent": "Type-ahead options for the 'Run group' (build group) facet of the build-list sidebar \u2014 the first 100 run groups of the selected projects, name-searchable.", - "returns": [ - "data", - "info" - ], - "guidance": [ - "POST but read-only. ONLY `projects` and `query` are read from the body \u2014 every other build-filter field is ignored here, so do not expect statuses/tags/dateRange to narrow the result.", - "Pagination is hardcoded to page 1, page size 100 and CANNOT be driven from the request: you always get at most the first 100 run groups. `info` reports paging but there is no way to fetch page 2 through this endpoint \u2014 use the per-project build-groups listing if you need to page.", - "`data` items are SLIM build-group rows {id, name, description, isClosed, state, updated_at} (note snake_case updated_at), not bare strings. For projectId/createdBy/closedAt use the per-project build-groups listing.", - "This facet is not available on a public dashboard link (no public-link-token auth); it needs an authenticated session or service key." - ], - "responses": { - "200": { - "$response": "RunGroupFilterOptionsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_widget_custom_field_filters", - "method": "GET", - "path": "/api/v1/dashboards/filters/{widgetType}", - "mode": "read", - "entity": "dashboard", - "intent": "List the Test Management custom fields usable as extra filters on a test-case widget, so the widget-config panel can offer them.", - "path_params": [ - { - "name": "widgetType", - "type": "string", - "required": true, - "values": [ - "TEST_CASE_TREND", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "AUTOMATION_COVERAGE" - ], - "description": "Widget type. ONLY these four are valid; any other widget type returns a 400 (BAD_REQUEST_ERROR)." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "required": false, - "description": "Page number. Presence of ANY of page/per_page/search switches the response to the paginated shape (see guidance)." - }, - { - "name": "per_page", - "type": "integer", - "required": false, - "description": "Page size. Passed through to Test Management's custom-fields API unchanged \u2014 no ceiling is enforced by this service." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Substring filter over custom-field names." - } - ], - "returns": [ - "data", - "info" - ], - "responses": { - "200": { - "$response": "WidgetCustomFieldFiltersResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "TWO response shapes from one route: omit all of page/per_page/search and you get a bare JSON ARRAY of {id, label, type, fieldType}; pass any one of them and you get the paginated object {data:[...], info:{...}} instead.", - "Only the four listed widget types are accepted; anything else is a 400, not an empty list.", - "This is a pass-through to Test Management's custom-fields API; `per_page` is forwarded without clamping, so a page ceiling cannot be assumed." - ] - }, - { - "name": "resolve_dashboard_filter_names", - "method": "POST", - "path": "/api/v1/dashboards/meta", - "mode": "read", - "entity": "dashboard", - "intent": "Batch-resolve project ids and user ids to their display names, so a saved dashboard's stored filter ids can be rendered as readable chips.", - "body": [ - { - "name": "projectIds", - "type": "array", - "required": true, - "description": "TRA project ids (Long) to resolve to names. Required key, but may be an empty array. Converted to Test Management project ids before lookup." - }, - { - "name": "userIds", - "type": "array", - "required": true, - "description": "User ids (Long) to resolve to names. Required key, but may be an empty array." - } - ], - "returns": [ - "projects", - "users" - ], - "responses": { - "200": { - "$response": "DashboardMetaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only \u2014 it changes nothing, it only looks names up.", - "Both `projectIds` and `userIds` are required keys (may be empty arrays); omitting either fails validation.", - "Returns {projects, users}, each a list of {id, name}. Project names come from the project repository (ids are mapped to Test Management project ids first); user names come from the user-details service." - ] - }, - { - "name": "search_unique_errors", - "method": "GET", - "path": "/api/v1/dashboards/errors", - "mode": "read", - "entity": "dashboard", - "intent": "Autocomplete over unique error signatures \u2014 given a partial error string, return the top matching unique errors so a user can pick one while configuring an error-focused dashboard widget.", - "query": [ - { - "name": "q", - "type": "string", - "required": false, - "description": "Partial error string to search for. Matched against the errors search index." - } - ], - "returns": [ - "errorClusterId", - "error" - ], - "responses": { - "200": { - "$response": "UniqueErrorSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is a top-level JSON ARRAY of {errorClusterId, error} \u2014 there is no wrapper object.", - "The result count is hardcoded to 10 and cannot be paged or raised; refine `q` to narrow rather than expecting more rows.", - "Backed by an Elasticsearch aggregation over the errors index; `errorClusterId` is the stable id of the unique-error cluster." - ] - }, - { - "name": "list_reports", - "method": "GET", - "path": "/api/v1/reports/list", - "mode": "read", - "entity": "dashboard", - "intent": "Power the Reports listing page: a paginated, searchable, sortable list of saved dashboards/reports across Test Observability and Test Management, each with owner, linked projects, type and schedule frequency, plus 'my reports' and total counts.", - "query": [ - { - "name": "projectIds", - "type": "array", - "required": false, - "description": "TestHub project ids (a project's thProjectId, e.g. from list_projects), NOT the TRA project id - a TRA id matches nothing and returns an empty 200. UDAC project filtering is applied on top; if UDAC blocks every project the page comes back empty." - }, - { - "name": "source", - "type": "string", - "required": false, - "values": [ - "TO", - "TCM" - ], - "description": "Product source. An INVALID value does not 4xx \u2014 it returns HTTP 200 with an `error` string and empty data." - }, - { - "name": "type", - "type": "string", - "required": false, - "description": "Report/dashboard type filter." - }, - { - "name": "userId", - "type": "integer", - "required": false, - "description": "Filter to a specific owner's reports." - }, - { - "name": "frequency", - "type": "string", - "required": false, - "description": "Schedule frequency filter." - }, - { - "name": "p", - "type": "integer", - "required": false, - "default": 1, - "description": "Page number (1-based)." - }, - { - "name": "size", - "type": "integer", - "required": false, - "default": 30, - "maximum": 100, - "description": "Page size. Clamped server-side to the range 1-100." - }, - { - "name": "sortBy", - "type": "string", - "required": false, - "default": "modifiedAt", - "description": "Sort column. Defaults to modifiedAt." - }, - { - "name": "sortOrder", - "type": "string", - "required": false, - "default": "desc", - "description": "Sort direction. Defaults to desc." - }, - { - "name": "q[query]", - "type": "string", - "required": false, - "description": "Free-text search. The query-parameter name is literally `q[query]`." - } - ], - "returns": [ - "data", - "my_report_count", - "total_report_count", - "info", - "error" - ], - "responses": { - "200": { - "$response": "ReportListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "An invalid `source` returns HTTP 200 with a top-level `error` string and empty `data` \u2014 check `error`, not the status code.", - "TRA report types (Build Comparison, Tabular Test Runs) are hidden unless the group has the Custom Dashboard entitlement AND the TM-TO-reports rollout flag is on; other TM reports stay visible.", - "`size` is clamped to a maximum of 100 (and a minimum of 1); asking for more returns at most 100 rows.", - "The search parameter is spelled `q[query]`, not `q` or `query`.", - "`my_report_count` / `total_report_count` are separate top-level counts; pagination cursors are under `info` (page/next/prev/count/page_size)." - ] - }, - { - "name": "list_dashboards", - "method": "GET", - "path": "/ext/v1/dashboards", - "mode": "read", - "entity": "dashboard", - "intent": "Public-API listing of the custom dashboards visible to the caller's group (used by the Atlas AI dashboard builder and API clients) to discover dashboard ids/names/widgets before reading or cloning one.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source to list. Defaults to TO." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardsListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Same underlying service as the internal listing: scheduled-report dashboards are excluded and there is no pagination. Rows are summaries - widgets, filters and reports are null (totalWidget counts them); read one dashboard by id for its definition." - ] - }, - { - "name": "get_dashboard", - "method": "GET", - "path": "/ext/v1/dashboards/{id}", - "mode": "read", - "entity": "dashboard", - "intent": "Fetch one dashboard's full definition (name, type, level and the widget list with each widget's config) via the public API, so a client can render or edit it.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Dashboard UUID (from list_dashboards_via_public_api)." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source. Defaults to TO." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Resolves the id as a dashboard (not a scheduled report)." - ] - }, - { - "name": "create_dashboard", - "method": "POST", - "path": "/ext/v1/dashboards", - "mode": "write", - "entity": "dashboard", - "intent": "Create a new custom dashboard with its widget layout via the public API, so a client (or the AI dashboard builder) can provision a dashboard programmatically instead of building it in the UI.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source for the new dashboard. Defaults to TO." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 100, - "description": "Dashboard name, 1-100 chars, unique in the group." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "values": [ - "Dashboard", - "View", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON" - ], - "description": "Dashboard type; use `Dashboard` for an ordinary dashboard." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Optional description, max 100 chars." - }, - { - "name": "filters", - "type": "object", - "description": "Global filters (same shape as the internal create; note buildName / branches / tcmTestPlanIds wire names)." - }, - { - "name": "widgets", - "type": "array", - "description": "Widget definitions at the body root. Each widget is {id, name, type, config, layout, globalFilters}. See guidance for the treemap normalization this surface applies.", - "fields": [ - { - "name": "id", - "type": "string", - "description": "Client widget id." - }, - { - "name": "name", - "type": "string", - "description": "Widget title." - }, - { - "name": "type", - "type": "string", - "description": "Widget type enum." - }, - { - "name": "config", - "type": "object", - "description": "Widget config; validated for render-safety before persist." - }, - { - "name": "layout", - "type": "object", - "description": "Grid layout." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "This surface silently DROPS a `visualization` override on a treemap widget (FAILURE_RATE_BY_FOLDERS) rather than rejecting it \u2014 don't rely on that override being honoured.", - "It also runs render-safety validation up front, so a widget config that would 5xx at render is rejected here. The internal create does neither of these." - ] - }, - { - "name": "update_dashboard", - "method": "PUT", - "path": "/ext/v1/dashboards/{id}", - "mode": "write", - "entity": "dashboard", - "intent": "Update an existing dashboard's name/type/level and replace its widget set via the public API.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Dashboard UUID to update." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "New name, 1-100 chars. May be omitted \u2014 it is backfilled from the stored dashboard (see guidance)." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2. Backfilled from the stored dashboard if omitted." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "values": [ - "Dashboard", - "View", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON" - ], - "description": "Dashboard type. Backfilled from the stored dashboard if omitted." - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget set at the body root. Same treemap-visualization stripping and render-safety validation as create." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters (same shape as create)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Unlike the internal PUT this TOLERATES a partial body: omitted name/type/level are backfilled from the stored dashboard (an extra read) so a null is not mistaken for a change. The widget set, however, still fully replaces the stored one \u2014 there is no undo.", - "Same normalization as create: a treemap `visualization` override is silently dropped and widget configs are validated up front.", - "In demo-sandbox environments updating the default dashboard is rejected with 400." - ] - }, - { - "name": "clone_dashboard", - "method": "POST", - "path": "/ext/v1/dashboards/{id}/clone", - "mode": "write", - "entity": "dashboard", - "intent": "Copy an existing dashboard (its persisted widgets) into a new dashboard via the public API \u2014 typically to fork a template dashboard under a new name/level.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "SOURCE dashboard UUID to copy from." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 100, - "description": "Name for the copy, 1-100 chars, unique in the group." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2 for the copy." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "description": "Dashboard type for the copy." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "The widgets in the request body are IGNORED \u2014 the clone copies the SOURCE dashboard's stored widgets; only name/type/level are taken from the body.", - "Render-safety validation runs against the resolved source dashboard (inside the service), not the request body, so a broken source can fail the clone." - ] - }, - { - "name": "get_project_automation_coverage", - "method": "GET", - "path": "/ext/v1/projects/automation-coverage", - "mode": "read", - "entity": "project", - "intent": "Get one project's automation-coverage figures - total test cases, automated count and coverage percent over all time - to chart automation progress from outside the UI.", - "query": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Exact project display NAME (not the normalised slug and not the id), matched within the caller's group. Blank or missing is a 400; a name that does not resolve in the group is a 404." - } - ], - "returns": [ - "project_id", - "project_name", - "total_test_case_count", - "automated_test_case_count", - "automation_coverage_percent", - "calculated_at" - ], - "responses": { - "200": { - "$response": "ProjectAutomationCoverageResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public REST API route - callable with a username + access key as well as service credentials; scoping is by the authenticated group only, with no project-level permission check on this route.", - "Lookup is by EXACT project name, case- and space-sensitive, within your group. The normalised slug used elsewhere and the numeric id are both rejected here - use the display name.", - "This reuses the Automation Coverage dashboard widget over an all-time window, so it is an aggregation query, not a cheap key lookup - expect it to be slower than a plain read.", - "Fields are serialised only when non-null (NON_NULL). `calculated_at` is when this figure was computed, not a project timestamp; treat it as the freshness of the number." - ] - }, - { - "name": "list_projects", - "method": "GET", - "path": "/ext/v1/projects", - "mode": "read", - "entity": "project", - "intent": "List the calling account's Test Observability projects (public REST API) - the entry point for scripted/CI consumers that then drill into a project's builds.", - "query": [ - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 cursor from the previous response's pagination.next_page (it encodes a page index, not a row offset). Omit for the first page. A malformed value is a 400 'Incorrect pagination params'." - } - ], - "returns": [ - "projects", - "pagination" - ], - "responses": { - "200": { - "$response": "ProjectListingExternalResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pages are tiny and uneven in practice (2, 5, 6 rows) and there is no name filter, so this is the WRONG route for 'find project X' or 'what projects do I have' on a large group: use list_projects_lite_paginated (`search`, 100 per page, newest activity first) or list_projects_full (whole group, capped at 30 rows).", - "Requires the PROJECT_VIEW permission; a credential without it is refused. A user OAuth credential that holds it is accepted - a service account is not required.", - "Up to 10 projects per page, and a page can hold fewer while has_next is still true - a short page is not the last page. There is no page-size parameter. Paginate only with the returned base64 next_page cursor, not a numeric page.", - "pagination.has_next is computed as 'this page was non-empty', so the last full page still reports has_next=true and you get one extra EMPTY page. Stop when projects comes back empty, not when has_next turns false.", - "There is no total count in the response. Each row carries the project's id, name and a ready-made observability_url into its builds page." - ] - }, - { - "name": "list_home_unique_builds", - "method": "GET", - "path": "/api/v1/home/uniqueBuilds", - "mode": "read", - "entity": "project", - "intent": "List the Home page's latest builds across the caller's visible projects - one row per build with its status and counts - optionally narrowed to 'my builds' and quick filters.", - "query": [ - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Only affects has_next (true while pageNumber*10 <= 99); it does NOT offset the search, so pageNumber=1 returns the same first page. Page with afterKey instead. The response's pagingParams.pageNumber echoes pageNumber+1." - }, - { - "name": "afterKey", - "type": "string", - "description": "Cursor (the last row's build id) for the next page. This is the ONLY real pagination: carry pagingParams.afterKey from the previous response." - }, - { - "name": "myBuilds", - "type": "boolean", - "default": false, - "description": "Restrict to builds owned by the calling user." - }, - { - "name": "applyQuickFilters", - "type": "boolean", - "description": "Turn the per-request quick filters below ON. When false (default) the endpoint applies only the user's persisted Home filters and ignores the quick-filter params." - }, - { - "name": "statuses", - "type": "array", - "description": "Quick-filter build statuses. Only applied when applyQuickFilters=true; invalid values are skipped rather than 400ing." - }, - { - "name": "users", - "type": "array", - "description": "Quick-filter user ids. Only applied when applyQuickFilters=true." - }, - { - "name": "frameworks", - "type": "array", - "description": "Quick-filter framework values. Only applied when applyQuickFilters=true." - }, - { - "name": "buildTags", - "type": "array", - "description": "Quick-filter build tags. Only applied when applyQuickFilters=true." - }, - { - "name": "projects", - "type": "array", - "description": "Quick-filter project ids. Only applied when applyQuickFilters=true." - }, - { - "name": "isRealDevice", - "type": "array", - "description": "Quick-filter device type (string, not boolean, so a stale value degrades instead of erroring). Only applied when applyQuickFilters=true." - } - ], - "returns": [ - "data", - "pagingParams" - ], - "responses": { - "200": { - "$response": "HomeUniqueBuildsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The user's saved Home filters (build tags, projects, users, teams, unique build names, statuses) are merged into the query server-side even when applyQuickFilters is off - results are always scoped by the caller's persisted personalisation and sub-group.", - "Pagination: pageNumber is not applied to the search (only to the has_next cap); page with afterKey = pagingParams.afterKey. pagingParams.hasNext is 'this page was full AND pageNumber*10 <= 99'.", - "Quick-filter params are inert unless applyQuickFilters=true. Bad/stale quick-filter values are silently dropped (mapSkippingInvalid), never 400.", - "Heavy on large groups (emits per-stage timing telemetry); each row is a BuildSummary - use displayStatus for the pass/fail verdict (status is the lifecycle state, e.g. finished).", - "Rows are the most recent builds, one row PER BUILD (not deduplicated by build name: several runs of one pipeline appear as separate rows), in the caller's Home scope. Page size is 10." - ] - }, - { - "name": "list_home_recent_views", - "method": "GET", - "path": "/api/v1/home/recents", - "mode": "read", - "entity": "project", - "intent": "Get the user's last few visited items (dashboards, builds, views) for the Home page 'Recently viewed' strip.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "HomeRecentViewsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read from a per-user Redis sorted set, capped at the 5 most recent entries; there are no parameters and no pagination.", - "Entries are written as a side effect of the user opening a dashboard - this endpoint never writes. A fresh user gets an empty data array.", - "Unparseable stored entries are silently skipped, so the list can be shorter than 5 even when 5 were recorded. Response is {\"data\": [ ... ]}; each item has type, link, id, name." - ] - }, - { - "name": "get_home_takeaways", - "method": "GET", - "path": "/api/v1/home/takeaway", - "mode": "read", - "entity": "project", - "intent": "Get the Home page 'key takeaways' cards with the aggregate time-saved metric - stability, pass-rate, always-failing/flaky/new-failure counts and a single time-savings percentage, versus the previous 7-day window.", - "returns": [ - "timeSavingsData", - "takeAwayData" - ], - "responses": { - "200": { - "$response": "HomeTakeawaysResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the older takeaways variant. It returns the aggregate `timeSavingsData` object (timeSavings %, testToVerify, uniqueErrors, flakiness, alwaysFailing, autoTagged) and sends the v2 `timeSavingV2Metric` key as null. For the shape the current Home page uses, pick get_home_takeaways_with_noise_breakdown instead.", - "Scoped implicitly by the caller's saved Home filters, sub-group and visible projects; no parameters.", - "Cached in Redis per USER id for ~1 hour, so a filter change is not reflected until the cache expires. All metrics compare the current 7 days to the prior 7 days (each TakeAwayMetric carries value and prev)." - ] - }, - { - "name": "get_home_takeaways_with_noise_breakdown", - "method": "GET", - "path": "/api/v1/home/v2/takeaway", - "mode": "read", - "entity": "project", - "intent": "Get the Home page 'key takeaways' cards with the failure-noise breakdown - the same stability/pass-rate story plus per-category (flaky, always-failing, auto-analyzed, unique-error) failure counts and their percentage reductions.", - "returns": [ - "timeSavingV2Metric", - "takeAwayData" - ], - "responses": { - "200": { - "$response": "HomeTakeawaysNoiseBreakdownResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the variant the current Home page uses. It returns the `timeSavingV2Metric` object (totalFailures plus flakyFailing/alwaysFailing/autoAnalyzedFailure/uniqueErrors, each a count + reduction%) and explicitly sets the legacy `timeSavingsData` to null - do not expect a single time-savings percentage here.", - "Same takeAwayData block (stability, unique executions, new failures, always-failing, flakiness, failures; each value vs prev 7-day) as the older variant.", - "Cached in Redis per (filter-hash + CAD product + sub-group), so the cache is SHARED across users with identical filters - unlike the per-user cache of the older variant. TTL ~1 hour.", - "When there are zero failures in the window, timeSavingV2Metric collapses to just {totalFailures: 0} with the per-category objects omitted." - ] - }, - { - "name": "list_project_builds_by_group", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/build-groups-listing", - "mode": "read", - "entity": "project", - "intent": "List a project's builds rolled up by build group (consolidated re-runs/shards shown as one row), with the full v2 build-listing filter set.", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL-safe slug), not its numeric id." - } - ], - "query": [ - { - "name": "buildNameIds", - "type": "array", - "description": "Filter by build name ids." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Filter by unique build names." - }, - { - "name": "users", - "type": "array", - "description": "Filter by user ids (numeric)." - }, - { - "name": "assignees", - "type": "array", - "description": "Assignee ids; each must match ^(\\$none|[0-9]+)$ ($none = unassigned)." - }, - { - "name": "teams", - "type": "array", - "description": "Filter by team ids." - }, - { - "name": "buildGroupIds", - "type": "array", - "description": "Filter by build group ids." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values (branded enum names and pass-through values like 'appium'); matched case-insensitively." - }, - { - "name": "statuses", - "type": "array", - "description": "Build display statuses." - }, - { - "name": "sources", - "type": "array", - "description": "Product sources." - }, - { - "name": "types", - "type": "array", - "description": "Build types." - }, - { - "name": "buildTags", - "type": "array", - "description": "Filter by build tags." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names (max 500); AND/OR selected via `match`." - }, - { - "name": "branches", - "type": "array", - "description": "Filter by branch names." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millis longs [from, to] (size must be 2)." - }, - { - "name": "search", - "type": "string", - "description": "Free-text build search." - }, - { - "name": "searchAfter", - "type": "string", - "description": "ES search_after cursor; must match ^[0-9a-zA-Z]{40}$ (a 40-char id)." - }, - { - "name": "state", - "type": "string", - "values": [ - "active", - "closed" - ], - "description": "Build state; defaults to active." - }, - { - "name": "isArchived", - "type": "boolean", - "description": "Restrict to archived builds." - }, - { - "name": "selfHealed", - "type": "boolean", - "description": "Only self-healed builds." - }, - { - "name": "aiAuthored", - "type": "boolean", - "description": "Only AI-authored builds." - }, - { - "name": "isRealDevice", - "type": "array", - "description": "Device-type multi-select (true=real, false=virtual); empty/both = no filter." - } - ], - "returns": [ - "id", - "name", - "buildGroupAggResult", - "activeCount", - "closedCount" - ], - "responses": { - "200": { - "$response": "ProjectBuildGroupListingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Sibling of the ungrouped v2 build listing: this one consolidates re-runs/shards into build groups (see buildGroupAggResult). Filters are the same rich v2 set, passed as query params (there is no request body).", - "Returns per-group AGGREGATES only (buildGroupAggResult: counts plus the TestHub group record) - no build rows: `builds` and `pagingParams` are always null here, and there is no pagination. To list the builds themselves use the ungrouped v2 build listing.", - "Side effect: reading this marks the project as SUBSCRIBED for the caller (a user-preference write) - a benign, recoverable effect, but it is not a pure read.", - "Unlike the ungrouped v2 listing, this route does NOT apply the group's custom-retention window, so it can surface builds older than that window." - ] - }, - { - "name": "get_test_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug), not its numeric id." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to], for the window the filters resolve within. Required; a null or non-2-element range is a 400." - }, - { - "name": "search", - "type": "string", - "description": "Free-text test-name search that is echoed back under `applied`." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag filter values (echoed back resolved under `applied.buildTags`)." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Applied unique-build-name filter values." - }, - { - "name": "testTags", - "type": "array", - "description": "Applied test-tag filter values." - }, - { - "name": "folders", - "type": "array", - "description": "Applied folder filter values." - }, - { - "name": "hostNames", - "type": "array", - "description": "Applied host-name (machine/agent) filter values." - }, - { - "name": "status", - "type": "array", - "description": "Applied test-status filter values." - }, - { - "name": "sources", - "type": "array", - "description": "Applied product-source filter values." - }, - { - "name": "users", - "type": "array", - "description": "Applied user-id filter values (longs)." - }, - { - "name": "lastExecutedBy", - "type": "array", - "maxItems": 500, - "description": "User ids (longs) whose executions to include. Capped at 500 by validation; exceeding it is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team-id filter values (longs)." - }, - { - "name": "failureCategories", - "type": "array", - "description": "Applied failure-category filter values." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state: set true/false to filter by flakiness, omit to not filter." - }, - { - "name": "isNewFailure", - "type": "boolean", - "description": "Tri-state new-failure filter; omit to not filter." - }, - { - "name": "isAlwaysFailing", - "type": "boolean", - "description": "Tri-state always-failing filter; omit to not filter." - }, - { - "name": "hasPmToolDefects", - "type": "boolean", - "description": "Tri-state filter for tests that have linked PM-tool defects; omit to not filter." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden-status filter; omit to not filter." - }, - { - "name": "osList", - "type": "array", - "description": "Applied OS filter values." - }, - { - "name": "browserList", - "type": "array", - "description": "Applied browser filter values." - }, - { - "name": "deviceList", - "type": "array", - "description": "Applied device filter values." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-test visibility. Accepts all / true (muted only) / false (unmuted only); the identifiers ALL / MUTED / UNMUTED are also accepted, case-insensitively." - }, - { - "name": "hooksVisibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Applied hooks visibility. The response also returns the project's own default hooks visibility separately under `hooksVisibility`." - } - ], - "intent": "Get the whole filter sidebar for the Suite Health test list: the currently applied filters resolved into display fields, the available static filter options (failure categories, OS/browser/device, sources, run-level options, etc.), and the project's default hooks visibility.", - "returns": [ - "applied", - "staticFilters", - "hooksVisibility" - ], - "guidance": [ - "This echoes back the filters you sent (resolved into {id,value,label} fields under `applied`) AND the catalogue of choosable options under `staticFilters` \u2014 send an empty query (just dateRange) to fetch the full option catalogue for building a filter UI.", - "It degrades open: the run-level option lists inside `staticFilters` (typeOfRun, assignedTo, state, testPlan) are fetched from an upstream service under a timeout, and on timeout/failure (or when there is nothing) they come back null or [] rather than erroring. An empty or null run-level dropdown does not mean there are no options.", - "`dateRange` is mandatory and must be exactly two millisecond timestamps; there are no lowerBound/upperBound scalars on this route.", - "Scoped to project visibility only; no plan gate." - ], - "responses": { - "200": { - "$response": "SPTestFilterOptions" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_branches", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/branches", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wrapped in wildcards for the Elasticsearch match, and additionally applied as a case-SENSITIVE literal `contains` post-filter \u2014 a lowercase query will not surface an upper-case branch. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 it is only consulted by the build-names suggestion route, not this one." - } - ], - "intent": "Type-ahead the branch filter on the Suite Health test list: get git branch names seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the Elasticsearch terms aggregation is capped at 100 branches, so it is not exhaustive; narrow with `query` rather than expecting the full set.", - "The `query` match is applied twice: a wildcard ES match AND a case-sensitive literal `contains` filter on the returned values, so casing matters when narrowing.", - "Each suggestion is a Field object; use its `value` (the branch name) when applying the branch filter elsewhere.", - "Omitting `query` returns an EMPTY `data` list, not the top branches - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildNames", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match against unique build names. Omit/blank to list the top build names in the window." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Whether manual runs are included. Unlike the sibling type-aheads this route honours it (it is carried into the build-level filter request)." - } - ], - "intent": "Type-ahead the build-name filter on the Suite Health test list: get unique build names for the project in the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "This route is the odd one out: it converts the request into a build-sidebar filter and resolves against the build-level index (BuildFilterServiceV2), NOT the test-level filter service the other type-aheads use \u2014 so its matching and result limits can differ from the branches/tags/hostNames suggestions.", - "Because it runs through the build path it honours `includeManualRuns`, which the other suggestion routes ignore.", - "Each suggestion is a Field object; use its `value` (the build name) when applying the uniqueBuildNames filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_build_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildTags", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the build-tag filter on the Suite Health test list: get build tags used in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results, so it is not exhaustive; narrow with `query`.", - "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", - "Each suggestion is a Field object; use its `value` (the tag) when applying the buildTags filter.", - "Omitting `query` returns an EMPTY `data` list, not the top build tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_host_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/hostNames", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match against host names, wrapped in wildcards for the ES match. Omit/blank to list the top host names in the window." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the host-name (machine/agent) filter on the Suite Health test list: get host names seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", - "Unlike the other type-aheads this route does NOT accept `isManuallyOverridden` (that filter is not applied here), and it does not apply a client-side literal `contains` filter \u2014 it relies purely on the wildcard ES match.", - "Each suggestion is a Field object; use its `value` (the host name) when applying the hostNames filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_test_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/testTags", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the test-tag filter on the Suite Health test list: get test tags seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", - "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", - "Each suggestion is a Field object; use its `value` (the tag) when applying the testTags filter.", - "Omitting `query` returns an EMPTY `data` list, not the top test tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_metrics_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/tests/metrics/chart", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required \u2014 the chart interval is derived from it; a null or non-2-element range is a 400." - }, - { - "name": "search", - "type": "string", - "description": "Free-text test-name search to scope the charts." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build-tag filter values." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Unique-build-name filter values." - }, - { - "name": "testTags", - "type": "array", - "description": "Test-tag filter values." - }, - { - "name": "folders", - "type": "array", - "description": "Folder filter values." - }, - { - "name": "hostNames", - "type": "array", - "description": "Host-name filter values." - }, - { - "name": "status", - "type": "array", - "description": "Test-status filter values." - }, - { - "name": "sources", - "type": "array", - "description": "Product-source filter values." - }, - { - "name": "users", - "type": "array", - "description": "User-id filter values (longs)." - }, - { - "name": "lastExecutedBy", - "type": "array", - "maxItems": 500, - "description": "User ids (longs). Capped at 500 by validation; exceeding it is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Team-id filter values (longs)." - }, - { - "name": "failureCategories", - "type": "array", - "description": "Failure-category filter values." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state flakiness filter; omit to not filter." - }, - { - "name": "isNewFailure", - "type": "boolean", - "description": "Tri-state new-failure filter; omit to not filter." - }, - { - "name": "isAlwaysFailing", - "type": "boolean", - "description": "Tri-state always-failing filter; omit to not filter." - }, - { - "name": "osList", - "type": "array", - "description": "OS filter values." - }, - { - "name": "browserList", - "type": "array", - "description": "Browser filter values." - }, - { - "name": "deviceList", - "type": "array", - "description": "Device filter values." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-test visibility (ALL / MUTED / UNMUTED identifiers also accepted, case-insensitively)." - }, - { - "name": "hooksVisibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Hooks visibility scope." - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, failure counts are sourced from the recorded status keyword (SDK-recorded automation status). When not true AND the account has the new build listing flag on, counts instead come from workflow status, which can change the numbers for the same window." - } - ], - "intent": "Get the Suite Health page chart data in one call: time-bucketed failures-over-executions, average duration, and average failure-rate series for the project's tests over a window, filtered by the full Suite Health filter set.", - "returns": [ - "failureChart", - "durationChart", - "failureRateChart" - ], - "guidance": [ - "Response is a top-level map of three charts \u2014 `failureChart`, `durationChart`, `failureRateChart` \u2014 each an SPChart with a `data` map of named series (arrays of {x,t,y,pointRange} points) and an `insights` summary. There is no `data` envelope wrapper.", - "Series keys inside `data`: failureChart carries `totalExecutions` and `totalFailures`; durationChart carries `averageDuration`; failureRateChart carries `averageFailureRates`.", - "Archived builds are excluded from every counter.", - "The same query can return DIFFERENT failure numbers depending on account config: with the new build listing flag on and `isAutomationLatest` not true, failures are counted from workflow status rather than the raw status keyword. Set `isAutomationLatest=true` to force the raw-status counts.", - "The failure series can come back with fewer buckets than the executions series when a slice has zero matches; the failure-rate series is computed by aligning on the `x` timestamp, so do not assume the two arrays are index-aligned." - ], - "responses": { - "200": { - "$response": "SPTestMetricsChart" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_base_info", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/info", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash, discovered from the Suite Health test listing." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ], - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start as an epoch-millisecond long. Optional on this route (there is no dateRange array here)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end as an epoch-millisecond long. Optional." - }, - { - "name": "buildName", - "type": "string", - "description": "Single build name to scope to (wrapped into a one-element list server-side); this route takes at most one, not a list." - } - ], - "intent": "Get the identifying header info for one test in the Test Health detail view \u2014 its name and the environment (OS/browser/device) combinations it ran on \u2014 for the selected window.", - "returns": [ - "name", - "combinations" - ], - "guidance": [ - "Response is a flat object `{name, combinations}` with NO data wrapper. `name` is the test's display name (empty string when the hash matches nothing in the window, still 200), and `combinations` is the list of OS/browser/device environments it ran on.", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Narrower filter set than the test listing: no browser/os/device/tag filters, and only a single `buildName`.", - "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 (no structured error body), so treat a 500 as opaque rather than parsing it." - ], - "responses": { - "200": { - "$response": "SPTestBaseInfo" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_environment_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/breakdown", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash, discovered from the Suite Health test listing." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ], - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start as an epoch-millisecond long. Optional." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end as an epoch-millisecond long. Optional." - }, - { - "name": "buildName", - "type": "string", - "description": "Single build name to scope to (wrapped into a one-element list server-side); at most one, not a list." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state flakiness filter; omit to not filter." - }, - { - "name": "orderKey", - "type": "string", - "values": [ - "reliability", - "average", - "totalFailures" - ], - "description": "Sort key for the returned rows. Unrecognised/absent falls back to reliability." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ], - "description": "Sort direction. Unrecognised/absent falls back to descending." - } - ], - "intent": "Break one test's results down by environment (OS / browser / device) over the window, so you can see whether its instability is platform-specific.", - "returns": [ - "id", - "reliability", - "duration", - "os", - "browser", - "browserDevice", - "totalFailures" - ], - "guidance": [ - "The body is a BARE JSON ARRAY of environment rows (no object wrapper, no `data` key). Each row: `id` (browser-platform key), `reliability` (fail %), `duration` (avg), `os`/`browser`/`browserDevice` (formatted platform objects), and `totalFailures` {failed,total}.", - "Uses the older explicit-argument service overload, so it accepts only a SINGLE `buildName` and a much smaller filter set than the test listing (no os/browser/device/tag filters).", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Rows are sorted server-side (default reliability, descending) and platform rows that carry no displayable browser or device are dropped, so the array may be shorter than the number of distinct environments.", - "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 with no structured body." - ], - "responses": { - "200": { - "$response": "SPTestEnvironmentBreakdown" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_app_logs", - "method": "GET", - "path": "/api/v1/appLogs/query", - "mode": "read", - "entity": "test_detail", - "intent": "Get a test run's application (app-under-test / SDK) log lines, parsed and flattened for display \u2014 use this for the app-logs tab when you want normalised rows rather than raw Loki JSON.", - "query": [ - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "The test run's UUID (not its integer id and not the TR-NNN identifier). A missing or foreign UUID collapses into an empty 500 with no body." - } - ], - "returns": [ - "status", - "appLogQuery", - "logs" - ], - "responses": { - "200": { - "description": "Parsed app logs. `status` is 'success', or 'failure' with an empty `logs` list (still HTTP 200) when Loki reports a non-success status.", - "schema": { - "$schema": "AppLogResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns ONLY abnormal (error-level) app-log lines \u2014 the Loki query hard-codes an abnormal filter \u2014 capped at 5000 lines and bounded to the run's own start/finish window (\u00b15s). A passing run with no error logs legitimately comes back with an empty `logs` list.", - "Two failure shapes, neither is an ordinary HTTP error: when Loki answers non-success you get HTTP 200 with `status:\"failure\"` and `logs:[]`; when anything throws you get a bare HTTP 500 with no body. Check `status`, and do not read an empty 500 as a specific cause \u2014 a missing/foreign run and a Loki outage look identical.", - "Each log line is split on a ' cSep=S ' separator and only the text after it is kept; a line that lacks the separator is returned raw and unsplit.", - "For the untouched Loki payload use the raw variant instead; this one is the parsed, UI-normalised form.", - "`level` on each line falls back to 'unknown' when Loki did not tag the stream with a level." - ] - }, - { - "name": "get_test_run_app_logs_raw", - "method": "GET", - "path": "/api/v1/appLogs/nativeQuery", - "mode": "read", - "entity": "test_detail", - "intent": "Get a test run's application logs as Loki's raw, unparsed JSON response \u2014 use this only when you need the native Loki shape; for displayable rows use the parsed app-logs capability instead.", - "query": [ - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "The test run's UUID (not its integer id and not the TR-NNN identifier)." - } - ], - "returns": [ - "status", - "data" - ], - "responses": { - "200": { - "description": "Loki's native query_range JSON body, relayed verbatim as an opaque JSON document (top-level `status` and `data.result[]` streams). This is NOT an app model \u2014 the shape is Loki's, not the service's.", - "schema": { - "type": "object", - "description": "Opaque pass-through of Loki's /query_range response. Typically `{status, data:{resultType, result:[{stream, values:[[epochNano, line]]}]}}`, but treat it as an untyped Loki document." - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pure pass-through of Loki's body \u2014 the same abnormal-only filter, 5000-line cap and run-time-window bounding as the parsed variant apply, but nothing is normalised. Log lines still carry the ' cSep=S ' separator and their level pattern; you must split them yourself.", - "The handler swallows every exception into a bare HTTP 500 with no body, so a missing or foreign testRunUuid is indistinguishable from a Loki outage.", - "Prefer the parsed app-logs capability unless you specifically need Loki's native envelope; this one returns a JSON string, not the service's AppLogResponse." - ] - }, - { - "name": "get_test_run_hooks", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/hooks", - "mode": "read", - "entity": "test_detail", - "intent": "Public API: list the before/after setup and teardown hook executions relevant to a test run, so a failure can be attributed to a hook rather than the test body.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [ - "testCode", - "testMetadata", - "hooksCode" - ], - "responses": { - "200": { - "description": "A MAP keyed by hook test-run id (numeric string keys), each value a TestDetailResponse for that hook. An empty object means no hooks matched.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestDetailResponse" - }, - "description": "Keyed by matched hook run id." - } - }, - "404": { - "$response": "NotFound" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", - "The result is a MAP keyed by hook test-run id, not a list; iterate the values.", - "Matching is heuristic, not a stored parent/child link: hooks are matched on the SAME build id + filename + OS + browser + device, latest only. A hook that ran in a differently-configured session will not appear here even though it belongs to the same build.", - "At most 100 hook runs are returned (the underlying search is size-capped); there is no paging to fetch more.", - "An unknown test_run_id returns TEST_RUN_NOT_FOUND (404). A run with no matching hooks returns an empty object with 200." - ] - }, - { - "name": "get_test_run_log_bundle", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/logs", - "mode": "read", - "entity": "test_detail", - "intent": "Public API: get the full log bundle for a test run \u2014 BrowserStack session logs (console/network/selenium/appium/video), SDK logs, hook-run logs, failure and step logs \u2014 as URLs plus the test start/finish timestamps and framework.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [ - "testStartedAt", - "testFinishedAt", - "browserstackLogs", - "sdkLogs", - "hookRunLogs", - "failureLogs", - "stepLogs", - "framework", - "sessionId" - ], - "responses": { - "200": { - "description": "Log URLs and metadata. `browserstackLogs` is a SessionLogs object of per-type log URLs; `sdkLogs` and `hookRunLogs` are lists of URL strings; `failureLogs` and `stepLogs` are inline log-entry objects.", - "schema": { - "$schema": "LogUrls" - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", - "Returns log URLs to fetch separately, NOT log content. The caller downloads each artefact from the URLs in `browserstackLogs`, `sdkLogs` and `hookRunLogs`; only `failureLogs` and `stepLogs` are inline.", - "The logs are always the TRIMMED variant \u2014 the trim flag is hard-coded true on this route, so there is no way to ask for the untrimmed logs here.", - "Plan-locked / retention-expired builds are rejected with FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", - "Several log sources are fetched concurrently, so latency tracks the slowest source; expect a slow response for large sessions." - ] - }, - { - "name": "get_session_network_log_url", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "intent": "Get the URL of a session's HAR network log for the Test Details network tab \u2014 returns a link to the HAR file plus its fetch status, not the parsed entries.", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "The session's HASHED id (not a numeric id)." - } - ], - "returns": [ - "networkLogs", - "status_code", - "message" - ], - "responses": { - "200": { - "description": "The network-log URL envelope. `networkLogs` is the HAR file URL, `status_code` is the upstream fetch status, `message` any upstream message.", - "schema": { - "$schema": "SessionNetworkLogUrlResponse" - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the name, this returns the HAR file URL in `networkLogs`, NOT the parsed HAR entries \u2014 the handler requests the unparsed form. Fetch the HAR yourself from that URL; it can be large.", - "On the demo-sandbox environment it short-circuits to an empty HAR (`{log:{entries:[]}, responseOrigin:\"demo\"}`) before any lookup.", - "An unknown sessionId returns SESSION_NOT_FOUND. Note the id here is the session's hashed id, the same id the sibling session log-URLs capability takes." - ] - }, - { - "name": "get_test_run_behaviour", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/behaviour", - "mode": "read", - "entity": "test_detail", - "intent": "Get the BDD/behaviour payload stored on a test run (the Gherkin feature/scenario structure in the run's meta) that backs the Behaviour tab for Cucumber-style frameworks.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [], - "responses": { - "200": { - "description": "The run's meta JSON (the Gherkin/behaviour structure) as an opaque object, or an empty object `{}` when the run has no meta.", - "schema": { - "type": "object", - "description": "Opaque BDD/behaviour meta object stored on the run. Empty object when no meta is present." - } - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns the run's raw meta object; only meaningful for BDD (Cucumber-style) builds. A run with no meta returns an empty object `{}` with HTTP 200, not a 404.", - "A MISSING test run does not give a typed error \u2014 the handler checks lock status before the null check, so a bad id throws a NullPointerException (HTTP 500), not TEST_RUN_NOT_FOUND.", - "Locked (retention-expired) runs are rejected with FORBIDDEN_OPERATION.", - "The Behaviour tab is only offered for BDD builds; for a non-BDD run this payload is not useful even when present." - ] - }, - { - "name": "get_test_run_merged_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/consolidatedLogs", - "mode": "read", - "entity": "test_detail", - "intent": "Get a single merged, time-ordered log stream for a test run \u2014 BrowserStack session logs, SDK logs, hook-run logs, failure and step logs combined into one list, with content merged server-side \u2014 for the Logs tab.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "required": false, - "default": false, - "description": "When true, restrict the merged logs to the test's own time window. Defaults to false (full session)." - } - ], - "returns": [ - "logLevel", - "logType", - "timeStamp", - "content", - "url", - "duration" - ], - "responses": { - "200": { - "description": "A top-level JSON ARRAY of merged, time-ordered log entries (GenericLog objects). Content is downloaded and merged server-side, so entries carry inline `content`, not just URLs.", - "schema": { - "type": "array", - "items": { - "$schema": "GenericLog" - } - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This form downloads and MERGES every log source server-side and returns the content inline as one time-ordered array, so it is slow for long sessions. A sibling capability returns log URLs instead (content fetched client-side) \u2014 prefer that one for large sessions.", - "The body is a bare JSON array of log entries, not an object wrapper \u2014 iterate it directly.", - "`trim=true` narrows the stream to the test's own time window; the default (false) includes the whole session.", - "Bad ids give TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND (404); locked builds give FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", - "On the demo-sandbox environment canned logs are returned, chosen by the test's name.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ] - }, - { - "name": "get_test_run_details", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/details", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test run id." - } - ], - "intent": "Get the full detail of one test run \u2014 its metadata plus the captured test code and the code of the before/after hooks that ran around it, i.e. everything needed to understand what the test did.", - "guidance": [ - "This is the heaviest of the detail reads: it assembles the run, its captured code, its meta and the related hook code from several sources, so prefer a lighter read when you only need status or metadata.", - "testCode (and each entry of hooksCode) can have null code/language when no code was captured for that run \u2014 the row is still returned; do not treat a null code as an error.", - "A run that does not belong to the caller's group, or does not exist, comes back as TEST_RUN_NOT_FOUND \u2014 the same answer for 'wrong group' and 'no such run'.", - "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ], - "returns": [ - "testCode", - "testMetadata", - "hooksCode" - ], - "responses": { - "200": { - "$response": "TestRunDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_baseline", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/baseLine", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric id of the (typically failing) run whose baseline you want." - } - ], - "intent": "Find a failing run's baseline \u2014 the most recent passing run of the same test in the last 30 days \u2014 returned with full test detail, so you can diff a failure against the last known-good execution.", - "guidance": [ - "Returns a MAP keyed by test run id, not a single object. Priority 1 is the newest passing run with the same test hash (one entry); if there is none it falls back to the newest passing run per device/os/browser combination (several entries). An EMPTY map means no baseline was found, not an error.", - "The look-back window is hardcoded to 30 days before this run's start time \u2014 an older last-good run will not appear, so an empty result does not prove the test has never passed.", - "Each map value is { testCode, testMetadata, hooksCode } - hooksCode is present but null on this route, and testCode is null when no code was captured. Read the passing run's numeric id from the map key." - ], - "returns": [ - " (map key)", - "testCode", - "testMetadata" - ], - "responses": { - "200": { - "$response": "TestRunDetailMapResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_build_insights", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/buildInsights", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test run id; its parent build is resolved from it." - } - ], - "intent": "Get the build-level insights that surround a failing run \u2014 the parent build's failure/flakiness summary, failure categorisation and top-errors analysis, plus this run's own file/module failure context \u2014 so a failure can be reasoned about in context.", - "guidance": [ - "The run's parent build is resolved first; a run whose build cannot be found returns BUILD_NOT_FOUND (distinct from TEST_RUN_NOT_FOUND when the run itself is missing).", - "topErrorsAnalysis.status is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED \u2014 when not COMPLETE, uniqueErrors may be empty or partial, so check the status before treating the error list as final.", - "failureContext (file and parentModule failure rates) is the per-run slice; the rest of the payload is aggregated across the whole build. A build-scoped variant that omits this per-run context exists internally but is not this capability." - ], - "returns": [ - "buildId", - "buildName", - "status", - "summary", - "failureCategorization", - "failureContext", - "topErrorsAnalysis", - "defectStats" - ], - "responses": { - "200": { - "$response": "BuildInsightsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_runs_sharing_error", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/similarError/{clusterId}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric id of the failing run whose error cluster you are matching against. Excluded from the returned set." - }, - { - "name": "clusterId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). Unique-error cluster id. It sits in the PATH but is bound onto the filter object's clusterId property by Spring's URI-template data binding \u2014 you do NOT also pass it as a query param.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Project the error breakdown is scoped to. Required \u2014 omitting it is a binding error, not an empty result." - }, - { - "name": "dateRange", - "type": "array", - "description": "Two epoch-millisecond bounds [from, to] scoping the error search. Bound onto the filter but NOT validated at request binding on this route (no @Valid), so a call without it will not 400 here yet may return nothing \u2014 supply it to get a meaningful window.", - "fields": [ - { - "name": "from", - "type": "integer" - }, - { - "name": "to", - "type": "integer" - } - ] - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true, search the error across all builds rather than only the run's own build." - }, - { - "name": "searchAfter", - "type": "array", - "description": "Elasticsearch search_after cursor echoed back from a previous page to continue paging the affected-runs scan." - } - ], - "intent": "List the other test runs that failed with the same unique-error cluster as this run \u2014 the 'tests affected by this error' list, each returned with full test detail.", - "guidance": [ - "Returns a MAP keyed by test run id (each value is { testCode, testMetadata }); the querying run itself is always excluded, and an EMPTY map means no other run shares the cluster.", - "projectId is a REQUIRED query param; the cluster is taken from the {clusterId} path segment via data binding, so do not duplicate it as a query param.", - "The affected-run scan also accepts the wider GetSPError filter set (test tags, statuses, devices/os/browsers, folders, users, etc.) as query params bound onto the filter object \u2014 use them to narrow which affected runs come back." - ], - "returns": [ - " (map key)", - "testCode", - "testMetadata" - ], - "responses": { - "200": { - "$response": "TestRunDetailMapResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/testRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id). The listing is scoped to this build." - } - ], - "query": [ - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 pagination cursor. Echo back the pagination.nextPage from the previous response verbatim; do not construct it. Stop when pagination.hasNext is false." - }, - { - "name": "sort", - "type": "string", - "values": [ - "EXECUTION_ORDER", - "TOP_LEVEL_NAME", - "DURATION", - "FAILED_TEST", - "PLATFORM", - "STATUS" - ], - "description": "Sort column." - }, - { - "name": "order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "description": "Sort direction. Required alongside sort \u2014 the two are read together." - }, - { - "name": "test_statuses", - "type": "array", - "description": "Filter by status strings (e.g. passed, failed, skipped)." - }, - { - "name": "re_runs", - "type": "array", - "description": "Filter by re-run numbers (list of integers)." - }, - { - "name": "ci_build_numbers", - "type": "array" - }, - { - "name": "test_tags", - "type": "array" - }, - { - "name": "host_names", - "type": "array" - }, - { - "name": "is_flaky", - "type": "boolean", - "description": "Smart-tag filter. On a freemium or source-fenced build this (and is_always_failing / is_new_failure / has_performance_anomaly) is REFUSED with a plan-restriction 403, not silently ignored." - }, - { - "name": "is_always_failing", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "is_new_failure", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "has_performance_anomaly", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "failure_categories", - "type": "array" - }, - { - "name": "devices", - "type": "array" - }, - { - "name": "os", - "type": "array" - }, - { - "name": "browsers", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hooks_visibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Which hooks to include in the tree. Defaults to failed." - }, - { - "name": "is_muted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-tests visibility: all, true (only muted), false (only unmuted). Also accepts ALL/MUTED/UNMUTED. An unrecognised value is rejected." - } - ], - "intent": "List a build's test runs as the UI suite/folder tree, with the full filter set (status, flaky, muted, re-runs, tags, failure category, device/os/browser, folders, performance anomaly, new/always-failing), sorted and cursor-paginated, with deep-link URLs back to the BrowserStack UI.", - "guidance": [ - "The response is a NESTED hierarchy (suites/folders/tests), each node carrying an external deep-link URL \u2014 it is not a flat array. For a flat list use the flat build listing instead.", - "Plan- and source-aware: on a freemium or source-fenced build the analysis keys are stripped from every node, and using any smart-tag filter (is_flaky / is_always_failing / is_new_failure / has_performance_anomaly) is refused with a 403 telling you to upgrade \u2014 so gate those filters on plan.", - "Service-account callers are additionally checked for the TEST_RUN_AUTOMATED_VIEW permission on the build's project; lacking it is a 403.", - "Pagination is an opaque base64 next_page token: pass `pagination.next_page` back unchanged and stop when `pagination.has_next` is false. Keys are snake_case. The envelope differs by an account flag: newer accounts add `is_manually_overridden` and `test_summary` carries workflow-status counts, older ones plain status counts." - ], - "returns": [ - "hierarchy", - "is_archived", - "build_name", - "build_id", - "original_name", - "build_number", - "project_id", - "name", - "group_id", - "test_summary", - "app_details", - "pagination" - ], - "responses": { - "200": { - "$response": "BuildTestRunListingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_with_attachments", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/testRuns/withAttachments", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id)." - } - ], - "query": [ - { - "name": "page_size", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Page size. Must be 1..100; outside that range is a 400 ('Limit should be between 1 and 100')." - }, - { - "name": "cursor", - "type": "integer", - "description": "NUMERIC cursor from the previous response's next_cursor (this route uses a numeric cursor, unlike the base64 cursor on the plain build test-run listing). Omit on the first page." - } - ], - "intent": "List only the test runs in a build that actually have attachments or screenshots, with per-run counts \u2014 the discovery step before downloading artefacts run by run.", - "guidance": [ - "Returns a TRIMMED row per run \u2014 id, name, uuid, build_id, attachments_count, screenshots_count \u2014 not the full test-run object; use the per-run attachments read to get the actual download URLs.", - "Pagination is a numeric cursor: pass the previous response's next_cursor back as cursor; a null next_cursor means the last page.", - "The build is resolved by (uuid, group); an unknown uuid for the caller's group is BUILD_NOT_FOUND." - ], - "returns": [ - "test_runs", - "next_cursor" - ], - "responses": { - "200": { - "$response": "BuildTestRunsWithAttachmentsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_bug_report_draft", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetails", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler \u2014 the draft is composed from testRunId alone (group-scoped). Any placeholder value is accepted." - }, - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Numeric test run id whose failure the draft describes." - } - ], - "intent": "Build the pre-filled bug-report draft for a failed test \u2014 test name/status/duration, platform/browser, failure category, CI and host info, build name, retries, screenshot/log attachments and the AI RCA snippet \u2014 i.e. everything the 'Report a bug to Jira/Azure' modal needs to compose a ticket.", - "guidance": [ - "Read-only despite the name: it COMPOSES a new draft description, it does not fetch a bug that was already filed. To record a filed bug use link_bug_to_test_run.", - "It requires screenshot/retry data to exist for the run \u2014 a run with no such data throws 'retry doesn't exist' (a 4xx), so treat that as 'nothing to report on yet', not an outage.", - "logStruct carries image/log attachment references and aiRca carries the AI RCA snippet; both can be empty/absent when the run has no screenshots or no RCA." - ], - "returns": [ - "buildName", - "buildId", - "ciInfo", - "testDetails", - "testUrl", - "videoUrl", - "status", - "platform", - "browser", - "hostInfo", - "logStruct", - "retries", - "issueType", - "isAutoAnalyzed", - "duration", - "aiRca" - ], - "responses": { - "200": { - "$response": "BugReportDraftResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "link_bug_to_test_run", - "method": "POST", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetailsCallback", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler. Any placeholder value is accepted; the run is resolved from testRunId." - }, - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Numeric test run id to attach the bug to." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "The external issue KEY (e.g. the Jira/Azure ticket id). Used for the idempotency check \u2014 a key already tracked on the run makes the call a no-op." - }, - { - "name": "type", - "type": "string", - "description": "The PM-tool name the issue belongs to (e.g. Jira, Azure Boards)." - }, - { - "name": "url", - "type": "string", - "description": "Deep link to the created issue, shown in the UI." - }, - { - "name": "status", - "type": "string", - "description": "The issue's status to display against the run." - }, - { - "name": "uniqueUserId", - "type": "integer", - "description": "Id of the user credited with filing the bug." - } - ], - "intent": "After a bug has been created in a PM tool (Jira, Azure Boards, \u2026), link that external issue to the test run \u2014 recording it in the run's integration tables and marking the test as bug-reported so the UI shows the issue key/link.", - "guidance": [ - "Idempotent on the issue key: if that bug name is already tracked on the run (checked in both quoted-legacy and unquoted forms) you get 204 No Content with no body \u2014 treat that as 'already linked', not a failure.", - "On a fresh link it returns 200 with { id } \u2014 the created integration id. The caller must have permission to create defects on the run's project (403 otherwise).", - "Linking also fires the PM-Tools integration trigger as a side effect; if that trigger fails it is only logged and does not fail the link.", - "Reversible: an unlink route removes the association, so this is a write, not a destructive op. Send the issue KEY in `name` (not in `url`).", - "Read-back: the link is written to the test run's search document and appears as details.pm_tool_details[] ({id,name,url,type,status}) in list_build_test_runs \u2014 the returned id is that entry's id." - ], - "returns": [ - "id" - ], - "responses": { - "200": { - "$response": "LinkBugToTestRunResponse" - }, - "204": { - "description": "The bug key is already tracked on this run; nothing was changed and there is no body." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_flat", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/testRuns/flat", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id). Resolved by (uuid, group); unknown \u2192 BUILD_NOT_FOUND." - } - ], - "query": [ - { - "name": "searchAfter", - "type": "string", - "description": "Elasticsearch search_after cursor \u2014 pass the previous page's cursor back to continue. Omit on the first page." - }, - { - "name": "testRunIds", - "type": "array", - "description": "Restrict to specific numeric run ids. Send this OR clusterIds: with neither the handler 500s. Takes precedence over clusterIds." - }, - { - "name": "clusterIds", - "type": "array", - "description": "Restrict to runs in these unique-error clusters (latest run per test). Send this OR testRunIds: with neither the handler 500s." - }, - { - "name": "status", - "type": "array" - }, - { - "name": "issueType", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "browser", - "type": "array" - }, - { - "name": "os", - "type": "array" - }, - { - "name": "folder", - "type": "array" - }, - { - "name": "flaky", - "type": "string" - }, - { - "name": "isMuted", - "type": "string" - }, - { - "name": "search", - "type": "array" - }, - { - "name": "virtualDevice", - "type": "boolean", - "description": "true restricts to virtual-device runs; false includes explicit-false AND runs missing the field." - } - ], - "intent": "List a build's test runs as a flat, filterable list (rather than the nested suite/folder hierarchy) \u2014 the data source for the flat test-listing view of a build.", - "guidance": [ - "Send testRunIds or clusterIds. With neither, the handler builds an Elasticsearch terms filter from a null clusterIds list and returns 500 Internal System Exception. The other filter params (status, issueType, tags, browser, os, folder, flaky, isMuted, search) are bound but NOT applied by this handler; only testRunIds, clusterIds and virtualDevice narrow the result.", - "Flat counterpart of the hierarchical build listing \u2014 returns a plain array under testRuns, no tree. Filters arrive as QUERY params bound onto a POJO, not as a JSON body.", - "In each row the top-level `id` is the test's HASH (a string); the numeric run id you need for other run reads is `details.id`. Do not pass the top-level id where a numeric run id is expected.", - "Pagination is Elasticsearch search_after: pass the prior page's cursor back as `searchAfter`. Note the underlying query fetches up to 10,000 hits, so on very large builds narrow with filters rather than relying on paging alone.", - "The ES indices queried are chosen from the build, so very old builds may resolve to different indices; results are limited to the latest run per test unless you pass explicit testRunIds." - ], - "returns": [ - "testRuns", - "pagingParams" - ], - "responses": { - "200": { - "$response": "FlatTestRunListingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_ai_failures", - "method": "GET", - "path": "/ext/v1/ai/failures/{buildUUID}", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "intent": "Public API: get a build's AI failure analysis (clustered failure themes and their workflows, sorted by failure share), the summary a build-RCA agent uses to explain a build failure.", - "returns": [ - "buildId", - "buildThemes", - "buildWorkflows", - "buildThemeWorkflow", - "stats", - "trialQuota" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Read-only: returns whatever has already been computed and never triggers analysis; use trigger_build_ai_failure_analysis to start it.", - "Themes and workflows are sorted by failure share, highest first.", - "trialQuota appears only for build-level trial accounts.", - "The build is resolved within the caller's group; a build in another group reads as not found." - ], - "responses": { - "200": { - "$response": "BuildAIFailuresResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "trigger_build_ai_failure_analysis", - "method": "POST", - "path": "/ext/v1/ai/failures/{buildUUID}", - "mode": "write", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "intent": "Public API: start build-level AI failure clustering for a build (get-or-trigger). Returns existing analysis if ready, otherwise enqueues it and flags that it was triggered.", - "returns": [ - "triggered", - "buildId", - "buildThemes", - "buildWorkflows", - "buildThemeWorkflow", - "stats", - "trialQuota" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Get-or-trigger and asynchronous: triggered=true means analysis was enqueued and results are NOT in the response yet; poll get_build_ai_failures.", - "Gated by the AI-TFA plan fence plus AI consent, the build-level RCA feature, source-fencing and a trial quota; a blocked account gets a 403 plan-not-allowed error.", - "Runs only the theme/workflow clustering pipeline; this is NOT per-test RCA.", - "Takes no request body.", - "Produces data only, never removes any; there is no undo for the analysis run it starts." - ], - "responses": { - "200": { - "$response": "BuildAIFailureTriggerResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_ai_failure_theme_test_runs", - "method": "GET", - "path": "/ext/v1/ai/failures/{buildUUID}/flat", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "query": [ - { - "name": "buildFailureThemeId", - "type": "integer", - "required": false, - "description": "Integer id of the failure theme to drill into, from a public AI-failures read. Give this OR buildFailureWorkflowId to scope to one cluster. One of theme/workflow id is required." - }, - { - "name": "buildFailureWorkflowId", - "type": "integer", - "required": false, - "description": "Integer id of the failure workflow to drill into. One of theme/workflow id is required." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend default; use searchAfter to page." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Opaque search-after cursor from the previous response's pagingParams.searchAfter." - } - ], - "intent": "Drill into one AI failure theme or workflow of a build and list the flat, paginated test runs behind it. Takes a theme or workflow id only \u2014 to fetch an explicit set of test-run ids instead, use list_build_ai_failure_cluster_test_runs, which accepts testRunIds.", - "returns": [ - "testRuns", - "pagingParams" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Send buildFailureThemeId OR buildFailureWorkflowId (integer ids from get_build_ai_failures). One of them is REQUIRED: with neither, the TestHub lookup 400s 'At least one of build_failure_theme_id or build_failure_workflow_id must be provided' (there is no whole-build mode).", - "There is no explicit-testRunIds variant on the public flat endpoint.", - "limit is ignored; page with searchAfter until pagingParams.hasNext is false." - ], - "responses": { - "200": { - "$response": "FlatAIFailureTestRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_queued_sessions", - "method": "GET", - "path": "/api/v1/queued-sessions", - "mode": "read", - "entity": "session", - "query": [ - { - "name": "userId", - "type": "integer", - "required": true, - "description": "The user whose queued sessions to list. Taken from the query, NOT the caller's identity, so any user id can be asked about." - }, - { - "name": "product", - "type": "string", - "required": true, - "description": "Product to filter the queue by (e.g. automate / app-automate)." - } - ], - "intent": "List the automation sessions currently queued for a user and product, each with session/build/project name, resolved capabilities, why it is queued and how long it has waited.", - "returns": [ - "sessions" - ], - "guidance": [ - "Both userId and product are required (400 otherwise).", - "userId comes from the query, so it is NOT scoped to the caller; only the build/project name enrichment is group-scoped (names resolve to null for builds outside your group).", - "queueReason explains the wait (e.g. no_terminal_available); duration is computed at read time.", - "The capabilities field's shape depends on the session's caps format (selenium / appium / default)." - ], - "responses": { - "200": { - "$response": "QueuedSessionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_app_profiling", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/appProfiling", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get app-profiling data (CPU/memory/etc. captured during an App Automate session) for the Test Details app-performance tab.", - "returns": [], - "guidance": [ - "App profiling is App Automate only; on an Automate session this is a 400 (\"App Profiling is only supported for App Automate sessions\").", - "An empty object ({}) is a normal 200 meaning the report is still generating (upstream 202) or was never captured (upstream 404); it is NOT an error, so re-poll rather than treating it as failure.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "The body is the opaque upstream app-profiling JSON.", - "On the demo sandbox environment this returns an empty object." - ], - "responses": { - "200": { - "$response": "SessionAppProfilingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_capabilities", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/capabilities", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get the raw desired/actual capabilities a session was started with (Automate or App Automate), so a user can see exactly how the run was configured.", - "returns": [], - "guidance": [ - "Product-dependent: the session's origin (automate / app-automate) is resolved and the caps are fetched from that product's API.", - "The body is opaque JSON: normally the upstream sessionParams object, but if the upstream fetch fails it falls back to {outputCapabilities:{capabilities:...}} built from the stored session capabilities. Do not assume a fixed key.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "On the demo sandbox environment this returns dummy capabilities." - ], - "responses": { - "200": { - "$response": "SessionCapabilitiesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_details", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/sessionDetails", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get the header/metadata block for a session on the Test Details page: video/audio log handles, session URL, platform/device, framework and timings the tabs are built from.", - "returns": [ - "videoLogs", - "audioLogs", - "browserstackSessionUrl", - "percyBuildUrl", - "isSdk", - "device", - "framework", - "customMetadata", - "testStartedAt", - "buildId" - ], - "guidance": [ - "While the session is still being linked to its build the call returns a RETRYABLE 425 (BUILD_LINK_NOT_READY); retry shortly rather than treating it as fatal.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "customMetadata and buildId are always null through this path today (buildId is not populated here despite the field name), so do not depend on them.", - "If the upstream media fetch fails, videoLogs/audioLogs come back null but the rest of the metadata is still returned with a 200.", - "On the demo sandbox environment this returns synthetic details with a sample video URL." - ], - "responses": { - "200": { - "$response": "SessionDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "425": { - "$response": "BuildLinkNotReady" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_test_code", - "method": "GET", - "path": "/ext/v1/sessions/{session_id}/testCode", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "session_id", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Public API: get the test source code captured for an Automate session, one entry per test executed in that session, so external tooling (e.g. AI agents) can read the code behind a session.", - "returns": [ - "session_id", - "build_uuid", - "tests" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Cross-group safe: the resolved build's group is re-checked against the caller's group and a mismatch is reported as SESSION_NOT_FOUND (not a distinct 403).", - "A session that exists but captured NO code is reported as SESSION_NOT_FOUND, indistinguishable from a bad id, so not-found does not prove the id is wrong.", - "TestHub caps and truncates very large responses server-side; the truncation is only logged, never surfaced, so a returned code body may be incomplete without any flag.", - "A plan-locked build returns a forbidden-operation error." - ], - "responses": { - "200": { - "$response": "SessionTestCodeResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_checks_mapping", - "entity": "check_config", - "method": "POST", - "path": "/api/v1/checks-mapping", - "mode": "write", - "intent": "Register a pull request's VCS checks (e.g. GitHub check runs) against existing BrowserStack check configurations, so the PR-checks / quality-gate pipeline can evaluate them and call back with results.", - "path_params": [], - "body": [ - { - "name": "event_name", - "type": "string", - "required": true, - "json_path": "/event_name", - "description": "VCS event name that triggered the mapping. Mandatory and must be non-blank." - }, - { - "name": "tool", - "type": "string", - "required": true, - "json_path": "/tool", - "description": "Source tool (e.g. the VCS provider). Mandatory and must be non-blank." - }, - { - "name": "callback_url", - "type": "string", - "required": true, - "json_path": "/callback_url", - "description": "URL the evaluation pipeline calls back once each mapped check is evaluated. Mandatory and must be non-blank. Stored verbatim on every created ingestion row." - }, - { - "name": "pr_data", - "type": "object", - "required": true, - "json_path": "/pr_data", - "description": "Pull-request metadata. Mandatory. The whole object is also stored verbatim as the ingestion row's PR metadata blob (with githubCheckId merged in per check).", - "fields": [ - { - "name": "organisationId", - "type": "string", - "description": "VCS organisation/owner NAME as stored on the check configuration (vcs_org_name, e.g. 'my-org'), NOT GitHub's numeric org id. Matched with repositoryId + group; a mismatch means no config is found and the whole call is a 400." - }, - { - "name": "repositoryId", - "type": "string", - "description": "Repository NAME as stored on the check configuration (repo_name, e.g. 'my-repo'), NOT GitHub's numeric repository id. Matched with organisationId + group." - }, - { - "name": "number", - "type": "integer", - "description": "Pull request number, stored as pr_number on every created row." - }, - { - "name": "commitSHA", - "type": "string", - "description": "Head commit SHA, stored as commit_sha on every created row." - }, - { - "name": "date", - "type": "string", - "format": "date-time", - "description": "Commit timestamp as an ISO-8601 instant (parsed with Instant.parse). An unparseable value fails the whole request with a 400 naming the check; when omitted, commit_timestamp is left unset." - }, - { - "name": "githubCheckId", - "type": "string", - "description": "Optional; note the per-check GitHub id is normally supplied inside pr_checks, not here." - }, - { - "name": "ticketId", - "type": "string" - }, - { - "name": "installationId", - "type": "string" - }, - { - "name": "autoMerge", - "type": "boolean" - }, - { - "name": "fullName", - "type": "string" - } - ] - }, - { - "name": "pr_checks", - "type": "array", - "required": true, - "json_path": "/pr_checks", - "description": "Non-empty list of the PR's checks to register. Each item's id is the CHECK CONFIGURATION id, and it must match an existing check configuration for the caller's group + this repository + organisation, or the whole call returns 400 'Check Configurations not found...'.", - "fields": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Check CONFIGURATION id as a string. Parsed with Long.parseLong, so a non-numeric value throws before validation and fails the call. NOT the GitHub check id." - }, - { - "name": "githubCheckId", - "type": "integer", - "required": true, - "description": "The GitHub check-run id for this check. Mandatory. Merged into the stored PR metadata blob for the matching ingestion row." - }, - { - "name": "name", - "type": "string", - "required": true, - "description": "Check name. Mandatory and must be non-blank." - } - ] - } - ], - "returns": [ - "id", - "prNumber", - "commitSha", - "callbackUrl", - "checkStates", - "checkType", - "retryCount", - "splitRepos", - "prMetadataJson", - "thProjectId", - "checkConfiguration" - ], - "responses": { - "201": { - "description": "Array of the created vcs_ingestion rows (one per matched check configuration), each in state 'Not Started'. On success but with no matching configs the flow instead returns 400.", - "schema": { - "type": "array", - "items": { - "$schema": "VcsCheckMapping" - } - } - }, - "400": { - "description": "Plain-text (not JSON) body. Covers validation failures, the CHECKS_EVALUATION-not-accessible case, an unparseable commit date, a non-empty-but-unmatched pr_checks set ('Check Configurations not found...'), and any other exception whose message is echoed as the body.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement; when it is off the call still comes back as a 400 with the exception message as a PLAIN-TEXT body, so errors here are untyped strings, not JSON.", - "pr_checks[].id is the BrowserStack check-CONFIGURATION id, not the GitHub check id and not the ingestion id. Send the GitHub id separately as pr_checks[].githubCheckId.", - "A non-numeric pr_checks[].id throws (Long.parseLong) before bean validation, so it fails the whole request rather than returning a clean per-field error.", - "If none of the pr_checks ids match an existing check configuration for the caller's group + repositoryId + organisationId, the whole call is 400 'Check Configurations not found...' and nothing is created; register the check configuration first.", - "One vcs_ingestion row is created per MATCHED configuration; ids that match nothing are silently skipped rather than erroring individually. Read the returned array to see exactly which were registered, and take each row's integer id to look the mapping up later.", - "pr_data.date must be an ISO-8601 instant; an unparseable value fails the call with a 400 naming the check, and omitting it just leaves the commit timestamp unset.", - "Live 2026-09-30: organisationId/repositoryId are compared to the configuration's stored owner/repo NAMES (VcsIngestionService findByIdInAndRepoNameAndVcsOrgNameAndGroupId); numeric GitHub ids never match. Creating a mapping only stores a 'Not Started' ingestion row \u2014 it posts nothing to GitHub itself." - ] - }, - { - "name": "get_checks_mapping", - "entity": "check_config", - "method": "GET", - "path": "/api/v1/checks-mapping/{id}", - "mode": "read", - "intent": "Fetch a single previously-registered PR-check mapping (a vcs_ingestion row plus its joined check configuration) to see its current evaluation state, retry count, PR metadata and callback URL.", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The vcs_ingestion row id returned by create_checks_mapping. NOT the GitHub check id and NOT the check-configuration id (the service calls it checkId, which is misleading)." - } - ], - "returns": [ - "id", - "prNumber", - "commitSha", - "callbackUrl", - "checkStates", - "checkType", - "retryCount", - "splitRepos", - "prMetadataJson", - "thProjectId", - "checkConfiguration" - ], - "responses": { - "200": { - "$response": "VcsCheckMappingResponse" - }, - "404": { - "description": "Plain-text (not JSON) body: 'No check mapping found for checkId: N'. Returned when no row matches the id within the caller's group (another group's mapping reads as not-found).", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Plain-text (not JSON) body: 'Error fetching check mapping: ...'.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "The path id is the INGESTION row id from create_checks_mapping's response, not the githubCheckId or the check-configuration id.", - "Scoped to the caller's group: a mapping owned by another group returns 404, so a 404 does not prove the id never existed.", - "The 404 and 500 bodies are PLAIN TEXT, not JSON error objects, so do not try to parse them as {error:...}.", - "checkStates carries the evaluation status (created rows start at 'Not Started'); the joined checkConfiguration object gives the underlying check's name, project and type." - ] - }, - { - "name": "list_check_configurations", - "entity": "check_config", - "method": "GET", - "path": "/api/v1/repos/{owner}/{repo}/check-runs", - "mode": "read", - "intent": "List the SCM check-run configurations set up for a repository \u2014 the rules deciding which BrowserStack build/project a commit or PR check reports on, and whether it posts quality-gate or build status.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner. Matched against the stored vcs_org_name." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name. Matched against the stored repo_name." - } - ], - "query": [ - { - "name": "filter", - "type": "string", - "required": false, - "description": "Only the value 'enabled' (case-insensitive) is honoured: it restricts the listing to enabled checks. ANY other value is silently ignored and all checks are returned \u2014 this is not a general-purpose filter.", - "values": [ - "enabled" - ] - } - ], - "returns": [ - "repoName", - "ownerName", - "checks" - ], - "responses": { - "200": { - "$response": "CheckConfigurationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement (contrary to what one might assume for a read); without it the call is refused as feature-inaccessible.", - "Rows are scoped to the caller's group AND user, so you only see check configs you created \u2014 not the whole repository's.", - "When nothing is configured you get HTTP 200 with an empty checks list, not a 404; and if the lookup throws internally it also returns 200 with an EMPTY response object (null repoName/ownerName and no checks), so treat an all-null body as an error, not as 'no checks'.", - "filter accepts only 'enabled'; any other token is ignored rather than rejected, so a mistyped filter silently returns everything.", - "PRODUCT BUG (live 2026-09-30): when the repository has NO checks, the empty response swaps the envelope \u2014 repoName carries the OWNER and ownerName carries the REPO (controller builds CheckConfigurationResponse(owner, repo, \u2026) against a (repoName, ownerName, \u2026) constructor). Non-empty listings are correct. Trust the path you sent, not the envelope, on an empty list." - ] - }, - { - "name": "create_check_configurations", - "entity": "check_config", - "method": "POST", - "path": "/api/v1/repos/{owner}/{repo}/check-runs", - "mode": "write", - "intent": "Register up to 10 check-run configurations on a repository so BrowserStack posts a named check (quality-gate or build status) back to the VCS for commits/PRs, bound to a given project, build name and branch.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner; stored as the check's vcs_org_name." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name; stored as the check's repo_name." - } - ], - "body": [ - { - "name": "checks", - "type": "array", - "required": true, - "json_path": "/checks", - "minItems": 1, - "maxItems": 10, - "description": "The check configurations to create (1..10). Empty is rejected (400 'Checks list cannot be empty'); more than 10 is rejected. This is also capped against the repository's EXISTING count \u2014 max 10 per repository in total.", - "fields": [ - { - "name": "checkName", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Display name of the check. Mandatory, non-blank. Part of the uniqueness key (checkName + repo + group + buildName + project)." - }, - { - "name": "checkType", - "type": "string", - "required": true, - "values": [ - "QG_STATUS", - "BUILD_STATUS" - ], - "description": "What the check reports: QG_STATUS (quality-gate status) or BUILD_STATUS (build status). Mandatory." - }, - { - "name": "tool", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Source tool / VCS provider. Mandatory, non-blank." - }, - { - "name": "splitRepos", - "type": "boolean", - "required": true, - "json_path": "/checks/splitRepos", - "description": "Split-repos flag. Mandatory (@NotNull) \u2014 omitting it is a 400 even though it looks optional." - }, - { - "name": "buildName", - "type": "string", - "required": true, - "json_path": "/checks/buildName", - "maxLength": 1024, - "description": "Target build name the check reports on. Mandatory, non-blank. Part of the uniqueness key." - }, - { - "name": "projectName", - "type": "string", - "required": true, - "json_path": "/checks/projectName", - "maxLength": 256, - "description": "Target project name. Mandatory, non-blank. Must resolve to an EXISTING project in the workspace, or the write is refused as project-not-found." - }, - { - "name": "branchName", - "type": "string", - "required": false, - "json_path": "/checks/branchName", - "maxLength": 256, - "description": "Branch to scope the check to (optional)." - }, - { - "name": "buildTags", - "type": "string", - "required": false, - "json_path": "/checks/buildTags", - "maxLength": 256, - "description": "Build tags used to select the build (optional)." - }, - { - "name": "commitCheck", - "type": "boolean", - "required": false, - "json_path": "/checks/commitCheck", - "description": "Whether the check reports per-commit (optional)." - }, - { - "name": "isEnabled", - "type": "boolean", - "required": false, - "json_path": "/checks/isEnabled", - "description": "Whether the check is active (optional)." - } - ] - } - ], - "returns": [ - "repoName", - "ownerName", - "checks" - ], - "responses": { - "201": { - "$response": "CheckConfigurationResponse" - }, - "200": { - "$response": "CheckConfigurationResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement; without it the write is refused as feature-inaccessible.", - "Success is HTTP 201, but the SAME route returns HTTP 200 when nothing was actually saved (empty or all-dropped) \u2014 check the returned checks list, not just the status code.", - "At most 10 checks per repository IN TOTAL (existing + new), not just 10 per request; exceeding it is a 400 'Maximum 10 checks per repository'.", - "splitRepos is required despite reading like a flag \u2014 omit it and the call is a 400.", - "projectName must name a project that already exists in the workspace, otherwise the whole request fails with project-not-found.", - "Uniqueness is (checkName, repo, group, buildName, project); a duplicate collides on that key.", - "Checks are stored against the caller's group and user, so only you will see and be able to manage them afterwards." - ] - }, - { - "name": "update_check_configuration", - "entity": "check_config", - "method": "PUT", - "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}", - "mode": "write", - "intent": "Edit an existing check-run configuration \u2014 its name, check type, tool, target project/build name, branch, tags, commit-check or enabled flag.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name." - }, - { - "name": "checkId", - "type": "string", - "required": true, - "description": "Numeric check-configuration id (as a string in the path). A non-numeric value is a 400 'Invalid check ID format'." - } - ], - "body": [ - { - "name": "checkName", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Check name. Mandatory, non-blank." - }, - { - "name": "checkType", - "type": "string", - "required": true, - "values": [ - "QG_STATUS", - "BUILD_STATUS" - ], - "description": "QG_STATUS or BUILD_STATUS. Mandatory." - }, - { - "name": "tool", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Source tool. Mandatory, non-blank." - }, - { - "name": "splitRepos", - "type": "boolean", - "required": true, - "json_path": "/splitRepos", - "description": "Split-repos flag. Mandatory (@NotNull)." - }, - { - "name": "buildName", - "type": "string", - "required": true, - "json_path": "/buildName", - "maxLength": 1024, - "description": "Target build name. Mandatory, non-blank." - }, - { - "name": "projectName", - "type": "string", - "required": true, - "json_path": "/projectName", - "maxLength": 256, - "description": "Target project name. Mandatory, non-blank; must resolve to an existing project or the update is refused as project-not-found." - }, - { - "name": "branchName", - "type": "string", - "required": false, - "json_path": "/branchName", - "maxLength": 256, - "description": "Branch scope (optional)." - }, - { - "name": "buildTags", - "type": "string", - "required": false, - "json_path": "/buildTags", - "maxLength": 256, - "description": "Build tags (optional)." - }, - { - "name": "commitCheck", - "type": "boolean", - "required": false, - "json_path": "/commitCheck", - "description": "Per-commit reporting flag (optional)." - }, - { - "name": "isEnabled", - "type": "boolean", - "required": false, - "json_path": "/isEnabled", - "description": "Enabled flag (optional)." - } - ], - "returns": [ - "checkId", - "checkName", - "repoName", - "vcsOrgName", - "vcsName", - "checkType", - "splitRepos", - "projectId", - "projectName", - "branchName", - "buildName", - "buildTags", - "groupId", - "userId", - "createdAt", - "updatedAt", - "commitCheck", - "isEnabled" - ], - "responses": { - "200": { - "$response": "CheckConfigurationItemResponseWrap" - }, - "204": { - "description": "Empty body. Returned when the service resolves no updatable check (used INSTEAD of a 404) \u2014 the caller cannot tell 'not found' from 'no-op' by status alone." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This is a FULL replace, not a patch: all mandatory fields (checkName, checkType, tool, splitRepos, buildName, projectName) must be present on every update, so read the check first and echo unchanged values back.", - "Gated by CHECKS_EVALUATION; scoped to the caller's group and user \u2014 you cannot edit a teammate's check.", - "A missing/foreign check comes back as HTTP 204 with an empty body, NOT a 404 \u2014 do not read 204 as success without re-reading.", - "A non-numeric checkId is a 400 'Invalid check ID format'; projectName must resolve to an existing project.", - "To only flip the enabled flag, prefer toggle_check_configuration \u2014 it takes a single query param instead of the whole body." - ] - }, - { - "name": "toggle_check_configuration", - "entity": "check_config", - "method": "PATCH", - "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}/toggle", - "mode": "write", - "intent": "Enable or disable a single check-run configuration without editing the rest of it \u2014 the quick on/off switch in the repo's checks settings.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name." - }, - { - "name": "checkId", - "type": "string", - "required": true, - "description": "Numeric check-configuration id (string in path); non-numeric is a 400 'Invalid check ID format'." - } - ], - "query": [ - { - "name": "isEnabled", - "type": "boolean", - "required": true, - "description": "New enabled state. This is a REQUIRED QUERY parameter, not a body field \u2014 omitting it is a 400 for a missing parameter." - } - ], - "returns": [ - "checkId", - "checkName", - "repoName", - "vcsOrgName", - "vcsName", - "checkType", - "splitRepos", - "projectId", - "projectName", - "branchName", - "buildName", - "buildTags", - "groupId", - "userId", - "createdAt", - "updatedAt", - "commitCheck", - "isEnabled" - ], - "responses": { - "200": { - "$response": "CheckConfigurationItemResponseWrap" - }, - "404": { - "description": "Empty body. Returned when no check matches the id for the caller's group/user." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "isEnabled is a REQUIRED QUERY parameter (?isEnabled=true|false), not a body field \u2014 this is the most common mistake on this route.", - "Gated by CHECKS_EVALUATION and scoped to the caller's group and user.", - "A check that is not found comes back as HTTP 404 with an EMPTY body (unlike update, which uses 204 for the same case).", - "On success it returns the full updated check configuration, so you can confirm the new isEnabled value from the body." - ] - }, - { - "name": "update_view", - "entity": "view", - "method": "PUT", - "path": "/api/v1/views/{viewType}/{id}", - "mode": "write", - "intent": "Rename a saved view, replace its stored filter query, or change its private/public visibility.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view belongs to; sets the type used for the name-uniqueness check. An unrecognised value is a 400." - }, - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "View UUID. A view that does not exist is a 400 'View does not exists', not a 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "json_path": "/name", - "description": "New name. Omit to keep the stored name. Despite @NotNull on the model, an omitted field falls back to the stored value (partial update). A clash returns VIEW_NAME_EXISTS." - }, - { - "name": "query", - "type": "string", - "required": false, - "json_path": "/query", - "description": "New serialised filter. Omit to keep the stored query." - }, - { - "name": "level", - "type": "integer", - "required": false, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "New visibility (1 = private, 2 = public). Omit to keep the stored level." - } - ], - "returns": [], - "responses": { - "200": { - "description": "Empty body \u2014 the updated view is NOT echoed back (unlike create_view). Re-list to see the result." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Requires the manage-custom-views permission (BUILD_MANAGE_VIEWS).", - "PARTIAL update despite being a PUT: omitted name/query/level fall back to the view's stored values.", - "For a PRIVATE view the caller must be the owner ('Not allowed to update this view'); the SQL also restricts updates to your own views OR public views in your group. A non-existent id is a 400 'View does not exists', not a 404.", - "A name clash returns VIEW_NAME_EXISTS (per screen type: within group for public, within creator for private).", - "Returns an empty 200 body on success \u2014 do not expect the updated view back; re-list to confirm.", - "There is no revision history: an overwritten name, query or level cannot be restored." - ] - }, - { - "name": "list_views", - "entity": "view", - "method": "GET", - "path": "/ext/v1/views/{viewType}", - "mode": "read", - "intent": "External/public API (used by the Atlas agent) to list the caller's saved views for a screen, so an integration can pick a saved view instead of rebuilding a filter query.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the views belong to. An unrecognised value is a 400." - } - ], - "query": [], - "returns": [ - "id", - "type", - "query", - "level", - "name", - "description", - "createdBy", - "groupId" - ], - "responses": { - "200": { - "$response": "ViewListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: authenticated with a BrowserStack username + access key over HTTP Basic, not the internal session token.", - "Returns a bare JSON ARRAY of view objects, scoped to the caller's user/group; not paginated. No public-link-token path here (unlike the internal list).", - "Thin adapter over the same view service as the internal list \u2014 results are the same set for the same caller. There is intentionally NO external delete operation.", - "An unrecognised viewType is a 400; valid values are builds, testRuns, testHealth, uniqueErrors, buildInsights." - ] - }, - { - "name": "create_view", - "entity": "view", - "method": "POST", - "path": "/ext/v1/views/{viewType}", - "mode": "write", - "intent": "External/public API to save a filter query as a named view (private or shared with the org) for a screen.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view is for; sets the view's type. An unrecognised value is a 400." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/name", - "minLength": 1, - "maxLength": 100, - "description": "View name. Enforced at this public boundary to be non-blank and at most 100 characters (stricter than the internal surface). Must be unique per screen type within the group (public) or creator (private)." - }, - { - "name": "query", - "type": "string", - "required": true, - "json_path": "/query", - "minLength": 1, - "maxLength": 4000, - "description": "Serialised filter to store, as a string. Enforced non-blank and at most 4000 characters here." - }, - { - "name": "level", - "type": "integer", - "required": true, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = shared with the org. Required; any other value is rejected with 'level must be 1 (private) or 2 (shared)'." - } - ], - "returns": [ - "id", - "type", - "query", - "level", - "name", - "description", - "createdBy", - "groupId" - ], - "responses": { - "200": { - "$response": "ViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: username + access-key HTTP Basic auth. Requires the manage-custom-views permission (works for both globalRBAC-on and -off orgs).", - "Stricter validation than the internal create: name must be non-blank and <=100 chars, query non-blank and <=4000 chars, level exactly 1 or 2 \u2014 each violation is its own 400 message.", - "type is set from the path and id/createdBy/groupId in the body are ignored; the creator and group come from the authenticated caller.", - "Name uniqueness is per screen type (group for public, creator for private); a clash returns VIEW_NAME_EXISTS.", - "Returns the stored view (with its UUID id) as a single object." - ] - }, - { - "name": "replace_view", - "entity": "view", - "method": "PUT", - "path": "/ext/v1/views/{viewType}/{id}", - "mode": "write", - "intent": "Rename a saved view or replace its stored filter query and visibility level. Name, query and level are all REQUIRED here \u2014 this replaces the view rather than patching it, so send the whole object; update_view is the partial-update route.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view belongs to; used for the name-uniqueness check and the ownership guard. An unrecognised value is a 400." - }, - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "View UUID. A non-UUID is a 400 'Invalid view id.'; an id not in the caller's own scoped view list is a 400 'View does not exist.' (closes a cross-tenant existence oracle)." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/name", - "minLength": 1, - "maxLength": 100, - "description": "View name. Validated non-blank and <=100 chars at this boundary." - }, - { - "name": "query", - "type": "string", - "required": true, - "json_path": "/query", - "minLength": 1, - "maxLength": 4000, - "description": "Serialised filter to store. Validated non-blank and <=4000 chars." - }, - { - "name": "level", - "type": "integer", - "required": true, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = shared. Required; other values are a 400." - } - ], - "returns": [], - "responses": { - "200": { - "description": "Empty body on success \u2014 the updated view is not echoed back." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: username + access-key HTTP Basic auth, plus the manage-custom-views permission.", - "Unlike the internal update, this validates the payload the same way create does (name <=100, query <=4000, level 1|2) \u2014 all three are effectively required here.", - "Explicit ownership guard: the id must appear in the caller's own scoped view list, otherwise a 400 'View does not exist.' \u2014 this deliberately hides whether some other tenant's view id exists. A non-UUID is a 400 'Invalid view id.'.", - "Returns an empty 200 body; re-list to confirm. No revision history, so overwritten values cannot be restored." - ] - }, - { - "name": "list_dashboard_views", - "method": "GET", - "path": "/api/v1/dashboards/views", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the dashboard views the current user can see (their own private ones plus group/shared ones) with name, visibility level, widget count and owner \u2014 the dashboard picker / dashboard list.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED even though this listing does not use the project to filter results \u2014 the controller resolves a project before every handler and 400s with 'One of `project_id`, `project_name` or `project_identifier` is required' when none is given." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which dashboard surface to resolve the project against. TO = Test Observability, TCM = Test Management." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "Dashboard views the caller can see, as a flat list under `data` (no success flag on this route).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "DashboardView" - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A project query param is mandatory here purely to satisfy the controller-wide project resolver; the returned list is not scoped by it, so passing any project the caller can access is enough.", - "The response is a bare `{data: [...]}` envelope with no `success` flag and no pagination \u2014 the whole visible set comes back in one call.", - "Each row carries the dashboard UUID under `data[].id`; carry that forward to get/update/delete/clone. The built-in views may or may not appear here, so address those by their `default` / `automation-trend` aliases on the read call rather than expecting them in this list." - ] - }, - { - "name": "create_dashboard_view", - "method": "POST", - "path": "/api/v1/dashboards/views", - "mode": "write", - "entity": "dashboard_view", - "intent": "Create a new custom dashboard view \u2014 its name, visibility level, global filters and widget layout \u2014 for the Test Observability (TO) or Test Management (TCM) dashboard page.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which surface the dashboard belongs to. TO = Test Observability, TCM = Test Management." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it both resolves the project and is the resource the DASHBOARD_CREATE permission check runs against." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "Dashboard name, 1-100 characters. Must be unique within the project \u2014 a duplicate name is rejected. Not annotated @NotNull, but a dashboard is expected to carry a name." - }, - { - "name": "level", - "type": "integer", - "default": 2, - "description": "Visibility level. Defaults to 2 (group / shared). Controls who else can see the dashboard." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Optional description. Capped at 100 characters (the validation message misstates the cap as 200)." - }, - { - "name": "filters", - "type": "object", - "description": "Global dashboard filters (DashboardFilters): dateRange, buildTags, tags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields. Applied across all widgets on the dashboard.", - "fields": [ - { - "name": "dateRange", - "type": "object", - "description": "Default time window for the dashboard (a DateRangeFilter with a `key` such as days30, or a custom range)." - }, - { - "name": "projects", - "type": "array", - "description": "Project ids the dashboard spans." - }, - { - "name": "buildName", - "type": "array", - "description": "Build-name filter values (wire key is `buildName`, not `buildNames`)." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names." - }, - { - "name": "tcmTestPlanIds", - "type": "array", - "description": "Test Plan ids (wire key `tcmTestPlanIds`; legacy `testPlanIds` is accepted as an alias)." - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "The widget layout to save. Each item is a Widgets object; see the widget-data capability for the per-widget field shape (id, name, type, globalFilters, desc, config, layout).", - "fields": [ - { - "name": "id", - "type": "string", - "description": "Client widget id, up to 100 chars." - }, - { - "name": "name", - "type": "string", - "description": "Widget title, 1-100 chars." - }, - { - "name": "type", - "type": "string", - "description": "Widget type (WidgetTypeEnum)." - }, - { - "name": "globalFilters", - "type": "object", - "description": "Per-widget filter overrides (DashboardFilters shape)." - }, - { - "name": "config", - "type": "object", - "description": "Widget configuration (display type, segments, filters, etc.)." - }, - { - "name": "layout", - "type": "object", - "description": "Grid position/size of the widget." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The created dashboard is returned under `data`, with the assigned UUID at `data.id` \u2014 take that for subsequent get/update/delete/clone.", - "Name must be unique in the project; a clash is rejected rather than silently de-duplicated.", - "`level` defaults to 2 (group/shared); set it explicitly if you need a private dashboard.", - "`source` (TO vs TCM) decides which dashboard surface the view belongs to and is not part of the body \u2014 pass it as a query param (defaults to TO)." - ] - }, - { - "name": "get_dashboard_view", - "method": "GET", - "path": "/api/v1/dashboards/views/{id}", - "mode": "read", - "entity": "dashboard_view", - "intent": "Load one dashboard view for rendering \u2014 its name, global filters and full widget configuration, with filter values injected for the built-in dashboards.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "A dashboard UUID, OR one of the read-only aliases `default` / `automation-trend`. `default` resolves to the TCM view when source=TCM and to the TO 'testing trends' view when source=TO; `automation-trend` resolves to the automation-trend view. Custom (non-built-in) UUIDs are scoped to the caller's user/group and read as not-found if they belong to someone else." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "TO or TCM. Also disambiguates which view the `default` alias resolves to." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide project resolver)." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SIDE EFFECT: reading a dashboard pushes it onto the caller's Redis 'recent views' list (capped at the most recent few), which is what the home recent-views listing returns. This write happens for ordinary users and is skipped only for super-users (except super-users in group 2). There is no read that avoids it.", - "For the built-in default dashboard the response is doctored: its date range is forced to the last 30 days regardless of what was stored, and widget filter values are injected \u2014 so what you read back is not necessarily what was persisted.", - "The aliases `default` and `automation-trend` work ONLY here (and other reads). Update, delete and clone-source require the raw UUID and reject the aliases.", - "The dashboard is under `data`; `data.isDefault` / `data.isAutomation` flag the built-in views, and `data.totalWidget` is the widget count." - ] - }, - { - "name": "update_dashboard_view", - "method": "PUT", - "path": "/api/v1/dashboards/views/{id}", - "mode": "write", - "entity": "dashboard_view", - "intent": "Save changes to an existing dashboard view \u2014 rename it, change its visibility level, change its global filters, or persist an edited widget layout.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "The dashboard UUID (raw UUID only \u2014 the `default` / `automation-trend` aliases are NOT accepted on this route). The update is additionally scoped to the caller's user/group." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_UPDATE permission check." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "New name, 1-100 characters. Must stay unique within the project." - }, - { - "name": "level", - "type": "integer", - "description": "New visibility level." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "New description (capped at 100 characters)." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters (DashboardFilters shape \u2014 same as create)." - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget layout (array of Widgets objects \u2014 same shape as create)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "NO UNDO: dashboards keep no version history, so an overwritten name, filter set or widget layout is gone for good \u2014 read the current dashboard first if you need to preserve anything.", - "The three built-in dashboards (default TCM, default TO/testing-trends, automation-trend) cannot be updated \u2014 the call returns 400 'Cannot update default dashboard'.", - "The body replaces the fields it carries; send the full intended state (e.g. the complete widgets array) rather than a partial patch.", - "The path takes a raw UUID only. Unlike the read, `default` / `automation-trend` are not accepted here." - ] - }, - { - "name": "clone_dashboard_view", - "method": "POST", - "path": "/api/v1/dashboards/views/{id}/clone", - "mode": "write", - "entity": "dashboard_view", - "intent": "Duplicate an existing dashboard \u2014 copy its filters, widgets, source and type into a new dashboard owned by the caller, under a new name.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "UUID of the SOURCE dashboard to copy (raw UUID only \u2014 aliases not accepted). The source is read with the caller's permission, so you can only clone a dashboard you can see." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_CREATE permission check." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "Name for the NEW cloned dashboard, 1-100 chars, unique within the project. This is the only field of the body that is used \u2014 level, filters and widgets are copied verbatim from the source, not taken from the body." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only `name` from the body matters. Any level/filters/widgets you send are ignored \u2014 the clone copies the source dashboard's filters, widgets, source and type verbatim, so edit the copy afterwards to change them.", - "The new name must be unique in the project; a duplicate is rejected.", - "The clone is owned by the calling user; the new dashboard (with its own UUID at `data.id`) is returned synchronously in `data`.", - "The source id must be a raw UUID \u2014 the `default` / `automation-trend` aliases are not accepted here even though the read accepts them." - ] - }, - { - "name": "list_dashboard_view_widget_types", - "method": "GET", - "path": "/api/v1/dashboards/views/widgets", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the catalogue of widget types that can be added to a dashboard view (the 'add widget' palette) with their display names.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED by the controller-wide resolver even though this static catalogue does not use it." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "name", - "key" - ], - "responses": { - "200": { - "description": "Static catalogue of addable widget types, returned as a BARE JSON ARRAY (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "WidgetCatalogueEntry" - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a bare array `[{name, key}]`, not wrapped in `data` or `success`.", - "This is static, hard-coded data (Test Case Statistics, Test Case Type, Test Case Trend) \u2014 it makes no DB or downstream call and does not vary by project, despite the mandatory project query param.", - "`key` is the WidgetTypeEnum value to send as a widget's `type` when building a dashboard layout or requesting widget data." - ] - }, - { - "name": "list_dashboard_view_custom_field_filters", - "method": "GET", - "path": "/api/v1/dashboards/views/filters/{widgetType}", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the project's custom (user-defined) test-case fields that can be used as filters when configuring a test-case dashboard widget.", - "path_params": [ - { - "name": "widgetType", - "type": "string", - "required": true, - "values": [ - "TEST_CASE_TREND", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE" - ], - "description": "A WidgetTypeEnum value. Only TEST_CASE_TREND, TEST_CASE_STATISTICS_CARD and TEST_CASE_TYPE are valid here; any other widget type returns 400 'Custom Filters are not available for widget type: X'." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide resolver); the custom fields returned are this project's." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "id", - "label", - "type", - "fieldType" - ], - "responses": { - "200": { - "description": "Custom-field filters for the project, as a BARE JSON ARRAY (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "CustomFieldFilterDetail" - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only the three test-case widget types are accepted; anything else is a 400 naming the widget type, not an empty list.", - "The response is a bare array `[{id, label, type, fieldType}]` \u2014 each entry is a custom field usable as a widget filter (type is 'Custom').", - "Distinct from the group-level `/api/v1/dashboards/filters/{widgetType}` route; this one is scoped to a single dashboard-view project." - ] - }, - { - "name": "list_dashboard_widget_filter_values", - "method": "POST", - "path": "/api/v1/dashboards/views/widgets/filters", - "mode": "read", - "entity": "dashboard_view", - "intent": "Fetch the selectable values for one filter inside a dashboard widget's filter editor (the type-ahead / value picker) \u2014 e.g. the build names, tags, users, teams or test-case field values available for a project, given the currently applied filters.", - "query": [ - { - "name": "page", - "type": "integer", - "required": false, - "default": 1, - "description": "Page number for the value list. Note the param is `page` (not `p`)." - }, - { - "name": "per_page", - "type": "integer", - "required": false, - "default": 30, - "description": "Values per page (default 30). No server-side upper bound was found in the resolved code \u2014 see guidance." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Type-ahead substring to narrow the returned values." - }, - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "TO or TCM. With source=TCM the request is forced to includeManualAndAutomated=true." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED. The resolved project OVERWRITES whatever `filters.projects` is in the body." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "widgetKey", - "type": "string", - "required": true, - "description": "REQUIRED (@NotNull). The widget type (WidgetTypeEnum) the filter belongs to. Note the wire key is `widgetKey`, not `widget` or `type`. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "json_path": "/widgetKey", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - }, - { - "name": "filterKey", - "type": "string", - "required": true, - "values": [ - "buildStatus", - "buildName", - "users", - "teams", - "frameworks", - "folders", - "buildTags", - "hostName", - "os", - "browser", - "device", - "tags", - "branches", - "projects", - "testCaseRequirements", - "testCasePriorities", - "testCaseStatuses", - "testCaseTypes", - "testCaseTags", - "testCaseOwners", - "testCaseAutomationStatuses", - "testCaseFolders", - "buildTypes", - "buildOwners", - "workflowStatus", - "custom", - "customFields", - "issueType", - "testCaseAutomationStates" - ], - "description": "REQUIRED (@NotNull). Which filter's values to fetch. This is the field that selects the value set (the server switches on filterKey, not on widgetKey). filterKey=buildName additionally turns on selective project filtering.", - "json_path": "/filterKey" - }, - { - "name": "filters", - "type": "object", - "description": "The currently applied filters (WidgetFiltersValue) used to scope the value list. Optional \u2014 an omitted/null value is treated as empty. Its `projects` is always overwritten by the project resolved from the query params, so setting it in the body is pointless.", - "json_path": "/filters" - }, - { - "name": "filterId", - "type": "integer", - "required": false, - "description": "Optional custom-field id, used when filterKey targets a specific custom field.", - "json_path": "/filterId" - }, - { - "name": "applyDateRange", - "type": "boolean", - "required": false, - "default": false, - "description": "Opt in to bounding the value list by `filters.dateRange`. Defaults to false, i.e. the full value list is returned regardless of date range.", - "json_path": "/applyDateRange" - } - ], - "returns": [ - "data", - "fullList", - "info", - "suggestedAfterKey", - "othersAfterKey" - ], - "responses": { - "200": { - "$response": "WidgetFilterValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but READ-ONLY (@ReadOnly) \u2014 it fetches filter values and changes nothing.", - "The value set is chosen by `filterKey`, and `widgetKey` must also be present (both are @NotNull); the two are separate fields and the server dispatches on filterKey.", - "Pagination params are `page` and `per_page` (default 30), NOT `p`/`count`. `data` is a map keyed by bucket (`suggested`, `others`), each a list of value structs - the same shape as search_widget_filter_values. `fullList` and `info` can be null; continue with suggestedAfterKey / othersAfterKey.", - "`filters.projects` is always replaced by the project from the query params, so passing project ids in the body is ignored \u2014 set the project via project_id/project_name/project_identifier.", - "A public-link token may be supplied via the `Public-Link-Token` (or the observability public-link-token) header to run in shared read-only dashboard mode.", - "With source=TCM the request is forced to include both manual and automated runs." - ] - }, - { - "name": "check_dashboard_widget_ignored_filters", - "method": "POST", - "path": "/api/v1/dashboards/views/widgets/filters/ignored", - "mode": "read", - "entity": "dashboard_view", - "intent": "Validate a widget's saved test-case filter values against the current project and report which are no longer valid \u2014 so stale filter chips (a deleted tag, priority, status, type or linked requirement) can be flagged or dropped.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED; the saved filter values are validated against this project." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "testCaseRequirements", - "type": "object", - "description": "Linked-requirement filter to validate. Only `issueIds` (array) and `issueType` are read.", - "fields": [ - { - "name": "issueIds", - "type": "array", - "description": "Requirement/issue ids to validate." - }, - { - "name": "issueType", - "type": "string", - "description": "Requirement issue type." - } - ] - }, - { - "name": "testCasePriorities", - "type": "array", - "description": "Saved test-case priority ids to validate." - }, - { - "name": "testCaseTags", - "type": "array", - "description": "Saved test-case tag values to validate." - }, - { - "name": "testCaseStatuses", - "type": "array", - "description": "Saved test-case status ids to validate." - }, - { - "name": "testCaseTypes", - "type": "array", - "description": "Saved test-case type ids to validate." - } - ], - "returns": [ - "filterMatches", - "ignoredFiltersCount", - "isMismatched" - ], - "responses": { - "200": { - "$response": "IgnoredFilterValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only \u2014 a pass-through to TestHub that computes validity and stores nothing.", - "The body is a full WidgetFiltersValue object, but ONLY five fields are actually read: testCaseRequirements (issueIds + issueType), testCasePriorities, testCaseTags, testCaseStatuses and testCaseTypes. Every other filter field you send is silently ignored here.", - "The response reports, per filter type, how many values still match vs are ignored (`filterMatches[]` with matchedValuesCount / ignoredValuesCount / isMismatched), a total `ignoredFiltersCount`, and an overall `isMismatched` flag \u2014 use these to flag or drop stale chips." - ] - }, - { - "name": "get_dashboard_view_widget_data", - "method": "POST", - "path": "/api/v1/dashboards/views/{dashboardId}/widgets/{widgetType}", - "mode": "read", - "entity": "dashboard_view", - "intent": "Compute the data for one widget on a project-scoped dashboard view \u2014 applying the caller's global filters and time window \u2014 to render that widget (charts, stat cards, trends, etc.).", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "The dashboard UUID. Present in the path for routing but NOT read by the handler \u2014 the widget is computed purely from the body and query params." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "A WidgetTypeEnum value identifying which widget to compute. FLAKINESS, ALWAYS_FAILING and NEW_FAILURES are plan-gated and return FEATURE_INACCESSIBLE if the group lacks the feature. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": true, - "values": [ - "TO", - "TCM" - ], - "description": "REQUIRED on this route (unlike the controller default): omitting it 400s with '`source` is required'. With source=TCM, TCM build widgets are forced to include both manual and automated runs." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED on this route. The resolved project id is injected into the widget's filters automatically (the TestHub project id for ClickHouse-powered widgets, the observability project id otherwise) \u2014 do not pass project ids in the body." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "isThProject", - "type": "boolean", - "required": false, - "default": false, - "description": "Treat the supplied project identifier as a TestHub project id." - }, - { - "name": "lowerBound", - "type": "integer", - "required": false, - "description": "Start of the time window, epoch milliseconds. Optional \u2014 when omitted the window is derived from the body's dateRange." - }, - { - "name": "upperBound", - "type": "integer", - "required": false, - "description": "End of the time window, epoch milliseconds. Optional \u2014 derived from the body's dateRange when omitted." - }, - { - "name": "pageNumber", - "type": "integer", - "required": false, - "description": "Optional page number for paginated widget data; copied into the widget's global filters." - } - ], - "body": [ - { - "name": "type", - "type": "string", - "description": "Widget type (WidgetTypeEnum). Describes the widget being computed." - }, - { - "name": "globalFilters", - "type": "object", - "description": "The applied dashboard filters (DashboardFilters): dateRange (drives the time window unless lowerBound/upperBound are given), tags, buildName, folders, tcmTestPlanIds, match, etc. Its `projects` is overwritten server-side with the resolved project.", - "json_path": "/globalFilters", - "fields": [ - { - "name": "dateRange", - "type": "object", - "description": "Time window used when lowerBound/upperBound query params are absent." - } - ] - }, - { - "name": "id", - "type": "string", - "description": "Widget id (up to 100 chars)." - }, - { - "name": "name", - "type": "string", - "description": "Widget name (1-100 chars)." - }, - { - "name": "desc", - "type": "string", - "description": "Widget description (up to 200 chars)." - }, - { - "name": "config", - "type": "object", - "description": "Widget configuration (display type, segments, per-widget filters, stats options, etc.) that shapes the computed data." - }, - { - "name": "layout", - "type": "object", - "description": "Grid layout of the widget (not needed to compute data)." - } - ], - "returns": [ - "(widget-type-specific)" - ], - "responses": { - "200": { - "$response": "WidgetDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but READ-ONLY (@ReadOnly, routed to a read replica) \u2014 it computes and returns widget data and persists nothing.", - "The response SHAPE depends entirely on `widgetType` (the handler returns an untyped object built per widget) \u2014 inspect it per widget type rather than assuming a fixed schema.", - "Unlike the other dashboard routes, BOTH `source` and a project identifier are mandatory here; omitting either 400s from the request binders.", - "The path `dashboardId` is not read \u2014 the widget is defined by the request body and `widgetType`. Do not rely on it selecting a saved widget.", - "Do not put project ids in `globalFilters.projects`; the resolved project (TestHub id for ClickHouse-powered widgets, observability id otherwise) is injected automatically and overrides the body.", - "FLAKINESS, ALWAYS_FAILING and NEW_FAILURES widgets are plan-gated \u2014 a group without the feature gets FEATURE_INACCESSIBLE, not empty data." - ] - }, - { - "name": "create_report", - "method": "POST", - "path": "/api/v1/reports", - "mode": "write", - "entity": "report", - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Report source. Defaults to TO (the end-user builder). TCM is the Test-Management-mirror source and is normally set by the sync path, not here." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Report name. Must be unique for the report's (type, level) combination or the create is rejected." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = group. Omit and the report is created with no level set." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "description": "Report type. Decides which widget list is persisted: a report-type value (in the backend's REPORT_TYPES set) persists reports[]; otherwise widgets[] is persisted. Defaults to 'Dashboard' when omitted, which would persist widgets[] instead." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Description. Max length is 100 characters (the validation message misstates it as 200)." - }, - { - "name": "filters", - "type": "object", - "description": "Global filters saved on the report.", - "fields": [ - { - "name": "dateRange", - "type": "object", - "description": "Date-range filter." - }, - { - "name": "projects", - "type": "array", - "description": "Observability project ids (List)." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build tag names." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names; AND/OR controlled via match['tags']." - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "buildNames", - "type": "array", - "description": "Wire key is buildName (singular).", - "json_path": "/filters/buildName" - }, - { - "name": "branchList", - "type": "array", - "description": "Wire key is branches.", - "json_path": "/filters/branches" - }, - { - "name": "testPlanIds", - "type": "array", - "description": "Wire key is tcmTestPlanIds; the legacy alias testPlanIds is also accepted for old persisted blobs.", - "json_path": "/filters/tcmTestPlanIds" - }, - { - "name": "order", - "type": "array", - "description": "Filter display order. Wire key is filterOrder.", - "json_path": "/filters/filterOrder" - }, - { - "name": "match", - "type": "object", - "description": "Per-filter AND/OR match map." - } - ] - }, - { - "name": "reports", - "type": "array", - "description": "The report widgets, persisted when type is a report-type value. Each item is a widget (see 'widgets' for its shape).", - "fields": [ - { - "name": "id", - "type": "string", - "maxLength": 100 - }, - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - { - "name": "type", - "type": "string", - "description": "WidgetTypeEnum." - }, - { - "name": "reportType", - "type": "string", - "description": "e.g. TABULAR_TEST_RUNS." - }, - { - "name": "dashboardFilters", - "type": "object", - "description": "Widget-level global filters. Wire key is globalFilters.", - "json_path": "/reports/globalFilters" - }, - { - "name": "config", - "type": "object", - "description": "WidgetsConfig \u2014 datasets, segments, display type, columns, etc." - }, - { - "name": "layout", - "type": "object" - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "Dashboard-style widgets, persisted only when type is NOT a report-type value. Send reports[] for a report-typed record; send the wrong list for the type and the record saves empty. Same item shape as reports[].", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "name", - "type": "string" - }, - { - "name": "type", - "type": "string" - }, - { - "name": "dashboardFilters", - "type": "object", - "json_path": "/widgets/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - } - ], - "intent": "Create a new saved report \u2014 its name, visibility level, global filters and the list of report widgets \u2014 and get back the persisted record.", - "returns": [ - "data" - ], - "guidance": [ - "Name must be unique for the given (type, level); a clash is rejected.", - "Whether the saved widgets come from reports[] or widgets[] depends on type: a report-type value persists reports[], anything else (including the default 'Dashboard') persists widgets[] \u2014 send the wrong list for the type and the report saves with no widgets.", - "Widget-level global filters ride under the key globalFilters (not dashboardFilters); the report-level filter list uses filterOrder / buildName / branches / tcmTestPlanIds on the wire (not their Java names).", - "source defaults to TO; TCM is the Test-Management-mirror source and normally set by the sync path, not here.", - "The new record is stamped with your user, group and team and returned under data with a fresh UUID id.", - "The response echoes the saved record, but `desc` is not echoed back, `widgets` is null for a report-typed record (not an empty array), and filters come back normalised to their wire keys (users, lastExecutedBy, teams, ... are added as empty/null)." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_saved_reports", - "method": "GET", - "path": "/api/v1/reports", - "mode": "read", - "entity": "report", - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which report source to list. TO = end-user reports (default); TCM = reports mirrored from Test Management." - } - ], - "intent": "List the reports visible to you \u2014 your own plus group-level public ones \u2014 for the Reports landing page, with each report's name, level, type, filters and widget config.", - "returns": [ - "data" - ], - "guidance": [ - "Requires the custom-dashboard plan feature; without it the call is refused (feature inaccessible) rather than returning an empty list.", - "This is the report-typed view of the shared report/dashboard store, so dashboard-only filter rewriting is skipped.", - "Returns a flat list under data with NO pagination \u2014 there is no page / per_page on this listing.", - "The report listing is the source of the UUIDs the single-report read, update, delete, clone, dataset and tiles calls need.", - "Rows are summaries: `reports`, `widgets` and `filters` come back null even when totalWidget > 0, and source may be null. Read the report by id (get_report) for its widgets and filters." - ], - "responses": { - "200": { - "$response": "ReportRecordListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report", - "method": "GET", - "path": "/api/v1/reports/{id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The report UUID." - } - ], - "intent": "Load one saved report's full definition \u2014 name, level, filters and widget/report configuration \u2014 to render or edit it.", - "returns": [ - "data" - ], - "guidance": [ - "Scoped to your own group; a report id belonging to another group reads as not found (DASHBOARD_NOT_FOUND).", - "The dashboard-only filter rewrites and the automation-state widget transform are deliberately skipped for reports, so you read back the raw saved definition.", - "The report sits under data; its widgets are under data.reports for a report-typed record (data.widgets for a dashboard-typed one)." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_report", - "method": "PUT", - "path": "/api/v1/reports/{id}", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The report UUID." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "New name; re-checked for (type, level) uniqueness when it changes. Omit to keep the current name." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility 1 = private, 2 = group." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100 - }, - { - "name": "desc", - "type": "string", - "maxLength": 100 - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters. Same shape/wire-key quirks as create (globalFilters, filterOrder, buildName, branches, tcmTestPlanIds). Omit to keep existing filters." - }, - { - "name": "reports", - "type": "array", - "description": "Replacement widget set for a report-typed record. Omit to keep the existing widgets. Item shape as in create.", - "fields": [ - { - "name": "dashboardFilters", - "type": "object", - "json_path": "/reports/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget set for a dashboard-typed record. Omit to keep the existing widgets.", - "fields": [ - { - "name": "dashboardFilters", - "type": "object", - "json_path": "/widgets/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - } - ], - "intent": "Update an existing saved report in place \u2014 rename, change visibility level, change global filters, or replace its widget set.", - "returns": [ - "data" - ], - "guidance": [ - "No version history \u2014 the previous name, filters and widgets are overwritten with no way to restore them (there is no undo).", - "Name uniqueness is re-checked only when the name, type or level changes.", - "Widget replacement is whole-set: report-typed records replace from reports[], dashboard-typed from widgets[]; omit the list and the existing widgets are kept.", - "For a GCD-disabled user, persisted ClickHouse widgets omitted from the payload are merged back in rather than dropped.", - "In the demo-sandbox environment the built-in default report cannot be updated (400).", - "The lookup is scoped to your user and group; a report you cannot see is reported as not found." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "clone_report", - "method": "POST", - "path": "/api/v1/reports/{id}/clone", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The SOURCE report UUID to duplicate." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Name for the copy. Must be unique for the (type, level)." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility for the copy: 1 = private, 2 = group." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100 - }, - { - "name": "desc", - "type": "string", - "maxLength": 100 - } - ], - "intent": "Duplicate an existing report under a new name \u2014 'Make a copy' in the Reports UI \u2014 copying the source report's saved filters and widgets.", - "returns": [ - "data" - ], - "guidance": [ - "The copy takes its filters and widgets from the SOURCE report, not from your body \u2014 the body essentially only supplies the new name and level. A filters/widgets payload here is ignored.", - "Give a name that is unique for the report's (type, level), or the clone is rejected.", - "The source report's widget config is validated first, so a corrupt source is refused rather than copied through.", - "The new report comes back under data with its own new UUID id." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_dataset_rows", - "method": "POST", - "path": "/api/v1/reports/{id}/datasets", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Rows per page." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Bound but not used for dataset computation." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body, not an error. Missing reportType is 400 'Report type is required'." - }, - { - "name": "dashboardFilters", - "type": "object", - "description": "Widget global filters. Wire key is globalFilters.", - "json_path": "/globalFilters" - }, - { - "name": "config", - "type": "object", - "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", - "fields": [ - { - "name": "DATASETS", - "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "type", - "type": "string", - "description": "WidgetTypeEnum." - }, - { - "name": "layout", - "type": "object" - }, - { - "name": "id", - "type": "string", - "maxLength": 100 - }, - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100 - } - ], - "intent": "Run a report widget's query and get its paginated result rows \u2014 the actual table data behind a tabular test-runs report tile.", - "returns": [ - "data", - "summary", - "sourceFencingFlags" - ], - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", - "Only reportType TABULAR_TEST_RUNS is implemented; any other type returns a 200 with a null body rather than erroring.", - "The {id} in the path is ignored entirely \u2014 everything comes from the body, so the path id is just a placeholder.", - "Widget global filters are sent under the key globalFilters, NOT dashboardFilters.", - "Rows come back under data with a summary; summary.totalDatasets is -1 (not a real total), so page through using page / per_page and summary.hasNext rather than trusting a count.", - "A validation failure other than a known ObservabilityException is only logged, not surfaced \u2014 a 200 with empty data can mask a silently dropped dataset." - ], - "responses": { - "200": { - "$response": "ReportDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_stat_tiles", - "method": "POST", - "path": "/api/v1/reports/{id}/tiles", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "description": "Bound but IGNORED \u2014 tiles are not paginated." - }, - { - "name": "per_page", - "type": "integer", - "description": "Bound but IGNORED \u2014 tiles are not paginated." - }, - { - "name": "source", - "type": "string", - "values": [ - "TO", - "TCM" - ], - "description": "Bound but not used for tiles." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body. Missing reportType is 400 'Report type is required'." - }, - { - "name": "dashboardFilters", - "type": "object", - "description": "Widget global filters. Wire key is globalFilters.", - "json_path": "/globalFilters" - }, - { - "name": "config", - "type": "object", - "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", - "fields": [ - { - "name": "DATASETS", - "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - } - ], - "intent": "Get the summary stat tiles (aggregate KPI numbers) for a report widget \u2014 the headline figures shown above the report table.", - "returns": [ - "data", - "sourceFencingFlags" - ], - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", - "Same body as the dataset call but returns aggregates instead of rows; only TABULAR_TEST_RUNS is implemented, other types return a 200 with a null body.", - "page / per_page / source are accepted and ignored here \u2014 tiles are not paginated.", - "The {id} path variable is unused; the widget definition comes entirely from the body.", - "Tiles come back under data as {meta, value} pairs (totalUniqueTests, flakyTestsCount, newFailureTestsCount, executions counts, \u2026); totalIssuesCount is added only when configuredColumns includes 'openIssues', and activeRunCount / closedRunCount / totalTestCasesCount appear only when present." - ], - "responses": { - "200": { - "$response": "ReportStatTilesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_report_custom_fields", - "method": "GET", - "path": "/api/v1/reports/custom-fields", - "mode": "read", - "entity": "report", - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "default": 300, - "description": "Page size, default 300 (the upstream max, i.e. the whole list in one call). The picker is not meant to paginate; values above 300 are clamped by the upstream, not rejected." - }, - { - "name": "search", - "type": "string", - "description": "Substring filter on field name." - }, - { - "name": "project_ids", - "type": "array", - "description": "Observability project ids (List, converted to TestHub ids for you). Omit for the whole group. Ids that resolve to nothing return an empty list, not an error." - } - ], - "intent": "List the custom-field / column definitions usable as report columns and filters, optionally narrowed to given projects \u2014 feeds the report builder's column picker and resolves stored field ids back to display names.", - "returns": [ - "customFields", - "info" - ], - "guidance": [ - "per_page defaults to 300 (the upstream max) because the picker is meant to load the whole list in one call; requests above 300 are clamped upstream, not rejected.", - "project_ids are observability project ids and are converted to TestHub project ids for you; omit them for the whole group.", - "A saved report stores columns by numeric fieldId \u2014 use this to map those ids back to fieldName. The same field name can appear twice, distinguished only by entityType (TestCase vs TestExecution)." - ], - "responses": { - "200": { - "$response": "ReportCustomFieldsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_report_static_builds", - "method": "GET", - "path": "/api/v1/reports/{projectNormalisedName}/builds", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised NAME (not its id). A report UUID and a project name share the /reports/{...} space, so this route matches only when the segment is a project name." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1 - }, - { - "name": "per_page", - "type": "integer", - "default": 10 - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to]. Required (@NotNull @Size(2)); omitted -> 400 'dateRange must not be null'." - }, - { - "name": "buildStatuses", - "type": "array", - "description": "Explicit build display statuses. When present, wins outright (the inherited 'status' is not unioned in)." - }, - { - "name": "status", - "type": "array", - "description": "Interpreted here as build DISPLAY statuses: used only when buildStatuses is empty, and only values that case-insensitively map to a build display status are kept \u2014 the rest are silently ignored." - }, - { - "name": "frameworks", - "type": "array" - }, - { - "name": "buildOwners", - "type": "array", - "description": "User ids (List)." - }, - { - "name": "buildStates", - "type": "array" - }, - { - "name": "buildGroupIds", - "type": "array" - }, - { - "name": "testPlanIds", - "type": "array" - }, - { - "name": "requirements", - "type": "array" - }, - { - "name": "buildName", - "type": "array" - }, - { - "name": "buildIds", - "type": "array" - }, - { - "name": "projects", - "type": "array" - }, - { - "name": "creationType", - "type": "string" - }, - { - "name": "isArchived", - "type": "boolean" - }, - { - "name": "excludeLocked", - "type": "string" - }, - { - "name": "search", - "type": "string" - }, - { - "name": "ordering", - "type": "string" - } - ], - "intent": "List a project's builds that match a report's build-level filter set \u2014 used to populate the build picker / static build scope when building a report against a fixed set of builds.", - "returns": [ - "data", - "summary", - "sourceFencingFlags" - ], - "guidance": [ - "The path segment is the project's normalised NAME, not its id.", - "status is treated as a build DISPLAY status here (the filter object is shared with the stability/performance request): explicit buildStatuses wins; otherwise status values that map to a build display status are back-filled and non-matching values are ignored.", - "Builds come back under data as build rows (not test-run rows), with a summary whose totalDatasets is -1 \u2014 page with page / per_page and read summary.hasNext rather than a total.", - "An empty match returns a 200 with empty data (plus sourceFencingFlags), not a 404." - ], - "responses": { - "200": { - "$response": "ReportDatasetResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_test_stats", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/stats", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, taken from a row of the report table." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence. Defaults to TO (Test Observability); TCM narrows to test-management data." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params: project ids to scope the window." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter: build ids to scope the window." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq filter. On this build-level report, status values that map to a build-display-status are used and non-matching values are ignored; an explicit buildStatuses set takes precedence and is not widened by status." - } - ], - "intent": "Get the headline stats for one test case in the tabular test-runs report - its reliability (failure ratio), average duration, and failed/total occurrence counts over the filtered window - for the detail drawer.", - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "responses": { - "200": { - "description": "Headline stats for the test, or an empty object when the test has no matching runs in the window.", - "schema": { - "$schema": "ReportTestStats" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "When the test has zero matching runs in the window the body is an empty JSON object `{}` - not zeroed stats - so branch on emptiness before reading `reliability`.", - "`reliability` is the failure ratio as a percentage (failed*100/total), `duration` is the average duration, and `failureOccurrence` carries the raw `{failed, total}` counts.", - "Requires the REPORT_VIEW project permission. Filters are read from GetReportsFilterRq query params, not a body, even though sibling report calls post a body.", - "The `globalTestHash` is the test's cross-run hash from a report-table row, not a numeric test id." - ] - }, - { - "name": "list_report_test_runs", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/testsLists", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, from a report-table row." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number. Defaults to 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Page size. Defaults to 10. No server-enforced ceiling was found in the handler - the value is passed through to the backend unclamped." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq status filter (build-display-status resolution as on the stats call)." - } - ], - "intent": "List the individual test runs behind one row of the tabular test-runs report - every execution of that test case in the window, with status, duration, build and settings-version detail - for the drill-down view.", - "returns": [ - "data", - "summary" - ], - "responses": { - "200": { - "description": "The runs for this test, plus a pagination summary. An empty result returns an object with null data and summary.", - "schema": { - "$schema": "TabularTestRunsReportResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are under `data` (each a report row: test/build ids, workflow status, avg duration in ms, build name/tags/number, project id, started-at, smart-tag settings). Paging state is under `summary` - `summary.hasNext` is true when the page came back full, and `summary.totalDatasets` is -1 (not a real total) on this route.", - "When there are no matching runs the body is an object with `data` and `summary` both null, rather than an empty array - check for null before iterating.", - "This call enriches each row with a build settings-version lookup, so it is heavier than a plain list; keep `per_page` modest.", - "`workflowStatus`/`status` are normalised to a known status or the literal 'unknown'.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "get_report_test_trend", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/trend", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, from a report-table row." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq status filter." - } - ], - "intent": "Get the per-run trend series for one test case in the tabular test-runs report - duration per execution plus each point's workflow status and colour, keyed by build - to draw the test's history chart.", - "returns": [ - "keys", - "data" - ], - "responses": { - "200": { - "description": "Trend series for the test.", - "schema": { - "$schema": "ReportTestTrendResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`keys` is the x-axis: build started-at epochs, index-aligned to `data`. `data` is the point series.", - "Each point's `y` is the run duration in MILLISECONDS, `color` is derived from the workflow status, and `buildId`/`buildName`/`creationType` let the chart link a point back to its run.", - "Point fields that are null are omitted (the point serialises non-null only), so do not assume every optional flag is present.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "run_tabular_test_runs_report", - "method": "POST", - "path": "/api/v1/reports/view/{id}/search", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Ignored by the handler - the report is defined by the body, not this id. Any value satisfies the route; a placeholder is fine." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, default 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Page size, default 10. No server-enforced ceiling found in the handler." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "values": [ - "TABULAR_TEST_RUNS" - ], - "description": "Report type. Required (missing/null reportType is a 400 'Report type is required'). Only TABULAR_TEST_RUNS is implemented; any other value falls through the switch and returns a null body with 200." - }, - { - "name": "config", - "type": "object", - "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", - "fields": [ - { - "name": "DATASETS", - "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "reportGlobalFilters", - "type": "object", - "description": "Global filters (WidgetFiltersValue) merged into each dataset before the query runs." - }, - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Dashboard filters block (the DTO field `dashboardFilters` serialises as `globalFilters`); projects/date-range etc." - }, - { - "name": "configuredColumns", - "type": "array", - "description": "Column keys to include; e.g. adding 'openIssues' also surfaces an issues count in the tiles sibling call." - }, - { - "name": "id", - "type": "string", - "maxLength": 100, - "description": "Optional widget/report id echoed for telemetry." - } - ], - "intent": "Run a configured report and return its paginated table - post the full report definition (datasets, segments, global filters) and get back the tabular test-runs result set.", - "returns": [ - "data", - "summary" - ], - "responses": { - "200": { - "description": "The report table page. Null body when reportType is not TABULAR_TEST_RUNS; an object with null data when the definition yields nothing or all datasets conflict.", - "schema": { - "$schema": "TabularTestRunsReportResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", - "This is a read despite being a write-shaped call: the body is the query, not new state, and the route is marked read-only.", - "The `{id}` in the address is never read - the report is defined entirely by the body, so pass any placeholder.", - "`reportType` is required and only TABULAR_TEST_RUNS works; any other type returns a 200 with a NULL body (not an error), so a null response can mean 'unsupported report type', not 'no data'.", - "When every dataset mutually conflicts the body comes back empty rather than erroring; distinct from the null-body case above.", - "Rows are under `data`, paging under `summary`; `summary.totalDatasets` is -1 on this route, use `summary.hasNext` to page.", - "Requires the REPORT_VIEW project permission (public-link-token callers are also accepted for shared reports)." - ] - }, - { - "name": "get_tabular_test_runs_report_tiles", - "method": "POST", - "path": "/api/v1/reports/view/{id}/search/tiles", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Ignored by the handler; any value satisfies the route." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "values": [ - "TABULAR_TEST_RUNS" - ], - "description": "Required (missing is a 400). Only TABULAR_TEST_RUNS is implemented; other types return a null body with 200." - }, - { - "name": "config", - "type": "object", - "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", - "fields": [ - { - "name": "DATASETS", - "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "reportGlobalFilters", - "type": "object", - "description": "Global filters merged into each dataset." - }, - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Dashboard filters block (DTO field `dashboardFilters`)." - }, - { - "name": "configuredColumns", - "type": "array", - "description": "Including 'openIssues' adds a totalIssuesCount tile." - } - ], - "intent": "Get the summary stat tiles that sit above a report's table (totals and aggregates for the same report definition) so the header cards can load independently of the paginated rows.", - "returns": [ - "data", - "sourceFencingFlags" - ], - "responses": { - "200": { - "description": "Stat tiles for the report. When all datasets conflict, or no stats are returned, `data` is empty and only the source-fencing flags are populated.", - "schema": { - "$schema": "ReportStatTilesResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", - "Read-only despite being write-shaped; same body as the table call, but no pagination.", - "Each tile in `data` is `{meta, value}` where `meta` is the tile name (totalUniqueTests, flakyTestsCount, newFailureTestsCount, alwaysFailingTestsCount, totalPassedExecutions, totalFailedExecutions, totalSkippedExecutions, totalUnknownExecutions, grandTotalExecutions) and `value` is a Long count. Note the key is `meta`, not `name`.", - "Extra tiles appear only conditionally: totalIssuesCount only when `configuredColumns` contains 'openIssues'; activeRunCount, closedRunCount and totalTestCasesCount only when the backend returns them.", - "When all datasets conflict, or the backend has no statistics, `data` is empty/absent and `sourceFencingFlags` (all false) is still returned - an empty tiles body is not an error.", - "Non-TABULAR_TEST_RUNS report types return a null body with 200.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "list_widget_filter_values", - "method": "GET", - "path": "/api/v1/dashboards/widgets/filters", - "mode": "read", - "entity": "widget", - "query": [ - { - "name": "key", - "type": "string", - "required": true, - "description": "Which filter's values to list.", - "values": [ - "buildStatus", - "buildName", - "users", - "teams", - "frameworks", - "folders", - "buildTags", - "hostName", - "os", - "browser", - "device", - "tags", - "branches", - "projects", - "testCaseRequirements", - "testCasePriorities", - "testCaseStatuses", - "testCaseTypes", - "testCaseTags", - "testCaseOwners", - "testCaseAutomationStatuses", - "testCaseFolders", - "buildTypes", - "buildOwners", - "workflowStatus", - "custom", - "customFields", - "issueType", - "testCaseAutomationStates" - ] - } - ], - "intent": "Look up the available values for a single widget filter key (build status, users, teams, frameworks, build tags, build name, branches, ...) as a flat list for a widget configuration dropdown.", - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "Flat list of filter values for the key.", - "schema": { - "$schema": "WidgetFilterValueList" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the simple/legacy flat-list variant. For search, paging, and suggested-vs-others buckets, use `search_widget_filter_values` (same path, richer request/response) instead.", - "`data` is a flat array of `{label, value, ...}` structs; there is no paging on this route.", - "Values come from Elasticsearch aggregations with deleted projects excluded.", - "The request binds `key` from query params only - there is no body.", - "Values are account-wide (every visible project), not scoped to one project. Each item is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null for most keys." - ] - }, - { - "name": "search_widget_filter_values", - "method": "POST", - "path": "/api/v1/dashboards/widgets/filters", - "mode": "read", - "entity": "widget", - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, default 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Page size, default 30. No server-enforced ceiling found in the handler." - }, - { - "name": "search", - "type": "string", - "description": "Free-text filter over the candidate values." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO. TCM forces manual+automated inclusion." - } - ], - "body": [ - { - "name": "widgetKey", - "type": "string", - "required": true, - "json_path": "/widgetKey", - "description": "The widget type the filter is being configured for (WidgetTypeEnum). REQUIRED. NOTE the JSON key is `widgetKey`, not `widget` - the DTO field `widget` is renamed on the wire; sending `widget` is dropped and the request fails @NotNull validation. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - }, - { - "name": "filterKey", - "type": "string", - "required": true, - "description": "Which filter's values to fetch (same value set as the GET `key`). REQUIRED (@NotNull)." - }, - { - "name": "filters", - "type": "object", - "description": "Already-applied filters (WidgetFiltersValue: projects, dateRange, ...) so the returned values are context-dependent. Defaults to empty when omitted.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids; for ClickHouse-powered widgets these are rewritten to TestHub project ids server-side." - }, - { - "name": "dateRange", - "type": "object", - "description": "Optional date-range scope; only applied when the widget/caller opts in." - } - ] - } - ], - "intent": "Fetch matching values for a widget filter, split into suggested and others with search and paging - the lookup that powers the dependent dropdowns in a widget's configuration panel.", - "returns": [ - "data", - "fullList", - "info", - "suggestedAfterKey", - "othersAfterKey" - ], - "responses": { - "200": { - "description": "Filter values bucketed by suggested/others, with paging cursors.", - "schema": { - "$schema": "WidgetEnhancedFilterResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read-only despite being a POST; same path as the flat GET variant but a different request and response shape - do not treat them as interchangeable.", - "The widget field is sent as `widgetKey` on the wire, NOT `widget`; both `widgetKey` and `filterKey` are required or the request 400s on validation.", - "`data` is a map keyed by bucket (suggested / others), each a list of value structs; page onward with `suggestedAfterKey` / `othersAfterKey` cursors rather than by page number alone.", - "Behaviour is context-dependent: the TEST_EXECUTION_DISTRIBUTION widget forces only-manual-builds, source=TCM forces manual+automated, and ClickHouse-powered widgets have their project ids translated to TestHub ids first.", - "`customFields` values are restricted to a hardcoded allow-list of group ids per environment, so a custom-field filter may return nothing for a group not on that list.", - "`fullList` and `info` can come back null; rely on suggestedAfterKey / othersAfterKey. Valid widgetKey values are the WidgetTypeEnum constants; filterKey names follow the widget's filter keys (e.g. projects) - an unknown pair is an opaque 400." - ] - }, - { - "name": "get_org_dashboard_widget_data", - "method": "POST", - "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}", - "mode": "read", - "entity": "widget", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "Not read by the handler on this route; the projects come from the body. Any value satisfies the route." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "The widget to compute (WidgetTypeEnum). Determines the response shape. Bound by exact enum constant name; any other value is a 400.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Optional window start (epoch ms). If omitted, the window is resolved from the widget's own date-range filter." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Optional window end (epoch ms)." - } - ], - "body": [ - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Filters block (DTO field `dashboardFilters`, serialised as `globalFilters`). Carries `projects[]` - the org/multi-project scope for the widget. For ClickHouse-powered widgets these project ids are translated to TestHub ids.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids scoping this org-level widget." - }, - { - "name": "dateRange", - "type": "object", - "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." - } - ] - }, - { - "name": "config", - "type": "object", - "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", - "fields": [ - { - "name": "FILTERS", - "type": "object", - "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." - } - ] - }, - { - "name": "type", - "type": "string", - "description": "Widget type enum on the body; the path widgetType is what actually selects the computation." - } - ], - "intent": "Compute the data for one widget on a multi-project (org-level) dashboard, from the widget definition plus the global filters and time window supplied in the body.", - "returns": [ - "(widget-type-specific)" - ], - "responses": { - "200": { - "description": "Widget data. The shape is polymorphic per widget type (line chart, stat card, distribution, table, ...), so it is not a single fixed envelope.", - "schema": { - "type": "object", - "description": "Widget-type-specific payload; read the widgetType you requested to know the shape. Common CH-powered widgets embed a `sourceFencingFlags` object." - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", - "Projects come from the body's `globalFilters.projects` (this is the org-level route), NOT from the address - the `{dashboardId}` is ignored here.", - "The response envelope depends on `widgetType`; there is no single fixed shape, so switch on the type you asked for.", - "For ClickHouse-powered widget types the project ids you send are translated to TestHub ids before the query runs.", - "Special case: group 2 requesting the ACTIVE_BUILDS widget is forced to a 7-day window unless it explicitly asked for 1 day.", - "Unlike the project-view variant of this route, this org-level call does NOT apply the FLAKINESS / ALWAYS_FAILING / NEW_FAILURES feature gate.", - "Read-only despite being a POST." - ] - }, - { - "name": "get_widget_drilldown_records", - "method": "POST", - "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}/drilldown", - "mode": "read", - "entity": "widget", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "Not read by the handler; any value satisfies the route." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "The widget whose data point is being drilled into (WidgetTypeEnum). Bound by exact enum constant name; any other value is a 400.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Optional window start (epoch ms)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Optional window end (epoch ms)." - } - ], - "body": [ - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Filters block (DTO field `dashboardFilters`) carrying the drill-down selection: projects, date range, page number, and drill-down date type.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids for the selection (translated to TestHub ids for CH-powered widgets)." - }, - { - "name": "pageNumber", - "type": "integer", - "description": "Page of drill-down rows." - }, - { - "name": "dateRange", - "type": "object", - "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." - } - ] - }, - { - "name": "config", - "type": "object", - "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", - "fields": [ - { - "name": "FILTERS", - "type": "object", - "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." - } - ] - } - ], - "intent": "Return the underlying row-level records behind a widget data point - the 'click a bar to see the builds/tests it represents' drill-down for a dashboard widget.", - "returns": [ - "data", - "insights", - "isConflicting", - "isEmptyNumerator", - "isEmptyDenominator" - ], - "responses": { - "200": { - "description": "Drill-down rows plus insight and conflict flags.", - "schema": { - "$schema": "WidgetDrillDownData" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", - "There is no separate project-view drill-down route: drilling down from a project view uses THIS route with the projects supplied in the body.", - "`data` rows carry project identity (projectId/thProjectId/projectName/normalisedName) plus widget-specific fields; `insights` is a `{value, meta}` summary and the boolean flags report a conflicting or empty numerator/denominator selection.", - "Same project-id translation for ClickHouse-powered widgets and same TCM manual+automated behaviour as the widget-data call.", - "Read-only despite being a POST; the dashboard id in the address is ignored." - ] - }, - { - "name": "get_parallel_queue_usage_summary", - "method": "GET", - "path": "/api/v1/parallels/queues/usageSummary", - "mode": "read", - "entity": "parallel_queue", - "query": [ - { - "name": "product", - "type": "string", - "description": "Product to report live queue usage for (e.g. automate / app-automate). OPTIONAL: omit it and only the test-execution usage block is returned, with no live parallel-queue fetch." - } - ], - "intent": "Get the top-level parallel-queue summary for the signed-in user's group: plan test-execution usage plus current parallels-in-use and queued sessions for the requested product.", - "returns": [ - "summary", - "testExecutionsUsage" - ], - "responses": { - "200": { - "description": "Group-level queue summary; `summary` present only when a product was supplied.", - "schema": { - "$schema": "ParallelQueueUsageSummary" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only works for JWT/session-token callers - it reads the authenticated user straight from the security context, not from a supplied credential.", - "Omitting `product` returns just the `testExecutionsUsage` block; the live `summary` (parallels/queued) is populated only when a product is given.", - "The internal userDetails block is stripped out before the response is returned.", - "`summary` is a list of per-product SummaryDetails (totalParallelsUsed, maximumParallelsAllowed, totalQueuedSessions, maximumQueueAllowed, with per-pool breakdowns)." - ] - }, - { - "name": "list_parallel_queue_team_usage", - "method": "GET", - "path": "/api/v1/parallels/queues/teams", - "mode": "read", - "entity": "parallel_queue", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product to break down (e.g. automate / app-automate). Required and non-empty, else 400; resolved to a per-product backend endpoint, so an unknown product fails downstream." - } - ], - "intent": "Break the group's parallel-session usage down by team and member - parallels used vs allowed, queued vs queue limit, with per-pool breakdowns - so an admin can see who is consuming the plan's concurrency.", - "returns": [ - "summary", - "teams" - ], - "responses": { - "200": { - "description": "Per-team and per-member concurrency usage, plus a group summary.", - "schema": { - "$schema": "ParallelQueueTeamUsage" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`product` is required and non-empty (empty/missing throws a 400); it is resolved to a per-product backend endpoint, so an unrecognised product fails there, not here.", - "Passes through to the billing backend with the caller's own BrowserStack username/access key, then reshapes and enriches members with user details.", - "`teams[].members[]` carry per-user parallelsUsed / sessionQueued and pool breakdowns; `showRunningBuildsButton` indicates whether a member has running builds to drill into (via `list_user_running_builds`)." - ] - }, - { - "name": "list_user_running_builds", - "method": "GET", - "path": "/api/v1/parallels/queues/running/builds/{userId}", - "mode": "read", - "entity": "parallel_queue", - "path_params": [ - { - "name": "userId", - "type": "integer", - "required": true, - "description": "The user whose currently-running builds to list. Taken from the path and NOT checked against the caller's own group." - } - ], - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "values": [ - "automate", - "app-automate" - ], - "description": "Product to query. Required and non-empty, else 400. Any value other than 'automate' is treated as App Automate." - } - ], - "intent": "Show which builds a given user currently has running for Automate or App Automate, with their projects and how long the oldest has been going - the drill-down from the parallel-queue view to who is holding the parallels.", - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "The user's currently-running builds.", - "schema": { - "$schema": "RunningBuildsResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`userId` is read from the path and is NOT checked against the caller's group, so this can surface another user's running builds.", - "`product` is required; anything other than the exact string 'automate' is treated as App Automate rather than rejected.", - "Backed by an Elasticsearch query (size 10000, locked builds excluded), so it returns up to that many rows without paging.", - "`data` is a list of build summaries (the standard build-summary shape)." - ] - }, - { - "name": "get_test_execution_usage_count", - "method": "GET", - "path": "/api/v1/usage", - "mode": "read", - "entity": "usage", - "query": [ - { - "name": "lowerBound", - "type": "integer", - "required": true, - "description": "Window start (epoch). Required." - }, - { - "name": "upperBound", - "type": "integer", - "required": true, - "description": "Window end (epoch). Required." - }, - { - "name": "userId", - "type": "integer", - "description": "User to count for. Optional. At least one of userId/groupId must be supplied; if both are given, userId wins." - }, - { - "name": "groupId", - "type": "integer", - "description": "Group to count for. Optional. Used only when userId is absent." - } - ], - "intent": "Count test executions reported to Observability in a time window for a user or group, split into total tests and tests that ran on BrowserStack infrastructure - the raw numbers behind usage/consumption reporting.", - "returns": [ - "totalTestCount", - "browserstackTestCount" - ], - "responses": { - "200": { - "description": "Execution counts for the window. When BOTH userId and groupId are missing, the body is instead the plain string 'Missing userId or groupId' with a 200.", - "schema": { - "$schema": "TestExecutionUsageCount" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`userId`/`groupId` are taken straight from the query with NO check that they belong to the caller - any authenticated caller can read another user's or group's counts.", - "Supply at least one of `userId`/`groupId`; if both are present `userId` takes precedence and `groupId` is ignored.", - "When BOTH are omitted the call returns HTTP 200 with the plain-text body `Missing userId or groupId` (not JSON, not a 400) - handle that string before parsing JSON.", - "`totalTestCount` is all tests reported; `browserstackTestCount` is the subset that carry a sessionId (ran on BrowserStack infra).", - "The action name behind this route mentions always-failing tests but that is a copy-paste artefact - this endpoint has nothing to do with always-failing tests." - ] - }, - { - "name": "get_group_plan_usage_details", - "method": "GET", - "path": "/api/v1/usageDetails", - "mode": "read", - "entity": "usage", - "intent": "Load the plan/usage panel: the group's plan info and plan details plus non-BrowserStack test-execution consumption - used vs total executions for the team and the whole group, usage percentages, BrowserStack-run count, and the billing reset date.", - "returns": [ - "planInfo", - "planDetails", - "nonBrowserstackTotalTestExecutions", - "nonBrowserstackUsedTestExecutions", - "nonBrowserstackPercentageOfTestExecutionUsage", - "billingResetDate", - "browserstackUsedTestExecutions", - "totalTestExecutions", - "nonBrowserstackPercentageOfGroupTestExecutionUsage", - "nonBrowserstackGroupTotalTestExecutions" - ], - "responses": { - "200": { - "description": "Plan info/details plus usage figures, all counts as compacted strings. Usage fields are null when the group has no execution limit.", - "schema": { - "$schema": "GroupPlanUsageDetails" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes no parameters; scoping comes from the authenticated session, so this needs a session/JWT caller (it reads the UserDetail from the security context).", - "All the execution counts come back as compacted DISPLAY STRINGS (e.g. '1.2K'), not numbers - do not do arithmetic on them.", - "This returns `planDetails` (not `userDetails`); it is the same underlying data as the usage block of the init call but requested with plan details included.", - "When the group has no execution limit configured, every usage figure is nulled out. Note the null-branch uses the key `nonBrowserstackTestExecutions` where the populated branch uses `nonBrowserstackUsedTestExecutions` - the key set differs between the two shapes.", - "`billingResetDate` is an epoch-millis timestamp." - ] - }, - { - "name": "get_device_tier_usage", - "method": "GET", - "path": "/api/v1/tier/info", - "mode": "read", - "entity": "usage", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product whose device tiers to report (e.g. automate / app-automate). Required and non-empty, else IllegalArgumentException (400)." - } - ], - "intent": "Get the group's mobile device-tier entitlement picture for a product - per tier, the devices in it, the device/parallel limits and how much of each is in use - for the device-tier/queue capacity view.", - "returns": [ - "tierInfo", - "devices" - ], - "responses": { - "200": { - "description": "Aggregated tier usage and device list for the product.", - "schema": { - "$schema": "DeviceTierUsageInfo" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only MOBILE tiers are returned; non-mobile tiers are filtered out.", - "`product` is required and non-empty; missing/empty is a 400 (IllegalArgumentException).", - "Server-side the handler resolves the caller's own BrowserStack username + selenium access key and calls the public backend with them - no extra caller-supplied credential is needed.", - "This is a pass-through aggregation over two backend endpoints (tier usage + devices); `tierInfo` and `devices` are raw JSON trees, so read them defensively." - ] - }, - { - "name": "search_entities", - "method": "POST", - "path": "/api/v1/search", - "mode": "read", - "entity": "search", - "body": [ - { - "name": "query", - "type": "string", - "required": true, - "minLength": 3, - "description": "Free-text query. Must be non-blank and at least 3 characters or the request 400s. ClickHouse dollar-quote tokens ($q$) are stripped from it server-side." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "Build", - "BuildTestCaseExecutionResult", - "Project", - "All" - ], - "description": "Entity type to search. Must be exactly one of the supported values (CASE-SENSITIVE); any other value 400s." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "1-based page. Values <= 0 or absent default to 1." - }, - { - "name": "days", - "type": "integer", - "default": 30, - "description": "Look-back window in days. Defaults to 30." - }, - { - "name": "filters", - "type": "object", - "description": "Per-entity filter block (build / buildTestCaseExecutionResult / project / all). Only recognised sub-fields are forwarded; e.g. build.onlyLocked, build.isArchived, build.userId, test.isFlaky/isMuted/failureCategories, project.userId.", - "fields": [ - { - "name": "build", - "type": "object", - "description": "Build filters: onlyLocked, isCIRun, isArchived, userId, status." - }, - { - "name": "buildTestCaseExecutionResult", - "type": "object", - "description": "Test-run filters: isMuted, isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly, failureCategories[], userId, status." - } - ] - }, - { - "name": "overlay", - "type": "boolean", - "description": "When true, returns the quick-overlay page (size 10) and OMITS pagingParams and totalCount. When false/absent, returns the full page (size 30) with paging and total count." - } - ], - "intent": "Global search across the product: given a free-text query and an entity type, return matching entities (builds, tests, projects) for the search bar or quick overlay, paginated and enriched with user names.", - "returns": [ - "response", - "pagingParams", - "totalCount" - ], - "responses": { - "200": { - "description": "Search results. `pagingParams` and `totalCount` are present only when overlay is off.", - "schema": { - "$schema": "GlobalSearchResult" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`entity` is case-sensitive and must be exactly Build, BuildTestCaseExecutionResult, Project, or All; anything else 400s. `query` must be >= 3 non-blank characters.", - "Sort is fixed to updated_at-desc and cannot be changed by the caller.", - "Page size is NOT caller-controlled: overlay=true fixes it to 10 and omits `pagingParams`/`totalCount`; overlay off fixes it to 30 and includes them. Page through with `pageNumber` and `pagingParams.has_next`.", - "This is a thin pass-through to the backend global-search API; TRA only validates/sanitises the query, fixes the sort, and sets the page size.", - "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id.", - "Keep `days` at or below 90: on a large group, windows of 365 or more returned HTTP 500 'Internal System Exception' instead of results.", - "entity=\"Build\" is the only cross-project way to find a build by NAME or by CI run id: each hit carries `projectName`/`projectNormalizedName`, `uuid`, `serialId` (the #N) and `buildRunId` (the CI run id). Use it when a user names a build or a run number without a project.", - "entity=\"Project\" is unreliable for name lookup (an existing project can return zero hits); find projects by name with list_projects_lite_paginated `search` instead." - ] - }, - { - "name": "create_self_healing_pr", - "entity": "self_healing", - "method": "POST", - "path": "/api/v1/aiHealing/builds/{buildUuid}/createPR", - "mode": "write", - "intent": "Open a GitHub pull request that rewrites broken selectors to their AI-healed values \u2014 pick the healed locators you accept from a build's healing report and ask BrowserStack to raise the PR in the customer's repo.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$", - "description": "The build whose healing report the selectors were chosen from." - } - ], - "body": [ - { - "name": "selectors", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 200, - "description": "The healed selectors to apply. 1..200 items. Each carries `using`/`value` (NOT `type`/`value`) for both the original and healed locator \u2014 note this differs from the verdict endpoint, which uses `type`. There is no `elementIdentifier` here.", - "fields": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "The healed-selector id from the healing report; must be non-blank. Used to group files and to derive the head branch name." - }, - { - "name": "originalLocator", - "type": "object", - "required": true, - "description": "The broken locator to find and replace in source.", - "fields": [ - { - "name": "using", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Locator strategy, e.g. xpath / css. Non-blank, max 64 chars." - }, - { - "name": "value", - "type": "string", - "required": true, - "maxLength": 4000, - "description": "The original locator string. Non-blank, max 4000 chars." - } - ] - }, - { - "name": "healedLocator", - "type": "object", - "required": true, - "description": "The replacement locator to write in.", - "fields": [ - { - "name": "using", - "type": "string", - "required": true, - "maxLength": 64 - }, - { - "name": "value", - "type": "string", - "required": true, - "maxLength": 4000 - } - ] - }, - { - "name": "sessionId", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Session the selector broke in. Non-blank, max 64. Distinct sessionIds across the batch must be <= 25 (each triggers one source-file lookup); over that the whole request is rejected." - } - ] - }, - { - "name": "baseBranch", - "type": "string", - "required": false, - "description": "Branch to open the PR against. Optional \u2014 defaults to the build's recorded VCS branch; if neither is present the call fails as missing VCS info." - }, - { - "name": "headBranch", - "type": "string", - "required": false, - "maxLength": 200, - "pattern": "^(?!/)(?!.*//)(?!.*\\.\\.)[A-Za-z0-9._/-]+(?- when blank. Must be a valid git ref: no '..', no leading/trailing '/', no '//'." - }, - { - "name": "repository", - "type": "string", - "required": false, - "maxLength": 200, - "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", - "description": "Target repo in `owner/repo` form. Optional \u2014 auto-derived from the build's GitHub origin remote when blank. Auto-derivation is GitHub-only; a GitHub Enterprise / non-github.com host MUST pass this explicitly or the call fails as unparseable remote." - }, - { - "name": "title", - "type": "string", - "required": false, - "minLength": 10, - "maxLength": 200, - "description": "PR title. Optional \u2014 a default is generated. If supplied it must be 10..200 chars; it is prefixed with '[BrowserStack Heal] ' unless it already starts with that." - }, - { - "name": "description", - "type": "string", - "required": false, - "maxLength": 4000, - "description": "PR body. Optional \u2014 a default is generated. Control characters are stripped (newlines/tabs kept)." - } - ], - "returns": [ - "healingPrId", - "status" - ], - "guidance": [ - "Asynchronous: returns HTTP 202 with only `{healingPrId, status:\"IN_PROGRESS\"}`. The PR is NOT open yet \u2014 there is no prUrl in this response. Capture healingPrId and poll get_self_healing_pr_status for the outcome and the PR URL.", - "The job id lives only in Redis with a short TTL and is not listed anywhere, so if you lose it or stop polling in time it is gone \u2014 store it from this response immediately.", - "Preconditions that reject synchronously before any work: an operational kill switch may disable the feature (global or per-group); the AI feature must be within plan/entitlement and the build must not be plan-locked; the build must carry VCS info and resolve to a GitHub owner/repo; at least one selector; <= 25 distinct sessions; and after source-file resolution <= 100 files.", - "Rate limited per user (25/hr) and per group (100/hr). Over-limit calls are rejected; a transient downstream failure refunds the token rather than burning your quota.", - "This is GitHub-only. A non-GitHub origin remote is silently skipped during auto-derivation (you then get an unparseable-remote error) \u2014 pass `repository` explicitly for those.", - "Each selector's locator uses `using`/`value`. Do not send `type` here (that is the verdict endpoint's field); an object missing `using` or `value` fails validation.", - "No API undo: this opens a real PR in the customer's repo. Closing/reverting is done in GitHub, not here.", - "Live 2026-09-30: the healing worker's integration key 'github-scm' is the GitHub 'Source Code View' integration (integrations page \u2192 Source Code View \u2192 GitHub), connected per user. Without it the job FAILS at fetch_files with 401 'Invalid credentials for the integration'. The account-wide GitHub (issues) connection does not satisfy it." - ], - "responses": { - "202": { - "$response": "CreateHealingPrAccepted" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "429": { - "description": "Rate limited (per-user 25/hr or per-group 100/hr) via HEALING_PR_RATE_LIMITED.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_self_healing_pr_status", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/healingPrs/{healingPrId}/status", - "mode": "read", - "intent": "Poll a self-healing PR job started by create_self_healing_pr \u2014 get its in-progress/terminal state and, once ready, the created pull request URL.", - "path_params": [ - { - "name": "healingPrId", - "type": "string", - "required": true, - "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", - "description": "The job id returned by create_self_healing_pr (strict lowercase 8-4-4-4-12 hex UUID)." - } - ], - "returns": [ - "healingPrId", - "status", - "prUrl", - "prNumber", - "headSha", - "perFileReport", - "errorMessage" - ], - "guidance": [ - "`status` is one of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED. Treat PARTIAL and NO_CHANGES_NEEDED as successful terminal outcomes, not failures.", - "prUrl / prNumber / headSha are null until a terminal, PR-producing state; perFileReport (per-file applied/skipped detail) and errorMessage appear depending on the outcome. Do not expect a PR URL while IN_PROGRESS.", - "Status is stored only in Redis under a group-scoped key with a TTL. An unknown or EXPIRED id returns HEALING_PR_NOT_FOUND \u2014 that is not a terminal failure state, just a lapsed/foreign id.", - "Only visible to the group that initiated the PR." - ], - "responses": { - "200": { - "$response": "HealingPrStatusResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "HEALING_PR_NOT_FOUND \u2014 the id is unknown or its Redis entry expired, or it belongs to another group.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "submit_self_healing_verdict", - "entity": "self_healing", - "method": "POST", - "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", - "mode": "write", - "intent": "Record a reviewer's verdict on one AI-healed locator ('accurate' or 'inaccurate', with optional feedback) to feed the human-in-the-loop healing loop.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$" - } - ], - "body": [ - { - "name": "healedLocator", - "type": "object", - "required": true, - "description": "The healed locator being judged. Note the field is `type` here (xpath|cssPath), NOT `using` as in create_self_healing_pr.", - "fields": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "xpath", - "cssPath" - ], - "description": "Locator strategy; must be exactly 'xpath' or 'cssPath'." - }, - { - "name": "value", - "type": "string", - "required": true, - "description": "The healed locator string. Non-blank." - } - ] - }, - { - "name": "verdict", - "type": "string", - "required": true, - "values": [ - "accurate", - "inaccurate" - ], - "description": "The review outcome; must be exactly 'accurate' or 'inaccurate'." - }, - { - "name": "elementIdentifier", - "type": "string", - "required": true, - "description": "Identifier of the element the locator targets; part of the hash that keys the verdict in TCG. Non-blank." - }, - { - "name": "baseUrl", - "type": "string", - "required": false, - "description": "Web automate: the page URL; required for the web hash (baseUrl + elementIdentifier + groupId). Leave null/empty for app automate." - }, - { - "name": "screenIdentifier", - "type": "string", - "required": false, - "description": "App automate only: native screen name used in the app hash (elementIdentifier + groupId + screenIdentifier). Null for web automate." - }, - { - "name": "client", - "type": "string", - "required": false, - "description": "Platform client type: 'app-automate' selects the app hash formula; empty/null means web automate." - }, - { - "name": "feedback", - "type": "object", - "required": false, - "description": "Optional free-form feedback (arbitrary JSON)." - }, - { - "name": "region", - "type": "string", - "required": false, - "maxLength": 64, - "description": "Optional session region code; only a lookup key into the TCG region-URL map (a miss is skipped, not an error). Max 64." - } - ], - "returns": [ - "elementHash", - "healedLocator", - "verdict", - "feedback", - "reviewedByUserId", - "reviewedAt", - "isCarryover", - "carryoverBuildSerialId" - ], - "guidance": [ - "Gated on the human-in-the-loop healing feature per group: when it is OFF this is refused outright (FORBIDDEN_OPERATION) \u2014 unlike list_self_healing_verdicts, which silently returns []. Check the feature before submitting.", - "Requires a full product role (Product Admin, Product User, Admin or User); Viewer / Read Only is rejected.", - "Dual-writes to observability storage AND the TCG service (keyed by a hash of the element identifier), so a TCG outage can fail the call even though it looks like a local write.", - "Unknown body keys are ignored. For web tests supply baseUrl; for app tests supply screenIdentifier and client='app-automate' \u2014 the wrong pairing computes a hash that won't match the element." - ], - "responses": { - "200": { - "$response": "SubmitHealingVerdictResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_self_healing_verdicts", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", - "mode": "read", - "intent": "List the human-in-the-loop verdicts (accurate/inaccurate) already recorded against a build's AI-healed locators, to show which healed selectors have been reviewed.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$" - } - ], - "returns": [ - "elementHash", - "healedLocator", - "verdict", - "feedback", - "reviewedByUserId", - "reviewedAt", - "isCarryover", - "carryoverBuildSerialId" - ], - "guidance": [ - "Returns a bare JSON array of verdict objects (no wrapper envelope).", - "Feature-flagged: when the human-in-the-loop healing feature is OFF for the group this returns an empty array with HTTP 200 \u2014 NOT an error. Do not read [] as 'no verdicts recorded' without knowing the feature is on.", - "`isCarryover` is true when the verdict was first recorded against a different (earlier) build; `carryoverBuildSerialId` names that build. Join `elementHash` with the healing report to line each verdict up with its selector.", - "A build UUID outside the caller's group is rejected as build-not-found." - ], - "responses": { - "200": { - "$response": "HealingVerdictListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_self_heal_summary", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/builds/{uuid}/selfHealSummary", - "mode": "read", - "intent": "Get a build's self-healing headline numbers for the build-insights page \u2014 how many tests and locators were healed, with counts and percentages.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$", - "description": "Build UUID." - } - ], - "returns": [ - "testSummary", - "locatorSummary", - "buildSummary" - ], - "guidance": [ - "Numbers come from search aggregations over the build's runs: testSummary (totalTests, healedTests, percentHealedTests), locatorSummary (totalLocators, healedLocators, percentHealedLocators) and buildSummary (totalBuild, healedBuilds, percentHealedBuilds) - buildSummary can be null.", - "Refused with FORBIDDEN_OPERATION / plan-feature-not-allowed when the build is locked by plan quota \u2014 you get an error, not zeroes.", - "In the demo-sandbox environment this returns hardcoded dummy stats, not real data." - ], - "responses": { - "200": { - "$response": "SelfHealSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_comparison_aggregation", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/aggregation", - "mode": "read", - "intent": "Get the headline counts for a base-vs-target build comparison \u2014 additional, dropped, no-change, newly failed, started passing, and failed-with-a-different-reason \u2014 the summary strip at the top of the Build Comparison page.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID (top-level parent build). Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID (top-level parent build). Empty/missing \u2192 build-not-found." - }, - { - "name": "filterParams", - "type": "object", - "required": false, - "description": "Standard V2 test filters bound from the query string (same filter keys as the build test listing), applied to both builds before comparing." - } - ], - "returns": [ - "newlyFailed", - "failedWithDifferentReason", - "startedPassing", - "additionalTests", - "droppedTests", - "noChange", - "sourceFencingFlags" - ], - "guidance": [ - "Plan-gated on the build-comparison feature: FORBIDDEN_OPERATION / plan-feature-not-allowed otherwise. A successful call also bumps feature-usage counters.", - "Both builds must be top-level parents and supported: a child/archived/locked build is rejected as unsupported (BUILD_COMPARISON_BUILD_NOT_SUPPORTED). Normalise UUIDs with resolve_build_comparison_parent_uuids first.", - "A cross-source (incompatible) pair is NOT an error: you get 200 with all six counts at 0 and `sourceFencingFlags` populated. Check sourceFencingFlags before trusting the zeroes.", - "Expensive: resolves both builds' child builds and runs search + aggregation across them.", - "The controller method is internally named getBuildUuid but it returns counts, not a UUID \u2014 ignore the name." - ], - "responses": { - "200": { - "$response": "BuildComparisonAggregationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "check_build_comparison_available", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/checkComparison", - "mode": "read", - "intent": "Pre-flight check of whether two builds can actually be compared (and why not), so the UI can show a 'comparison unavailable' state before firing the expensive aggregation.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "status", - "message", - "sourceFencingFlags" - ], - "guidance": [ - "Same build-comparison plan gate as the aggregation \u2014 refused when the plan lacks the feature.", - "For an incompatible-source pair it returns `sourceFencingFlags` and NO availability payload (status/message null); otherwise status/message describe whether the comparison is available. Check the flags first.", - "Same unsupported-build rejection as the other comparison endpoints for child/archived/locked builds.", - "Use this before get_build_comparison_aggregation / list_build_comparison_bucket_tests to avoid the heavy call when the pair can't be compared." - ], - "responses": { - "200": { - "$response": "BuildComparisonCheckResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "resolve_build_comparison_parent_uuids", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/getActual", - "mode": "read", - "intent": "Normalise two build UUIDs (which may be re-run/child builds) to their top-level parent build UUIDs \u2014 call this first so a rerun UUID pasted into the comparison picker is compared at the parent level.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID to resolve (may be a child/rerun UUID). Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID to resolve. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "base", - "target", - "sourceFencingFlags" - ], - "guidance": [ - "Returns `{base, target}` where each is the resolved top-level PARENT build UUID. Feed these into the other comparison calls.", - "NOT plan-gated (unlike the other four comparison endpoints) \u2014 works even without the build-comparison feature.", - "Any resolution failure is collapsed into a single BUILD_NOT_FOUND naming whichever of the two UUIDs appeared in the underlying error message; you cannot tell from the error which side genuinely failed.", - "sourceFencingFlags is part of the envelope but is not set on the normal success path here." - ], - "responses": { - "200": { - "$response": "BuildComparisonActualResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_comparison_bucket_tests", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/list", - "mode": "read", - "intent": "List the actual tests inside one bucket of a build comparison (e.g. newly-failed, started-passing, dropped, additional), grouped for display \u2014 the drill-down when a user clicks an aggregation tile.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "bucket", - "type": "string", - "required": true, - "description": "Comparison bucket, as the exact BucketEnum constant name (case-sensitive). The aggregation's camelCase keys (newlyFailed ...) are NOT accepted: any other value -> 500 'No enum constant'.", - "values": [ - "NEWLY_FAILED_TEST", - "STARTED_PASSING_TEST", - "DROPPED_TEST", - "ADDITIONAL_TEST", - "NO_CHANGE", - "FAILED_WITH_DIFFERENT_REASON" - ] - }, - { - "name": "fileOffset", - "type": "integer", - "required": true, - "description": "Pagination cursor (Long) over the grouped result; start at 0 and advance it to page through groups. Required." - }, - { - "name": "filterParams", - "type": "object", - "required": false, - "description": "Standard V2 test filters bound from the query string, applied before matching." - } - ], - "returns": [ - "bucketName", - "groups", - "sourceFencingFlags" - ], - "guidance": [ - "Plan-gated on the build-comparison feature.", - "Returns `{bucketName, groups}` where each group bundles a file/browser/device/os and its `scopes`, each scope holding base/target element pairs. A cross-source pair returns an empty `groups` with `sourceFencingFlags` set rather than an error.", - "Expensive: pulls test-run ids for both builds (and their child builds) from search with a large per-build ceiling (~10k) before matching.", - "`fileOffset` is a cursor, not a page number; carry the last value forward to continue. The page size / total is not returned as a simple count \u2014 walk until a page comes back empty." - ], - "responses": { - "200": { - "$response": "BuildComparisonListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_comparison_meta", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/meta", - "mode": "read", - "intent": "Get the two build summary headers shown side by side on the Build Comparison page \u2014 name, status, timings, framework and status counts for both the base and the target build.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "base", - "target", - "sourceFencingFlags" - ], - "guidance": [ - "Returns `{base, target}`, each a full build summary object. A cross-source pair returns the envelope with `sourceFencingFlags` set and base/target null.", - "NOT explicitly plan-gated (unlike aggregation/list/checkComparison), but it DOES apply the same unsupported-build rejection and source-fencing behaviour.", - "The status counts are AMR-corrected: it re-runs the same dedup/coalesce aggregation the buckets use so the header agrees with them, and silently falls back to raw automation counts if that recompute fails. This makes it heavier than a plain build-summary fetch." - ], - "responses": { - "200": { - "$response": "BuildComparisonMetaResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_comments", - "entity": "comment", - "method": "GET", - "path": "/api/v1/projects/{normalizedName}/comments", - "mode": "read", - "intent": "Fetch the comments to render inline on a build-insights or test listing page \u2014 comments for a build (and a window of preceding build numbers), optionally narrowed to one test, to your own comments/mentions, and optionally excluding re-runs.", - "path_params": [ - { - "name": "normalizedName", - "type": "string", - "required": true, - "description": "Project normalised name (resolved within the caller's group)." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "description": "Comment page type (same enum as create_comment, by NAME). REQUIRED in practice \u2014 omitting it returns [] with 200.", - "values": [ - "TESTS", - "ALWAYS_FAILING", - "FLAKINESS", - "NEW_FAILURES", - "MUTED", - "BUILD_SUMMARY", - "BUILD_HISTORY", - "UNIQUE_ERRORS", - "BUILD_STABILITY", - "RUN_SUMMARY", - "ALERTS", - "FAILURE_CATEGORIES", - "DEFECT_STATS", - "BUILD_RERUN_STATS", - "TOP_ERROR_STATS", - "QUALITY_GATES", - "FAILURES_BY_FOLDERS", - "ATTACHMENTS", - "BUILD_OPTIMISATION", - "SELF_HEALING" - ] - }, - { - "name": "buildSerialId", - "type": "integer", - "description": "The build's numeric id (build_id, e.g. 160459727) - NOT its build_number. REQUIRED in practice \u2014 omitting it returns [] with 200 (not an error). The read looks back over a fixed window of preceding ids from this value." - }, - { - "name": "buildUuid", - "type": "string", - "description": "Build UUID to scope to when no runUuid is given." - }, - { - "name": "runUuid", - "type": "string", - "description": "When set (and not in re-run mode), scopes to this run instead of the whole build." - }, - { - "name": "testHash", - "type": "string", - "description": "Required when type=TESTS \u2014 type=TESTS without a testHash returns [] with 200. Also used to scope re-run/expand-all test queries." - }, - { - "name": "expandAll", - "type": "boolean", - "description": "Expand the full set for the context rather than a collapsed view; changes how buildUuid/testHash filtering is applied." - }, - { - "name": "showMyComments", - "type": "boolean", - "default": false, - "description": "Restrict to comments you authored or were mentioned in." - }, - { - "name": "showReRunComments", - "type": "boolean", - "default": false, - "description": "Include comments across re-run builds. REQUIRES parentBuildUuid \u2014 set without it throws INVALID_PARAMETERS_FOR_COMMENTS." - }, - { - "name": "parentBuildUuid", - "type": "string", - "description": "Parent build UUID; mandatory whenever showReRunComments=true." - } - ], - "returns": [ - "id", - "type", - "threadId", - "userId", - "groupId", - "commentNode", - "projectId", - "buildUuid", - "buildSerialId", - "runUuid", - "testHash", - "mentions", - "createdAt", - "modifiedAt" - ], - "guidance": [ - "Returns a bare JSON array of comment objects (no envelope). Note the array items carry `commentNode` but NOT the `content` HTML.", - "Silent empties: missing buildSerialId, missing type, or type=TESTS without testHash all return [] with HTTP 200 \u2014 easy to misread as 'no comments'. Supply buildSerialId and type (and testHash for TESTS).", - "showReRunComments=true without parentBuildUuid is a hard error (INVALID_PARAMETERS_FOR_COMMENTS), not an empty list.", - "Only looks back a FIXED number of preceding build serial ids from buildSerialId (so recurring discussion carries over), not all history, and is further bounded by the plan's data-retention window." - ], - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_comment_threads", - "entity": "comment", - "method": "GET", - "path": "/api/v1/projects/{normalizedName}/builds/{buildId}/comments", - "mode": "read", - "intent": "Power the build-level comments side panel \u2014 list the comment threads across a build and all its re-runs, grouped by thread and ordered by most recent activity, with each thread's page type and test-run uuid for deep-linking.", - "path_params": [ - { - "name": "normalizedName", - "type": "string", - "required": true, - "description": "Project normalised name (resolved within the caller's group)." - }, - { - "name": "buildId", - "type": "string", - "required": true, - "description": "PARENT build UUID. Threads are aggregated across all builds sharing this parent uuid \u2014 passing a re-run's own uuid returns nothing." - } - ], - "query": [ - { - "name": "showMyComments", - "type": "boolean", - "description": "Restrict to threads you authored or were mentioned in. REQUIRED in practice: omitted -> 500 (null Boolean unboxed). Send false for all threads.", - "required": true - } - ], - "returns": [ - "threadId", - "name", - "browser", - "os", - "device", - "type", - "testRunUuid", - "commentCount", - "testHash" - ], - "guidance": [ - "Returns a bare JSON array of thread summaries (threadId, commentCount, the thread's page type and testRunUuid for deep-linking), not the individual comments \u2014 fetch a thread's comments via list_comments.", - "`buildId` must be the PARENT build uuid; a re-run's uuid yields an empty result, not an error.", - "Thread aggregation is capped at 1000 threads, and results are bounded by the plan's data-retention window so older threads silently drop out.", - "Always send showMyComments (false for every thread): the server unboxes it without a null check, so omitting it returns 500 Internal System Exception." - ], - "responses": { - "200": { - "$response": "CommentThreadsSidePanelResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_comments", - "entity": "comment", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/comments", - "mode": "read", - "intent": "Public API: fetch the discussion thread on a test run \u2014 the comments testers left against that failure, with author info.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test-run id (Long)." - } - ], - "returns": [ - "id", - "type", - "threadId", - "userId", - "groupId", - "commentNode", - "projectId", - "buildUuid", - "buildSerialId", - "runUuid", - "testHash", - "mentions", - "createdAt", - "modifiedAt" - ], - "guidance": [ - "Public /ext/v1 route: authenticate with the customer username + access key, not the internal session.", - "Read-only \u2014 there is NO external endpoint to post a comment; use the internal create_comment for that.", - "A run not in the caller's group returns TEST_RUN_NOT_FOUND.", - "Returns a bare JSON array of comment objects (same shape as list_comments items)." - ], - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_unique_errors", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors", - "mode": "read", - "entity": "sp_error", - "intent": "List the Suite Health unique errors for a project \u2014 test failures clustered by error signature over a date window, each cluster with its occurrence count, impacted-test count and a sample of the error text \u2014 so a team can attack the few root errors behind many failing tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], sent as a repeated query param (dateRange=&dateRange=). Required \u2014 missing, or not exactly two values, is a 400. lowerBound + upperBound singles are the only alternative." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match against the error signature / sample text." - }, - { - "name": "status", - "type": "array", - "description": "Test-status filter values. Note the list is failed-only regardless." - }, - { - "name": "sources", - "type": "array", - "description": "Product-source filter.", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array", - "description": "Failure-category ids to include." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Restrict to these unique build names." - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array", - "description": "Owner user ids (integers)." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (integers)." - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "branches", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "description": "Muted-tests visibility. Unmuted is the default; an unrecognised value is a 400 (IllegalArgumentException).", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the cluster-id constraint is dropped, so results span all builds of a test rather than one error cluster." - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column. Ignored (forced to errorCount) for free / non-pro accounts.", - "values": [ - "errorCount", - "testCount" - ] - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "minimum": 0, - "default": 0, - "description": "Zero-based page index into the clustered result. Page size is fixed at 15. The response's pagingParams.pageNumber is the NEXT index to request (requested + 1), not the one you sent." - } - ], - "returns": [ - "pagingParams", - "errors", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "responses": { - "200": { - "$response": "UniqueErrorListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Take each row's `id` (a numeric string, the errorClusterId) \u2014 that is the cluster id every per-error detail view wants in its path.", - "Page with `pageNumber` (0-based) and a fixed size of 15. Walk pages while the response `pagingParams.hasNext` is true; note `pagingParams.pageNumber` is already the NEXT page to request, not the one you asked for.", - "An empty `errors` with a 200 is the honest answer for a demo tenant or a project with no builds \u2014 it does NOT mean the project is missing. A truly absent project is a 404.", - "Before concluding coverage is complete, check automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 source fencing may have silently dropped whole product families from the clustering.", - "Free / non-pro accounts get exactly one error back (the top one) and the sort is forced to errorCount desc; do not read that single row as the only error that exists.", - "Heavy filter combinations run under an Elasticsearch time budget and can be cut short, so a smaller-than-expected result on an expensive query may be a budget cut rather than the true count.", - "Any unrecognised extra query params are collected into customParams rather than rejected, so a mis-typed filter name is silently ignored, not errored." - ] - }, - { - "name": "get_unique_error_summary", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/info", - "mode": "read", - "entity": "sp_error", - "intent": "Get the header/summary block for one unique error \u2014 its test name, the sample error text (split into lines), and the OS/browser/device combinations it was seen on \u2014 to describe what the error actually is on the detail page.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the summary aggregates across all failed runs of the test (by stability hash) rather than this one cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "browser", - "type": "string", - "description": "Optional single-value browser narrowing (pro accounts only)." - }, - { - "name": "os", - "type": "string", - "description": "Optional single-value OS narrowing (pro accounts only)." - }, - { - "name": "device", - "type": "string", - "description": "Optional single-value device narrowing (pro accounts only)." - } - ], - "returns": [ - "name", - "error", - "combinations" - ], - "responses": { - "200": { - "$response": "UniqueErrorSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Pass the cluster id as a numeric `errorId`. A non-numeric id is NOT a 400 here \u2014 it is a raw 500 from Long.parseLong, because this handler does not validate or wrap exceptions the way its siblings do.", - "If no failing run matches the filters+window, the handler dereferences the first (absent) hit and 500s rather than returning an empty summary \u2014 treat a 500 here as possibly 'no sample found', and widen the window.", - "`error` is the sample text already split on newlines into an array of lines, not a single string.", - "`combinations` lists the platform combos the error appeared on ({os, browser} where browser also carries a `device`); combos that are NA on both os and browser are dropped from the list." - ] - }, - { - "name": "get_unique_error_occurrence_count", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/errorCount", - "mode": "read", - "entity": "sp_error", - "intent": "Get the total number of times one unique error occurred in the window, plus a per-day count series \u2014 the headline count and sparkline for the error detail panel.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the count spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "errorCount", - "chartData" - ], - "responses": { - "200": { - "$response": "UniqueErrorOccurrenceCountResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "`chartData` is a flat array of per-day occurrence counts with NO timestamps \u2014 reconstruct the x-axis yourself from the requested window (one bucket per calendar day, extended-bounds padded to the window).", - "`errorCount` is the total matching-run count for the window, independent of the bucket array length.", - "Buckets are fixed at one calendar day; the interval is not requestable." - ] - }, - { - "name": "get_unique_error_build_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/trend", - "mode": "read", - "entity": "sp_error", - "intent": "Get the occurrence trend of one unique error across builds over time \u2014 matching runs (timestamp, status, duration) grouped by status series and enriched with build info \u2014 to plot when and in which builds the error appeared.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the trend spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "data", - "keys" - ], - "responses": { - "200": { - "$response": "UniqueErrorBuildTrendResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Hard-capped at 5000 matching runs: for a very common error the trend is silently truncated to the first 5000 runs by start time, so totals here can understate the real count \u2014 use get_unique_error_occurrence_count for the true total.", - "Heavier than the other detail calls \u2014 it does a second relational lookup to enrich each run with build info \u2014 so expect higher latency.", - "`data` is a list of status series ({name, color, data[]}); `keys` is the parallel list of run start timestamps (epoch). A truly missing project is re-thrown as a 404; other failures surface as 500." - ] - }, - { - "name": "list_unique_error_builds", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds", - "mode": "read", - "entity": "sp_error", - "intent": "List the builds in which one unique error occurred, cursor-paginated, so a user can jump from an error cluster to the actual runs and builds where it appeared.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "searchAfter", - "type": "array", - "description": "Cursor: pass back the `pagingParams.searchAfter` array from the previous page verbatim. Follow this cursor rather than a page number; page size is fixed at 15." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped so all failed runs of the test are listed, not just this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "pagingParams", - "builds" - ], - "responses": { - "200": { - "$response": "UniqueErrorBuildsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Cursor pagination only \u2014 there is no page number. Take `pagingParams.searchAfter` from the response and send it back as the `searchAfter` query array for the next page; page size is fixed at 15.", - "Stop when the response has no further rows: `pagingParams.hasNext` is set to false only on an empty page, so an empty `builds` (or hasNext=false) is the end of the list.", - "Each build carries isFailedWithSameError, saying whether that build's run failed with THIS exact cluster or merely contains the test; use it to distinguish same-error recurrences from unrelated runs.", - "Some fields are exposed only through computed getters, not raw columns: versionControlInfo ({url, commitId}), issueType ({isAutoAnalyzed, value}, null unless the run is failed) and historyAggregate ({isAlwaysFailing, isFlaky, isNewlyFailed})." - ] - }, - { - "name": "get_unique_error_platform_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/platforms", - "mode": "read", - "entity": "sp_error", - "intent": "Break one unique error down by environment \u2014 which OS / browser / device combinations it occurred on and how often \u2014 so a team can tell whether the error is platform-specific.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped so the breakdown spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "browser", - "os", - "chartData", - "totalFailure", - "errorCount" - ], - "responses": { - "200": { - "$response": "UniqueErrorPlatformBreakdownResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Returns a FLAT array of platform buckets, not a nested os->browser->device tree; each entry is one {browser (with a `device`), os} combination.", - "Per entry, `totalFailure` is the occurrence doc count and `errorCount` is the distinct impacted-test count; `chartData` is per-day occurrence counts with no timestamps (reconstruct from the window).", - "Combos that are NA on both os and browser are dropped from the list.", - "The breakdown is a single composite aggregation page (up to 1000 combinations) and is not paginated, so a project with a very large platform matrix could be truncated." - ] - }, - { - "name": "get_unique_error_impacted_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{clusterId}/breakdown", - "mode": "read", - "entity": "sp_error", - "intent": "Expand one unique-error cluster into the individual tests that hit it \u2014 each with the browsers/devices/platforms it failed on and an occurrence series \u2014 the timeline/detail view behind a row in the unique-errors list.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project name path variable, resolved within the caller's group." - }, - { - "name": "clusterId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). The error cluster id. It reaches the query only because Spring binds the URI variable onto the command object's `clusterId` setter \u2014 a query param of the same name would be overridden by this path value, so set it here.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Restrict the breakdown to these unique build names (pro accounts only)." - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "orderValue", - "type": "string", - "description": "Sorts the returned tests by occurrence count.", - "values": [ - "asc", - "desc" - ] - } - ], - "returns": [ - "id", - "title", - "filePath", - "scopeList", - "browsers", - "platforms", - "device", - "errorCount", - "testRunId", - "chartData" - ], - "responses": { - "200": { - "$response": "UniqueErrorImpactedTestsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a bare JSON array of impacted-test objects (no envelope). Each test's `id` is a stability hash (the test signature), not a numeric id; `testRunId` is one representative failing run.", - "Set the cluster id in the PATH \u2014 the {clusterId} path variable overrides any clusterId query param of the same name, because the handler never reads the path variable itself and relies on request binding.", - "The full Suite Health filter surface is accepted and narrows the matched runs, but pro-account filters (build names, flaky, etc.) are skipped for free / non-pro users.", - "It pages internally through all composite buckets under a loop deadline, so for a very large cluster the returned list can be truncated when the deadline is hit rather than erroring.", - "A genuinely absent project is re-thrown as a 404; other failures surface as a 500. `chartData` is per-day occurrence counts with no timestamps." - ] - }, - { - "name": "get_unique_error_timeline_charts", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/errors/metrics/chart", - "mode": "read", - "entity": "sp_error", - "intent": "Get the Suite Health unique-errors timeline in one call \u2014 three day-bucketed time series over the window (unique errors, unique impacted tests, total impacted executions), each with its headline total \u2014 for the project's failed tests.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group. No permission check beyond project visibility." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "search", - "type": "string" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array" - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "allBuilds", - "type": "boolean" - } - ], - "returns": [ - "uniqueErrorChart", - "uniqueTestChart", - "impactedTestChart" - ], - "responses": { - "200": { - "$response": "UniqueErrorTimelineChartsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Response is a map with exactly three keys: uniqueErrorChart, uniqueTestChart, impactedTestChart. Each is {data, insights}, where data is a single-entry map whose series key differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", - "Read the headline number from each chart's `insights.value` (a STRING, e.g. '42') with `insights.metric` as its label \u2014 not by summing the points.", - "Each series point carries x (bucket epoch), y (value, may be null), t and pointRange; the bucket interval is derived from the window, not requestable.", - "Only failed tests are counted; archived and usage-locked builds are excluded, and the failed-status field used depends on the group's new-build-listing flag, so totals can shift when that flag changes." - ] - }, - { - "name": "get_unique_error_filters", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters", - "mode": "read", - "entity": "sp_error", - "intent": "Build the whole filter sidebar for a project's Unique Errors page in one call \u2014 the currently applied selections echoed back as display fields, the static dropdown option sets (failure categories, statuses, platforms, users, teams, sources, folders...), and the project's default hooks visibility.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. Scopes which option values exist. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "search", - "type": "string" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array" - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "branches", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "applied", - "staticFilters", - "hooksVisibility" - ], - "responses": { - "200": { - "$response": "UniqueErrorFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`applied` echoes the caller's current selections resolved into display objects (each option is {id, value, label, count, pId}); `staticFilters` is the full set of selectable option lists for the page.", - "Options are fetched from Elasticsearch and TestHub concurrently and degrade to EMPTY dropdowns on timeout or rejection rather than failing the request \u2014 an empty option list does NOT mean there is no data.", - "This is the errors-page filter surface; do not confuse it with the general build/test sidebar filters. Use the sibling typeahead capabilities (branches / build names / build tags / host names / test tags) for search-as-you-type option lookups." - ] - }, - { - "name": "search_unique_error_filter_branches", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/branches", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the branch filter on the Unique Errors page \u2014 return git branch names in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; applied as a leading+trailing Elasticsearch wildcard." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (this is the QueryRq shape, NOT the full error filter). lowerBound/upperBound singles are NOT accepted here." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Whether manual (Test Management) runs are considered. Defaults to true on this endpoint (unlike build-names, which forces it false)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Uses the compact QueryRq body (query + dateRange + isManuallyOverridden + includeManualRuns) \u2014 NOT the full Unique Errors filter set. dateRange must be exactly two bounds; there is no lowerBound/upperBound fallback.", - "Returns a BOUNDED set under `data` (each {id, value, label, count, pId}), matched by wildcard on `query`; it is a typeahead, not the full branch list.", - "Source fencing is applied, so branches from fenced-off product families may not appear." - ] - }, - { - "name": "search_unique_error_filter_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildNames", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the build-name filter on the Unique Errors page \u2014 return unique build names in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "IGNORED / silently forced to false on this endpoint \u2014 manual (Test Management) runs never appear in build-name suggestions whatever you send." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Unlike its sibling typeahead endpoints this one FORCES includeManualRuns=false server-side, so manual (Test Management) build names are always excluded regardless of the value you pass.", - "It delegates to the shared build-filter service rather than the suite-health filter service, so its result set can differ subtly from the other error-filter typeaheads.", - "Returns a bounded set under `data` (each {id, value, label, count, pId}); it is a typeahead, not the full list. Uses the compact QueryRq shape (no lowerBound/upperBound fallback)." - ] - }, - { - "name": "search_unique_error_filter_build_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildTags", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the build-tag filter on the Unique Errors page \u2014 return build tags used in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; applied as an Elasticsearch wildcard terms lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true; this endpoint does NOT force it false (only build-names does)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard terms lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape (query + dateRange + isManuallyOverridden + includeManualRuns); dateRange must be exactly two bounds with no lowerBound/upperBound fallback." - ] - }, - { - "name": "search_unique_error_filter_host_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/hostNames", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the host-name (CI machine / runner) filter on the Unique Errors page \u2014 return matching host names within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; Elasticsearch wildcard lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false).", - "Runs without a recorded host name aggregate into one option with empty id/value/label (and a count) - skip it when building a host filter." - ] - }, - { - "name": "search_unique_error_filter_test_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/testTags", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the test-tag filter on the Unique Errors page \u2014 return tags attached to tests in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; Elasticsearch wildcard lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false)." - ] - }, - { - "name": "trigger_bulk_test_rca", - "method": "POST", - "path": "/api/v1/ai/builds/{buildUuid}/bulkTestRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "The build's UUID. All failed tests of this build (optionally narrowed by the filter body) are queued for AI RCA." - } - ], - "body": [ - { - "name": "filters", - "type": "object", - "description": "Optional. Same test-listing filter shape (FilterParamsV2) the build's test list uses \u2014 narrows which failed tests are analysed. Omit to target the build's failures as a whole.", - "fields": [ - { - "name": "status", - "type": "array", - "description": "Test statuses to include (e.g. failed). Part of the shared build test-listing filter." - } - ] - }, - { - "name": "limit", - "type": "integer", - "description": "Optional cap on how many failed runs are queued in this request." - } - ], - "intent": "Kick off AI root-cause analysis for all failed tests of a build in one go (the 'Analyse all failures' action) \u2014 optionally narrowed by the test-listing filters and capped by a limit.", - "returns": [ - "state", - "buildUuid", - "bulkRcaUuid", - "trialQuota" - ], - "responses": { - "200": { - "$response": "BulkTestRcaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: this only queues work. The reply's bulkRcaUuid is the handle for the batch and each run's finished RCA arrives later \u2014 poll the individual test runs' RCA (get_test_rca_data) rather than expecting analyses in this response.", - "Plan-fenced: a free group that is not test-level-trial eligible is refused with 403 (PLAN_FEATURE_NOT_ALLOWED). It also requires the group's AI opt-in consent and the test-runs-RCA feature on the AI token; a source-fenced build is refused with AI_FEATURE_FENCED.", - "Needs the caller's own BrowserStack username + Selenium access key, resolved server-side from the caller's user record; a caller whose record lacks them gets 400 'User details are not available'.", - "Disabled entirely in the demo-sandbox environment.", - "The body is optional \u2014 POST with no body queues the build's failures with no extra filtering." - ] - }, - { - "name": "get_test_rca_data", - "method": "GET", - "path": "/api/v1/ai/testRuns/{testRunId}/testRca/data", - "mode": "read", - "entity": "test_rca", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Read an already-generated AI RCA for a test run with NO side effects \u2014 the safe counterpart to get_test_rca that never creates a record or queues a generation.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "bulkRcaId", - "isAgentic", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pure read: no AI consent/feature checks and no generation queued. Use this to poll for a result after triggering one elsewhere.", - "When nothing has been generated yet this is a 400 (BAD_REQUEST, 'Test Run RCA not found for the given test run ID'), NOT a 404 or an empty 200 \u2014 treat that 400 as 'not generated yet', not as a bad request.", - "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found.", - "404 'RCA not found' means no RCA has been generated for this test run yet (not a bad id): generating one is a write (get_test_rca / trigger_test_rca) and needs user confirmation." - ] - }, - { - "name": "get_test_rca", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/testRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "peek", - "type": "boolean", - "default": false, - "description": "peek=true makes the call strictly read-only: it returns the current RCA state and never enqueues a generation. With the default peek=false the GET is get-or-create and CAN queue work \u2014 treat that form as a trigger, not a read." - } - ], - "intent": "Public-API fetch of a failed test run's AI root-cause analysis; by default get-or-create (queues a generation and returns the pending state if none exists), or a strict read with peek=true.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "isLive", - "pendingMessageId", - "alreadyTracked", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the public-API twin of get_test_rca and accepts username + access-key auth. Behaviour splits on peek: peek=true is safe (never enqueues); peek=false (the default) is get-or-create and has a side effect.", - "To poll without triggering, always pass peek=true. The peek-only fields isLive / pendingMessageId / alreadyTracked describe an in-flight generation; gate a re-trigger on isLive (alreadyTracked only reflects whether a chat row holds a handle and is null for web/MCP-started runs).", - "Requires AI opt-in consent and AI enablement; source-fenced groups get AI_FEATURE_FENCED. Resolves the caller's BrowserStack username/access key server-side and 400s if missing.", - "To register an https callback instead of polling, use the POST twin trigger_test_rca.", - "peek=true on a run with NO RCA returns 200 with every field null (id, state, isAgentic, ...), not a 404 - check id/state for null. isAgentic can read false on the first (Pending) response and true once generation starts." - ] - }, - { - "name": "trigger_test_rca", - "method": "POST", - "path": "/ext/v1/testRun/{test_run_id}/testRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "body": [ - { - "name": "webhook_url", - "type": "string", - "maxLength": 2048, - "description": "Optional https callback URL the pipeline POSTs the finished RCA to when a generation is actually queued. https only; localhost/.internal/.local/loopback/private/link-local/multicast hosts are rejected (SSRF-guarded at accept time and re-checked at dispatch). Absent/blank means no callback." - } - ], - "intent": "Public-API trigger for a test run's AI RCA with a fire-and-forget callback: same get-or-queue behaviour as the GET, plus an https webhook the pipeline notifies when the analysis is ready.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "isLive", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "With no body this behaves exactly like get_test_rca with peek=false (get-or-queue). The only added capability is registering webhook_url.", - "The callback fires ONLY when a generation is actually queued \u2014 if the RCA already exists (or is already in flight) no callback is sent, so still read the 200 body's state.", - "webhook_url must be https and pass the SSRF host checks both at accept time and again at dispatch; a URL that resolves to a blocked host is rejected with a 400 naming the reason.", - "The field is named webhook_url on the wire (snake_case), not webhookUrl." - ] - }, - { - "name": "list_build_groups", - "method": "GET", - "path": "/api/v1/projects/{projectId}/build-groups", - "mode": "read", - "entity": "build_group", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "TestHub project id (NOT the observability project id) used by the build-group backend." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "description": "1-based page number." - }, - { - "name": "pageSize", - "type": "integer", - "default": 30, - "description": "Rows per page (passed through to TestHub; server default 30)." - }, - { - "name": "name", - "type": "string", - "description": "Substring name search over run groups." - }, - { - "name": "ids", - "type": "string", - "description": "Comma-separated build-group ids to fetch a specific set instead of the full listing." - } - ], - "intent": "List a project's run groups (build groups) with paging and name search \u2014 the source of run-group ids for linking, closing, editing or deleting; can also fetch a specific set by ids.", - "returns": [ - "data", - "info" - ], - "responses": { - "200": { - "$response": "BuildGroupListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The groups are at the top-level `data` array (there is no `success` wrapper on this route); paging is under `info` (page, pageSize, count, prev, next).", - "By default closed groups are excluded from this listing (the controller requests excludeClosed=true).", - "projectId in the path is the TestHub project id, not the observability project id \u2014 using the wrong one returns an empty/foreign result.", - "Each row's `state` is derived: ACTIVE until closed, then CLOSED. Take the integer `id` for the link/close/edit/delete calls." - ] - }, - { - "name": "create_build_group", - "method": "POST", - "path": "/api/v1/projects/{projectId}/build-groups", - "mode": "write", - "entity": "build_group", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "TestHub project id." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "maxLength": 255, - "description": "Run-group name. Required and must not be blank; the reserved name 'Ungrouped' (any case) is rejected with 400 'Please enter a valid group name'." - }, - { - "name": "description", - "type": "string", - "maxLength": 1000, - "description": "Optional description." - } - ], - "intent": "Create a new run group (build group) in a project so builds can be grouped for a release or test cycle.", - "returns": [ - "success", - "data", - "message" - ], - "responses": { - "200": { - "$response": "BuildGroupOperationResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Requires the project's BUILD_ADD_TO_RUN_GROUP permission.", - "The created group is at `data` (a build-group object with its integer `id`); `success` is true and `message` is 'Build group created successfully'.", - "Creation actually happens in TestHub and is mirrored back \u2014 the returned id is the TestHub group id used by the other build-group calls.", - "'Ungrouped' is reserved and rejected even though it is otherwise a valid name." - ] - }, - { - "name": "list_build_accessibility_test_runs", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/a11y-testRuns", - "mode": "read", - "entity": "accessibility", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "status", - "type": "string", - "description": "Filter by test status." - }, - { - "name": "searchQuery", - "type": "string", - "description": "Substring search over test name." - }, - { - "name": "run", - "type": "array", - "description": "Restrict to specific run ids.", - "fields": [ - { - "name": "", - "type": "integer", - "description": "A run id." - } - ] - }, - { - "name": "page", - "type": "integer", - "description": "Page number (see guidance on 0/1 indexing)." - }, - { - "name": "limit", - "type": "integer", - "description": "Rows per page." - } - ], - "intent": "List the accessibility-scanned tests of one build for the build's Accessibility tab \u2014 per test: name, file, status, OS/browser, a11y score, page/component counts and a critical/serious/moderate/minor issue breakdown, with paging.", - "returns": [ - "testCases", - "pagingParams" - ], - "responses": { - "200": { - "$response": "AccessibilityTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The build must have FINISHED (or TIMEOUT) and carry accessibility data, else a typed 4xx: A11Y_BUILD_NOT_FOUND (no build/no a11y), A11Y_BUILD_STATUS_INVALID (no status), or A11Y_BUILD_NOT_COMPLETED (still running).", - "Feature-flag fork: when the App-Accessibility flag is on for the group AND the build's source is the App-Accessibility automation source, the whole call is proxied to a separate Accessibility service and the RESPONSE SHAPE IS THAT SERVICE'S, not the one described here. Do not assume this shape for App-Accessibility builds.", - "Tests with no a11y summary/severity breakdown are SILENTLY DROPPED from the list, so the row count can be lower than the build's total tests.", - "Paging is re-based to 0-index for the frontend: pagingParams.nextRoot.page is (upstream next page - 1), and pagingParams.hasNext says whether to continue.", - "Accepts a public-link token in addition to the normal auth types (public dashboards)." - ] - }, - { - "name": "get_test_run_accessibility_meta", - "method": "GET", - "path": "/api/v1/testRuns/{btcerId}/a11y-testMeta", - "mode": "read", - "entity": "accessibility", - "path_params": [ - { - "name": "btcerId", - "type": "integer", - "required": true, - "description": "TestHub build-test-case-execution-result (btcer) id \u2014 a plain integer, NOT a test-run TR-NNN identifier." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "description": "Page number. Page size is fixed server-side at 10 and cannot be changed by the caller." - } - ], - "intent": "Accessibility detail for a single test on the Test Details page \u2014 that test's a11y score, total issues and severity breakdown, and its report URL.", - "returns": [ - "totalIssues", - "severityBreakdown", - "reportURL", - "score", - "testCaseUuid" - ], - "responses": { - "200": { - "$response": "AccessibilityTestMetaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Error mapping is LOSSY: every failure except A11Y_SERVICE_USER_UNAUTHORIZED and A11Y_SERVICE_NOT_CONFIGURED is rewritten to A11Y_TESTCASE_RESULTS_NOT_FOUND \u2014 so a genuine upstream error surfaces as 'no results'. Do not read 'not found' as definitely empty.", - "App-Accessibility feature-flag fork again: when the flag is on and the run's source is the App-Accessibility automation source, the call is proxied to a separate Accessibility service and returns that service's shape, not the one described here.", - "Page size is fixed at 10 server-side; only `page` is caller-controllable.", - "reportURL points to the a11y report to fetch elsewhere; the response itself does not embed the full report." - ] - }, - { - "name": "update_code_fix_preference", - "method": "PUT", - "path": "/api/v1/ai/projects/{projectId}/codeFixPreferences/{id}", - "mode": "write", - "entity": "ai_insight", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id. Must belong to the caller's group (IDOR-checked) and must match the group+project the preference row was created under." - }, - { - "name": "id", - "type": "integer", - "required": true, - "description": "Integer id of the code-fix preference row to edit. The row is located by this id alone; buildName in the body does not select it." - } - ], - "body": [ - { - "name": "buildName", - "type": "string", - "required": true, - "description": "Required and must be non-blank (validation), but its value is IGNORED on update \u2014 the row is located by the path id and buildName is never re-written. Send any non-blank string (echo the preference's existing build name)." - }, - { - "name": "instruction", - "type": "string", - "maxLength": 5000, - "description": "Free-text code-fix guidance. Omit (null) to leave the stored instruction unchanged. There is no way to send null to clear it; to end up with no instruction you must keep at least one file attached, else the call is 400." - }, - { - "name": "fileAttachments", - "type": "array", - "maxLength": 1, - "description": "TRI-STATE. Omit (null) = leave existing attachments untouched. Send [] = remove ALL existing attachments. Send a non-empty array = REPLACE them. On replace or remove the previously attached S3 objects are permanently deleted (no undo, no version history). At most ONE attachment (MAX_PREFERENCE_FILES=1).", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "maxLength": 512, - "description": "S3 key of the already-uploaded file from a prior code-fix-preference presign+upload. Confirmed against this group/project; an s3Key that is not confirmable is rejected." - }, - { - "name": "fileName", - "type": "string", - "required": true, - "maxLength": 200 - }, - { - "name": "contentType", - "type": "string", - "required": true, - "maxLength": 100 - } - ] - } - ], - "intent": "Edit an existing AI code-fix preference for a project/build \u2014 change the instruction text and/or replace (or remove) its attached context file.", - "guidance": [ - "fileAttachments is tri-state: null (omit) leaves attachments untouched, [] removes all of them, a non-empty array replaces them. Replacing or removing permanently deletes the old S3 objects \u2014 there is no undo and preferences keep no version history, so an overwritten instruction or removed file is gone for good.", - "The final state must still carry either an instruction or at least one file: a request that would leave the row empty is rejected 400 'At least one of instruction or fileAttachments is required' before any S3 work runs.", - "buildName is required by validation on every update but its value is silently ignored \u2014 the update targets the row named by the path id and never rewrites buildName. Do not use this call to rename the build a preference is bound to.", - "instruction omitted (null) means 'unchanged'; only a non-null instruction overwrites it. Max 5000 characters.", - "Only one attachment is allowed (max 1). Each must reference an s3Key produced by the code-fix-preference presign+upload flow; a key not confirmable for this group/project fails the request.", - "The path id must belong to the SAME group and project as the path projectId, else 400 'Preference does not belong to this project' (not 404). A missing id is 404 code-fix-preferences-not-found.", - "Plan-gated: 403 plan-feature-not-allowed when the AI-TFA fence trips, and per-user AI must be enabled for the caller." - ], - "returns": [ - "id", - "projectId", - "buildName", - "instruction", - "fileAttachments", - "createdBy", - "updatedBy", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "description": "Updated code-fix preference. fileAttachments[].downloadUrl is a freshly presigned GET URL, omitted when its generation failed.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "projectId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "instruction": { - "type": "string" - }, - "fileAttachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "s3Key": { - "type": "string" - }, - "fileName": { - "type": "string" - }, - "contentType": { - "type": "string" - }, - "downloadUrl": { - "type": "string", - "description": "Presigned GET URL for the attachment. Present only when it could be generated (NON_NULL)." - } - } - } - }, - "createdBy": { - "type": "integer" - }, - "updatedBy": { - "type": "integer" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "improve_test_rca", - "method": "POST", - "path": "/ext/v1/ai/testRuns/{test_run_id}/rcaContext/retrigger", - "mode": "write", - "entity": "ai_insight", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id. IDOR-scoped to the caller's group via its build." - } - ], - "body": [ - { - "name": "instruction", - "type": "string", - "maxLength": 5000, - "description": "Extra user instruction to steer the improved RCA. Optional." - }, - { - "name": "attachments", - "type": "array", - "maxLength": 3, - "description": "Up to 3 context files, each already uploaded via the RCA-context presign flow.", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "maxLength": 512 - }, - { - "name": "fileName", - "type": "string", - "required": true, - "maxLength": 200 - }, - { - "name": "contentType", - "type": "string", - "required": true, - "maxLength": 100 - }, - { - "name": "description", - "type": "string", - "maxLength": 500 - } - ] - }, - { - "name": "usePreferences", - "type": "boolean", - "description": "Whether to fold the project's stored code-fix preferences into the retrigger." - }, - { - "name": "webhook_url", - "type": "string", - "maxLength": 2048, - "description": "Optional https callback the pipeline POSTs the finished RCA to. SSRF-validated at accept time and again at dispatch: https only, and NOT localhost/private/link-local/.internal/.local hosts \u2014 a violation is 400. Absent/blank means no callback." - } - ], - "intent": "Re-run ('improve') a test's RCA with an extra instruction and optional attachments, on behalf of an agent acting for the customer \u2014 returns the new conversation message id to poll.", - "guidance": [ - "Precondition-heavy: requires an existing RCA row for the test run, AI opt-in consent, and RCA-v2 eligibility, and the test run must not exceed the age limit \u2014 otherwise it is refused before any generation.", - "Refused with FORBIDDEN_OPERATION when the run is already owned by the MCP-driven agent flow.", - "webhook_url must be https and must not target localhost/private/link-local/.internal/.local hosts; it is validated when accepted and re-validated at dispatch, so a bad URL is a 400 up front.", - "instruction is capped at 5000 chars and at most 3 attachments are accepted; each attachment needs an s3Key from a prior RCA-context presign+upload.", - "Asynchronous: the body carries only messageId (a UUID) \u2014 poll the RCA conversation/turn-status read for the outcome; the RCA itself is delivered elsewhere (the polled read, or the webhook when supplied). trialQuota.used appears only for trial tenants.", - "This is the external mirror of the session-auth internal retrigger; authenticate with the customer's BrowserStack username + access key." - ], - "returns": [ - "messageId", - "trialQuota" - ], - "responses": { - "200": { - "description": "Retrigger accepted; poll the returned messageId.", - "schema": { - "type": "object", - "properties": { - "messageId": { - "type": "string", - "description": "UUID of the new conversation turn to poll." - }, - "trialQuota": { - "type": "object", - "description": "Present (NON_NULL) only for trial tenants.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_split_build_legs", - "method": "GET", - "path": "/api/v1/builds/{uuid}/getSplitBuildDetails", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The PARENT build's 40-char uuid. The child legs are resolved as builds whose parentId is this build's id." - } - ], - "query": [ - { - "name": "pageLimit", - "type": "integer", - "default": 10, - "description": "Page size (child legs per page). Defaults to 10 when omitted. Bound from the query string despite the DTO marking it @JsonIgnore (that only affects JSON body binding, not query binding)." - }, - { - "name": "searchAfter", - "type": "string", - "pattern": "^[0-9a-zA-Z]{40}$", - "description": "Cursor for the NEXT page: the build UUID of the last leg you received, NOT an offset or page number. Must be a 40-char alphanumeric build uuid or the request is rejected. Echoed back to you in pagingParams.searchAfter." - } - ], - "intent": "List the individual legs (child builds) of a split / parallel build with their ids and uuids, so you can offer a leg switcher or aggregate stats across the whole split run.", - "returns": [ - "id", - "name", - "builds", - "pagingParams" - ], - "responses": { - "200": { - "$response": "SplitBuildLegsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pagination is CURSOR-based, keyed by build uuid: read `pagingParams.searchAfter` (the last leg's uuid) from the response and pass it as the next call's `searchAfter`. There is no offset/page-number paging here.", - "Walk pages while `pagingParams.hasNext` is true; hasNext is set from whether the page came back full (>= pageLimit), so a final short page ends the walk.", - "An unknown parent uuid is ObservabilityException(BUILD_NOT_FOUND), not a 200 with an empty list.", - "Each leg in `builds` is a full build-summary object (same shape as get_build_details), so you get every leg's status and counts without a per-leg call.", - "`builds` are the CHILD legs only; the parent build itself is addressed by get_build_details." - ] - }, - { - "name": "get_build_notification_summary", - "method": "GET", - "path": "/api/v1/builds/{id}/summaryLite", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The NUMERIC build id (not the 40-char uuid). This route takes the integer id; passing a uuid will not resolve." - } - ], - "intent": "Get the compact build summary used to render the build-summary email / notification card \u2014 name, project, run-by, status/issue-type/history aggregates, muted and total counts, build alerts and quality-gate status.", - "returns": [ - "uuid", - "name", - "projectName", - "status", - "totalCount", - "mutedCount", - "qualityGateStatus", - "statusStats", - "issueTypeAggregate", - "historyAggregate", - "alerts", - "buildNumber", - "colorMapping" - ], - "responses": { - "200": { - "$response": "BuildNotificationSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes the INTEGER build id, unlike almost every other build endpoint (which take the uuid). Get the integer id from a build read or listing first.", - "An unknown id returns HTTP 200 with a NULL body, not a 404 \u2014 treat an empty/null response as 'build not found', not as an empty summary.", - "This is NOT group-scoped in the lookup: it resolves the build by id alone. Do not rely on it to enforce workspace ownership.", - "This is a different payload from get_build_details / get_build_summary_stats \u2014 it is the trimmed 'email card' shape (BuildSummaryEmail) with formatted duration/date helpers, not the full detail object.", - "`accessibility` is present only for APP_ALLY_AUT builds (populated fail-open) and omitted entirely otherwise; do not treat its absence as a11y failure.", - "`startedAt` is not serialised as a raw timestamp \u2014 the payload exposes formatted date/time/duration helper fields instead." - ] - }, - { - "name": "get_build_stability_tile", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildStabilityStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Any extra query params not bound to the FilterParamsV2 fields are collected into a custom-params map and forwarded to the query. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the stability tile for a build \u2014 the share of tests that ran cleanly versus flaky / always-failing / new-failure noise, with a percentile insight versus recent builds \u2014 for the build insights page.", - "returns": [ - "data", - "keys", - "overview", - "median", - "commentCount" - ], - "responses": { - "200": { - "$response": "BuildStabilityTileResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403 (FORBIDDEN_OPERATION / plan feature not allowed) \u2014 the stability tile is a gated feature.", - "An unknown uuid is BUILD_NOT_FOUND, not an empty tile.", - "This is the stability TILE (single build, based on the last ~30 runs, `overview.meta` says how many) \u2014 not the historical stability chart (that is get_build_stability_chart).", - "This route accepts the full FilterParamsV2 filter set as query params plus arbitrary customParams; filters narrow which of the build's tests feed the stability number.", - "`overview.count` is a percentage string (e.g. \"92.3\") and `overview.insights` is a positive/negative/default verdict versus recent builds \u2014 it is only populated once there are >= 10 comparison runs." - ] - }, - { - "name": "get_build_summary_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildSummary", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the headline test-count strip for a build under the currently applied filters \u2014 total / passed / failed / skipped / pending roll-ups plus AI-RCA and session counts \u2014 the summary strip at the top of the build insights page.", - "returns": [ - "data", - "aiRcaStats", - "isArchived", - "commentCount", - "hooksVisibility", - "manuallyOverriddenCount", - "sessionStats" - ], - "responses": { - "200": { - "$response": "BuildSummaryStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Name-collision trap: this is `/{uuid}/buildSummary` (headline counters, action getBuildStats). It is NOT `/{uuid}` build metadata (get_build_details) and NOT `/{uuid}/getBuildStats` (get_build_non_sdk_test_counts). The similar names back completely different payloads.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "`data` is an array of colored metric tiles (each `{id, name, value, color, testsCount, hooksCount, ...}`), not a flat counts object \u2014 read each tile's `value`.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the counters reflect the applied filters, so an unfiltered total needs an unfiltered call." - ] - }, - { - "name": "get_build_timeline_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildTimelineStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "intent": "Get the build's execution timeline \u2014 per-status series (passed / failed / pending=running) over the build's lifetime \u2014 for the timeline visualisation on the build page.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildTimelineStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No filters apply on this route \u2014 it takes only the uuid; any query params are ignored.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "The payload is a single top-level `data` array of three series (ids passed, failed, pending - pending is the running count), each carrying `[x, value]` pairs where x is milliseconds since the first test started, not an epoch timestamp. Only passed/failed runs are counted." - ] - }, - { - "name": "get_build_details", - "method": "GET", - "path": "/api/v1/builds/{uuid}", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "run", - "type": "integer", - "required": false, - "description": "Optional attempt / run number to scope the summary to a specific re-run attempt. Omit for the latest." - } - ], - "intent": "Get the main build-detail payload for one build \u2014 status, run counts, framework/CI metadata, cross-product and test-plan info \u2014 plus the per-build UI gates (whether to show the Self-Healing tab, the Authoring Agent tab, and whether App Accessibility is enabled).", - "returns": [ - "uuid", - "id", - "name", - "status", - "displayStatus", - "duration", - "statusStats", - "issueTypeAggregate", - "historyAggregate", - "projectId", - "qualityGateStatus", - "showHealingReport", - "showAuthoringReport", - "appAccessibilityEnabled", - "tags", - "tcmTestPlans", - "workflowStatusStats" - ], - "responses": { - "200": { - "$response": "BuildDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "An unknown uuid throws ObservabilityException(BUILD_NOT_FOUND) (a 404-equivalent), not a 200 with an empty body.", - "`showAuthoringReport` and `showHealingReport` are UI gates derived from an S3 key-existence check on the build metadata \u2014 they say whether the report EXISTS to show, not whether you may access it. On demo-sandbox environments `showAuthoringReport` is forced true regardless.", - "`appAccessibilityEnabled` is a per-group feature flag and fails CLOSED (false) if the flag lookup errors \u2014 do not read false as 'definitely no a11y data'.", - "`duration` is the consolidated duration when present, otherwise the raw duration \u2014 the field already resolves this, so use it directly.", - "`displayStatus` is the settled pipeline verdict and is preferred over `status` for the final outcome; `status` can still reflect a live aggregation mid-run.", - "Use the `run` query param to inspect a specific re-run attempt; omitting it returns the latest attempt's summary." - ] - }, - { - "name": "get_build_failure_category_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/defectStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the failure-category (defect) breakdown for a build \u2014 how many failures fall into each issue type/category, how many have AI RCA available, and the comment count \u2014 for the 'Failure Categories' panel.", - "returns": [ - "data", - "aiRcaStats", - "commentCount", - "message" - ], - "responses": { - "200": { - "$response": "BuildFailureCategoryStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "While the build is still PENDING (or has no status yet) this returns HTTP 200 with an EMPTY `data` array, zeroed `aiRcaStats`/`commentCount`, and `message` = 'Failure Categories will be available after your build finishes'. That is a valid 200, not an error \u2014 check `message`/`data` before concluding there are no defects.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Each `data` entry is a category with a `value` count and optional nested `categories` sub-buckets; `aiRcaStats.completedCount` is how many of these failures already have AI RCA.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the breakdown reflects the applied filters." - ] - }, - { - "name": "get_build_failures_by_module", - "method": "GET", - "path": "/api/v1/builds/{uuid}/failureByModules", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get a build's failures grouped by test module / file path, so you can see which modules or suites contribute most of the failures.", - "returns": [ - "data", - "commentCount", - "isConflicting", - "sourceFencingFlags" - ], - "responses": { - "200": { - "$response": "BuildFailuresByModuleResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Each `data` entry is a module node with a `value` (total) and `failedTests` count, plus `parent`/`customPathSplit` so you can rebuild the folder/module tree \u2014 the path separator is inferred per node (`/` or `\\`).", - "`sourceFencingFlags` is present only when the group's source-fencing feature excludes the build's product; when present it names which products' tests were skipped from the aggregation, so counts may be partial.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_filter_values", - "method": "GET", - "path": "/api/v1/builds/{buildId}/getFilters", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (despite the `buildId` name, this is the 40-char uuid, resolved via getBuildbyUuId)." - } - ], - "intent": "Get the distinct filter values available for one build's test listing (browsers, OSes, tags, issue types, statuses, plus static history/flaky options) so the UI can render the filter dropdowns.", - "returns": [ - "filters" - ], - "responses": { - "200": { - "$response": "BuildFilterValuesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the OLDER filter shape. The richer replacement is get_build_test_filters (route `/{buildUuid}/filters`), which additionally returns the currently-applied filters, hooks visibility, split-build children and freemium pro-gating. Prefer that one unless you specifically need this compact shape.", - "Not group-scoped in the lookup (resolves the build by uuid alone).", - "The payload is a single `filters` map keyed by facet name (browser, os, issueType, tags, status, folder, history, flaky), each a list of `{label, value}` options; `history` and `flaky` are static option lists always present.", - "Values are computed from the build's LATEST test runs, so a facet a filter narrowed away will not appear as an option here." - ] - }, - { - "name": "get_build_flaky_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/flakyStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the flaky-tests tile for a build \u2014 how many of the build's tests were detected as flaky (with a 'out of total' meta) plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary: count/meta/commentCount/autoUnmuteCount) as get_build_muted_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the flaky count; `meta` carries the 'out of N' context string.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "list_build_test_folders", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/folder", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (despite the `buildId` name it is the 40-char uuid). WARNING: there is no not-found guard \u2014 an unknown uuid NPEs into a 500, not a 404." - } - ], - "query": [ - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Absolute folder path within the build's test listing. OMIT it (or send blank) to get the paginated ROOT folders with pass/fail roll-ups; provide it to get that folder's details and its immediate sub-folders." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "description": "Page number, used ONLY on the root-folder (no folder_path) branch. Ignored on the sub-folder branch." - }, - { - "name": "size", - "type": "integer", - "default": 30, - "description": "Page size, used ONLY on the root-folder branch. The sub-folder branch ignores it and uses default paging." - }, - { - "name": "after_key", - "type": "string", - "required": false, - "description": "Composite-aggregation cursor for the root-folder branch. Ignored on the sub-folder branch. NOTE: the response's `after_key` is returned null even on the root branch (see guidance)." - }, - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded." - } - ], - "intent": "Browse the folder-tree view of a build's test listing: with no folder_path, list the paginated root folders (with pass/fail roll-ups); with a folder_path, get that folder's details and its sub-folders. All the build's test-listing filters apply.", - "returns": [ - "project_id", - "folders", - "name", - "folder_path", - "cases_count", - "total_cases_count", - "sub_folders_count", - "sub_folders", - "pagingParams", - "after_key" - ], - "responses": { - "200": { - "$response": "BuildTestFoldersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response SHAPE depends on the branch. No `folder_path` -> root shape `{project_id, folders[], pagingParams, after_key}`. With `folder_path` -> folder-detail shape `{project_id, name, folder_path, cases_count, total_cases_count, sub_folders_count, sub_folders[], pagingParams}`. Do not expect `folders` on the detail branch or `sub_folders` on the root branch.", - "There is NO build-not-found guard on this route: an unknown uuid throws an NPE that surfaces as a 500, not a clean 404. Validate the uuid (e.g. via get_build_details) first.", - "Root-folder pagination is composite-aggregation based; walk it with `pagingParams` (and `after_key`/`p`). Note the top-level `after_key` in the response is set to null even on the root branch \u2014 rely on `pagingParams` to page.", - "Passing a `folder_path` on this route ignores `p`/`size`/`after_key` entirely and returns that folder with default paging.", - "The full FilterParamsV2 test-listing filter set (status, tags, users, dateRange, isFlaky, ...) is bound from query params and applied to the folder roll-ups; delegates to TestHub's folder-hierarchy API.", - "A blank/whitespace `folder_path` is treated as 'no path' (root branch); a non-blank but unresolvable path on the detail branch is a 400 'Invalid folder_path'." - ] - }, - { - "name": "get_build_muted_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/mutedStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the muted-tests tile for a build \u2014 how many of the build's tests are muted (with a 'out of total' meta) plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the muted count; `autoUnmuteCount` is how many are scheduled to auto-unmute.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_new_failure_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/newFailureStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the new-failures tile for a build \u2014 how many tests failed in this build that were not failing before \u2014 plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_muted_stats \u2014 only the metric differs. `count` is the new-failure count; `meta` carries the 'out of N' context.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_non_sdk_test_counts", - "method": "GET", - "path": "/api/v1/builds/{uuid}/getBuildStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid. The counts include the build's split child builds." - } - ], - "intent": "Get data-hygiene counters for a build (including its split child builds) \u2014 how many test runs are untitled, how many are unmarked, and how many have no failure reason \u2014 used to nudge non-SDK / untitled instrumentation setup.", - "returns": [ - "untitled_test_count", - "unmarked_test_count", - "missing_failure_reason_count" - ], - "responses": { - "200": { - "$response": "BuildNonSdkTestCountsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Name-collision trap: this is `/{uuid}/getBuildStats` (data-hygiene counters, action getNonSdkUntitledTestCount). It is NOT the build headline counters \u2014 those are `/{uuid}/buildSummary` (get_build_summary_stats).", - "The body is a JSON *string* (a Gson JsonObject serialised via toString), not a JSON object \u2014 the caller must parse the string to read the three keys `untitled_test_count`, `unmarked_test_count`, `missing_failure_reason_count`.", - "Counts span the parent build AND its split child builds combined; there is no per-leg breakdown here." - ] - }, - { - "name": "get_build_insights", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/insights", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID. Looked up group-scoped; an unknown uuid is BUILD_NOT_FOUND (404)." - } - ], - "intent": "One-call failure summary for a build, built for agents and CI bots \u2014 status metrics (test vs hook counts), failure categorisation (new failures / always-failing / flaky), defect stats and a top-errors analysis for the run.", - "returns": [ - "buildId", - "buildName", - "status", - "startedAt", - "finishedAt", - "duration", - "summary", - "failureCategorization", - "failureContext", - "topErrorsAnalysis", - "defectStats" - ], - "guidance": [ - "This is the build-keyed sibling of the test-run buildInsights call. Because no test run is supplied, `failureContext` (the file / parent-module drill-down) is ALWAYS null on this route \u2014 use the test-run variant if you need it.", - "`topErrorsAnalysis.status` is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED. When it is not COMPLETE, `uniqueErrors` may be empty even though the build has failures \u2014 the clustering has not finished, so re-read rather than concluding there are no errors.", - "`summary.statusMetrics` splits every status into `testsCount` vs `hooksCount`; `summary.totalTests` and `summary.totalHooks` are the run-wide totals.", - "`topErrorsAnalysis.uniqueErrors[].truncatedError` is only the first ~200 chars of the error \u2014 an overview string, not the full message. Carry `clusterId` to the unique-errors endpoints for the full text.", - "`defectStats` is keyed by issueType, each with per-category failure test/hook counts." - ], - "responses": { - "200": { - "$schema": "BuildInsights__w4_build4" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_latest_build", - "method": "GET", - "path": "/ext/v1/builds/latest", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project name (normalised internally). Missing \u2192 400 'Parameter project_name is required'. Resolved within the caller's group." - }, - { - "name": "build_name", - "type": "string", - "description": "Exact original build name to match." - }, - { - "name": "user_name", - "type": "string", - "description": "Triggering user's username. If it resolves to no user id, the whole call is BUILD_NOT_FOUND (404) \u2014 not a validation error." - }, - { - "name": "build_tags", - "type": "string", - "description": "Comma-separated build tags; split and trimmed server-side into a tag set." - }, - { - "name": "framework", - "type": "string", - "description": "Test framework name; enum-validated. An unrecognised framework is reported as BUILD_NOT_FOUND (404), not a 400." - } - ], - "intent": "Find the most recent build in a project matching optional filters (build name, triggering user, build tags, framework) and get its full build-details payload \u2014 the entry point for 'the latest run of X' when you don't know the build uuid.", - "returns": [ - "build_id", - "id", - "name", - "build_number", - "status", - "started_at", - "finished_at", - "duration", - "user", - "tags", - "status_stats", - "observability_url", - "tcmTestRunIdentifier" - ], - "guidance": [ - "Always returns a SINGLE build (the page limit is fixed at 1), not a list \u2014 the newest match. No match for the given filters is BUILD_NOT_FOUND (404).", - "The requester must hold automated-test-run view permission on the project (service-account project permission is enforced); otherwise the call is refused.", - "The response shape is feature-flagged per group: the newer build-listing flag returns the V2 build-details envelope, otherwise the v1 one. Both are FURTHER field-gated by the account's plan through the external JsonView wrapper \u2014 a field absent from the body may be plan-gated rather than empty, so do not treat a missing field as 'no value'.", - "Carry the returned `build_id` (the hashed uuid; keys are snake_case and there is no `uuid` key) to the per-build endpoints (insights, reports, history); the display `tcmTestRunIdentifier` is not a build id." - ], - "responses": { - "200": { - "$schema": "ExternalBuildDetails" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_project_builds_by_project_id", - "method": "GET", - "path": "/ext/v1/projects/{project_id}/builds", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric TRA project id." - } - ], - "query": [ - { - "name": "unique_build_names", - "type": "array", - "description": "Filter to these build names." - }, - { - "name": "build_tags", - "type": "array", - "description": "Filter to builds carrying any of these tags." - }, - { - "name": "users", - "type": "array", - "description": "Triggering user ids (numeric)." - }, - { - "name": "frameworks", - "type": "array", - "description": "Test framework names; enum-validated. An invalid framework value is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (numeric)." - }, - { - "name": "build_status", - "type": "array", - "description": "Build display statuses; enum-validated. An invalid status value is a 400." - }, - { - "name": "branches", - "type": "array", - "description": "VCS branch names." - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Restrict to archived (true) or non-archived builds." - }, - { - "name": "is_ci_run_only", - "type": "boolean", - "description": "Restrict to CI-triggered builds." - }, - { - "name": "exclude_lock", - "type": "boolean", - "description": "Exclude locked builds." - }, - { - "name": "date_range", - "type": "array", - "description": "Exactly two epoch-millisecond values [start, end]. OPTIONAL despite the DTO's @NotNull \u2014 this route binds it without validation, so when omitted it DEFAULTS to the last 7 days. Provide both to widen the window.", - "fields": [ - { - "name": "(item)", - "type": "integer" - } - ] - }, - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 pagination cursor from the previous page's `pagination.next_page`. Omit for the first page." - } - ], - "intent": "List a project's builds over the public REST API with status, counts and a deep link back to the TRA UI \u2014 filterable like the UI build listing (names, users, teams, tags, branches, frameworks, statuses, date range) and cursor-paginated.", - "returns": [ - "id", - "name", - "builds", - "pagination" - ], - "guidance": [ - "Cursor pagination only: read `pagination.has_next` and pass `pagination.next_page` back as `next_page` (snake_case keys). There is NO caller-settable page size; walk the cursor until `has_next` is false.", - "The item shape is feature-flagged per group: with the 'new build listing' flag on, `status_stats` holds workflow-status counts and items add `is_manually_overridden`; otherwise v1 status counts. Same keys otherwise.", - "When `date_range` is omitted the window is the LAST 7 DAYS \u2014 older builds simply will not appear unless you pass an explicit [start,end] in epoch millis.", - "Within a page builds come back OLDEST-first: the handler reverses the fetched page (Lists.reverse) before returning.", - "Requires automated-test-run view permission on the project.", - "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls.", - "For 'the latest / last N builds' prefer list_project_builds_by_name (newest-first, `pageLimit`, project-scoped `search`): this route is oldest-first within a page, 10 per page and defaults to the last 7 days, so 'last 10 builds' can cost several calls.", - "Each item carries `build_number` (the serial #N) and, when the CI set one, the CI run id; a user's 'run ' may be the CI run id rather than `build_number`.", - "The filters are NOT the UI set: this route takes users, build_tags, unique_build_names, is_ci_run_only and exclude_lock, while list_project_builds_by_name takes search, sortOrder, statuses, sources and types. Pick the route by the filter you need." - ], - "responses": { - "200": { - "$schema": "ExternalProjectBuildListing" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_project_builds_by_name", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/builds/v2", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "query": [ - { - "name": "statuses", - "type": "array", - "description": "Build display statuses to include (enum)." - }, - { - "name": "sources", - "type": "array", - "description": "Product sources (e.g. Automate / App Automate) (enum)." - }, - { - "name": "types", - "type": "array", - "description": "Build types (enum)." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (numeric)." - }, - { - "name": "frameworks", - "type": "array", - "description": "Test framework names (enum)." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build tags to filter on." - }, - { - "name": "branches", - "type": "array", - "description": "VCS branch names." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond values [start, end] (@Size min=2,max=2).", - "fields": [ - { - "name": "(item)", - "type": "integer" - } - ] - }, - { - "name": "search", - "type": "string", - "description": "Free-text search over builds." - }, - { - "name": "searchAfter", - "type": "string", - "description": "Cursor for the next page (from the previous response's paging params)." - }, - { - "name": "pageLimit", - "type": "integer", - "description": "Page size. Defaults to 10 when omitted. No hard maximum is enforced in this handler \u2014 see could_not_determine." - }, - { - "name": "sortOrder", - "type": "string", - "values": [ - "Asc", - "Desc" - ], - "description": "Sort direction on the build ordering." - }, - { - "name": "state", - "type": "string", - "description": "Build state filter (defaults to `active`)." - }, - { - "name": "isArchived", - "type": "boolean", - "description": "Restrict to archived / non-archived builds." - } - ], - "intent": "The main build-listing endpoint: a filtered, sorted, cursor-paginated page of a project's builds with their status and run counts, for the builds table.", - "returns": [ - "id", - "name", - "builds", - "pagingParams", - "activeCount", - "closedCount" - ], - "guidance": [ - "PREFERRED build listing for agents: newest-first, `pageLimit` sets the page size (keep it at or below 50 \u2014 each row is large and a big page overflows the tool output), `search` filters by build name, and rows carry buildNumber, `buildRunId` (CI run id), statusStats, historyAggregate (flaky / newFailure / alwaysFailing counts) and qualityGateStatus, so one call answers 'last N builds with status'.", - "Takes the project's NORMALISED NAME in the path, not a numeric id. Uses the same large filter object as the build-groups listing.", - "SIDE EFFECT: this call marks the project as subscribed for the calling user \u2014 a read that quietly changes the user's subscription state. It also applies the group's custom data-retention window for the duration of the request, so builds older than the retention window will not appear regardless of `dateRange`.", - "Cursor pagination via `pagingParams` / `searchAfter`; page through until the paging params report no next cursor. `pageLimit` defaults to 10.", - "Each build row is a rich BuildSummary (status, statusStats, run/session counts, quality-gate status, tags, etc.), not the trimmed external shape.", - "This route does NOT accept username+access-key auth (it is not on the /ext/v1 public surface)." - ], - "responses": { - "200": { - "$schema": "ProjectBuildListing" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_running_builds_on_device", - "method": "GET", - "path": "/api/v1/tier/running/builds", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product; mapped to Automate ('automate') vs App Automate (anything else). Missing/empty \u2192 400." - }, - { - "name": "os", - "type": "string", - "required": true, - "description": "Operating system. Missing/empty \u2192 400." - }, - { - "name": "osVersion", - "type": "string", - "required": true, - "description": "OS version. A trailing '.0' is also matched without it. Missing/empty \u2192 400." - }, - { - "name": "device", - "type": "string", - "required": true, - "description": "Device name. Missing/empty \u2192 400." - } - ], - "intent": "Show which of the group's builds are currently queued/running on a specific device+OS combination, with the number of parallel sessions each consumes \u2014 used to explain why a device tier is saturated.", - "returns": [ - "data" - ], - "guidance": [ - "All four query params (product, os, osVersion, device) are REQUIRED \u2014 any missing/empty one is a 400.", - "Only counts test runs in `pending` status for the caller's group; a build with no pending runs on that device+OS will not appear.", - "Returns `{data: []}` (empty list) when nothing matches \u2014 that is a normal 'tier not saturated' result, not an error.", - "Each build in `data` carries `parallelsUsed` \u2014 the count of unique parallel sessions that build is consuming on the combination.", - "This route does NOT accept username+access-key auth.", - "Live 2026-09-30: a running non-SDK App Automate (plain Appium) session on the queried device/OS did NOT appear (data:[]) \u2014 the listing counts TRA test runs in pending status, which non-SDK sessions do not create." - ], - "responses": { - "200": { - "$schema": "RunningBuildsOnDevice" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_user_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/users", - "mode": "read", - "entity": "build_filter", - "intent": "List the users to offer in the build-list sidebar's Users facet - the people who actually triggered builds under the current filter, returned as {id, name} and searchable by name.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the projects of the caller's sub-group.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Substring searched against each user's FULL NAME (wrapped as a *query* wildcard inside Elasticsearch); special characters are escaped. Blank/absent returns the top users by build count." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time. Only projects, query and dateRange from the body affect this route.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is the start, element 1 the end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarUserFilterResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Only `projects`, `query` and `dateRange` from the body affect the result on this route. Every other build-filter field you send (teams, statuses, buildTags, branches, sources, buildIds, ...) is accepted and SILENTLY IGNORED here - it does not narrow the users returned.", - "Each element is an object {id, name} where `name` is the user's full name - NOT a bare string. `id` is the user id you would pass back as a `users` filter value.", - "`query` matches the full name as a substring; ids are aggregated from the builds index, then names are resolved from the database.", - "Results are an unordered, de-duplicated set capped at 100 options. There is no paging on this route, so a filter matching more than 100 users returns only the first 100 the aggregation surfaces.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_tag_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/tags", - "mode": "read", - "entity": "build_filter", - "intent": "List build-tag options for the build-list sidebar's Tags facet - the top tags on builds under the current filter, or the tags matching a search string.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Tag search substring (wrapped as *query* wildcard, special chars escaped). Blank/absent switches to the top-tags-by-frequency path instead of a search." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is start, element 1 end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Two different code paths depending on `query`: a blank/absent query returns the top build tags by frequency (terms aggregation), a non-blank query runs a wildcard substring tag search with highlighting. Elements are bare tag strings either way.", - "Only `projects`, `query` and `dateRange` from the body affect the result. Every other build-filter field in the body is accepted and SILENTLY IGNORED on this route.", - "Results are an unordered, de-duplicated set capped at 100 options; there is no paging, so more than 100 matches returns only the first 100.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_team_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/teams", - "mode": "read", - "entity": "build_filter", - "intent": "List the team options for the build-list sidebar's Teams facet - the BrowserStack teams whose sub-groups own builds under the current filter, returned as {id, name} and optionally filtered by name.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Team-name search substring. NOTE it is applied by the BrowserStack teams service AFTER the sub-group aggregation, not inside Elasticsearch - see guidance." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is start, element 1 end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarTeamFilterResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A request body is REQUIRED even when every field is optional: calling with no body is a 400 'Invalid request payload'. Send at least {} (or {projects:[...]}).", - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Two-step and `query` does NOT filter at the Elasticsearch stage: the sub-group ids that own matching builds are aggregated first (capped at 100), then the BrowserStack teams service resolves those ids to team names and applies `query` to the names. So a very large group can have owning sub-groups beyond the 100 aggregated that never appear.", - "Each element is an object {id, name} (team id + team name) - NOT a bare string. Names come from central/rails team data, not the builds index.", - "Only `projects`, `query` and `dateRange` from the body affect the result; every other build-filter field is accepted and SILENTLY IGNORED here.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_test_case_status_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/testCaseStatuses", - "mode": "read", - "entity": "build_filter", - "intent": "List the manual test-case status options (Test Management's TestResult custom-field values) configured for the selected projects, to offer manual test-case statuses as a build-list sidebar filter.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids. The ONLY body field this route reads; they are translated to Test Management project ids before the lookup. Omit/empty to default to the sub-group projects, then to the group's first 100 projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - } - ], - "returns": [], - "responses": { - "200": { - "$response": "SidebarTestCaseStatusesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only. Only `projects` is read - `query`, `dateRange` and every other body field are ignored, and the result is NOT scoped by the current build filter at all; it returns the configured status options for the projects regardless of which builds exist.", - "Pure pass-through to Test Management's custom-fields API for entity `TestResult`: the response body is the RAW TestHub JSON, not the {data:[...]} envelope the other sidebar facets return. Do not assume a `data` array here.", - "Observability project ids are converted to Test Management project ids first; a project with no Test Management mapping contributes nothing.", - "Unlike the other sidebar facets this route is not exposed to public-link tokens." - ] - }, - { - "name": "list_home_filter_options", - "method": "GET", - "path": "/api/v1/home/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List the selectable values for one dropdown of the Observability home/overview page's personal filters (build status, product source, framework, build tag, unique build name, or folder).", - "query": [ - { - "name": "key", - "type": "string", - "description": "Which filter dropdown's values to return. Required - omitting it fails the call. One of the UserFilterKey values.", - "values": [ - "buildTags", - "uniqueBuildNames", - "folders", - "sources", - "buildStatus", - "frameworks" - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "HomeUserFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`key` is required and must be one of the six UserFilterKey values; a value outside the enum is a 400, and omitting `key` entirely fails the request.", - "`buildStatus` and `sources` are computed purely from enums with no backend call, so they always return the full option set.", - "`frameworks` FAILS OPEN: on an internal error (ES or the fence setup) it returns the full hardcoded framework list rather than erroring. On success it lists only frameworks seen in the caller's builds (plus the saved selection), which can be EMPTY.", - "The build-backed keys (`buildTags`, `uniqueBuildNames`, `folders`) aggregate Elasticsearch scoped to the caller's sub-group, and to visible projects only when UDAC is enabled, returning up to 10000 values. There is no paging - the whole set comes back in one call.", - "Each element is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null here. `folders` labels embed the count ('Language(45)') and can repeat a value across projects, and folders span every project in scope, not one." - ] - }, - { - "name": "list_project_webhook_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectName}/settings/webhooks/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List type-ahead/facet values for a project's webhook rule builder (build names, tags, test names, folders, and other trigger-condition fields), with search and cursor paging - the values behind 'fire only when is ...'.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project whose values to aggregate. Scope is this project only on this route." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", - "values": [ - "build_name", - "build_tag", - "qg_status", - "build_status", - "test_name", - "folder", - "alert_name", - "failure_category", - "smart_tags", - "browser", - "os", - "device", - "alert_severity", - "test_tag", - "test_status" - ] - }, - { - "name": "projectIds", - "type": "string", - "description": "IGNORED on this route - the scope is the path `projectName` only. Use the group-level webhook filter route to scope by projectIds." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response to get the next page." - } - ], - "returns": [ - "data", - "afterKey" - ], - "responses": { - "200": { - "$response": "WebhookFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The value set returned depends entirely on `type`. Only build_name, build_tag, test_name, test_tag, folder, failure_category, alert_name, os, browser, device and test_status are wired; the remaining enum values (qg_status, build_status, smart_tags, alert_severity) return empty `data`.", - "`projectIds` in the query is IGNORED here - scope is the path `projectName`. This is the opposite of the group-level route, which reads projectIds and ignores any project path.", - "Cursor-paged via a composite aggregation: pass the response's `afterKey` back as the `afterKey` query param for the next page. afterKey is set even on the final page, so stop when a page returns fewer rows than the page size or none.", - "Per-page size is 10 for most types and up to 1000 for `folder`. `query` and `afterKey` are each capped at 100 characters.", - "Each element is {label, value, pId, count}. For `folder`, `label` is 'name(count)', `value` is the full path, and `pId` is the parent path; for other types `pId` echoes the value." - ] - }, - { - "name": "list_group_webhook_filter_options", - "method": "GET", - "path": "/api/v1/settings/webhooks/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List type-ahead/facet values for the group-level webhook rule builder across selected projects (build names, tags, and the other supported condition fields), with search and cursor paging.", - "query": [ - { - "name": "type", - "type": "string", - "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", - "values": [ - "build_name", - "build_tag", - "qg_status", - "build_status", - "test_name", - "folder", - "alert_name", - "failure_category", - "smart_tags", - "browser", - "os", - "device", - "alert_severity", - "test_tag", - "test_status" - ] - }, - { - "name": "projectIds", - "type": "string", - "description": "Comma-separated TestHub/Test Management project ids (thProjectId, NOT the TRA project id - a TRA id gives an empty 200). Each is translated to a TO project id before the query. A non-numeric entry fails the request; blank resolves to no projects and empty `data`." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response for the next page." - } - ], - "returns": [ - "data", - "afterKey" - ], - "responses": { - "200": { - "$response": "WebhookFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Gated by a controller-wide entitlement: WEBHOOKS (non-TM groups) or GROUP_WEBHOOKS (TM groups) must be accessible, otherwise the whole controller is refused as feature-inaccessible.", - "`projectIds` is a comma-separated string of TestHub/Test Management project ids (a project's thProjectId, not the TRA id - a TRA id silently yields empty `data`), mapped to TO project ids before the aggregation. A non-numeric entry throws (parse failure) and fails the call; an empty/blank value yields no projects and empty `data`.", - "The value set returned depends entirely on `type`; qg_status, build_status, smart_tags and alert_severity return empty `data`.", - "Cursor-paged like the project route: pass the response's `afterKey` back for the next page. afterKey is set even on the last page, so stop when a page has fewer than the page size (10 for most types, 1000 for `folder`) or is empty. `query` and `afterKey` are capped at 100 characters.", - "Each element is {label, value, pId, count}; for `folder`, `pId` is the parent path and `label` is 'name(count)'." - ] - }, - { - "name": "list_project_build_names", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/buildNames", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug). It is URL-decoded and every '+' is turned back into a space before lookup, so a project named 'My App' is addressed as 'My+App'. A name that matches no project returns 404." - } - ], - "intent": "List the distinct build names seen in a project \u2014 the values that populate the build-name filter dropdown on the build listing.", - "returns": [], - "guidance": [ - "The 200 body is a bare JSON array of strings (the build names), with no wrapper object \u2014 not {success, ...}.", - "Names are drawn only from builds inside the account's retention window, so a very old build's name may not appear even though the build once existed.", - "No product-source filter is applied on this route, so names from every source (Automate, App Automate, SDK, etc.) are merged into one list.", - "The path takes the normalised name, not the numeric project id; '+' in the slug is decoded to a space." - ], - "responses": { - "200": { - "description": "Distinct build names for the project.", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_non_sdk_summary", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/nonSdkSummary", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug); '+' is decoded to a space. Resolved to the project first, so an unknown name returns 404." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs, [lowerBound, upperBound]. OPTIONAL despite the DTO marking it required: when the range (or either bound) is absent the handler substitutes the last 7 days. If you do send it, send both bounds \u2014 a one-element range is rejected.", - "fields": [ - { - "name": "lowerBound", - "type": "integer", - "description": "dateRange[0], epoch millis, inclusive start." - }, - { - "name": "upperBound", - "type": "integer", - "description": "dateRange[1], epoch millis, inclusive end." - } - ] - } - ], - "intent": "Get, over a date range, how many of a project's builds were ingested WITHOUT the BrowserStack SDK plus the resulting data-quality gaps (untitled tests, unmarked tests, tests with no failure reason) \u2014 the numbers behind the 'switch to the SDK' nudge.", - "returns": [ - "nonSDKBuildCount", - "unnamedTestCount", - "unmarkedTestCount", - "missingFailureReasonCount" - ], - "guidance": [ - "Elasticsearch-backed and scoped to a date range: with no dateRange it reports the last 7 days, so a zero here can just mean 'nothing in the last week', not 'nothing ever' \u2014 use get_project_non_sdk_build_count for the unbounded count.", - "Deleted and archived builds (and their tests) are excluded from every count, so the numbers track the live project view rather than raw ingest.", - "The three test-quality counts (unnamedTestCount, unmarkedTestCount, missingFailureReasonCount) come from a separate aggregation over the test index; each is the count of non-SDK tests hitting that gap in the window.", - "The other QueryRq fields (query, isManuallyOverridden, includeManualRuns) are not consulted on this route \u2014 only the date range matters." - ], - "responses": { - "200": { - "description": "Non-SDK build count and data-quality gap counts for the window.", - "schema": { - "type": "object", - "properties": { - "nonSDKBuildCount": { - "type": "integer", - "description": "Builds in the window ingested without the SDK." - }, - "unnamedTestCount": { - "type": "integer", - "description": "Non-SDK tests with no title in the window." - }, - "unmarkedTestCount": { - "type": "integer", - "description": "Non-SDK tests left unmarked in the window." - }, - "missingFailureReasonCount": { - "type": "integer", - "description": "Non-SDK tests with no failure reason in the window." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_non_sdk_build_count", - "method": "GET", - "path": "/api/v1/projects/{projectId}/nonSDKBuildCount", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The observability project id (numeric). Note this route takes the numeric id, not the normalised name that the summary/build-names routes take." - } - ], - "intent": "Get just the count of non-SDK builds in a project \u2014 a cheap, all-time yes/no signal for whether to show SDK-onboarding prompts.", - "returns": [ - "project_id", - "non_sdk_build_count" - ], - "guidance": [ - "The 200 body is a JSON STRING, not a JSON object: the payload {\"project_id\":..,\"non_sdk_build_count\":..} is serialised to a string and returned as-is, so you must JSON-parse the response body a second time before reading the fields.", - "Unbounded in time \u2014 unlike get_project_non_sdk_summary there is no date range, so this counts every non-SDK build ever (excluding deleted/archived builds).", - "Keys are snake_case here (project_id, non_sdk_build_count), unlike the camelCase summary route.", - "The count falls back to 0 rather than erroring when the search returns no total." - ], - "responses": { - "200": { - "description": "A JSON-encoded STRING of {project_id, non_sdk_build_count}. Parse the string to get the object.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_by_id_or_name", - "method": "GET", - "path": "/api/v1/projects/{idOrName}", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "idOrName", - "type": "string", - "required": true, - "description": "Either the numeric project id OR the (normalised) project name \u2014 the handler resolves both. Because this is a catch-all mapping, the literal segments 'lite', 'lite/v2', 'pusherAuth' and any other declared path win first, so you cannot address a project whose name collides with a reserved segment through this route." - } - ], - "intent": "Fetch one project's full resource representation by either numeric id or normalised name \u2014 the project header/settings context (id, name, entry type, configuration, permissions).", - "returns": [ - "projectId", - "projectName", - "entryType", - "configuration", - "organization", - "permissions" - ], - "guidance": [ - "Accepts either the numeric id or the normalised name in the one path segment \u2014 carry whichever you have, no separate lookup needed.", - "configuration.attributes is always present; configuration.subTypes and configuration.alertConfiguration are omitted when null (the DTO is NON_NULL), so treat their absence as 'none configured', not an error.", - "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer.", - "The numeric id must be the TRA project id (e.g. from list_projects_lite_paginated `id`), NOT the TestHub/Test Management id (`thProjectId`): a TestHub id gives 404 and an unknown id can give a bare 500 'Internal System Exception'.", - "`permissions` reflects the API role, which can be broader than what the UI shows for the same user; do not tell the user they can edit settings based on it alone." - ], - "responses": { - "200": { - "description": "Single project resource.", - "schema": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "entryType": { - "type": "string" - }, - "configuration": { - "type": "object", - "properties": { - "attributes": { - "type": "object", - "description": "Map of project attribute key -> value." - }, - "subTypes": { - "type": "object", - "description": "Map of issue type -> list of issue sub-type resources. Omitted when null." - }, - "alertConfiguration": { - "type": "object", - "description": "Project alert config. Omitted when null." - } - } - }, - "organization": { - "type": "string" - }, - "permissions": { - "type": "array", - "description": "Caller's permission entries for this project." - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_full", - "method": "GET", - "path": "/api/v1/projects", - "mode": "read", - "entity": "project", - "intent": "List the caller's group's projects as full records with complete metadata (attributes, issue types, alerts) \u2014 the heavyweight variant to use when you need full project detail rather than a picker payload.", - "returns": [ - "id", - "name", - "userId", - "groupId", - "thProjectId", - "normalisedName" - ], - "guidance": [ - "This is the heaviest of the project-list variants (full entity records, no paging). For pickers, sidebars and dashboards prefer list_projects_lite, list_projects_lite_paginated or list_projects_for_sidebar instead.", - "The group is taken from the session \u2014 there are no parameters; you get exactly the projects the caller's group owns.", - "The 200 body is a bare JSON array of project objects, with no wrapper and no pagination envelope, so the whole group is returned in one response." - ], - "responses": { - "200": { - "description": "All of the group's projects as full records.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "normalisedName": { - "type": "string", - "description": "Derived slug: spaces become '+', '#' becomes '%23', but case and characters such as '&' are kept, so it is not URL-safe - encode it before putting it in a path." - }, - "projectAttributes": { - "type": "array", - "description": "Project attribute rows." - }, - "projectIssueTypes": { - "type": "array", - "description": "Issue-type rows configured on the project." - }, - "alerts": { - "type": "array", - "description": "Alert rows attached to the project." - }, - "appLogFilter": { - "type": "object", - "description": "App-log filter settings: {apps, envs}, each null when unset." - }, - "latestAlerts": { - "type": "array", - "description": "Most recent alert rows for the project." - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_default_project", - "method": "GET", - "path": "/api/v1/projects/lite/default", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "normalisedName", - "type": "string", - "description": "Optional. If given and it resolves, that project is returned; if it does not resolve the call silently falls back to the group's most recently updated project rather than 404-ing." - } - ], - "intent": "Resolve which project the UI should land on: the named project if normalisedName is given and exists, otherwise the group's most recently updated project \u2014 for app load and deep links where the named project may no longer exist.", - "returns": [ - "id", - "name", - "thProjectId", - "normalisedName", - "userId", - "groupId", - "createdAt", - "finishedAt" - ], - "guidance": [ - "Fault-tolerant by design: a normalisedName that does not resolve does NOT 404 \u2014 it falls back to the last-updated project. So a 200 does not confirm the name you asked for exists; compare the returned normalisedName if you need to know.", - "If the group has no projects at all, this returns HTTP 200 with a null body (not 404). Guard for a null/empty response.", - "Returns a single lightweight project object, not a list." - ], - "responses": { - "200": { - "description": "The resolved landing project (lightweight), or null when the group has no projects.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "rename_project", - "method": "PUT", - "path": "/api/v1/projects/{projectId}", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The observability project id to rename." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "The new project name. Trimmed server-side; a blank or whitespace-only name is rejected with 400 ('Project name cannot be empty.'). Sent flat at the top level of the body." - } - ], - "intent": "Rename a project \u2014 use this to change a project's display name (and, as a side effect, its URL slug).", - "returns": [ - "message", - "normalisedName" - ], - "guidance": [ - "Renaming CHANGES THE URL SLUG: the new normalised name becomes the project's slug, so existing links and any saved normalisedName keyed on the old name stop resolving. There is no automatic redirect \u2014 update stored slugs from the returned normalisedName.", - "No true undo: to reverse it you must rename back to the old name, and links broken in the meantime are not restored \u2014 treat it as a write with no history.", - "Blocked in demo-sandbox environments: returns 400 'Cannot rename default project' there.", - "The rename happens in TestHub and is mirrored locally; TestHub's own 400/401/404 are translated into observability errors, so a 404 here can mean TestHub could not find the project.", - "The 200 body carries a human-readable message and the new normalisedName \u2014 read normalisedName, not the message, to rebuild links." - ], - "responses": { - "200": { - "description": "Rename succeeded.", - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "normalisedName": { - "type": "string", - "description": "The project's new URL slug." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "assign_issue_types_to_test_runs", - "method": "PUT", - "path": "/api/v1/projects/{projectId}/updateIssueTypes", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Project id scoping the test runs. Despite the project-scoped path this call updates TEST RUNS, not the project. In TM the id is interpreted as a TestHub project id." - } - ], - "body": [ - { - "name": "issues", - "type": "array", - "required": true, - "description": "The list of (test run -> issue type) assignments to apply. Sent flat at the top level as 'issues'.", - "fields": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id to categorise. The wire key is 'testRunId' \u2014 a plain 'id' will not bind." - }, - { - "name": "issueTypeId", - "type": "integer", - "required": true, - "description": "The issue-type / failure-category id to assign. The wire key is 'issueTypeId' \u2014 not 'issueType'." - } - ] - } - ], - "intent": "Bulk-assign a failure category / issue type to a list of test runs (the 'mark failure reason' action on the failure-analysis screens) and get back how many rows were updated.", - "returns": [ - "updatedCount" - ], - "guidance": [ - "Despite the action's project-scoped path and 'updateProject'-style naming, this updates TEST RUNS \u2014 it does not modify the project itself.", - "Each item's wire keys are testRunId and issueTypeId; a body using id/issueType (the shape some callers assume) silently fails to bind those fields. Send exactly testRunId and issueTypeId.", - "Reversible: re-run with a different issueTypeId to re-categorise, so this is a write, not destructive. There is no per-call undo, though.", - "Rejected with 400 while a project archive is in progress \u2014 retry after the archive completes rather than treating it as bad input.", - "The 200 body is just {updatedCount}: the number of test-run rows actually changed. A count lower than the number you sent means some ids did not match \u2014 it is not an error." - ], - "responses": { - "200": { - "description": "Count of test runs updated.", - "schema": { - "type": "object", - "properties": { - "updatedCount": { - "type": "integer", - "description": "Number of test-run rows whose issue type was updated." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_for_sidebar", - "method": "POST", - "path": "/api/v2/projects/lite", - "mode": "read", - "entity": "project", - "body": [ - { - "name": "queryStr", - "type": "string", - "description": "Optional case-insensitive project-name substring (ILIKE)." - }, - { - "name": "projects", - "type": "array", - "description": "Optional list of project ids (Long) to EXCLUDE from the page - the already-selected projects the picker shows separately (SQL `p.id NOT IN (:selectedProjectIds)`). It does not pin them in. Nulls are dropped.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "A selected project id to leave out." - } - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 1, - "description": "1-based page number. Values <= 0 (and absent) default to 1." - } - ], - "intent": "Populate the build-list sidebar project picker: a page of lightweight project records for the group ordered by most recent build activity, with optional name search and the ability to exclude the already-selected project ids from the page.", - "returns": [ - "data", - "pagingParams" - ], - "guidance": [ - "POST but READ-ONLY \u2014 the body is only search/paging/selection state; nothing is mutated.", - "hasNext is inferred from 'the page came back non-empty', so the last full page still reports has_next=true and next_page = pageNumber+1; the empty page after it is how you learn you are past the end. Stop when data is empty, not when has_next flips.", - "Page size is fixed server-side at 10 records and cannot be changed; a short page (under 10 rows) is the last one, even though has_next is still true.", - "Pagination keys are snake_case here (has_next, next_page under pagingParams) \u2014 different from the GET lite/aggregate routes which use hasNext/searchAfter.", - "Each row is the sidebar-lite shape (thProjectId, id, displayName, normalisedName) \u2014 note displayName, not name. This is distinct from list_projects_lite_paginated (a GET with offset paging and no id-pinning).", - "`projects[]` EXCLUDES the listed ids from the result (the picker renders selected projects itself); pass it only to leave those out. Combining a search with a selected id that matches it returns an empty page." - ], - "responses": { - "200": { - "description": "A page of sidebar-lite projects plus pagination.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "thProjectId": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "displayName": { - "type": "string" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "pagingParams": { - "type": "object", - "properties": { - "has_next": { - "type": "boolean", - "description": "True whenever the page was non-empty; not a true 'more rows exist' signal." - }, - "next_page": { - "type": "string", - "description": "pageNumber+1 as a string, or null when has_next is false." - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_lite_paginated", - "method": "GET", - "path": "/api/v2/projects/lite/v2", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "search", - "type": "string", - "description": "Optional project-name search." - }, - { - "name": "searchAfter", - "type": "integer", - "default": 1, - "description": "Despite the cursor-style name this is a PLAIN OFFSET (integer). Defaults to 1 when absent. In demo-sandbox environments it is forced to 0. Advance it using the value returned in pagingParams.searchAfter." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route \u2014 paging is driven by searchAfter." - } - ], - "intent": "Get a paginated, optionally name-searched list of the group's projects in lightweight form (id, name, normalised name, TestHub id) for pickers and listing pages that do not need per-project metrics.", - "returns": [ - "projectLiteList", - "pagingParams" - ], - "guidance": [ - "PREFERRED route to resolve a project by name: pass `search` with the user's words; the exact match is usually on the first page. The row carries the TRA `id` (for integer-id routes), `normalisedName` (for name routes) and `thProjectId` (TestHub/TM id, NOT accepted by TRA routes).", - "searchAfter is an OFFSET, not a cursor \u2014 the name is misleading. Page by reading pagingParams.searchAfter back into the next request; hasNext is computed by over-fetching one row.", - "Page size is hardcoded at 100 and cannot be changed.", - "In demo-sandbox environments the offset is forced to 0, so you always get the first page there regardless of searchAfter.", - "Ordered by most recent build activity (build_updated_at desc). The 200 wraps the rows under projectLiteList with pagingParams alongside \u2014 not a bare array.", - "Overlaps heavily with list_projects_for_sidebar (POST /api/v2/projects/lite): that one pins selected ids and pages at size 10; this one does neither and pages at 100." - ], - "responses": { - "200": { - "description": "A page of lite projects plus pagination.", - "schema": { - "type": "object", - "properties": { - "projectLiteList": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "The offset to send as searchAfter on the next request." - }, - "nextRoot": { - "type": "object" - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_with_build_aggregates_paginated", - "method": "GET", - "path": "/api/v2/projects/v2", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "search", - "type": "string", - "description": "Optional project-name search." - }, - { - "name": "searchAfter", - "type": "integer", - "default": 1, - "description": "A PLAIN OFFSET (integer) despite the cursor-style name. Defaults to 1 when absent; forced to 0 in demo-sandbox. Advance using pagingParams.searchAfter from the response." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route." - } - ], - "intent": "Back the Projects landing page: the same paginated project list as the lite paginated variant but enriched with per-project build aggregates (build counts/status roll-ups, stability) from Elasticsearch, so each project card can show its health at a glance.", - "returns": [ - "projectWithAggregates", - "pagingParams" - ], - "guidance": [ - "Heavier than list_projects_lite_paginated because it runs an aggregate pass over builds for the page \u2014 use it only when you need the per-project numbers.", - "Same paging quirks as the lite paginated route: searchAfter is an offset (not a cursor), page size is hardcoded at 100, hasNext is computed by over-fetching one row, and the offset is forced to 0 in demo-sandbox.", - "When the project page is empty the response still returns 200 with an empty projectWithAggregates list (no aggregate pass is run).", - "The 200 wraps rows under projectWithAggregates with pagingParams alongside \u2014 not a bare array." - ], - "responses": { - "200": { - "description": "A page of projects with build aggregates plus pagination.", - "schema": { - "type": "object", - "properties": { - "projectWithAggregates": { - "type": "array", - "items": { - "$schema": "ProjectWithAggregates" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "The offset to send as searchAfter on the next request." - }, - "nextRoot": { - "type": "object" - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_health_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests", - "mode": "read", - "entity": "sp_test", - "intent": "List a project's tests on the legacy Test Health surface \u2014 one row per unique test (stability hash) over a date range, with its reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find the least reliable tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "description": "Window as EXACTLY two epoch-millis values [start, end]. NOT validated on this legacy route (no @Valid), so omitting it does not 400 \u2014 the window then falls back to lowerBound/upperBound, and with neither the query is unbounded and slow. Prefer dateRange.", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis). Used only when dateRange is absent." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis). Used only when dateRange is absent." - }, - { - "name": "search", - "type": "string", - "description": "Substring match on test name." - }, - { - "name": "status", - "type": "array", - "description": "Test statuses to include." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array", - "description": "Numeric user ids." - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Numeric user ids of last executor. Capped at 500 entries (@Size max 500) \u2014 more is a 400.", - "maxItems": 500 - }, - { - "name": "teams", - "type": "array", - "description": "Numeric team ids." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "When true (pro accounts only), keeps only tests with a non-zero flaky count." - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "isMuted", - "type": "string", - "description": "Muted-tests visibility. One of the three tokens below; anything else is treated as ALL.", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column. Honoured only for pro accounts; free accounts are forced to reliability. Recognised: reliability, average (duration), totalFailures." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. 10 tests per page." - }, - { - "name": "offset", - "type": "integer", - "description": "Accepted but internally capped at 100 (getRestrictedOffset)." - } - ], - "intent_notes": "", - "returns": [ - "pagingParams", - "tests", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "guidance": [ - "This is the OLDER Test Health generation. For current behaviour (validated window, public-link sharing, workflow-status-aware failure counts) use list_suite_health_tests instead.", - "reliability is a FAILURE percentage (failed*100/total) \u2014 HIGHER means LESS stable. duration is the average run duration. Neither is a 0-100 stability score.", - "Free (non-pro) accounts get a truncated result: only the single least-reliable test is returned, and orderKey/orderValue are ignored (forced to reliability desc).", - "Paged by pageNumber at 10 per page; walk pages until pagingParams.hasNext is false. There is no per-page size control (offset is capped at 100 and does not set the page size).", - "isMuted is a plain query token, not a boolean: send \"true\"/\"false\"/\"all\".", - "Auth: central-auth, obs-token, service-key, rails-basic or speedboat-token. Unlike the Suite Health list this route is NOT reachable with a public-link token.", - "ES-aggregation backed and internally paginated (loops pages until the slice is filled), so wide date ranges are slow.", - "browsers and platforms in each row are empty on this listing path \u2014 read browserList and platformList for the platform combinations." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_suite_health_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests", - "mode": "read", - "entity": "sp_test", - "intent": "List a project's tests on the current Suite Health surface \u2014 one row per unique test (stability hash) over a required date range, with reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find flaky, always-failing or slow tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as EXACTLY two epoch-millis values [start, end]. REQUIRED and validated here (@NotNull @Size(min=2,max=2)) \u2014 missing it, or a length other than 2, is a 400.", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "search", - "type": "string", - "description": "Substring match on test name." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "description": "Product sources to include." - }, - { - "name": "users", - "type": "array" - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Numeric user ids; capped at 500 (@Size max 500).", - "maxItems": 500 - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "hooksVisibility", - "type": "string", - "description": "Whether test hooks are included." - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, failure counts are sourced from the recorded automation status rather than workflow status." - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column (pro only). Recognised: reliability, average (duration), totalFailures." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. 10 tests per page." - } - ], - "returns": [ - "pagingParams", - "tests", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "guidance": [ - "Any query key this route does not recognise is SWALLOWED into customParams, never rejected \u2014 so a misspelled filter name is silently ignored and widens the result instead of erroring. Check spelling against the parameter list.", - "reliability is a FAILURE percentage (failed*100/total), HIGHER = less stable; duration is the average run duration.", - "A build-less-but-existing project still resolves (it falls back to its Postgres id and returns an honest empty result). Only a genuinely absent project is a 404 PROJECT_NOT_FOUND.", - "When source feature-fencing hides tests, the response sets automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 an empty-looking list may just be fenced, not truly empty.", - "Free (non-pro) accounts get only the single least-reliable test, with sorting forced to reliability desc.", - "Paged by pageNumber at 10 per page; stop when pagingParams.hasNext is false. No per-page size control.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token, and (unlike the Test Health list) public-link-token \u2014 so this listing can back a shared public link.", - "browsers and platforms per row are empty on this path; use browserList and platformList." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_health_stats", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/stats", - "mode": "read", - "entity": "sp_test", - "intent": "Get the aggregate stat tiles for one test on the legacy Test Health detail view \u2014 reliability (failure %), average duration and failed/total run counts \u2014 optionally narrowed to one browser/OS/device.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (the id field from a test-listing row)." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ] - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis)." - }, - { - "name": "buildName", - "type": "string", - "description": "Restrict to a single build name." - } - ], - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "guidance": [ - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "reliability is a failure percentage (higher = worse); duration is the average run duration; failureOccurrence carries {failed, total} raw counts.", - "Failure count on this legacy route is always the raw status count. The Suite Health equivalent (get_suite_health_stats) instead uses the workflow-status count when new build listing is enabled, so the two can disagree for the same test.", - "Any backend error is rethrown as a bare RuntimeException and surfaces as HTTP 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." - ], - "responses": { - "200": { - "$response": "SuiteHealthStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_stats", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/stats", - "mode": "read", - "entity": "sp_test", - "intent": "Get the aggregate stat tiles for one test on the Suite Health detail page \u2014 reliability (failure %), average duration and failed/total run counts over the selected window.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash sent in the query." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "guidance": [ - "stabilityHash comes from the PATH and overrides any value in the query string.", - "Failure count uses the workflow-status aggregation when new build listing is enabled for the group, otherwise the raw status count \u2014 so it can differ from the legacy get_test_health_stats for the same test and window.", - "reliability is a failure percentage (higher = worse); duration is average run duration; failureOccurrence = {failed, total}.", - "Unknown query keys are swallowed into customParams rather than rejected.", - "Any backend error surfaces as a bare 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthStatsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_health_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/trend", - "mode": "read", - "entity": "sp_test", - "intent": "Get the per-execution trend line for one test on the legacy Test Health detail view \u2014 each run's status colour, duration and build context over the window \u2014 to plot stability and duration over time.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (read from the path on this route)." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ] - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis)." - }, - { - "name": "buildName", - "type": "string", - "description": "Restrict to one build name." - }, - { - "name": "browser", - "type": "string" - }, - { - "name": "os", - "type": "string" - }, - { - "name": "device", - "type": "string" - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." - } - ], - "returns": [ - "keys", - "data" - ], - "guidance": [ - "Response is {keys, data}: keys is the parallel array of startedAt epochs, data is the points. Each point is MINIMAL \u2014 {color, y (duration), id (test-run id), buildId (serial id), buildName}. There are NO smart-tag flags and no workflow status here; use get_suite_health_trend for those.", - "allBuilds and isFlaky are accepted by the endpoint but silently dropped (never passed to the service) \u2014 do not rely on them to filter.", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Points are ordered oldest-first (startedAt asc) and capped at 5000 executions with no paging.", - "Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." - ], - "responses": { - "200": { - "$response": "TestHealthTrendResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/trend", - "mode": "read", - "entity": "sp_test", - "intent": "Get the per-execution time series for one test on the Suite Health detail page \u2014 each run's status, workflow status, duration, build context and smart-tag flags (flaky / newly failed / always failing / performance anomaly) \u2014 for the trend chart.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. This handler declares no @PathVariable and never calls setStabilityHash \u2014 the {stabilityHash} path segment is bound into the filter object by ExtendedServletRequestDataBinder (it binds URI path vars onto the command object). Supply it in the path as the route requires." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "browser", - "type": "string" - }, - { - "name": "os", - "type": "string" - }, - { - "name": "device", - "type": "string" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, only the most recent run per build is kept in the series." - } - ], - "returns": [ - "keys", - "data" - ], - "guidance": [ - "Response is {keys, data}: keys is the parallel array of startedAt epochs; data points are RICH \u2014 {color, workflowStatus, y (duration), id (test-run id), buildId (serial id), buildName, isFlaky, isAlwaysFailing, isNewlyFailed, isPerformanceAnomaly, flakyReason, creationType}. Null-valued fields are omitted (JsonInclude NON_NULL).", - "Unlike get_test_health_trend, this carries the smart-tag flags and workflow status \u2014 prefer it when you need them.", - "This handler relies on the data binder for stabilityHash rather than an explicit @PathVariable; the source inventory's claim that the path segment is ignored does not hold under ExtendedServletRequestDataBinder. Pass stabilityHash in the path.", - "Points are ordered oldest-first and capped at 5000 executions with no paging. With isAutomationLatest=true the series is deduped to the latest run per build.", - "Unknown query keys are swallowed into customParams, not rejected. Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthTrendResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_test_info", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/info", - "mode": "read", - "entity": "sp_test", - "intent": "Get the header metadata for one test's Suite Health detail page \u2014 the test's display name plus the list of OS / browser / device combinations it has executed on \u2014 used to populate the platform filter.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "name", - "combinations" - ], - "guidance": [ - "Returns {name, combinations}. name is the test's display name from a sample run \u2014 an empty string \"\" when no run matches the window (not null, not an error).", - "combinations lists the platform combos the test ran on, each {os, browser} where browser also nests its device. Combos whose browser and device are both non-displayable are filtered out, so the list can be shorter than the raw platform set.", - "stabilityHash from the path overrides anything sent in the query. Unknown query keys are swallowed into customParams.", - "Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token.", - "This is the Suite Health platform/identity header \u2014 distinct from the get_test_base_info capability." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestInfoResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_test_platform_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/breakdown", - "mode": "read", - "entity": "sp_test", - "intent": "Break one test's stability and performance down per OS / browser / device combination, so you can see which platform is driving its flakiness or slowness.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "[]" - ], - "guidance": [ - "The 200 body is a BARE JSON ARRAY (no wrapping object), one row per OS/browser/device combination, sorted by reliability descending.", - "Each row's id is the composite \"-\" string, and carries reliability (failure %), duration (average), totalFailures {failed,total}, plus os / browser / browserDevice objects for display.", - "Rows whose browser and device are both non-displayable are dropped, so the array can be shorter than the raw combination set.", - "stabilityHash from the path overrides the query string. Unknown query keys are swallowed into customParams.", - "Any service-level error is rethrown as a bare RuntimeException \u2192 HTTP 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthPlatformBreakdownResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_suite_health_test_runs", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds", - "mode": "read", - "entity": "sp_test", - "intent": "List the individual executions of one test across builds \u2014 per-run status, duration, smart tags (flaky / newly failed / always failing / performance anomaly) and build context \u2014 for the run-history table on the Suite Health detail page.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (set from the path onto the filter object)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "start", - "type": "integer", - "description": "Pagination cursor (epoch millis). Pass the pagingParams.start returned by the previous page to fetch the next, older page; runs are returned newest-first and start acts as the exclusive upper time bound. Omit for the first page." - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, keeps only the latest run per build within the page." - } - ], - "returns": [ - "pagingParams", - "builds" - ], - "guidance": [ - "Cursor-paged, NOT page-numbered: 20 runs per page, newest-first. To get the next (older) page, pass the pagingParams.start from the previous response as the `start` query param.", - "The offset field inside pagingParams is a fixed placeholder (always 20) kept only for the FE contract \u2014 the backend does not read it; drive paging with start.", - "pagingParams.hasNext is true whenever the page returned any rows; a subsequent empty page is how you know you have reached the end.", - "Smart-tag flags for each run are NOT top-level \u2014 they are under the run's historyAggregate object {isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly}, resolved per run from that build's setting version.", - "Two-stage internally: Elasticsearch resolves the matching test-run ids, then per-run detail (status, duration, CI/VCS info, PM-tool links) is read from the test-run store.", - "Unknown query keys are swallowed into customParams. Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_overview", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testOverview", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id (the numeric BTCer id, not a TR-NNN string)." - } - ], - "intent": "Get the Overview-tab payload for a single test run \u2014 framework/SDK and platform context (browser, OS, device), the BrowserStack session id, user and BrowserStack tags, and the run's start time.", - "guidance": [ - "runInfo is a NESTED object ({duration, browser, os, device}); duration, browser, os and device do NOT appear at the top level even though they read like headline attributes \u2014 read them under runInfo.", - "sessionId is not always a plain BrowserStack Automate session id: for App Automate non-SDK frameworks it is resolved differently, so do not assume it addresses the Automate session API directly.", - "bsTags is the BrowserStack-side tag list (array of objects); userTags is the plain string array the user set \u2014 they are two different fields.", - "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ], - "returns": [ - "runInfo", - "sessionId", - "userTags", - "bsTags", - "isRealDevice", - "startedAt", - "unmuteConditionConfig" - ], - "responses": { - "200": { - "description": "Overview payload for the test run.", - "schema": { - "type": "object", - "properties": { - "runInfo": { - "type": "object", - "description": "Nested platform block; duration/browser/os/device live here, not at top level.", - "properties": { - "duration": { - "type": "integer", - "description": "Run duration in ms." - }, - "browser": { - "type": "object", - "description": "Formatted browser descriptor." - }, - "os": { - "type": "object", - "description": "Formatted platform/OS descriptor." - }, - "device": { - "description": "Device name, or null for non-real-device runs." - } - } - }, - "sessionId": { - "type": "string", - "description": "BrowserStack session id; resolved differently (not a plain Automate id) for App Automate non-SDK frameworks." - }, - "userTags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "User-set tags." - }, - "bsTags": { - "type": "array", - "items": { - "type": "object" - }, - "description": "BrowserStack-side tag objects." - }, - "isRealDevice": { - "type": "boolean" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "unmuteConditionConfig": { - "type": "object", - "description": "Auto-unmute condition config for the run, when set." - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_network_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "default": false, - "description": "When true, keep only HAR entries whose startedDateTime falls within the test's start/finish window. Default false returns all session entries." - } - ], - "intent": "Get a test run's network (HAR) entries parsed server-side \u2014 obs-api downloads the HAR, filters it to the test window and returns the request entries so the Network tab renders without the browser downloading the raw file.", - "guidance": [ - "Returns {log:{entries:[\u2026HAR entries\u2026]}, responseOrigin} \u2014 NOT the raw HAR envelope. When the HAR exceeds the network-log size limit the download is skipped: entries comes back EMPTY and a meta:{isTruncated:true} flag is added \u2014 fall back to get_test_run_network_log_url for oversized logs.", - "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration \u2014 treat that as 'no logs', not an error.", - "This is the server-parsed counterpart of get_test_run_network_log_url; prefer the URL read when you only need the pointer or the file may be large.", - "In non-prod a Redis key can make it return mocked network logs." - ], - "returns": [ - "log", - "responseOrigin", - "meta" - ], - "responses": { - "200": { - "description": "Parsed network logs, or null when the run has no BrowserStack integration.", - "schema": { - "type": "object", - "properties": { - "log": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "type": "object" - }, - "description": "HAR entries; empty when the file was oversized (see meta.isTruncated) or absent." - } - } - }, - "responseOrigin": { - "type": "string", - "description": "Where the logs were sourced from, e.g. 'observability' or 'bstack'." - }, - "meta": { - "type": "object", - "description": "Present ONLY when the HAR was too big to parse.", - "properties": { - "isTruncated": { - "type": "boolean" - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_network_log_url", - "method": "GET", - "path": "/api/v1/testRuns/v2/{testRunId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "default": false, - "description": "When true, ALSO return testStartedAt/testFinishedAt so the client can window the HAR itself. It does NOT filter the file server-side (that is get_test_run_network_logs)." - } - ], - "intent": "Get the pointer to a test run's network (HAR) log rather than its content \u2014 the S3/BrowserStack URL plus status code and message, so the client fetches (or skips) the file itself.", - "guidance": [ - "Returns {networkLogs:, status_code, message}. The URL key is `networkLogs`, NOT `url`.", - "When the file is oversized a meta:{isTruncated:true, fileSize:} block is added so the UI can show the long-session notice instead of downloading \u2014 the URL is still returned alongside it.", - "trim=true adds testStartedAt/testFinishedAt to the body but does not trim the file; use get_test_run_network_logs when you want the entries filtered server-side.", - "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration. In non-prod a Redis key can make it return mocked network logs." - ], - "returns": [ - "networkLogs", - "status_code", - "message", - "testStartedAt", - "testFinishedAt", - "meta" - ], - "responses": { - "200": { - "description": "Network-log descriptor, or null when the run has no BrowserStack integration.", - "schema": { - "type": "object", - "properties": { - "networkLogs": { - "type": "string", - "description": "S3/BrowserStack URL of the raw HAR (the log content is fetched from here)." - }, - "status_code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "testStartedAt": { - "type": "string", - "nullable": true, - "description": "Present only when trim=true. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." - }, - "testFinishedAt": { - "type": "string", - "nullable": true, - "description": "Present only when trim=true; null when the run has not finished. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." - }, - "meta": { - "type": "object", - "description": "Present ONLY when the HAR is oversized.", - "properties": { - "isTruncated": { - "type": "boolean" - }, - "fileSize": { - "type": "integer", - "description": "HAR size in bytes." - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_detail_tabs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/tabs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "List which tabs the test-detail page should render for a run \u2014 Overview, Behaviour, Logs, Network, Test code, Test cases and so on \u2014 computed from the run's data, the build's framework and whether it is a BDD build.", - "guidance": [ - "The tab list is DERIVED per run, not stored; each element is {label, value} and the set varies from run to run.", - "A missing/unknown test run surfaces here as a 500 (an internal null-pointer), NOT a typed TEST_RUN_NOT_FOUND \u2014 do not read the 500 as an outage.", - "Use this as a UI hint: a tab's presence implies the corresponding data read is worth calling for this run, its absence that the run has nothing for it." - ], - "returns": [ - "label", - "value" - ], - "responses": { - "200": { - "description": "Ordered list of tabs to render.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Display label for the tab." - }, - "value": { - "type": "string", - "description": "Tab key (Overview, Behaviour, Logs, Network, TestCode, TestCases, \u2026)." - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_linked_test_cases", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testCases", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "searchAfter", - "type": "integer", - "description": "Offset cursor. Pass the searchAfter value returned in the previous response's pagingParams. Defaults to 1 server-side when omitted; forced to 0 in the demo sandbox." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Opaque JSON paging token; echo back the previous response's pagingParams.nextRoot unchanged. Optional." - } - ], - "intent": "List the Test Management test cases linked to a test run (its BTCer test cases), paginated, for the Test cases tab of the test-detail page.", - "guidance": [ - "Page size is FIXED at 100 server-side \u2014 there is no page-size parameter. Walk pages while pagingParams.hasNext is true.", - "The cursor is an OFFSET that advances by 1 per page (the response's pagingParams.searchAfter = current offset + 1), NOT by the page size of 100 \u2014 echo the returned pagingParams rather than computing your own offset.", - "In the demo sandbox the offset is forced to 0, so paging is effectively disabled there.", - "The linked cases are under `testCases`; `pagingParams` carries hasNext, searchAfter and nextRoot for the next call." - ], - "returns": [ - "pagingParams", - "testCases" - ], - "responses": { - "200": { - "description": "A page of linked test cases plus the cursor for the next page.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "Offset for the next page (current offset + 1)." - }, - "nextRoot": { - "type": "object", - "description": "Opaque paging token map.", - "nullable": true - } - } - }, - "testCases": { - "type": "array", - "items": { - "type": "object", - "description": "BtcerTestCases entry \u2014 a TM test case linked to the run (identifier, name, status and TM links; keys vary)." - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_test_code", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testCode", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Get the source location of the test that produced a run \u2014 the file name and a deep link into the connected VCS (GitHub/GitLab/etc.) built from the build's VCS info \u2014 for the Test code tab.", - "guidance": [ - "The `url` deep link depends on VCS-integration data captured on the build; without it only `filename` is meaningful and `url` may be empty.", - "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build is refused with FORBIDDEN_OPERATION.", - "`code`, `language` and `type` are populated only when the source was captured; often only `filename` is set. `testRunId` comes back null - keep the id you asked with." - ], - "returns": [ - "language", - "code", - "filename", - "url", - "testRunId", - "type" - ], - "responses": { - "200": { - "description": "Test-code descriptor for the run.", - "schema": { - "type": "object", - "properties": { - "language": { - "type": "string", - "nullable": true - }, - "code": { - "type": "string", - "description": "Source of the test, when available.", - "nullable": true - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string", - "description": "VCS deep link; empty when the build has no VCS integration." - }, - "testRunId": { - "type": "integer", - "nullable": true, - "description": "Null on this route (not an echo of the requested id)." - }, - "type": { - "type": "string", - "nullable": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_media_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testDetails", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Get the media/session block for a test run's detail page \u2014 the session video (with playback offsets), audio log, the BrowserStack session and Percy build URLs, framework, whether the run is SDK-based, plus custom metadata.", - "guidance": [ - "Despite the /testDetails route this returns the MEDIA + session-link block (videoLogs, audioLogs, browserstackSessionUrl, percyBuildUrl, framework, isSdk, customMetadata), NOT the run's metadata \u2014 use get_test_run_meta for identity/status/history.", - "videoLogs carries startOffset/finishOffset/startTimeStamp for aligning playback with the log timeline; audioLogs and percyBuildUrl are present only when the run actually has them.", - "testStartedAt drives the FE's 30-day-expired empty state and buildId feeds the long-session cURL fallback \u2014 they are context flags, not log content. buildId can be null even for a real SDK run.", - "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build returns FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED rather than partial data." - ], - "returns": [ - "videoLogs", - "audioLogs", - "browserstackSessionUrl", - "percyBuildUrl", - "isSdk", - "device", - "framework", - "customMetadata", - "testStartedAt", - "buildId" - ], - "responses": { - "200": { - "description": "Media/session block for the test run.", - "schema": { - "type": "object", - "properties": { - "videoLogs": { - "$schema": "ObsSessionVideo" - }, - "audioLogs": { - "description": "Audio-log descriptor; null when absent.", - "nullable": true - }, - "browserstackSessionUrl": { - "type": "string" - }, - "percyBuildUrl": { - "type": "string", - "description": "Percy build URL; null when the run has no Percy build.", - "nullable": true - }, - "isSdk": { - "type": "boolean" - }, - "device": { - "type": "string" - }, - "framework": { - "type": "string" - }, - "customMetadata": { - "description": "Opaque custom metadata attached to the run.", - "nullable": true - }, - "testStartedAt": { - "type": "string", - "description": "Drives the FE 30-day-expired empty state for network logs.", - "nullable": true - }, - "buildId": { - "type": "string", - "description": "Hashed BrowserStack build id for the long-session cURL fallback; can be null even for an SDK Automate run with a session.", - "nullable": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_meta", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testMeta", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "excludeHistory", - "type": "boolean", - "default": false, - "description": "When true, skip execution-history assembly (much cheaper). Use when only the header is needed; fetch history separately via list_test_run_execution_history." - } - ], - "intent": "Get the primary metadata blob for a single test run \u2014 identity (name, file/test-case identifier), status, flakiness/mute flags, issue type and RCA data, retries, build context and, unless excluded, its recent execution history \u2014 feeding the header of the test-detail drawer.", - "guidance": [ - "excludeHistory=true skips history assembly and is much cheaper \u2014 use it when you only need the header; the history is separately available from list_test_run_execution_history.", - "The test-case identifier is OVERWRITTEN with the third-party (TCM/Jira) identifier when the case has one, so testCase.identifier may not be the native TC-NNN id.", - "`status` comes back NULL when the run is retention-locked (the run itself is still returned) \u2014 do not read a null status as 'no status recorded'.", - "`history` is [] when excludeHistory=true and `retries` is null when the test has no retries \u2014 both are absent-as-empty, not errors." - ], - "returns": [ - "testName", - "status", - "testHash", - "buildId", - "buildName", - "history", - "retries", - "issueType", - "testCase", - "buildDetails", - "observabilityVersion", - "testRunRcaData", - "isMuted", - "isFlaky" - ], - "responses": { - "200": { - "description": "Test-run metadata blob.", - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "'HOOK' or 'TEST'." - }, - "isHookAsTest": { - "type": "boolean" - }, - "errorClusterId": { - "type": "string", - "description": "Unique-error cluster id; only resolved for FAILED runs." - }, - "history": { - "type": "array", - "items": { - "$schema": "ObsHistoryTab" - }, - "description": "Recent executions across builds; [] when excludeHistory=true." - }, - "retries": { - "description": "List of retry-attempt metadata, or null when there are none." - }, - "testName": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Null when the run is retention-locked." - }, - "isFlaky": { - "type": "boolean" - }, - "testHash": { - "type": "string" - }, - "buildId": { - "type": "string", - "description": "Build UUID." - }, - "buildName": { - "type": "string" - }, - "commentCount": { - "type": "integer" - }, - "isMuted": { - "type": "boolean" - }, - "isLatest": { - "type": "boolean" - }, - "origin": { - "type": "string" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "issueType": { - "type": "object", - "description": "Failure issue type with resolved displayName." - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "isAutoAnalyzerRunning": { - "type": "boolean" - }, - "isUniqueErrorsRunning": { - "type": "boolean" - }, - "buildDetails": { - "type": "object", - "description": "Build context: status, state, reRun eligibility, issueTypes, ciBuildData, isArchived, finishedAt, id." - }, - "observabilityVersion": { - "type": "object", - "description": "Framework/SDK version info for the build." - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "workflowStatusEditedBy": { - "type": "integer" - }, - "workflowStatusEditedAt": { - "type": "string", - "format": "date-time" - }, - "testCaseVersionId": { - "type": "integer" - }, - "testCaseId": { - "type": "integer" - }, - "testCase": { - "type": "object", - "description": "Linked TM test case; identifier is the third-party (TCM/Jira) id when one exists." - }, - "thProjectId": { - "type": "integer" - }, - "testRunRcaData": { - "type": "object", - "description": "RCA data with resolved failureTypeDisplayName." - }, - "projectId": { - "type": "integer" - }, - "device": { - "type": "string" - }, - "cbtBrowser": { - "type": "string" - }, - "cbtPlatform": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "meta": { - "description": "Opaque JSON meta blob for the run." - }, - "source": { - "type": "string" - }, - "testCaseCount": { - "type": "integer" - }, - "note": { - "description": "Session note (banner parity); null when absent." - }, - "sdkNudge": { - "description": "SDK nudge; null when absent." - }, - "nudges": { - "description": "Mobile-CTM upsell nudges for the current viewer; {} when ineligible." - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_execution_history", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testMetaHistory", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "List the recent execution history of the same test across previous builds (the history strip on the test-detail page) so you can tell whether a failure is new, flaky or long-standing.", - "guidance": [ - "The same data is embedded in get_test_run_meta unless excludeHistory=true \u2014 this endpoint exists so the header can load fast and the history be fetched separately.", - "Each row carries testRunId, status, serialId, testRunUuid, buildUuid, workflowStatus, creationType, usageQuotaStatus, isHittingRetentionQuota and thProjectId; startedAt is used for ordering but is not returned.", - "An unknown id returns TEST_RUN_NOT_FOUND." - ], - "returns": [ - "testRunId", - "status", - "serialId", - "testRunUuid", - "buildUuid", - "workflowStatus", - "creationType", - "isHittingRetentionQuota" - ], - "responses": { - "200": { - "description": "List of past executions of the same test.", - "schema": { - "type": "array", - "items": { - "$schema": "ObsHistoryTab" - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_runs_history_details", - "method": "POST", - "path": "/api/v1/builds/testRuns/historyDetails", - "mode": "read", - "entity": "test_run", - "intent": "For a batch of test runs (or hook runs), get each one's execution history across previous builds together with its logs and failure/issue-type info \u2014 the data behind the test details 'history' panel, used to judge flakiness and whether a failure is new.", - "body": [ - { - "name": "testRunIds", - "type": "array", - "required": true, - "description": "Integer ids of the test runs to fetch history for. When `isHook` is true these are HOOK run ids, resolved against a different backing store \u2014 mixing test-run and hook-run ids in one call returns history for whichever set matches the `isHook` flag.", - "fields": [ - { - "name": "item", - "type": "integer", - "description": "A test run id (or hook run id when isHook=true)." - } - ] - }, - { - "name": "isHook", - "type": "boolean", - "json_path": "/isHook", - "description": "Routes the whole request. false (default) treats `testRunIds` as test run ids via the test-run service; true reinterprets them as hook run ids and resolves them through the hook-run service instead. Bound from the JSON key `isHook` exactly (a differently cased key is ignored and defaults to false)." - } - ], - "guidance": [ - "This is a POST that only carries a filter body \u2014 it reads and changes nothing (routed to a read replica), so it is safe to call freely.", - "`isHook: true` switches the ENTIRE lookup to hook runs: the ids you send are then hook run ids, not test run ids. Sending test run ids with isHook=true returns history for the hook runs (if any) with those ids, not your tests.", - "The response is a JSON OBJECT keyed by the id you asked for (`{ \"\": {\u2026history\u2026} }`), not an array. An id with no history is simply absent from the map \u2014 iterate the ids you sent rather than assuming every one comes back.", - "Each entry's `logs` is an object with only the keys that exist for that run: `TEST_FAILURE` (an ARRAY of strings - message then stack), `TEST_LOG` (an array of screenshot/attachment URLs the caller fetches directly from storage), and `BROWSERSTACK_ERROR`. `logs` is {} for passed runs. Several flags (isNewlyFailed, isPerformanceAnomaly, flakySetting) can be null.", - "Scoped to the caller's group in the repository query \u2014 ids outside the caller's group are dropped from the result, not errored." - ], - "returns": [ - "buildId", - "serialId", - "status", - "issueType", - "duration", - "isFlaky", - "isAlwaysFailing", - "isNewlyFailed", - "isPerformanceAnomaly", - "flakyReason", - "flakySetting", - "creationType", - "tags", - "logs", - "workflowStatus", - "workflowStatusEditedBy", - "workflowStatusEditedAt" - ], - "responses": { - "200": { - "$response": "TestRunHistoryDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_retry_logs", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunUuid}/retryLogs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler \u2014 any value satisfies routing; the run is located by testRunUuid alone." - }, - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "UUID of the retry attempt / test run whose evidence to fetch. This route keys off the UUID, unlike the sibling /testRuns/{testRunId} routes which take the numeric id. A malformed UUID throws before any lookup." - } - ], - "intent": "Get the screenshot/log attachment structure captured for a single retry attempt of a test, so the retries tab can render that attempt's evidence.", - "guidance": [ - "Identify the run by UUID here \u2014 the numeric id that the other /testRuns/{\u2026} endpoints take will not resolve on this route.", - "The response is a log-structure object with only the keys that exist: `TEST_FAILURE` (failure backtrace), `TEST_LOG` (an array of screenshot URLs \u2014 fetch each from storage yourself; they are not proxied through this API), and `BROWSERSTACK_ERROR`. An attempt with no captured evidence throws `IllegalArgumentException(\"retry doesn't exist\")` rather than returning an empty object, so treat that error as 'no screenshot data', not a missing run.", - "Reads through the group-scoped repository factory, so a run in another group behaves as absent." - ], - "returns": [ - "TEST_FAILURE", - "TEST_LOG", - "BROWSERSTACK_ERROR" - ], - "responses": { - "200": { - "$response": "TestRunRetryLogsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_run_sdk_logs", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRunLogs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (NOT a numeric build id), resolved by (uuid, groupId). A UUID outside the caller's group 404s with BUILD_NOT_FOUND." - } - ], - "query": [ - { - "name": "cursor", - "type": "integer", - "required": false, - "description": "Opaque forward cursor \u2014 pass back `cursorInfo.nextCursor` from the previous page. Omit it for the first page; the per-build `totalAttachments` count is computed ONLY on that first (cursor-absent) page and is absent on later pages." - } - ], - "intent": "Page through the test runs in a build that have SDK-captured logs/attachments, with the per-build attachment total \u2014 backs the build's log/attachment viewer.", - "guidance": [ - "Page size is fixed at 15 and cannot be changed; walk pages by feeding `cursorInfo.nextCursor` back as `cursor` until `cursorInfo.hasMore` is false.", - "`totalAttachments` is returned only on the first page (when `cursor` is omitted). Read it there and carry it; do not expect it on subsequent pages.", - "Each row's `url` is a direct attachment/log URL fetched from storage, not through this API. `attachmentsCount` and `screenshotsCount` are per-test-run totals.", - "Path variable is named buildId but is bound to the build UUID string \u2014 sending a numeric id yields BUILD_NOT_FOUND." - ], - "returns": [ - "data", - "cursorInfo", - "totalAttachments" - ], - "responses": { - "200": { - "$response": "TestRunSdkLogsListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "assign_test_run_failure_categories", - "method": "PUT", - "path": "/api/v1/builds/{projectId}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "A PROJECT id, despite the /builds/ route prefix. Interpreted per product: when the caller's CAD product is TM it is a TestHub project id and mapped to the observability project; otherwise it is the observability project id directly." - } - ], - "body": [ - { - "name": "issues", - "type": "array", - "required": true, - "description": "The per-run category assignments to apply. Required and validated (@NotNull); each item is validated too.", - "fields": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Id of the test run to categorise. Bound from the JSON key `testRunId` (maps to the internal `id`); required per item." - }, - { - "name": "issueTypeId", - "type": "integer", - "required": true, - "description": "Id of the issue type / sub-type to assign to that run. Required per item." - } - ] - } - ], - "intent": "Bulk-assign failure categories (issue types / sub-types) to a set of test runs \u2014 the 'categorise these failures' action on the test listing; returns the resulting issue types.", - "guidance": [ - "Requires the BUILD_UPDATE_FAILURE_CATEGORIES permission on the (resolved) project; otherwise AccessDenied. The path id is a project id, not a build id \u2014 do not pass a build id.", - "This overwrites each run's current category; there is no version history, so the previous category is not recoverable through this API. Read the runs first if you need to record what they were.", - "A 409 (AUTO_ANALYSIS_IN_PROGRESS) means auto-analysis is running \u2014 it is SAFE to retry, because the whole batch is validated up front and nothing is written until it passes, so a 409 wrote nothing.", - "The whole batch is pre-checked before any write, so an invalid item fails the entire request rather than partially applying.", - "Response is a flat array of the resulting IssueType objects, not a per-run map." - ], - "returns": [ - "id", - "name", - "locator", - "abbreviation", - "color", - "groupId", - "userId", - "displayName", - "issueGroup" - ], - "responses": { - "200": { - "$response": "AssignTestRunFailureCategoriesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "409": { - "$response": "Conflict" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_bstack_error_sessions", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/bstackErrorSessions", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID. Looked up with findOneByUuid WITHOUT the group scoping the other endpoints on this controller apply \u2014 do not rely on cross-group isolation here." - } - ], - "intent": "List the sessions in a build that failed because of BrowserStack-side infrastructure errors, so the test listing can separate platform problems from genuine test failures.", - "guidance": [ - "SDK builds only. A non-SDK build \u2014 and the demo-sandbox environment \u2014 returns an EMPTY array, so `[]` does not mean 'no infra errors occurred'; confirm the build is an SDK build before concluding there were none.", - "The response is a raw JSON array of session objects straight from the session repository (no envelope, no fixed schema) \u2014 treat the element fields as opaque and read defensively.", - "Unlike the sibling listing endpoints, the build here is resolved without group scoping (findOneByUuid)." - ], - "returns": [], - "responses": { - "200": { - "$response": "BstackErrorSessionsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_flat_filtered", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/flatTestRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "headers": [ - { - "name": "Public-Link-Token", - "type": "string", - "required": false, - "description": "Public-link token; when present (or via X-Public-Link-Token) the response is restricted \u2014 pending/in-progress tests are excluded from shared reports." - }, - { - "name": "X-Public-Link-Token", - "type": "string", - "required": false, - "description": "Alternate public-link token header, same effect as Public-Link-Token." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "lastExecutedBy", - "type": "array", - "required": false, - "description": "User ids; capped at 500 (@Size max 500)." - }, - { - "name": "isFlaky", - "type": "boolean", - "required": false, - "description": "Pro filter. On a freemium account, applying any pro filter (isFlaky / hasPerformanceAnomaly / isNewFailure / isAlwaysFailing) returns an EMPTY list rather than erroring." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token (bound via a custom setter)." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level entries; echo it back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Elasticsearch search_after cursor; echo it back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Requested run-count flag, but FORCED to false outside the demo-sandbox environment regardless of what you send \u2014 do not rely on it in production." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as a flat, filterable, sortable, cursor-paged list (no suite/file nesting) \u2014 the build's test-listing table, with the full V2 filter surface and public-link sharing support.", - "guidance": [ - "This is the flat listing on the newer surface: it takes the full FilterParamsV2 filter set, sorting, folder scoping, cursor paging (nextRoot + searchAfter) and public-link tokens. The older flat listing (list_build_test_runs_flat) has none of that; pick this one when you need filtering or sorting.", - "Any unrecognised query params are collected and forwarded as custom filter params \u2014 a misspelled filter name is NOT rejected, it is silently passed through and ignored, so verify the filter took effect against the result.", - "`showRunCount` is forced to false outside the demo sandbox, so run counts will be absent in production even if you ask for them.", - "On a freemium account, applying a pro filter (isFlaky, hasPerformanceAnomaly, isNewFailure, isAlwaysFailing) returns an empty `testRuns` with `pagingParams.hasNext=false` \u2014 an empty result there is a plan gate, not 'no matches'.", - "Page by feeding `pagingParams` (nextRoot + searchAfter) back; the per-page size is a server default that is not caller-configurable here.", - "Send the public-link token via Public-Link-Token or X-Public-Link-Token to get the shared-report view (pending/in-progress hidden)." - ], - "returns": [ - "testRuns", - "pagingParams", - "isArchived", - "smartTagSettings", - "status", - "workflowStatus", - "folder_path" - ], - "responses": { - "200": { - "$response": "FlatTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_hierarchy", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "headers": [ - { - "name": "Public-Link-Token", - "type": "string", - "required": false, - "description": "Public-link token; when present (or via X-Public-Link-Token) pending/in-progress tests are hidden from the shared report." - }, - { - "name": "X-Public-Link-Token", - "type": "string", - "required": false, - "description": "Alternate public-link token header, same effect." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "lastExecutedBy", - "type": "array", - "required": false, - "description": "User ids; capped at 500." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level suite/file roots; echo back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "search_after cursor; echo back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Forced to false outside the demo-sandbox environment regardless of what you send." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as the hierarchical suite/file/test tree the build detail page renders, with full filtering, sorting and cursor paging; this is the variant that honours public-link sharing and forwards custom filter params.", - "guidance": [ - "This is the canonical hierarchical listing: use it for shared/public reports (it reads the Public-Link-Token / X-Public-Link-Token headers and hides pending/in-progress tests for public callers) and when you want arbitrary extra query params forwarded as custom filters. Its twin list_build_test_runs_hierarchy_without_public_link does neither.", - "Unrecognised query params are silently collected and passed through as custom filters \u2014 a misspelled filter name is not rejected; verify it took effect against the tree.", - "Paging is by top-level ROOT nodes, not individual tests: the page holds 5 roots (just 1 for builds over 2000 runs). Walk pages by feeding `pagingParams` (nextRoot + searchAfter) back until it reports no next.", - "`showRunCount` is forced false outside the demo sandbox.", - "The tree sits under the top-level `hierarchy` array (each element is a suite/file/test node); aggregates come back under `status` and `workflowStatus`." - ], - "returns": [ - "hierarchy", - "pagingParams", - "status", - "workflowStatus", - "isArchived", - "smartTagSettings", - "source", - "folder_path" - ], - "responses": { - "200": { - "$response": "HierarchyTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_hierarchy_without_public_link", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns/hierarchy", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level roots; echo back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "search_after cursor; echo back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Forced to false outside the demo-sandbox environment." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as the same hierarchical suite/file/test tree, for session-authenticated callers that do not need public-link sharing or custom-param pass-through.", - "guidance": [ - "Identical tree to list_build_test_runs_hierarchy (same underlying query) with two deliberate differences: this route does NOT read the Public-Link-Token / X-Public-Link-Token headers, and it does NOT collect arbitrary query params as custom filters. Prefer this only for internal/session views; use the public-link-aware twin for shared reports.", - "Because it ignores the public-link headers, a public-link caller hitting this route is NOT given the pending/in-progress suppression \u2014 do not use it to serve shared/public reports.", - "Only the declared query params are honoured here; unknown params are dropped rather than forwarded.", - "Paging is by top-level ROOT nodes (5 per page, or 1 for builds over 2000 runs); walk `pagingParams` (nextRoot + searchAfter). `showRunCount` is forced false outside the demo sandbox." - ], - "returns": [ - "hierarchy", - "pagingParams", - "status", - "workflowStatus", - "isArchived", - "smartTagSettings", - "source", - "folder_path" - ], - "responses": { - "200": { - "$response": "HierarchyTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_run_counts", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns/count", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "intent": "For every unique test in a build (keyed by test hash), get how many times it ran across the build and its re-runs, and whether those runs spanned different platforms \u2014 what the UI uses for rerun/retry badges.", - "guidance": [ - "Returns per-test rows under a top-level `data` array (`{testHash, count, isDifferentPlatformsUsed}`), NOT a single total. Sum `count` yourself if you need a build total.", - "Counts are aggregated over the PARENT build id, so a re-run chain (a build and its re-runs) is counted as one build, not per re-run.", - "`isDifferentPlatformsUsed` is true when the run count for that test hash spanned more than one platform." - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "TestRunCountListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_top_error_stats", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/topErrorStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build uuid." - } - ], - "query": [ - { - "name": "filter", - "type": "object", - "required": false, - "description": "FilterParamsV2 listing-filter set (statuses, frameworks, tags, date range, search, etc.) bound from flat query params; the panel is computed over the tests these match." - }, - { - "name": "customParams", - "type": "object", - "required": false, - "description": "Any extra query params are collected verbatim into a custom-params map and folded into the filter." - } - ], - "intent": "Get the 'Top unique errors' insight panel for a build \u2014 the most frequent error clusters with their test-run counts, respecting the active listing filters.", - "returns": [ - "overview", - "data", - "ready", - "commentCount", - "message" - ], - "responses": { - "200": { - "$response": "BuildTopErrorStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Handle the not-ready state: while the auto-analyser is still running you get ready:false with only a `message` ('Unique Errors are being detected') and the default 'Analysis yet to run' overview \u2014 not an error and not the final data.", - "While clustering is incomplete the panel comes back with an empty `data` set; poll again rather than concluding there are no errors.", - "`data` is sorted by count descending; each row carries the cluster id, title (the error, often a full stack trace), count, testRuns and aiRcaTestRunIds. impactedTests can be null and testRuns can be empty even for a ready panel - use aiRcaTestRunIds (populated) to reach the runs.", - "Build lookup is group-scoped, so a build uuid from another group reads as not found." - ] - }, - { - "name": "recalculate_build_quality_gate", - "method": "POST", - "path": "/api/v1/builds/{uuid}/recalculate-quality-gate", - "mode": "write", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid." - } - ], - "body": [ - { - "name": "close", - "type": "boolean", - "required": false, - "description": "Part of the RecalculateQGBody shape; effectively ignored on this route \u2014 the handler forces the recalculation branch regardless." - }, - { - "name": "reCalculateQG", - "type": "boolean", - "required": false, - "description": "Ignored \u2014 the handler forces reCalculateQG=true. The body must be PRESENT (a valid RecalculateQGBody), but its contents do not change behaviour; unknown keys are ignored." - } - ], - "intent": "Re-run the quality-gate evaluation for a build (e.g. after profile rules or a status override changed) \u2014 use after quality-gate inputs change to refresh the build's gate result.", - "returns": [ - "message" - ], - "responses": { - "200": { - "$response": "BuildQualityGateRecalcAckResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: responds immediately with 200 {message:'Quality Gate result will be available after recalculation finishes'} \u2014 the result is NOT ready yet. Poll the build's quality-gate summary read for the outcome.", - "This OVERWRITES the build's previous quality-gate result; there is no undo for the prior result.", - "A request body must be sent (an object; {} is fine) even though its fields are ignored \u2014 the handler forces the recalculation.", - "403 PLAN_FEATURE_NOT_ALLOWED when the build is plan-locked.", - "Live 2026-09-30: results can take a few minutes to reappear; if they stay empty, one enabled profile in the project may be un-evaluable (see create_quality_profile) \u2014 it blanks every profile's result, not just its own." - ] - }, - { - "name": "resolve_build_uuid_from_identifiers", - "method": "GET", - "path": "/api/v2/builds/buildUuid", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "creationType", - "type": "string", - "required": true, - "description": "Build.CreationTypeEnum. AUTOMATION uses the name/serial identifiers below; any other (manual/TCM) type uses ONLY tcmTestRunId and ignores the name/serial params." - }, - { - "name": "buildNormalisedName", - "type": "string", - "required": false, - "default": "", - "description": "Normalised build name (AUTOMATION path)." - }, - { - "name": "projectNormalisedName", - "type": "string", - "required": false, - "default": "", - "description": "Project normalised name (AUTOMATION path); pair with buildNormalisedName + buildSerialId." - }, - { - "name": "buildSerialId", - "type": "integer", - "required": false, - "default": 0, - "description": "Per-project sequential build number (AUTOMATION path). On a serial-id collision the first match wins (logged)." - }, - { - "name": "tcmTestRunId", - "type": "integer", - "required": false, - "default": 0, - "description": "TCM test-run id \u2014 the only identifier used for non-AUTOMATION creation types." - }, - { - "name": "thProjectId", - "type": "integer", - "required": false, - "default": 0, - "description": "TestHub project id, an alternative to projectNormalisedName for scoping the AUTOMATION lookup." - } - ], - "intent": "Resolve a build UUID from human/CI-facing identifiers \u2014 project name (or TestHub project id) + build name + serial id for automation builds, or a TCM test-run id for manual/TCM runs \u2014 so a caller can deep-link to a build without already knowing its uuid.", - "returns": [ - "uuid" - ], - "responses": { - "200": { - "$response": "ResolvedBuildUuidResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No match returns HTTP 200 {uuid:null} \u2014 NOT a 404. Check the uuid value, not the status code.", - "For non-AUTOMATION creation types only tcmTestRunId is consulted; buildNormalisedName / projectNormalisedName / buildSerialId are ignored on that path.", - "Serial-id collisions are possible \u2014 the first matching build wins (a pager marker is logged)." - ] - }, - { - "name": "list_sidebar_build_runs", - "method": "POST", - "path": "/api/v2/builds", - "mode": "read", - "entity": "build", - "body": [ - { - "name": "projects", - "type": "array", - "required": false, - "description": "Project ids to scope to. When null and UDAC is on, the accessible project list is resolved for the caller's sub-group." - }, - { - "name": "query", - "type": "string", - "required": false, - "description": "Free-text search over build names/identifiers." - }, - { - "name": "afterKey", - "type": "string", - "required": false, - "description": "Forward cursor \u2014 pass the response's pagingParams.afterKey to page down." - }, - { - "name": "beforeKey", - "type": "string", - "required": false, - "description": "Backward cursor \u2014 pass the response's pagingParams.beforeKey to page up." - }, - { - "name": "selectedBuildUuid", - "type": "string", - "required": false, - "description": "Centre the returned window on this build (roughly half a page above it, the rest below) so a deep-linked build appears in context." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "required": false, - "description": "Include manual/TCM runs in the listing (default true)." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "required": false, - "description": "Optional filter on the manual-override flag." - }, - { - "name": "buildNames", - "type": "array", - "required": false, - "description": "Part of the inherited GetBuildFilterRqV2 filter surface: build-name filter. The body also accepts users, teams, frameworks, statuses, sources, tags, branches, dateRange, search, etc." - }, - { - "name": "isArchived", - "type": "boolean", - "required": false, - "description": "Filter to archived vs live builds (inherited filter field)." - } - ], - "intent": "List a page of unique build runs for the build sidebar/list rail, matching a large filter body, with bidirectional cursor paging and optional centring on a deep-linked build.", - "returns": [ - "data", - "pagingParams" - ], - "responses": { - "200": { - "$response": "SidebarBuildRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "OBSERVED LIVE: `buildNames` and `query` are IGNORED by the product (200 with the unfiltered page, even when `projects` is set). Do not use this route to find a build by name; use list_project_builds_by_name (project-scoped, `search`) or search_entities (cross-project).", - "Only the body fields declared here are sent; the inherited filter surface mentioned in the field description (users, teams, statuses, tags, dateRange, search...) is not bindable through this capability.", - "POST but a pure READ \u2014 the body is a filter; nothing is written.", - "Page size is fixed server-side (BUILDS_PAGESIZE); it is NOT caller-controllable, so there is no page-size body field. Page with the afterKey/beforeKey cursors from pagingParams (hasNext/hasPrev tell you when to stop).", - "Honours UDAC project scoping: with projects null and UDAC on, only the caller's accessible projects are returned.", - "Each row's usageQuotaStatus splits retention-accessible builds from 'pro quota' ones \u2014 a build can appear but be gated by quota.", - "Use selectedBuildUuid to keep a deep-linked build visible in the window rather than paging to find it." - ] - }, - { - "name": "generate_build_report", - "method": "POST", - "path": "/ext/v1/builds/buildReport", - "mode": "read", - "entity": "build", - "body": [ - { - "name": "requestingCi", - "type": "string", - "required": true, - "description": "The CI requesting the report (e.g. the Jenkins plugin). Missing \u2192 error." - }, - { - "name": "requestType", - "type": "string", - "required": true, - "values": [ - "FIRST", - "POLL" - ], - "description": "FIRST for the initial request, POLL for follow-ups. An unknown value raises a 400 'Invalid request_type'. Missing \u2192 error." - }, - { - "name": "reportFormat", - "type": "array", - "required": true, - "description": "Requested formats: richHtml, basicHtml and/or plainText (case-sensitive). Other values render nothing -> 400 'Build Report request format is invalid'.", - "values": [ - "richHtml", - "basicHtml", - "plainText" - ] - }, - { - "name": "buildUuid", - "type": "string", - "required": false, - "description": "Optional hint passed with originalBuildName (+buildStartedAt); on its own it does not resolve the build." - }, - { - "name": "originalBuildName", - "type": "string", - "required": true, - "description": "Build's original name. REQUIRED: resolution only runs when it is present; buildUuid alone -> 400 'Original build name not found' (40021)." - }, - { - "name": "buildStartedAt", - "type": "integer", - "required": false, - "description": "Build start time in epoch milliseconds; pairs with originalBuildName for resolution." - }, - { - "name": "userTimeout", - "type": "integer", - "required": false, - "description": "Caller's overall timeout budget, used to bound the polling protocol." - } - ], - "intent": "Ask for a build's summary report in the requested formats \u2014 the CI-plugin endpoint. It is a polling protocol: the first request usually returns IN_PROGRESS with the retry count and interval to use, and later poll requests return the finished report once the build's data has settled.", - "returns": [ - "reportStatus", - "retryCount", - "pollingInterval", - "report", - "errorReason" - ], - "responses": { - "200": { - "$response": "BuildReportPollingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Polling contract, not a single call: on reportStatus IN_PROGRESS you also get retryCount and pollingInterval \u2014 wait that interval and POST again with requestType=POLL until reportStatus is COMPLETED/TEST_AVAILABLE (or NOT_AVAILABLE/DATA_NOT_AVAILABLE).", - "Body is an untyped map validated by hand: requestingCi, requestType and reportFormat are each required and their absence is a distinct error; an unknown requestType is a 400.", - "Resolve the build by originalBuildName (+ buildStartedAt; buildUuid optional). Without originalBuildName the handler skips resolution and returns 400 'Original build name not found'. If the resolved build row is missing you get a 200 with a NULL body: handle that as 'not found', not a report.", - "The report itself (report.richHtml/richCss/basicHtml/plainText) is only populated on the terminal statuses; the envelope is @JsonInclude(NON_NULL) so absent fields are omitted.", - "In the demo-sandbox environment this returns HTTP 404 with body 'NOT FOUND'." - ] - }, - { - "name": "get_build_summary", - "method": "GET", - "path": "/ext/v1/builds/{build_id}", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build uuid. A re-run uuid resolves through its parent, so you get the parent build's aggregate." - } - ], - "intent": "Get one build's public record by uuid \u2014 identity and naming, status and per-status test counts, plus the pro-plan smart tags and unique errors. Narrower than get_build_details, which is the same build seen from inside the product.", - "returns": [ - "build_id", - "id", - "project_id", - "project_name", - "name", - "build_number", - "status", - "status_stats" - ], - "responses": { - "200": { - "$response": "PublicBuildDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Enforces the TEST_RUN_AUTOMATED_VIEW service-account project permission on the build's project.", - "Passing a re-run uuid returns the PARENT build's aggregate, not the re-run's own numbers.", - "A build in 'deleted' state raises BUILD_ID_DELETED, not a plain 404 \u2014 distinguish 'deleted' from 'never existed'.", - "The response SHAPE is feature-flagged: when new build listing is enabled it is the V2 build-details shape, otherwise the legacy one \u2014 do not hard-code one envelope.", - "Pro-only fields (smart_tags, unique_errors, failure_categories, alerts) are stripped by JsonView for non-pro plans, so their absence reflects the plan, not the build.", - "`status` here is the build's LIFECYCLE ('finished'), not its outcome ('failed') as get_build_details reports it \u2014 the two routes answer different questions with the same field name.", - "project_id, project_name and created_at can come back null on this route even when get_build_details has them; read those from the internal route when they matter.", - "Each route carries what the other lacks: this one has smart_tags and unique_errors, while get_build_details has the quality-gate status and AI RCA counts." - ] - }, - { - "name": "list_build_history_with_defect_counts", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/history", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build uuid the history is anchored on." - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "required": false, - "default": 10, - "minimum": 1, - "maximum": 10, - "description": "How many builds to return (this build plus prior runs of the same name). Silently CLAMPED to 1..10 \u2014 asking for more than 10 is ignored, not an error." - } - ], - "intent": "Get the trend context for a build for an automated failure agent \u2014 this build plus up to N earlier runs of the same build name, newest first, each with number, status, timings and a pass/fail/skip plus defect-type count summary.", - "returns": [ - "builds" - ], - "responses": { - "200": { - "$response": "BuildHistoryWithDefectCountsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Not a general history read \u2014 distinct from the internal build-history capability: this is the agent-facing trend view with per-build defect and status counts.", - "limit is clamped server-side to 1..10; there is no paging beyond 10 runs.", - "Only the anchor build and EARLIER runs of the same name are returned; later runs of the same name are filtered out. The anchor build is flagged current:true.", - "Build lookup is group-scoped (the IDOR gate), so a foreign uuid is not found.", - "Per-build counts (statusStats + defectStats: automation bug / product bug / to-be-investigated / not-determined / environment issue) come from a single Elasticsearch aggregation; per-entity recurrence (flaky/theme names) is intentionally not included." - ] - }, - { - "name": "list_build_ai_failure_cluster_test_runs", - "method": "GET", - "path": "/api/v1/ai/failures/{buildUUID}/flat", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "query": [ - { - "name": "testRunIds", - "type": "array", - "required": false, - "description": "Comma-separated integer test-run ids to fetch directly, skipping the theme/workflow lookup. Max 200 (more => 400). Sending this selects the by-ids behaviour; omit it to scope by cluster or whole build.", - "fields": [ - { - "name": "item", - "type": "integer" - } - ] - }, - { - "name": "buildFailureThemeId", - "type": "integer", - "required": false, - "description": "Integer id of a failure THEME to drill into (from get_build_ai_failures). Give this OR buildFailureWorkflowId to scope to one cluster. Ignored if testRunIds is sent." - }, - { - "name": "buildFailureWorkflowId", - "type": "integer", - "required": false, - "description": "Integer id of a failure WORKFLOW to drill into (from get_build_ai_failures). Ignored if testRunIds is sent." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend's Elasticsearch default; do not rely on it. Use searchAfter to page." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Opaque search-after cursor. Pass the previous response's pagingParams.searchAfter (JSON-encoded) to fetch the next page." - } - ], - "intent": "List a build's AI-failure test runs as a flat, cursor-paginated list with full details \u2014 fetch an explicit set of test-run ids, drill into one failure theme or workflow cluster, or omit both to span the whole build.", - "returns": [ - "testRuns", - "pagingParams" - ], - "guidance": [ - "ONE route, three modes chosen by which query params you send: testRunIds -> fetch exactly those runs (skips the theme/workflow lookup); buildFailureThemeId OR buildFailureWorkflowId -> drill into one cluster (ids from get_build_ai_failures); neither -> the whole build.", - "testRunIds is comma-separated integer ids, capped at 200; more returns 400 naming testRunIds.", - "Group-scoped / IDOR-safe: results filter on buildId, so ids or clusters outside the caller's build simply do not match \u2014 a short or empty testRuns (with hasNext=false) is not an error.", - "limit is ACCEPTED BUT IGNORED (fixed backend page size). Paginate with searchAfter and stop when pagingParams.hasNext is false.", - "Not plan-fenced; a plan-downgraded account can still read prior content." - ], - "responses": { - "200": { - "$response": "FlatAIFailureTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - } - ], - "schemas": { - "ErrorResponse": { - "type": "object", - "description": "Standard TRA error body built by GlobalExceptionHandler.", - "properties": { - "errorTraceId": { - "type": "string", - "description": "UUID correlating the error to server logs." - }, - "message": { - "type": "string", - "description": "Human-readable error message; for relayed TestHub errors this is the upstream message." - }, - "code": { - "type": "string", - "description": "Application/TestHub error code. Present on many 4xx; absent on generic 500s." - }, - "classification": { - "type": "string", - "description": "Blame classification when stamped, e.g. USER_ISSUE. Never present on /ext responses.", - "example": "USER_ISSUE" - }, - "hasAccess": { - "type": "boolean", - "description": "Present (false) on access-denied 403s.", - "example": false - } - } - }, - "BuildGroupAggResult": { - "type": "object", - "description": "One build-group row in the grouped build listing (models/BuildGroupAggResult).", - "properties": { - "buildGroupId": { - "type": "integer", - "description": "Build group id." - }, - "id": { - "type": "integer", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "buildCount": { - "type": "integer" - }, - "closedBuildCount": { - "type": "integer" - }, - "closingBuildCount": { - "type": "integer", - "nullable": true - }, - "isGroupClosing": { - "type": "boolean", - "nullable": true - }, - "manualBuildCount": { - "type": "integer" - }, - "automationBuildCount": { - "type": "integer" - }, - "latestCreatedAt": { - "type": "number", - "description": "Epoch millis of the group's newest build (a double)." - }, - "buildGroup": { - "type": "object", - "nullable": true, - "description": "The TestHub build-group record (id, name, closedBy, closedByName, ...), when the group exists in TestHub." - } - } - }, - "SourceFencingFlags": { - "type": "object", - "description": "Cross-source feature-fencing flags; embedded in many widget/build responses. A true flag means results for that product were skipped for this caller.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - }, - "PageInfo": { - "type": "object", - "description": "Standard pagination info envelope.", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer", - "description": "Previous page number, null on the first page." - }, - "next": { - "type": "integer", - "description": "Next page number, null on the last page." - } - } - }, - "BuildAggregate": { - "type": "object", - "description": "Run counts by status for one build name.", - "properties": { - "count": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "BuildSummary": { - "type": "object", - "description": "Build summary (models/response/BuildSummary). Every field except uuid/id/name/status can be null - nulls are serialised, not omitted. About 30 further keys are also returned (alertCount, buildError, creationType, workflowStatusStats, aiHealingMeta, origins, testCaseCount, parallelsUsed, ...); only the ones agents normally need are declared.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "derivedName": { - "type": "string", - "nullable": true - }, - "originalName": { - "type": "string", - "nullable": true - }, - "isAutoDetectedName": { - "type": "boolean", - "nullable": true - }, - "normalisedName": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "description": "Lifecycle status (e.g. finished); the pass/fail verdict is displayStatus." - }, - "duration": { - "type": "integer", - "nullable": true - }, - "consolidatedDuration": { - "type": "integer", - "nullable": true - }, - "finishedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "startedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "sessionCount": { - "type": "integer", - "nullable": true - }, - "hostInfo": { - "type": "object", - "nullable": true - }, - "observabilityVersion": { - "type": "object", - "nullable": true - }, - "ciBuildData": { - "type": "object", - "nullable": true - }, - "user": { - "type": "string", - "nullable": true - }, - "userId": { - "type": "integer", - "nullable": true - }, - "closedBy": { - "type": "string", - "nullable": true - }, - "closedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "metaData": { - "type": "string", - "nullable": true, - "description": "JSON-encoded string." - }, - "percyBuildUrl": { - "type": "string", - "nullable": true - }, - "requestlyRunUrl": { - "type": "string", - "nullable": true, - "description": "Not serialised on every route (absent from the daily-summary by-ids listing)." - }, - "source": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "issueTypes": { - "type": "array", - "nullable": true - }, - "statusStats": { - "type": "object", - "description": "StatusAggregate: pending/passed/failed/skipped/unknown counts, plus an integer `aggregate` total.", - "nullable": true - }, - "aiRcaCompletedCount": { - "type": "integer", - "nullable": true - }, - "selfHealedCount": { - "type": "integer", - "nullable": true - }, - "showHealingReport": { - "type": "boolean", - "nullable": true - }, - "showAuthoringReport": { - "type": "boolean", - "nullable": true - }, - "appAccessibilityEnabled": { - "type": "boolean", - "nullable": true - }, - "issueTypeAggregate": { - "type": "object", - "nullable": true - }, - "historyAggregate": { - "type": "object", - "nullable": true - }, - "buildNumber": { - "type": "integer", - "nullable": true - }, - "sdKVersion": { - "type": "string", - "nullable": true - }, - "displayStatus": { - "type": "string", - "nullable": true, - "description": "Build verdict shown in the UI (passed/failed/...); prefer this over status." - }, - "state": { - "type": "string", - "nullable": true, - "description": "Build lifecycle state, e.g. NEW or CLOSED." - }, - "projectId": { - "type": "integer", - "nullable": true, - "description": "TRA project id." - }, - "thProjectId": { - "type": "integer", - "nullable": true, - "description": "TestHub project id." - }, - "projectName": { - "type": "string", - "nullable": true - }, - "qualityGateStatus": { - "type": "string", - "nullable": true - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "reRun": { - "type": "boolean", - "nullable": true - }, - "tcmTestRunIdentifier": { - "type": "string", - "nullable": true, - "description": "Linked Test Management run identifier (TR-...), when any." - }, - "runInfos": { - "type": "array", - "nullable": true, - "items": { - "type": "object" - } - }, - "versionControlInfo": { - "type": "object", - "description": "{url, name, commitId, branch}; always an object, fields null when no VCS data.", - "properties": { - "url": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "commitId": { - "type": "string", - "nullable": true - }, - "branch": { - "type": "string", - "nullable": true - } - } - } - } - }, - "BuildRunsSummary": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "BuildAggregateV2": { - "type": "object", - "description": "Top failed build entry, optionally enriched with best-effort AI summaries (omitted when null).", - "properties": { - "name": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "serialId": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "aiRcaSummary": { - "type": "string", - "description": "Best-effort; may be absent if enrichment failed." - }, - "aiRcaStatus": { - "type": "string" - }, - "themeCount": { - "type": "integer" - }, - "workflowCount": { - "type": "integer" - }, - "selfHealingSummary": { - "type": "string", - "description": "Best-effort; omitted when null." - }, - "authoringSummary": { - "type": "string", - "description": "Best-effort; omitted when null." - } - } - }, - "FailedTestSummary": { - "type": "object", - "properties": { - "uniqueErrors": { - "type": "integer" - }, - "flaky": { - "type": "integer" - }, - "totalFailed": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "newlyFailing": { - "type": "integer" - } - } - }, - "IssueTypeSummary": { - "type": "object", - "description": "Failure-category counts (pb=Product Bug, tbi=To be Investigated, ei=Environment Issue, ab=Automation Bug, nd=No Defect).", - "properties": { - "pb": { - "type": "integer" - }, - "tbi": { - "type": "integer" - }, - "ei": { - "type": "integer" - }, - "ab": { - "type": "integer" - }, - "nd": { - "type": "integer" - } - } - }, - "ProjectSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true, - "description": "Can be null in the daily-summary payload; key projects by id." - }, - "normalisedName": { - "type": "string", - "nullable": true - }, - "buildRunsSummary": { - "$schema": "BuildRunsSummary" - }, - "topFailedBuilds": { - "type": "array", - "items": { - "$schema": "BuildAggregateV2" - } - }, - "failedTestSummary": { - "$schema": "FailedTestSummary" - }, - "issueTypeSummary": { - "$schema": "IssueTypeSummary" - } - } - }, - "OverallDailySummary": { - "type": "object", - "description": "Aggregated daily-summary report across projects.", - "properties": { - "filterQueries": { - "type": "object", - "description": "Fixed canned query strings (dateFilter, buildRuns, topFailedBuilds, failedTests, issueTypes) used to deep-link filtered views." - }, - "projects": { - "type": "array", - "items": { - "$schema": "ProjectSummary" - } - } - } - }, - "QualityProfile": { - "type": "object", - "description": "A quality-gate profile: a named rule set plus the build filters it applies to. Serialized with null fields omitted (JSON include NON_NULL). Note the @JsonProperty renames: the wire fields are `isGlobalProfile` (internal isRootProfile) and `applicableBuilds` (internal buildFilters).", - "properties": { - "id": { - "type": "integer", - "description": "Internal DB id; changes on every edit because an edit writes a new version row." - }, - "uuid": { - "type": "string", - "format": "uuid", - "description": "Public profile identifier. NOTE: a new UUID is minted on every edit, so it is not stable across updates." - }, - "name": { - "type": "string" - }, - "ruleStatus": { - "type": "string", - "enum": [ - "pass", - "fail" - ], - "description": "'pass' = profile passes only when all rules hold; 'fail' = profile fails when any rule holds." - }, - "rules": { - "type": "array", - "items": { - "$schema": "QualityProfileRule" - } - }, - "applicableBuilds": { - "$schema": "BuildFilter", - "description": "Wire name for the internal buildFilters." - }, - "isGlobalProfile": { - "type": "boolean", - "description": "Wire name for the internal isRootProfile. True = applies group-wide." - }, - "hooksVisibility": { - "type": "string", - "enum": [ - "all", - "failed", - "beforeFailed", - "none" - ] - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "isLatest": { - "type": "boolean", - "description": "Whether this is the current version of the profile; a retired/deleted version has this false." - } - } - }, - "QualityProfileRule": { - "type": "object", - "description": "One rule inside a quality profile. Serialized with null fields omitted.", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Client-generated rule UUID; required on write." - }, - "type": { - "type": "string", - "enum": [ - "TEST_STATUS", - "TEST_STATUS_THRESHOLD", - "STABILITY", - "TEST_EXECUTIONS_THRESHOLD", - "BUILD_DURATION", - "SMART_TAGS", - "SMART_TAGS_THRESHOLD", - "FAILURE_CATEGORY", - "FAILURE_CATEGORY_THRESHOLD", - "ALERTS", - "UNIQUE_ERROR_THRESHOLD", - "NEW_UNIQUE_ERROR", - "UNIQUE_ERROR_STACK_TRACE" - ] - }, - "operator": { - "type": "string", - "enum": [ - "has", - "triggered", - "not_triggered", - "is", - "is_in", - "is_not", - "less_than", - "less_than_equals", - "equals", - "greater_than", - "greater_than_equals" - ] - }, - "lhs": { - "$schema": "RuleLHS" - }, - "rhs": { - "$schema": "RuleRHS" - } - } - }, - "RuleLHS": { - "type": "object", - "description": "Left-hand side of a rule. Null fields omitted.", - "properties": { - "value": { - "type": "string", - "enum": [ - "ALL_TESTS", - "SELECTED_TESTS", - "OVERALL_BUILD", - "ALL_ALERTS", - "SELECTED_ALERTS", - "SHOULD_BE", - "SHOULD_NOT_BE" - ] - }, - "helperConfig": { - "type": "object", - "description": "Scoping for SELECTED_* rules: {regex, testTags[], folders[], alert}.", - "properties": { - "regex": { - "type": "string" - }, - "testTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "folders": { - "type": "array", - "items": { - "type": "string" - } - }, - "alert": { - "type": "object" - } - } - }, - "with": { - "type": "string", - "maxLength": 500, - "description": "Operand (test status, smart tag, failure-category key, ...)." - }, - "displayWith": { - "type": "string", - "description": "Human display name for a failure-category operand; populated only on the single-profile detail reads for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules." - }, - "denominator": { - "type": "string" - } - } - }, - "RuleRHS": { - "type": "object", - "description": "Right-hand side / threshold of a rule. Null fields omitted.", - "properties": { - "type": { - "type": "string", - "enum": [ - "number", - "percentage", - "string" - ] - }, - "value": { - "type": "string", - "maxLength": 500 - } - } - }, - "BuildFilter": { - "type": "object", - "description": "Which builds a profile is evaluated against.", - "properties": { - "allBuilds": { - "type": "boolean", - "description": "Required. True = every build; otherwise scope with buildNames/buildTags." - }, - "buildTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "buildNames": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "QualityProfileLiteDto": { - "type": "object", - "description": "Compact profile row in the settings/list response.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "rulesCount": { - "type": "integer" - }, - "applicableBuilds": { - "$schema": "BuildFilter", - "description": "Wire name for buildFilters." - }, - "isGlobalProfile": { - "type": "boolean", - "description": "Wire name for isRootProfile." - }, - "enabled": { - "type": "boolean" - } - } - }, - "QualityGateResponse": { - "type": "object", - "description": "A project's quality-gate settings: the project-level switches plus the profiles mapped to it.", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether Quality Gates is on for the project (from a project attribute)." - }, - "shouldOverrideBuildStatus": { - "type": "boolean", - "description": "Whether a failing gate also marks the build failed (from a project attribute)." - }, - "qualityProfiles": { - "type": "array", - "items": { - "$schema": "QualityProfileLiteDto" - }, - "description": "Default profile sorted to the front." - } - } - }, - "QualityProfileResultStats": { - "type": "object", - "description": "Per-status rule counts for a profile on a build.", - "properties": { - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - } - } - }, - "QualityProfileResultLiteDto": { - "type": "object", - "description": "Per-profile result summary for a build.", - "properties": { - "uuid": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Computed profile outcome: 'passed', 'failed' or 'skipped'." - }, - "statusStats": { - "$schema": "QualityProfileResultStats" - } - } - }, - "QualityGateSummary": { - "type": "object", - "description": "Readiness-aware build gate summary. Exactly one of qgResult / message / state is populated per response.", - "properties": { - "qgResult": { - "type": "array", - "items": { - "$schema": "QualityProfileResultLiteDto" - }, - "description": "Per-profile results when ready." - }, - "message": { - "type": "string", - "description": "Present instead of results when the build is pending or the auto-analyser is still running." - }, - "state": { - "type": "string", - "description": "A cached evaluation state string, present instead of results/message when one is cached." - } - } - }, - "QualityProfileRuleResult": { - "type": "object", - "description": "One rule's outcome for a build in the profile detail drawer.", - "properties": { - "rule": { - "$schema": "QualityProfileRule" - }, - "ruleStatus": { - "type": "string", - "description": "Per-build status of this rule (e.g. running/passed/failed)." - }, - "ruleResult": { - "type": "string", - "description": "Rendered result/value for this rule on the build." - } - } - }, - "QualityRuleResult": { - "type": "object", - "description": "One rule's evaluated result inside the CI quality-gate result. Null fields omitted.", - "properties": { - "condition": { - "type": "string", - "description": "Human-rendered rule condition text." - }, - "value": { - "type": "string", - "description": "The rule's per-build status." - }, - "result": { - "type": "string" - }, - "regex": { - "type": "string" - }, - "testTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "folders": { - "type": "array", - "items": { - "type": "string" - } - }, - "alert": { - "type": "string" - } - } - }, - "QualityProfiles": { - "type": "object", - "description": "A profile block inside the CI quality-gate result.", - "properties": { - "id": { - "type": "string", - "description": "Profile UUID." - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "description": "Rendered as ' if', e.g. 'pass if'." - }, - "result": { - "type": "string", - "description": "'passed', 'failed' or 'skipped'." - }, - "rules": { - "type": "array", - "items": { - "$schema": "QualityRuleResult" - } - } - } - }, - "ExtQualityGateResult": { - "type": "object", - "description": "CI-facing quality gate result for a build. Serialized with null fields omitted (JSON include NON_NULL), so while running only build_uuid/status/quality_gate_result appear.", - "properties": { - "build_uuid": { - "type": "string", - "format": "uuid" - }, - "build_url": { - "type": "string", - "description": "Deep link to the build in the product; absent while the build is still running." - }, - "status": { - "type": "string", - "description": "'running' while unfinished/analysing, otherwise 'passed'/'failed'/'skipped'." - }, - "quality_gate_result": { - "type": "string", - "description": "While running, a human 'try again' message; once done, the same value as status." - }, - "quality_profiles": { - "type": "array", - "items": { - "$schema": "QualityProfiles" - }, - "description": "Per-profile breakdown; absent while running." - } - } - }, - "FilterStruct": { - "type": "object", - "description": "One filter value option for the quality-profile filter builder.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true - }, - "pId": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "displayName": { - "type": "string", - "description": "Present only when set (JSON include NON_NULL)." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - } - } - }, - "QGFilterResponseData": { - "type": "object", - "description": "Filter values for the quality-profile filter builder, with a cursor.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterStruct" - } - }, - "afterKey": { - "type": "string", - "description": "The last key of this page. Still set on a final short page, so stop when fewer than the page size come back.", - "nullable": true - }, - "query": { - "type": "string", - "description": "Echo of the search prefix; null when not sent.", - "nullable": true - } - } - }, - "TrendTimelineDataPoint": { - "type": "object", - "description": "One point of a Testing Trends timeline series. `y` is the metric value (null - or 0 on count cards - when the bucket had no runs). `t` is the total unique tests in the bucket, null on most cards. `pointRange` is [bucketStart, bucketStart+interval] epoch ms, or null on series that do not set it (e.g. performance-chart lineData) - the key is always present.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket start, epoch milliseconds." - }, - "y": { - "type": "number", - "description": "Metric value for the bucket (count, percentage or duration depending on the card) as a JSON number. Null (percent/duration cards) or 0 (count cards such as test executions) for an empty bucket.", - "nullable": true - }, - "t": { - "type": "integer", - "description": "Total unique tests in the bucket; null on most cards (observed null on the test-executions and performance cards).", - "nullable": true - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "[bucketStart, bucketEnd] epoch ms; null (not omitted) on series that do not set it, e.g. performance-chart lineData.", - "nullable": true - } - } - }, - "TrendInsight": { - "type": "object", - "description": "Headline card summary. Fields are computed on serialisation: `count` is the headline number as a STRING (a percentage for flakiness/new-failures/always-failing, a raw total for test-executions); `meta` is a human sentence ('N of M unique tests are ') or null; `metric` is the metric label or null.", - "properties": { - "metric": { - "type": "string", - "description": "Metric label (e.g. 'flaky', 'new failures'); null when not applicable.", - "nullable": true - }, - "meta": { - "type": "string", - "description": "Human summary sentence; null when there is no metric.", - "nullable": true - }, - "count": { - "type": "string", - "description": "Headline value as a string - a percentage (one decimal) for the gated cards, or a raw count for test-executions." - } - } - }, - "PercentTimelineCard": { - "type": "object", - "description": "Standard Testing Trends card: a timeline series plus an optional headline insight. `insights` is null on the test-growth card.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - }, - "insights": { - "$schema": "TrendInsight" - } - } - }, - "FailureCategorySeries": { - "type": "object", - "description": "One defect/failure category with its own timeline.", - "properties": { - "name": { - "type": "string", - "description": "Defect/issue category name." - }, - "color": { - "type": "string", - "description": "Display colour from the group's defect-type mapping." - }, - "data": { - "type": "array", - "description": "Per-bucket counts. Each point's y is null when no tests ran in that bucket (distinct from 0 failures).", - "items": { - "type": "object", - "properties": { - "x": { - "type": "integer", - "description": "Bucket start, epoch ms." - }, - "y": { - "type": "integer", - "description": "Failure count in the bucket; null when the bucket had no runs." - } - } - } - } - } - }, - "CbtDataPoint": { - "type": "object", - "description": "A cross-browser coverage slice. Every point carries name/value/percentage/drilldown/color/os/browser/device; os/browser/device are null on primary points, drilldown is null on secondary points.", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "integer", - "description": "Raw execution count for the slice." - }, - "percentage": { - "type": "string", - "description": "Share as a formatted string (two decimals)." - }, - "drilldown": { - "type": "string", - "description": "Key linking a primary slice to its drill-down entry; null on secondary points.", - "nullable": true - }, - "color": { - "type": "string" - }, - "os": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - }, - "browser": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - }, - "device": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - } - } - }, - "StabilityLeaderboardRow": { - "type": "object", - "description": "One build's row in the stability leaderboard.", - "properties": { - "id": { - "type": "string", - "description": "Row id: \"all\" for the project-wide row, otherwise \"buildName-\"." - }, - "percentage": { - "type": "string", - "description": "Pass-rate / stability percentage as a formatted string (one decimal)." - }, - "buildName": { - "type": "string", - "description": "Build name, or \"All\" for the project-wide row." - }, - "chartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-bucket pass-rate series; entries may be null for empty buckets." - } - } - }, - "PerformanceLeaderboardRow": { - "type": "object", - "description": "One build's row in the performance leaderboard.", - "properties": { - "id": { - "type": "string", - "description": "Row id, \"buildName-\"." - }, - "buildName": { - "type": "string" - }, - "avgDuration": { - "type": "number", - "description": "Average execution duration over the window." - }, - "testCount": { - "type": "integer", - "description": "Average test-run count." - }, - "lineChartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-bucket test COUNTS (null for empty buckets)." - }, - "barChartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-bucket average DURATIONS (null for empty buckets)." - } - } - }, - "TrendPagingParams": { - "type": "object", - "description": "Fixed-page pagination echo. Note the leaderboards nest this under the key `pagingParam` (singular) while recent-runs uses `pagingParams` (plural).", - "properties": { - "hasNext": { - "type": "boolean", - "description": "True when another page exists." - }, - "pageNumber": { - "type": "integer", - "description": "One-based page number just returned (the requested zero-based pageNumber + 1)." - } - } - }, - "BuildFrequencyStats": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Positional index into the list, NOT a build id." - }, - "buildName": { - "type": "string" - }, - "runs": { - "type": "integer", - "description": "Number of runs of this build name in the window." - } - } - }, - "TrendBuildSummary": { - "type": "object", - "description": "Standard build summary as used by the recent-runs picker. Large object; the salient fields are listed. Serialised with default (nulls included) inclusion; vcsInfo is write-only and not returned.", - "properties": { - "uuid": { - "type": "string", - "description": "Build's stable uuid identifier." - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "description": "Timestamp." - }, - "finishedAt": { - "type": "string", - "description": "Timestamp." - }, - "sessionCount": { - "type": "integer" - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "statusStats": { - "type": "object", - "description": "Aggregated pass/fail counts for the build." - }, - "source": { - "type": "string" - }, - "qualityGateStatus": { - "type": "string" - }, - "currentQGOverrideStatus": { - "type": "boolean" - } - } - }, - "WebhookProjectMapping": { - "type": "object", - "description": "Projects a rule is linked to, with pagination info. Hydrated from TestHub.", - "properties": { - "info": { - "$schema": "WebhookPaginationInfo" - }, - "projects": { - "type": "array", - "items": { - "$schema": "WebhookProjectInfoLite" - } - } - } - }, - "WebhookPaginationInfo": { - "type": "object", - "description": "Project-picker / project-mapping pagination info (distinct from WebhookListInfo).", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "totalProjectsCount": { - "type": "integer" - }, - "linkedProjectsCount": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "WebhookProjectInfoLite": { - "type": "object", - "description": "A project row in the webhook project picker / mapping list.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "isSelected": { - "type": "boolean", - "description": "True when this project is already linked to the queried webhook rule." - } - } - }, - "RcaRetrigger": { - "type": "object", - "description": "Result of retrigger_rca_analysis. Asynchronous \u2014 carries only the new turn's id and, for trial users, the quota used.", - "properties": { - "messageId": { - "type": "string", - "format": "uuid", - "description": "Id of the new RCA conversation turn; poll this for the refreshed analysis." - }, - "trialQuota": { - "type": "object", - "description": "Present (non-null) only for test-level trial users; omitted otherwise.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - }, - "BuildAccessibilityMeta": { - "type": "object", - "description": "Web/Automate build-level accessibility summary. Note: App-Automate builds with App Accessibility enabled are proxied and return a different shape.", - "properties": { - "totalIssue": { - "type": "integer", - "description": "Sum of the severity counts that were present." - }, - "severityBreakdown": { - "type": "object", - "description": "Per-severity issue counts as the build summary reports them (zero counts included when reported).", - "properties": { - "critical": { - "type": "integer" - }, - "serious": { - "type": "integer" - }, - "moderate": { - "type": "integer" - }, - "minor": { - "type": "integer" - } - } - }, - "autoEnabledBuild": { - "type": "boolean", - "description": "Whether accessibility auto-scan was enabled for the build." - }, - "autoEnabled": { - "type": "boolean", - "description": "Duplicate of autoEnabledBuild." - }, - "reportUrl": { - "type": "string", - "description": "Deep link into the Accessibility dashboard (not an API endpoint)." - } - } - }, - "SimilarTestRun": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "uuid": { - "type": "string" - }, - "name": { - "type": "string" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "stackTrace": { - "type": "array", - "items": { - "type": "string" - } - }, - "projectNormalisedName": { - "type": "string" - }, - "buildNormalisedName": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - } - } - }, - "SimilarTestRunRs": { - "type": "object", - "properties": { - "similar": { - "type": "array", - "items": { - "$schema": "SimilarTestRun" - } - }, - "current": { - "$schema": "SimilarTestRun" - }, - "message": { - "type": "string" - } - } - }, - "SingleMetricSummary": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "description": "The metric value (e.g. always-failing test count)." - }, - "meta": { - "type": "string", - "description": "Metric metadata, typically the total the count is out of." - }, - "commentCount": { - "type": "integer" - }, - "autoUnmuteCount": { - "type": "integer" - } - } - }, - "BuildAISummary": { - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": "Narrative failure summary; null when status is NOT_AVAILABLE." - }, - "topFailureThemes": { - "type": "array", - "description": "Up to 5 failure themes.", - "items": { - "type": "object", - "properties": { - "themeId": { - "type": "string" - }, - "themeName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failurePercentage": { - "type": "string" - } - } - } - }, - "topAffectedWorkflows": { - "type": "array", - "description": "Up to 5 affected workflows.", - "items": { - "type": "object", - "properties": { - "workflowId": { - "type": "string" - }, - "workflowName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failurePercentage": { - "type": "string" - } - } - } - }, - "status": { - "type": "string", - "description": "Availability status; \"NOT_AVAILABLE\" on soft failure." - } - } - }, - "WorkflowStatusAggregate": { - "type": "object", - "description": "Per-workflow-status counts. Null-valued keys are omitted. Statuses use display-cased JSON keys.", - "properties": { - "Passed": { - "type": "integer" - }, - "Failed": { - "type": "integer" - }, - "In Progress": { - "type": "integer" - }, - "Skipped": { - "type": "integer" - }, - "Retest": { - "type": "integer" - }, - "Blocked": { - "type": "integer" - }, - "Untested": { - "type": "integer" - }, - "Pending": { - "type": "integer" - }, - "Unknown": { - "type": "integer" - }, - "otherCount": { - "type": "object", - "description": "Counts for any non-standard statuses, keyed by status name." - } - } - }, - "BuildAttemptRunStats": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "attemptNumber": { - "type": "integer" - }, - "totalAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "BuildAttemptRunStatsResponse": { - "type": "object", - "properties": { - "attempts": { - "type": "array", - "items": { - "$schema": "BuildAttemptRunStats" - } - }, - "summary": { - "type": "object", - "description": "Aggregate across attempts (same fields as an attempt entry, without a label).", - "properties": { - "attemptNumber": { - "type": "integer" - }, - "totalAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - } - } - }, - "BuildHistoryEntry": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Series name (a test/workflow status)." - }, - "data": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-build value of this status, aligned with the response `keys`." - }, - "color": { - "type": "string" - } - } - }, - "BuildHistory": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildHistoryEntry" - }, - "description": "One series per status." - }, - "keys": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Build labels along the x-axis (order of the ~10 recent builds)." - }, - "commentCount": { - "type": "integer" - } - } - }, - "BuildIdFromCoordinates": { - "type": "object", - "properties": { - "buildId": { - "type": "string", - "description": "The resolved build UUID." - } - } - }, - "BuildCoordinates": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "description": "URI-encoded, spaces as '+'." - }, - "buildSerialId": { - "type": "integer" - }, - "projectNormalisedName": { - "type": "string", - "description": "URI-encoded, spaces as '+'." - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "projectFinishedAt": { - "type": "string" - }, - "projectCreatedAt": { - "type": "string" - }, - "toProjectId": { - "type": "integer", - "description": "Observability project id." - }, - "thProjectId": { - "type": "integer", - "description": "TestHub project id." - } - } - }, - "BuildReRunEntry": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "e.g. \"First Run\", \"Re-run1\", \"Manual Run\"." - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "id": { - "type": "string", - "description": "The leg's integer build id, as a string." - }, - "buildSdkLogs": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "meta": { - "type": "object", - "nullable": true - } - } - }, - "BuildReRun": { - "type": "object", - "properties": { - "retryData": { - "type": "array", - "items": { - "$schema": "BuildReRunEntry" - }, - "description": "Flat list of re-run legs." - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - } - } - }, - "RerunSummary": { - "type": "object", - "properties": { - "totalTestAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "SplitResult": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "totalTestAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - }, - "metadata": { - "type": "object", - "description": "Build summary metadata for the split." - } - } - }, - "RerunGroup": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "label": { - "type": "string" - }, - "splits": { - "type": "array", - "items": { - "$schema": "SplitResult" - } - }, - "summary": { - "$schema": "RerunSummary" - }, - "metadata": { - "type": "object", - "description": "Build summary metadata for the group." - } - } - }, - "BuildReRunV2": { - "type": "object", - "properties": { - "retryData": { - "type": "array", - "items": { - "$schema": "RerunGroup" - }, - "description": "Re-run groups, each with per-split breakdowns." - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - }, - "summary": { - "$schema": "RerunSummary" - } - } - }, - "FilterField": { - "type": "object", - "description": "A single selectable filter option. `id`/`value` are what you send back as the filter selection; `label` is the display text. For branch/tag options all three are the raw value; for user/team options `id` is the numeric id (stringified) and value/label are the name.", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "string" - }, - "label": { - "type": "string" - }, - "count": { - "type": "integer", - "description": "Match count for this option; usually null on these facet lists.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Optional parent-id grouping token; empty string on these lists.", - "nullable": true - } - } - }, - "BuildFilterStatic": { - "type": "object", - "description": "Static catalogue of selectable build-listing filter options. Each list is the full menu of choices, not a presence list.", - "properties": { - "statusList": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "frameworkList": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "types": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "sources": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "creationType": { - "type": "array", - "items": { - "$schema": "FilterField" - } - } - } - }, - "BuildFilterApplied": { - "type": "object", - "description": "The caller's applied build-listing filter, echoed back in resolved form (ids turned into name-bearing objects). Anything not sent comes back null (not an empty list).", - "properties": { - "uniqueBuildNames": { - "type": "array", - "items": { - "$schema": "FilterField" - }, - "nullable": true - }, - "statuses": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "dateRange": { - "type": "object", - "nullable": true, - "description": "Resolved range {lowerBound, upperBound} (epoch ms); null when not sent.", - "properties": { - "lowerBound": { - "type": "integer" - }, - "upperBound": { - "type": "integer" - } - } - }, - "frameworks": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Raw framework values echoed back.", - "nullable": true - }, - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, - "nullable": true - }, - "assignees": { - "type": "array", - "nullable": true - }, - "teams": { - "type": "array", - "nullable": true - }, - "buildGroupIds": { - "type": "array", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Echo of the request's buildTags (build-level tags)." - }, - "branches": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "search": { - "type": "string", - "nullable": true, - "description": "Echo of the request's `search` field (not `query`)." - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "state": { - "type": "string" - }, - "types": { - "type": "array", - "nullable": true - }, - "sources": { - "type": "array", - "nullable": true - }, - "excludeLocked": { - "type": "boolean", - "nullable": true - }, - "isCiRun": { - "type": "boolean", - "nullable": true - }, - "selfHealed": { - "type": "boolean", - "nullable": true - }, - "aiAuthored": { - "type": "boolean", - "nullable": true - }, - "appCrash": { - "type": "boolean", - "nullable": true - }, - "selfHosted": { - "type": "boolean", - "nullable": true - }, - "creationType": { - "type": "array", - "nullable": true - }, - "testCasesStatuses": { - "type": "array", - "nullable": true - } - } - }, - "FilterStruct__w2_build_filter1": { - "type": "object", - "description": "A static test-filter option: a display label and the value to send back. `pId` is present only for hierarchical options (e.g. folder trees).", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "pId": { - "type": "string" - } - } - }, - "Dashboard": { - "type": "object", - "description": "A custom dashboard's full definition, as returned under `data` by the dashboard read/create/update/clone endpoints.", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "Visibility level (1 or 2)." - }, - "filters": { - "type": "object", - "description": "Saved global filters (DashboardFilters shape).", - "nullable": true - }, - "widgets": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Widget definitions with id/name/type/config/layout.", - "nullable": true - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id." - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer", - "nullable": true - }, - "modifiedByName": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdByName": { - "type": "string" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM.", - "nullable": true - }, - "type": { - "type": "string", - "description": "Dashboard | View | TABULAR_TEST_RUNS | BUILD_COMPARISON." - }, - "isDefault": { - "type": "boolean", - "nullable": true - }, - "isAutomation": { - "type": "boolean", - "nullable": true - }, - "reports": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Report-widget definitions (present for report-type dashboards).", - "nullable": true - } - } - }, - "MetaItem": { - "type": "object", - "description": "An id resolved to its display name.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, - "ErrorSearchResult": { - "type": "object", - "description": "A unique-error search hit.", - "properties": { - "errorClusterId": { - "type": "string", - "description": "Stable id of the unique-error cluster." - }, - "error": { - "type": "string", - "description": "The error signature text." - } - } - }, - "FilterDetails": { - "type": "object", - "description": "A custom field offered as a dashboard widget filter.", - "properties": { - "id": { - "type": "integer", - "description": "Custom-field id." - }, - "label": { - "type": "string", - "description": "Custom-field name/key." - }, - "type": { - "type": "string", - "description": "Filter type: Basic or Custom (always Custom on this route)." - }, - "fieldType": { - "type": "string", - "description": "The custom field's data type." - } - } - }, - "DashboardPageInfo": { - "type": "object", - "description": "Pagination cursor for the paginated widget-custom-field-filters response.", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "ReportSummary": { - "type": "object", - "description": "One row of the reports/dashboards listing. Many fields are conditionally present (external-integration and project fields only appear for scheduled/linked reports).", - "properties": { - "id": { - "description": "Dashboard/report id (UUID or numeric depending on row)." - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer" - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM." - }, - "type": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "isAutomation": { - "type": "boolean" - }, - "external_report_id": { - "type": "integer" - }, - "report_type": { - "type": "string" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - }, - "frequency": { - "type": "string", - "description": "Schedule frequency (scheduled reports only)." - }, - "next_run_at": { - "type": "string", - "format": "date-time" - }, - "identifier": { - "type": "string" - }, - "is_scheduled": { - "type": "boolean" - }, - "owner": { - "type": "object", - "description": "{id, full_name, email, browserstack_user_id}." - }, - "project_id": { - "type": "integer", - "description": "The TestHub project id (thProjectId), not the TRA project id." - }, - "project_name": { - "type": "string" - }, - "projects": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Linked projects as {project_id, project_name}." - } - } - }, - "ReportListPageInfo": { - "type": "object", - "description": "Pagination cursor for the reports listing.", - "properties": { - "page": { - "type": "integer" - }, - "next": { - "type": "integer", - "nullable": true - }, - "prev": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer" - }, - "page_size": { - "type": "integer" - } - } - }, - "TakeAwayMetric": { - "type": "object", - "description": "A single Home takeaway metric: current value and the previous-window value.", - "properties": { - "value": { - "type": "number" - }, - "prev": { - "type": "number", - "nullable": true, - "description": "Previous-window value; null (time-savings cards) or 0 when the prior 7 days have no data." - } - } - }, - "TakeAwayMetricV2": { - "type": "object", - "description": "A v2 failure-noise category: absolute count and its percentage of total failures.", - "properties": { - "count": { - "type": "integer" - }, - "reduction": { - "type": "number", - "description": "This category as a percentage of totalFailures (for uniqueErrors it is the complementary reduction)." - } - } - }, - "TakeAwayData": { - "type": "object", - "description": "Home key-takeaway cards, each metric comparing the current 7 days to the prior 7 days.", - "properties": { - "stability": { - "$schema": "TakeAwayMetric" - }, - "uniqueTestExecutions": { - "$schema": "TakeAwayMetric" - }, - "newFailures": { - "$schema": "TakeAwayMetric" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetric" - }, - "flakiness": { - "$schema": "TakeAwayMetric" - }, - "failures": { - "$schema": "TakeAwayMetric" - } - } - }, - "TimeSavingMetric": { - "type": "object", - "description": "Aggregate time-saved breakdown returned by the older Home takeaways variant.", - "properties": { - "timeSavings": { - "$schema": "TakeAwayMetric" - }, - "testToVerify": { - "$schema": "TakeAwayMetric" - }, - "uniqueErrors": { - "$schema": "TakeAwayMetric" - }, - "flakiness": { - "$schema": "TakeAwayMetric" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetric" - }, - "autoTagged": { - "$schema": "TakeAwayMetric" - } - } - }, - "TimeSavingV2Metric": { - "type": "object", - "description": "v2 failure-noise breakdown returned by the newer Home takeaways variant. When totalFailures is 0 the per-category objects are omitted.", - "properties": { - "totalFailures": { - "type": "integer" - }, - "flakyFailing": { - "$schema": "TakeAwayMetricV2" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetricV2" - }, - "autoAnalyzedFailure": { - "$schema": "TakeAwayMetricV2" - }, - "uniqueErrors": { - "$schema": "TakeAwayMetricV2" - } - } - }, - "ProjectExternal": { - "type": "object", - "description": "A project as exposed by the public/external API.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "created_by": { - "type": "integer", - "description": "User id of the project owner." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Derived from the project's finishedAt; can be null.", - "nullable": true - }, - "observability_url": { - "type": "string", - "description": "Ready-made deep link to the project's builds page." - } - } - }, - "ExternalPagination": { - "type": "object", - "description": "Cursor pagination envelope for the external API.", - "properties": { - "has_next": { - "type": "boolean", - "description": "True whenever the current page was non-empty; the final full page is followed by one empty page." - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor to pass as next_page for the following page." - } - } - }, - "RecentView": { - "type": "object", - "description": "One recently-viewed item on the Home page.", - "properties": { - "type": { - "type": "string", - "description": "Item kind (e.g. dashboard, build, view)." - }, - "link": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "Field": { - "type": "object", - "description": "A filter option / type-ahead suggestion item as returned across the Suite Health filter routes.", - "properties": { - "id": { - "type": "string", - "description": "Stable identifier for the option (often equal to value)." - }, - "value": { - "type": "string", - "description": "The raw value to send back when applying this option as a filter." - }, - "label": { - "type": "string", - "description": "Human-readable display label." - }, - "count": { - "type": "integer", - "description": "Occurrence count; null on suggestion routes that do not compute it.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Parent id for hierarchical options (e.g. folders); null or empty when not applicable.", - "nullable": true - } - } - }, - "TimelineDataPoint": { - "type": "object", - "description": "One time-bucketed chart point.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket timestamp (epoch ms)." - }, - "t": { - "type": "integer", - "description": "Secondary timestamp/marker for the bucket; may be null." - }, - "y": { - "type": "number", - "description": "Value at the bucket (count, average, or percentage depending on the series); null when the series has no value for this bucket." - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "The [start, end] epoch-ms span the bucket covers (present on the failure-rate series)." - } - } - }, - "SPChart": { - "type": "object", - "description": "A Suite Health chart: named data series plus a summary insight.", - "properties": { - "data": { - "type": "object", - "description": "Map of series name -> array of TimelineDataPoint. Series keys depend on the chart (e.g. totalExecutions/totalFailures, averageDuration, averageFailureRates).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "TimelineDataPoint" - } - } - }, - "insights": { - "type": "object", - "description": "Headline summary for the chart.", - "properties": { - "metric": { - "type": "string", - "description": "Metric label, e.g. 'Total Failures'." - }, - "value": { - "type": "string", - "description": "Formatted headline value, e.g. '12/340' or '3.5%' or '02m 10s'." - }, - "meta": { - "type": "string", - "description": "Optional secondary text; null when not set." - } - } - } - } - }, - "SPTestBreakdownRow": { - "type": "object", - "description": "One environment (OS/browser/device) row in a test's stability breakdown.", - "properties": { - "id": { - "type": "string", - "description": "Composite browser-platform key, e.g. 'chrome-windows'." - }, - "reliability": { - "type": "number", - "description": "Failure percentage (failed*100/total), 2 decimals; 0 when there were no executions." - }, - "duration": { - "type": "number", - "description": "Average duration for this environment." - }, - "os": { - "type": "object", - "description": "Formatted platform descriptor." - }, - "browser": { - "type": "object", - "description": "Formatted browser descriptor, with a nested `device` field." - }, - "browserDevice": { - "type": "object", - "description": "Alternate browser descriptor carrying the full device object." - }, - "totalFailures": { - "type": "object", - "description": "Failure tally for this environment.", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - } - } - }, - "AppLogResponse": { - "type": "object", - "description": "Parsed application-log response from /api/v1/appLogs/query.", - "properties": { - "status": { - "type": "string", - "description": "'success', or 'failure' (with empty logs) when Loki reported a non-success status.", - "values": [ - "success", - "failure" - ] - }, - "appLogQuery": { - "type": "object", - "description": "The echoed Loki query parameters the service built from the run.", - "properties": { - "groups": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Group (tenant) ids the query was scoped to." - }, - "apps": { - "type": "array", - "items": { - "type": "string" - } - }, - "envs": { - "type": "array", - "items": { - "type": "string" - } - }, - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time" - } - } - }, - "logs": { - "type": "array", - "description": "Flat, timestamp-sorted app-log lines (abnormal/error level only). Empty on a clean run or a Loki non-success.", - "items": { - "$schema": "AppLog" - } - } - } - }, - "AppLog": { - "type": "object", - "description": "One parsed application-log line.", - "properties": { - "app": { - "type": "string", - "description": "App name from the Loki stream." - }, - "env": { - "type": "string", - "description": "Environment from the Loki stream." - }, - "level": { - "type": "string", - "description": "Log level; 'unknown' when Loki did not tag a level." - }, - "epochNano": { - "type": "integer", - "description": "Timestamp in epoch nanoseconds; the list is sorted ascending by this." - }, - "line": { - "type": "string", - "description": "Log text after the ' cSep=S ' separator; the raw line when the separator is absent." - } - } - }, - "LogUrls": { - "type": "object", - "description": "Test-run log bundle as URLs plus metadata (from /ext/v1/testRun/{id}/logs and the v2 consolidated-logs route).", - "properties": { - "testStartedAt": { - "type": "string", - "format": "date-time" - }, - "testFinishedAt": { - "type": "string", - "format": "date-time" - }, - "browserstackLogs": { - "$schema": "SessionLogs", - "description": "Per-type BrowserStack session log URLs." - }, - "sdkLogs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "SDK log URLs." - }, - "hookRunLogs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Hook-run log URLs." - }, - "failureLogs": { - "type": "array", - "items": { - "$schema": "GenericLog" - }, - "description": "Inline failure log entries." - }, - "stepLogs": { - "type": "array", - "items": { - "$schema": "GenericLog" - }, - "description": "Inline step log entries." - }, - "framework": { - "type": "string" - }, - "sessionId": { - "type": "string" - } - } - }, - "SessionLogs": { - "type": "object", - "description": "Per-type session log artefacts (URLs / nodes). Null-valued types are omitted (JsonInclude NON_NULL).", - "properties": { - "text": { - "type": "object", - "description": "Raw/text log artefact." - }, - "console": { - "type": "object" - }, - "selenium": { - "type": "object" - }, - "appium": { - "type": "object" - }, - "playwright": { - "type": "object" - }, - "performance": { - "type": "object", - "description": "{urls: [], count}." - }, - "network": { - "$schema": "SessionNetwork" - }, - "device": { - "type": "object" - }, - "video": { - "type": "object", - "description": "{url, offsetUrl, status_code, startTime, startTimeStamp, startOffset, finishOffset, launchingAppTime, isIos, isV2}." - }, - "audio": { - "type": "object" - }, - "screenshots": { - "type": "array", - "items": { - "type": "string" - } - }, - "terminal": { - "type": "object", - "description": "{session: {url}, build: {url}}." - }, - "telemetry": { - "type": "object", - "description": "{isSupported, url?}." - }, - "crash": { - "type": "object" - }, - "appProfiling": { - "type": "object" - }, - "instrumentation": { - "type": "object" - }, - "browserprofiling": { - "type": "object" - }, - "issues": { - "type": "object" - }, - "maestro": { - "type": "object" - }, - "commandLogs": { - "type": "object" - }, - "maestroScreenshot": { - "type": "object" - }, - "config": { - "type": "object" - }, - "cypress_screenshots": { - "type": "object", - "description": "Serialised as cypress_screenshots." - } - } - }, - "SessionNetwork": { - "type": "object", - "description": "A session's network (HAR) log artefact pointer.", - "properties": { - "status_code": { - "type": "integer", - "description": "Upstream fetch status code." - }, - "url": { - "type": "string", - "description": "URL of the HAR file." - }, - "message": { - "type": "string", - "description": "Upstream message, if any." - } - } - }, - "SessionNetworkLogUrlResponse": { - "type": "object", - "description": "Envelope returned by /api/v1/sessions/{sessionId}/networkLogs (unparsed form): the HAR URL plus fetch status.", - "properties": { - "networkLogs": { - "type": "string", - "description": "URL of the session's HAR network log file (NOT the parsed entries)." - }, - "status_code": { - "type": "integer", - "description": "Upstream fetch status code." - }, - "message": { - "type": "string", - "description": "Upstream message, if any." - } - } - }, - "GenericLog": { - "type": "object", - "description": "One log entry in a merged/consolidated log stream. Null fields are omitted (JsonInclude NON_NULL).", - "properties": { - "logLevel": { - "type": "string", - "description": "Log level enum (e.g. ERROR, INFO)." - }, - "logType": { - "type": "string", - "description": "Log source/type enum (e.g. FAILURE, APPLICATION_LOGS)." - }, - "timeStamp": { - "type": "string", - "format": "date-time" - }, - "finishedTimeStamp": { - "type": "string", - "format": "date-time" - }, - "content": { - "type": "string", - "description": "Inline log text (present because content is merged server-side)." - }, - "url": { - "type": "string", - "description": "URL for entries backed by an external artefact rather than inline content." - }, - "duration": { - "type": "number", - "description": "Duration in seconds, formatted to 2 decimals." - }, - "startOffset": { - "type": "integer" - }, - "finishOffset": { - "type": "integer" - }, - "noOfSimilarErrors": { - "type": "integer" - }, - "customHookRunUuid": { - "type": "string" - }, - "customHookType": { - "type": "string" - }, - "failure": { - "type": "string" - }, - "message": { - "type": "string" - }, - "selfHealData": { - "type": "string" - }, - "aiAuthoringData": { - "type": "string" - }, - "logSubType": { - "type": "string" - } - } - }, - "TestDetailResponse": { - "type": "object", - "description": "Per-hook (or per-test) detail bundle: its test code, opaque metadata and any hook code.", - "properties": { - "testCode": { - "$schema": "TestCodeResponse", - "description": "The hook's source/file info; null for SDK-ingested hooks without captured code.", - "nullable": true - }, - "testMetadata": { - "type": "object", - "description": "Test-run metadata: type (BEFORE_ALL/AFTER_ALL...), testName, status, isFlaky, buildId, origin, issueType, observabilityVersion, scopes, isNewFailure, isAlwaysFailing, plus many null keys." - }, - "hooksCode": { - "type": "array", - "items": { - "$schema": "TestCodeResponse" - }, - "description": "Source for each associated hook.", - "nullable": true - } - } - }, - "TestCodeResponse": { - "type": "object", - "description": "A test's source code and location.", - "properties": { - "code": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string", - "description": "VCS URL to the file, built from the run's VCS info." - }, - "language": { - "type": "string" - } - } - }, - "TestRunCode": { - "type": "object", - "description": "Captured source for a test or hook run. code/language are null when nothing was captured for that run; the row is still returned.", - "properties": { - "language": { - "type": "string" - }, - "code": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "testRunId": { - "type": "integer" - }, - "type": { - "type": "string", - "description": "Run type (test vs hook kind)." - } - } - }, - "TestRunDetail": { - "type": "object", - "description": "Full detail of a single test run: its captured code, its metadata, and the code of the hooks that ran around it.", - "properties": { - "testCode": { - "$schema": "TestRunCode", - "nullable": true, - "description": "Captured code; null when nothing was captured (e.g. JUnit-report builds)." - }, - "testMetadata": { - "type": "object", - "description": "Metadata for the run - the broad internal test-meta object (testName, status, buildId, history, retries, testCase, projectId, device/cbtBrowser/cbtPlatform, observabilityVersion, origin, workflowStatus*, errorClusterId, isAutoAnalyzed*, ... many null). Read keys off the actual response." - }, - "hooksCode": { - "type": "array", - "description": "Code of before/after hooks. Populated on get_test_run_details; the key is present but null on the baseline map read.", - "items": { - "$schema": "TestRunCode" - }, - "nullable": true - } - } - }, - "BuildInsights": { - "type": "object", - "description": "Build-level failure/flakiness insights surrounding a test run.", - "properties": { - "buildId": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer" - }, - "summary": { - "type": "object", - "properties": { - "statusMetrics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" - } - } - } - }, - "totalTests": { - "type": "integer" - }, - "totalHooks": { - "type": "integer" - } - } - }, - "failureCategorization": { - "type": "object", - "properties": { - "newFailures": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "flaky": { - "type": "integer" - } - } - }, - "failureContext": { - "type": "object", - "description": "Per-run failure context: file and parent-module failure rates for the run under inspection.", - "properties": { - "file": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failedCount": { - "type": "integer" - }, - "failureRate": { - "type": "number", - "description": "failedCount/testCount * 100" - } - } - }, - "parentModule": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failedCount": { - "type": "integer" - }, - "failureRate": { - "type": "number" - } - } - } - } - }, - "topErrorsAnalysis": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty/partial unless COMPLETE." - }, - "uniqueErrorCount": { - "type": "integer" - }, - "uniqueErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "clusterId": { - "type": "string" - }, - "occurrences": { - "type": "integer" - }, - "truncatedError": { - "type": "string", - "description": "First ~200 chars of the error for a quick overview." - } - } - } - } - } - }, - "defectStats": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issueType": { - "type": "string" - }, - "totalFailureTestsCount": { - "type": "integer" - }, - "totalFailureHooksCount": { - "type": "integer" - }, - "categories": { - "type": "array", - "items": { - "type": "object", - "properties": { - "category": { - "type": "string" - }, - "failureTestCount": { - "type": "integer" - }, - "failureHooksCount": { - "type": "integer" - } - } - } - } - } - } - } - } - }, - "BuildTestRunWithAttachments": { - "type": "object", - "description": "Trimmed test-run row for the 'runs having attachments' discovery listing.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "build_id": { - "type": "integer" - }, - "attachments_count": { - "type": "integer" - }, - "screenshots_count": { - "type": "integer" - } - } - }, - "FlatTestRun": { - "type": "object", - "description": "One row of the flat build test-run listing. Top-level `id` is the test HASH (string); the numeric run id is `details.id`.", - "properties": { - "id": { - "type": "string", - "description": "Test hash, NOT the numeric run id." - }, - "title": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric test run id." - }, - "status": { - "type": "string" - }, - "os": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "browser": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "device": { - "type": "string" - }, - "isRealDevice": { - "type": "boolean" - }, - "filePath": { - "type": "string" - }, - "vcFileUrl": { - "type": "string" - }, - "issueType": { - "type": "object" - }, - "aiRcaState": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isMuted": { - "type": "boolean" - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "VcsCheckMapping": { - "type": "object", - "description": "A vcs_ingestion row: a PR check registered against a check configuration for evaluation. Serialised directly from the JPA entity (no wrapper).", - "properties": { - "id": { - "type": "integer", - "description": "Ingestion row id \u2014 the id used by get_checks_mapping." - }, - "prNumber": { - "type": "integer", - "description": "Pull request number." - }, - "commitSha": { - "type": "string" - }, - "callbackUrl": { - "type": "string", - "description": "URL the evaluation pipeline calls back with results." - }, - "checkStates": { - "type": "string", - "description": "Evaluation state; newly created rows are 'Not Started'." - }, - "checkType": { - "type": "string", - "description": "QG_STATUS or BUILD_STATUS, copied from the matched check configuration.", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "retryCount": { - "type": "integer", - "description": "Evaluation retry count; 0 on creation." - }, - "splitRepos": { - "type": "boolean" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id, copied from the matched check configuration." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "commitTimestamp": { - "type": "string", - "format": "date-time", - "description": "Parsed from pr_data.date; may be unset when the date was omitted." - }, - "prMetadataJson": { - "type": "object", - "description": "The pr_data object stored verbatim, with githubCheckId merged in." - }, - "thProjectId": { - "type": "integer", - "description": "Target project id, copied from the matched check configuration." - }, - "buidlParentId": { - "type": "integer", - "description": "Build parent id (field is spelled 'buidlParentId' in the payload); typically null at creation." - }, - "checkConfiguration": { - "$schema": "CheckConfigurationEntity" - } - } - }, - "CheckConfigurationEntity": { - "type": "object", - "description": "A checks_configuration row as embedded inside a vcs_ingestion mapping (the raw JPA entity, camelCase fields).", - "properties": { - "id": { - "type": "integer", - "description": "Check-configuration id." - }, - "checkName": { - "type": "string" - }, - "repoName": { - "type": "string" - }, - "vcsName": { - "type": "string" - }, - "vcsOrgName": { - "type": "string" - }, - "splitRepos": { - "type": "boolean" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "branchName": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "checkType": { - "type": "string", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "commitCheck": { - "type": "boolean" - }, - "isEnabled": { - "type": "boolean" - }, - "buildName": { - "type": "string" - }, - "buildTags": { - "type": "string" - }, - "thProjectId": { - "type": "integer" - } - } - }, - "CheckConfigurationItem": { - "type": "object", - "description": "A single check-run configuration as returned by the check-runs endpoints (CheckConfigurationItemResponse).", - "properties": { - "checkId": { - "type": "integer", - "description": "Check-configuration id \u2014 the id used in the update/toggle/delete path." - }, - "checkName": { - "type": "string" - }, - "repoName": { - "type": "string" - }, - "vcsOrgName": { - "type": "string" - }, - "vcsName": { - "type": "string" - }, - "checkType": { - "type": "string", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "splitRepos": { - "type": "boolean" - }, - "projectId": { - "type": "integer", - "description": "Resolved BrowserStack project id for projectName." - }, - "projectName": { - "type": "string" - }, - "branchName": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "buildTags": { - "type": "string" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "commitCheck": { - "type": "boolean" - }, - "isEnabled": { - "type": "boolean" - } - } - }, - "View": { - "type": "object", - "description": "A saved view (filters row) as returned by the view endpoints.", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "View id (UUID)." - }, - "type": { - "type": "integer", - "description": "Screen type code: 1 builds, 2 testRuns, 3 testHealth, 4 uniqueErrors, 5 buildInsights." - }, - "query": { - "type": "string", - "description": "The stored serialised filter." - }, - "level": { - "type": "integer", - "description": "1 = private, 2 = public/shared.", - "enum": [ - 1, - 2 - ] - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Optional free-text description; typically null (not settable through create/update)." - }, - "createdBy": { - "type": "integer", - "description": "Creator user id (from the session, not the body)." - }, - "groupId": { - "type": "integer", - "description": "Owning group id (from the session, not the body)." - } - } - }, - "DashboardView": { - "type": "object", - "description": "A dashboard view as returned by the dashboards/views read, create, update and clone endpoints (the object under the response `data` key).", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Dashboard UUID." - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "Visibility level (2 = group/shared by default)." - }, - "filters": { - "type": "object", - "description": "Global dashboard filters (DashboardFilters): dateRange, tags, buildTags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields." - }, - "widgets": { - "type": "array", - "description": "Widget layout (Widgets objects: id, name, type, globalFilters, desc, config, layout).", - "items": { - "type": "object" - } - }, - "totalWidget": { - "type": "integer", - "description": "Count of widgets on the dashboard." - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id." - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "modifiedByName": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdByName": { - "type": "string" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM." - }, - "type": { - "type": "string", - "description": "DashboardType." - }, - "isDefault": { - "type": "boolean", - "description": "True for the built-in default dashboard (date range then forced to last 30 days)." - }, - "isAutomation": { - "type": "boolean", - "description": "True for the built-in automation-trend dashboard." - }, - "reports": { - "type": "array", - "description": "Report widgets, when present.", - "items": { - "type": "object" - } - } - } - }, - "WidgetCatalogueEntry": { - "type": "object", - "description": "One entry in the addable-widget catalogue (WidgetDetails).", - "properties": { - "name": { - "type": "string", - "description": "Display name, e.g. 'Test Case Statistics'." - }, - "key": { - "type": "string", - "description": "WidgetTypeEnum value to use as a widget's `type`." - } - } - }, - "CustomFieldFilterDetail": { - "type": "object", - "description": "A custom (user-defined) test-case field usable as a dashboard-widget filter (FilterDetails).", - "properties": { - "id": { - "type": "integer", - "description": "Custom field id." - }, - "label": { - "type": "string", - "description": "Filter key / display label." - }, - "type": { - "type": "string", - "description": "FilterType \u2014 'Custom' for custom-field filters." - }, - "fieldType": { - "type": "string", - "description": "The underlying field data type." - } - } - }, - "ReportRecord": { - "type": "object", - "description": "A saved report (the DashboardsData shape; reports and dashboards share it). Nulls are included. Widgets for a report-typed record are under `reports`; for a dashboard-typed record under `widgets`.", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "1 = private, 2 = group." - }, - "filters": { - "type": "object", - "description": "Saved global filters (DashboardFilters shape); null in the listing.", - "nullable": true - }, - "widgets": { - "type": "array", - "description": "Widget list for a dashboard-typed record; null for a report-typed one.", - "nullable": true - }, - "reports": { - "type": "array", - "description": "Widget list for a report-typed record ({id, name, type, config, layout, reportType}); null in the listing.", - "nullable": true - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer", - "nullable": true - }, - "modifiedByName": { - "type": "string", - "nullable": true - }, - "createdByName": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM.", - "nullable": true - }, - "type": { - "type": "string" - }, - "isDefault": { - "type": "boolean", - "nullable": true - }, - "isAutomation": { - "type": "boolean", - "nullable": true - } - } - }, - "TestRunReportRow": { - "type": "object", - "description": "One row in a tabular test-runs report dataset OR one build row in the static-builds listing (the same TestRunReport class, populated differently per endpoint). A large flat row; only representative fields are listed. For datasets these are test-run rows (testName, status, flakiness, customFields, \u2026); for static-builds these are build rows (buildId, buildName, buildNumber, displayStatus, buildStartedAt, workflowStatusStats, \u2026).", - "properties": { - "uuid": { - "type": "string" - }, - "testName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "buildId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "displayStatus": { - "type": "string", - "description": "Build display status (static-builds rows)." - }, - "projectId": { - "type": "integer" - }, - "customFields": { - "type": "object" - }, - "isFlaky": { - "type": "boolean" - }, - "flakiness": { - "type": "number" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - } - } - }, - "PaginationSummary": { - "type": "object", - "description": "Paging summary for report datasets/builds. totalDatasets is -1 on these routes (not a real total); page using hasNext.", - "properties": { - "totalDatasets": { - "type": "integer" - }, - "paginatedResults": { - "type": "integer" - }, - "hasNext": { - "type": "boolean" - } - } - }, - "StatTile": { - "type": "object", - "description": "One aggregate stat tile: a metric key and its value.", - "properties": { - "meta": { - "type": "string", - "description": "Metric key, e.g. totalUniqueTests, flakyTestsCount, totalPassedExecutions." - }, - "value": { - "type": "integer" - } - } - }, - "ReportCustomFieldItem": { - "type": "object", - "description": "A custom-field / column definition usable in a report.", - "properties": { - "fieldId": { - "type": "integer" - }, - "fieldName": { - "type": "string" - }, - "fieldType": { - "type": "string" - }, - "entityType": { - "type": "string", - "description": "TestCase or TestExecution \u2014 the only thing distinguishing two same-named fields." - } - } - }, - "ReportPageInfo": { - "type": "object", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "ReportTestStats": { - "type": "object", - "description": "Headline stats for one test in the tabular report. An empty object `{}` is returned when the test has no matching runs.", - "properties": { - "reliability": { - "type": "number", - "description": "Failure ratio as a percentage: failed*100/total." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "failureOccurrence": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - } - } - }, - "ReportTrendDataPoint": { - "type": "object", - "description": "One point in a test's trend series. Null fields are omitted.", - "properties": { - "color": { - "type": "string", - "description": "Colour derived from the workflow status." - }, - "workflowStatus": { - "type": "string" - }, - "y": { - "type": "integer", - "description": "Run duration in MILLISECONDS." - }, - "id": { - "type": "integer", - "description": "Test run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id." - }, - "buildName": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "flakyReason": { - "type": "integer" - } - } - }, - "ReportTestTrendResponse": { - "type": "object", - "description": "Per-run trend series for one test.", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Build started-at epochs, index-aligned to `data`." - }, - "data": { - "type": "array", - "items": { - "$schema": "ReportTrendDataPoint" - } - } - } - }, - "TabularReportRow": { - "type": "object", - "description": "One row of the tabular test-runs report as built by the report handler (only the fields this path populates).", - "properties": { - "testId": { - "type": "integer" - }, - "globalTestHash": { - "type": "string" - }, - "testCaseId": { - "type": "integer" - }, - "folderId": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "avgDuration": { - "type": "number", - "description": "Average duration in ms." - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAutoAnalyzerRunning": { - "type": "boolean" - }, - "buildId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "originalBuildName": { - "type": "string" - }, - "buildTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "buildNumber": { - "type": "integer" - }, - "projectId": { - "type": "integer" - }, - "buildStartedAt": { - "type": "string", - "format": "date-time" - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "normalisedBuildName": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Normalised workflow status, or 'unknown'." - }, - "workflowStatus": { - "type": "string" - }, - "smartTagSettings": { - "type": "object", - "description": "Present only for builds newer than the applied schema date." - } - } - }, - "TabularTestRunsReportResponse": { - "type": "object", - "description": "Paginated tabular test-runs report page. Empty result returns null data and null summary.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TabularReportRow" - } - }, - "summary": { - "type": "object", - "properties": { - "totalDatasets": { - "type": "integer", - "description": "-1 on these routes (not a real total)." - }, - "paginatedResults": { - "type": "integer", - "description": "Count of rows on this page." - }, - "hasNext": { - "type": "boolean", - "description": "True when the page came back full." - } - } - }, - "sourceFencingFlags": { - "type": "object", - "description": "Present only when non-null.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "ReportStatTilesResponse": { - "type": "object", - "description": "Summary tiles for the tabular report.", - "properties": { - "data": { - "type": "array", - "description": "Tiles. Empty/absent when all datasets conflict or no stats were returned.", - "items": { - "type": "object", - "properties": { - "meta": { - "type": "string", - "description": "Tile name (e.g. totalUniqueTests). Note the key is `meta`, not `name`." - }, - "value": { - "type": "integer" - } - } - } - }, - "sourceFencingFlags": { - "type": "object", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "WidgetFilterValue": { - "type": "object", - "description": "A single selectable filter value.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true - }, - "pId": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - } - } - }, - "WidgetFilterValueList": { - "type": "object", - "description": "Flat list of values for one widget filter key.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - } - }, - "WidgetEnhancedFilterResponse": { - "type": "object", - "description": "Widget filter values bucketed with paging cursors.", - "properties": { - "data": { - "type": "object", - "description": "Map keyed by bucket (e.g. suggested / others); each value is a list of filter-value structs.", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - }, - "fullList": { - "type": "boolean", - "nullable": true - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - }, - "nullable": true - }, - "suggestedAfterKey": { - "type": "string", - "description": "Cursor to continue the suggested bucket.", - "nullable": true - }, - "othersAfterKey": { - "type": "string", - "description": "Cursor to continue the others bucket.", - "nullable": true - } - } - }, - "WidgetDrillDownData": { - "type": "object", - "description": "Row-level records behind a widget data point.", - "properties": { - "data": { - "type": "array", - "description": "Drill-down rows. Each carries project identity (projectId, thProjectId, projectName, normalisedName) plus widget-specific fields.", - "items": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "insights": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "meta": { - "type": "string" - } - } - }, - "isConflicting": { - "type": "boolean" - }, - "isEmptyNumerator": { - "type": "boolean" - }, - "isEmptyDenominator": { - "type": "boolean" - } - } - }, - "ParallelQueueSummaryDetails": { - "type": "object", - "description": "Per-product concurrency figures.", - "properties": { - "product": { - "type": "string" - }, - "totalParallelsUsed": { - "type": "integer" - }, - "maximumParallelsAllowed": { - "type": "integer" - }, - "totalQueuedSessions": { - "type": "integer" - }, - "maximumQueueAllowed": { - "type": "integer" - }, - "userLevelQueuedSessions": { - "type": "integer" - }, - "totalParallelsBreakdown": { - "type": "object", - "description": "Per-pool breakdown." - }, - "maximumParallelsBreakdown": { - "type": "object" - }, - "totalQueuedBreakdown": { - "type": "object" - }, - "maximumQueueBreakdown": { - "type": "object" - } - } - }, - "ParallelQueueUsageSummary": { - "type": "object", - "description": "Group-level parallel-queue summary.", - "properties": { - "summary": { - "type": "array", - "description": "Per-product SummaryDetails; present only when a product was supplied.", - "items": { - "$schema": "ParallelQueueSummaryDetails" - } - }, - "testExecutionsUsage": { - "type": "object", - "description": "Plan test-execution usage block." - } - } - }, - "ParallelQueueTeamUsage": { - "type": "object", - "description": "Per-team and per-member parallel-session usage.", - "properties": { - "summary": { - "$schema": "ParallelQueueSummaryDetails" - }, - "teams": { - "type": "array", - "items": { - "type": "object", - "properties": { - "teamName": { - "type": "string" - }, - "totalCount": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "maximumTeamParallelsAllowed": { - "type": "integer" - }, - "members": { - "type": "array", - "items": { - "type": "object", - "properties": { - "userId": { - "type": "integer" - }, - "userName": { - "type": "string" - }, - "parallelsUsed": { - "type": "integer" - }, - "sessionQueued": { - "type": "integer" - }, - "lastBuildRanAt": { - "type": "integer" - }, - "showRunningBuildsButton": { - "type": "boolean" - }, - "parallelsBreakdown": { - "type": "object" - }, - "sessionQueuedBreakdown": { - "type": "object" - } - } - } - } - } - } - } - } - }, - "RunningBuildsResponse": { - "type": "object", - "description": "A user's currently-running builds.", - "properties": { - "data": { - "type": "array", - "description": "Running builds in the standard build-summary shape (uuid, id, name, status, duration, startedAt, projectId, tags, statusStats, ...).", - "items": { - "$schema": "BuildSummary" - } - } - } - }, - "DeviceTierUsageInfo": { - "type": "object", - "description": "Aggregated mobile device-tier usage and device list for a product. Both fields are raw JSON trees from the backend.", - "properties": { - "tierInfo": { - "type": "array", - "description": "One row per mobile tier: {id, name, maxUsed, maxAvailable}.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Tier id, e.g. Tier1." - }, - "name": { - "type": "string" - }, - "maxUsed": { - "type": "integer" - }, - "maxAvailable": { - "type": "integer" - } - } - } - }, - "devices": { - "type": "object", - "description": "Map of tier id (Tier1..Tier4) to the array of devices in that tier (each carrying maxParallels and parallelsUsed).", - "additionalProperties": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "TestExecutionUsageCount": { - "type": "object", - "description": "Execution counts for a window. NOTE: when both userId and groupId are missing the endpoint instead returns the plain string 'Missing userId or groupId'.", - "properties": { - "totalTestCount": { - "type": "integer", - "description": "All tests reported in the window." - }, - "browserstackTestCount": { - "type": "integer", - "description": "Subset that ran on BrowserStack infra (carry a sessionId)." - } - } - }, - "GroupPlanUsageDetails": { - "type": "object", - "description": "Plan info/details plus non-BrowserStack usage figures. All counts are compacted display strings (e.g. '1.2K'). Usage fields are null when the group has no execution limit; the null-branch uses `nonBrowserstackTestExecutions` in place of `nonBrowserstackUsedTestExecutions`.", - "properties": { - "planInfo": { - "type": "object" - }, - "planDetails": { - "type": "object" - }, - "nonBrowserstackTotalTestExecutions": { - "type": "string" - }, - "nonBrowserstackUsedTestExecutions": { - "type": "string", - "description": "Populated branch only." - }, - "nonBrowserstackTestExecutions": { - "type": "string", - "description": "Null-branch key only (when no execution limit)." - }, - "nonBrowserstackPercentageOfTestExecutionUsage": { - "type": "string" - }, - "billingResetDate": { - "type": "integer", - "description": "Epoch millis; null when no limit." - }, - "browserstackUsedTestExecutions": { - "type": "string" - }, - "totalTestExecutions": { - "type": "string" - }, - "nonBrowserstackPercentageOfGroupTestExecutionUsage": { - "type": "string" - }, - "nonBrowserstackGroupTotalTestExecutions": { - "type": "string" - } - } - }, - "GlobalSearchEntity": { - "type": "object", - "description": "One search result. Concrete shape is per-type (Build / BuildTestCaseExecutionResult / Project), discriminated by `entity`.", - "properties": { - "id": { - "type": "integer" - }, - "entity": { - "type": "string", - "description": "Type discriminator: Build / BuildTestCaseExecutionResult / Project." - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userName": { - "type": "string", - "description": "Resolved from userId server-side." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "GlobalSearchResult": { - "type": "object", - "description": "Search results. pagingParams and totalCount are omitted in overlay mode.", - "properties": { - "response": { - "type": "array", - "items": { - "$schema": "GlobalSearchEntity" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string" - } - } - }, - "totalCount": { - "type": "integer" - } - } - }, - "CreateHealingPrResponse": { - "type": "object", - "description": "Self-healing PR job state. At create time only healingPrId + status are set (status=IN_PROGRESS); the remaining fields are populated by the background worker and read back via the status endpoint.", - "properties": { - "healingPrId": { - "type": "string", - "description": "The job id (UUID)." - }, - "status": { - "type": "string", - "description": "One of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED." - }, - "prUrl": { - "type": "string", - "description": "URL of the opened PR; null until a terminal PR-producing state." - }, - "prNumber": { - "type": "integer", - "description": "PR number; null until opened." - }, - "headSha": { - "type": "string", - "description": "Head commit SHA of the PR branch; null until committed." - }, - "perFileReport": { - "type": "object", - "description": "Per-file applied/skipped detail (arbitrary JSON); present on terminal outcomes." - }, - "errorMessage": { - "type": "string", - "description": "Failure detail; present on FAILED/PARTIAL outcomes." - } - } - }, - "HealingVerdictResponse": { - "type": "object", - "description": "A recorded verdict on one AI-healed locator.", - "properties": { - "elementHash": { - "type": "string", - "description": "Hash keying the healed element (used to join with the healing report)." - }, - "healedLocator": { - "type": "object", - "description": "The healed locator that was judged (arbitrary JSON node, e.g. {type, value})." - }, - "verdict": { - "type": "string", - "description": "'accurate' or 'inaccurate'." - }, - "feedback": { - "type": "object", - "description": "Optional free-form feedback (arbitrary JSON); null when none." - }, - "reviewedByUserId": { - "type": "integer" - }, - "reviewedAt": { - "type": "string" - }, - "isCarryover": { - "type": "boolean", - "description": "True when this verdict was first recorded against an earlier build." - }, - "carryoverBuildSerialId": { - "type": "integer", - "description": "The earlier build's serial id when isCarryover is true." - } - } - }, - "SelfHealSummaryStats": { - "type": "object", - "description": "Build-level self-healing headline numbers.", - "properties": { - "testSummary": { - "type": "object", - "properties": { - "totalTests": { - "type": "integer" - }, - "healedTests": { - "type": "integer" - }, - "percentHealedTests": { - "type": "number" - } - } - }, - "locatorSummary": { - "type": "object", - "properties": { - "totalLocators": { - "type": "integer" - }, - "healedLocators": { - "type": "integer" - }, - "percentHealedLocators": { - "type": "number" - } - } - }, - "buildSummary": { - "type": "object", - "properties": { - "totalBuild": { - "type": "integer" - }, - "healedBuilds": { - "type": "integer" - }, - "percentHealedBuilds": { - "type": "number" - } - }, - "nullable": true, - "description": "Build-level roll-up; null on a single-build read without healing data." - } - } - }, - "BuildComparisonAggregation": { - "type": "object", - "description": "Headline counts for a base-vs-target build comparison. On a fenced (cross-source) pair all counts are 0 and sourceFencingFlags is set.", - "properties": { - "newlyFailed": { - "type": "integer" - }, - "failedWithDifferentReason": { - "type": "integer" - }, - "startedPassing": { - "type": "integer" - }, - "additionalTests": { - "type": "integer" - }, - "droppedTests": { - "type": "integer" - }, - "noChange": { - "type": "integer" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags", - "description": "Present only on a fenced (cross-source) pair; OMITTED (not null/false) on a normal pair." - } - } - }, - "BuildComparisonAvailable": { - "type": "object", - "description": "Comparison availability pre-check. On a fenced pair only sourceFencingFlags is set (status/message null).", - "properties": { - "status": { - "type": "string" - }, - "message": { - "type": "string" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonStringEnvelope": { - "type": "object", - "description": "base/target build UUIDs resolved to their parent builds.", - "properties": { - "base": { - "type": "string", - "description": "Resolved parent build UUID for the base side." - }, - "target": { - "type": "string", - "description": "Resolved parent build UUID for the target side." - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonMetaEnvelope": { - "type": "object", - "description": "The two build summary headers, base and target. On a fenced pair base/target are null and sourceFencingFlags is set. Status counts are AMR-corrected.", - "properties": { - "base": { - "$schema": "BuildSummary" - }, - "target": { - "$schema": "BuildSummary" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags", - "description": "Present only on a fenced pair; omitted otherwise." - } - } - }, - "BuildComparisonList": { - "type": "object", - "description": "One comparison bucket's tests, grouped for display. Fenced pairs return an empty groups list with sourceFencingFlags set.", - "properties": { - "bucketName": { - "type": "string" - }, - "groups": { - "type": "array", - "items": { - "$schema": "BuildComparisonListGroup" - } - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonListGroup": { - "type": "object", - "description": "A file/environment grouping of compared tests.", - "properties": { - "file": { - "type": "string" - }, - "browser": { - "type": "string" - }, - "device": { - "type": "string" - }, - "os": { - "type": "string" - }, - "vcFilepath": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "$schema": "BuildComparisonListGroupScope" - } - } - } - }, - "BuildComparisonListGroupScope": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "elements": { - "type": "array", - "description": "base/target pairs of test elements.", - "items": { - "type": "object", - "properties": { - "base": { - "$schema": "BuildComparisonListGroupScopeElement" - }, - "target": { - "$schema": "BuildComparisonListGroupScopeElement" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - } - } - } - }, - "BuildComparisonListGroupScopeElement": { - "type": "object", - "description": "One compared test's detail on one side.", - "properties": { - "id": { - "type": "integer" - }, - "testHash": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "isManuallyOverridden": { - "type": "boolean" - }, - "error": { - "type": "string" - }, - "issueType": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isPerformanceAnamoly": { - "type": "boolean" - }, - "fileName": { - "type": "string" - }, - "browser": { - "type": "string" - }, - "device": { - "type": "string" - }, - "os": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "vcFilepath": { - "type": "string" - } - } - }, - "CommentRes": { - "type": "object", - "description": "A comment as returned by the comment list reads. Note it carries `commentNode` but NOT the `content` HTML.", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "nullable": true, - "description": "Comment page type; null in list reads even when filtered by type." - }, - "threadId": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "commentNode": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "projectId": { - "type": "integer" - }, - "buildUuid": { - "type": "string" - }, - "buildSerialId": { - "type": "integer" - }, - "runUuid": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "testHash": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "mentions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "BuildRunComment": { - "type": "object", - "description": "A comment-thread summary for the build-level side panel.", - "properties": { - "threadId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "browser": { - "type": "object", - "description": "Browser descriptor (name/version)." - }, - "os": { - "type": "object", - "description": "OS descriptor (name/version)." - }, - "device": { - "type": "string" - }, - "type": { - "type": "string", - "description": "The thread's comment page type." - }, - "testRunUuid": { - "type": "string", - "format": "uuid" - }, - "commentCount": { - "type": "integer" - }, - "testHash": { - "type": "string" - } - } - }, - "SPFilterField": { - "type": "object", - "description": "A single filter option / labelled value used across the Unique Errors filter surface.", - "properties": { - "id": { - "type": "string", - "description": "Option key (for OS/browser the comma-encoded value is turned into an underscore id)." - }, - "value": { - "type": "string", - "description": "Backing value; usually equal to id for tags/branches." - }, - "label": { - "type": "string", - "description": "Display text." - }, - "count": { - "type": "integer", - "description": "Occurrence count for the option; usually populated, may be null.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Parent grouping key (JSON key is `pId`); null or empty for non-hierarchical options.", - "nullable": true - } - } - }, - "UniqueErrorClusterRow": { - "type": "object", - "description": "One error cluster row in the unique-errors list (serialised ErrorResult).", - "properties": { - "id": { - "type": "string", - "description": "The errorClusterId as a numeric string \u2014 the cluster id to pass as errorId / clusterId to the detail views." - }, - "impactedTests": { - "type": "integer", - "description": "Distinct tests affected by this error (cardinality of stabilityHash)." - }, - "errorCount": { - "type": "integer", - "description": "Total occurrences of the error in the window." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps; one bucket per calendar day over the window)." - }, - "error": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample error text split on newlines into lines." - } - } - }, - "UniqueErrorImpactedTest": { - "type": "object", - "description": "One impacted test in an error-cluster breakdown (serialised ErrorTests).", - "properties": { - "id": { - "type": "string", - "description": "Stability hash (test signature), not a numeric id." - }, - "title": { - "type": "string", - "description": "Test name (empty string when absent)." - }, - "filePath": { - "type": "string", - "description": "Source file path; may be null." - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Scope/describe hierarchy; may be null." - }, - "browsers": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Browser objects (each with a formatted browser label and a `device`)." - }, - "platforms": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Formatted OS/platform objects." - }, - "device": { - "type": "string", - "description": "Device label; may be null." - }, - "errorCount": { - "type": "integer", - "description": "Occurrences of the error for this test." - }, - "testRunId": { - "type": "integer", - "description": "A representative failing run id; may be null." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps)." - } - } - }, - "UniqueErrorBuild": { - "type": "object", - "description": "One build in which a unique error occurred (serialised ErrorBuild). Several derived fields are exposed only via getters.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "originalName": { - "type": "string" - }, - "normalisedName": { - "type": "string", - "description": "URL-encoded form of name (getter-derived)." - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "startedAt": { - "type": "string", - "description": "Timestamp." - }, - "finishedAt": { - "type": "string", - "description": "Timestamp." - }, - "duration": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "browser": { - "type": "object", - "description": "Formatted browser object." - }, - "os": { - "type": "object", - "description": "Formatted platform object." - }, - "device": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "ciBuildData": { - "type": "object", - "description": "CI info; null when the build has none." - }, - "isFailedWithSameError": { - "type": "boolean", - "description": "True when this build's run failed with THIS exact cluster." - }, - "thProjectId": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "testCreationType": { - "type": "string" - }, - "state": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string" - }, - "tcmTestRunId": { - "type": "integer" - }, - "pmToolDetails": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Linked PM-tool (Jira etc.) entries; empty array when none." - }, - "flakyReason": { - "type": "integer" - }, - "smartTagSettings": { - "type": "object", - "description": "Smart-tag settings applied to the build; may be null." - }, - "workflowStatus": { - "type": "string" - }, - "versionControlInfo": { - "type": "object", - "description": "Getter-derived {url, commitId}.", - "properties": { - "url": { - "type": "string" - }, - "commitId": { - "type": "string" - } - } - }, - "issueType": { - "type": "object", - "description": "Getter-derived {isAutoAnalyzed, value}; null unless the run status is failed.", - "properties": { - "isAutoAnalyzed": { - "type": "boolean" - }, - "value": { - "type": "string" - } - } - }, - "historyAggregate": { - "type": "object", - "description": "Getter-derived flags.", - "properties": { - "isAlwaysFailing": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - } - } - } - } - }, - "SPTimelineDataPoint": { - "type": "object", - "description": "One point in a Suite Health chart series.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket epoch." - }, - "y": { - "type": "number", - "description": "Value; may be null." - }, - "t": { - "type": "integer", - "description": "Secondary timestamp; may be null." - }, - "percentage": { - "type": "number", - "description": "Percentage value; may be null." - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "SPChart__w3_sp_error": { - "type": "object", - "description": "A Suite Health chart: one or more named series plus a headline insight.", - "properties": { - "data": { - "type": "object", - "description": "Single-entry map from series name to a list of points. The series name differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "SPTimelineDataPoint" - } - } - }, - "insights": { - "type": "object", - "properties": { - "metric": { - "type": "string", - "description": "Human label, e.g. 'Total Unique Errors'." - }, - "value": { - "type": "string", - "description": "Headline total, formatted as a STRING." - }, - "meta": { - "type": "string" - } - } - } - } - }, - "TestRunsRcaData": { - "type": "object", - "description": "The stored AI RCA for a test run. Peek-only fields (pendingMessageId, alreadyTracked, isLive) and NON_NULL fields (failureTypeDisplayName, conversation, trialQuota) appear only when set. rcaData/thinkingData are model output populated once state is Completed. A peek on a run with no RCA returns 200 with every field null.", - "properties": { - "id": { - "type": "integer", - "description": "The stored RCA row id.", - "nullable": true - }, - "testRunId": { - "type": "integer", - "nullable": true - }, - "rcaData": { - "type": "object", - "description": "Generated analysis {root_cause, description, failure_type, failure_sub_type, possible_fix, related_prs, alternatives_considered, unique_id}; {} while Pending.", - "nullable": true - }, - "thinkingData": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "data": {} - } - }, - "description": "Reasoning/thinking trace: an ARRAY of {type, data}." - }, - "state": { - "type": "string", - "description": "Generation state.", - "enum": [ - "Pending", - "FETCHING_LOGS", - "GENERATING_RCA", - "GENERATED_RCA", - "THINKING", - "RETRYING", - "LLM_SERVICE_ERROR", - "LOG_FETCH_ERROR", - "AI_SYSTEM_ERROR", - "UNKNOWN_ERROR", - "TIMEOUT", - "Completed" - ], - "nullable": true - }, - "bulkRcaId": { - "type": "string", - "description": "UUID linking runs analysed together via a bulk RCA request; null for a singly-generated RCA.", - "nullable": true - }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { - "type": "string", - "nullable": true - }, - "isAgentic": { - "type": "boolean", - "description": "Whether this RCA used the agentic flow. Forced false for LTS-product callers.", - "nullable": true - }, - "isMcpDriven": { - "type": "boolean", - "description": "True when the RCA is owned by the MCP-powered chat agent (the web composer is then disabled).", - "nullable": true - }, - "failureTypeDisplayName": { - "type": "string", - "description": "Present only when set." - }, - "pendingMessageId": { - "type": "string", - "description": "Peek-only: messageId of the in-flight PENDING conversation row, if any.", - "nullable": true - }, - "alreadyTracked": { - "type": "boolean", - "description": "Peek-only: whether a build-chat row already tracks pendingMessageId. Null for web/MCP-started generations.", - "nullable": true - }, - "isLive": { - "type": "boolean", - "description": "Peek-only: true when a generation is genuinely running right now \u2014 the field to gate a re-trigger on.", - "nullable": true - }, - "conversation": { - "type": "object", - "description": "Present only for a chat-driven RCA turn; its `state` uses the UPPER-CASE conversation enum (COMPLETED) while the top-level state uses the RCA enum (Completed)." - }, - "trialQuota": { - "type": "object", - "description": "Present only for trial users; carries used-quota info." - } - } - }, - "BuildGroup": { - "type": "object", - "description": "A run group (build group). Serialized with non-null fields only; `state` is computed (ACTIVE until closed, then CLOSED).", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "project_id": { - "type": "integer", - "description": "TestHub project id the group belongs to." - }, - "group_id": { - "type": "integer" - }, - "created_by": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "isClosed": { - "type": "boolean" - }, - "closedAt": { - "type": "string", - "format": "date-time" - }, - "closedBy": { - "type": "integer" - }, - "closedByName": { - "type": "string" - }, - "state": { - "type": "string", - "description": "Computed from isClosed.", - "enum": [ - "ACTIVE", - "CLOSED" - ] - } - } - }, - "SplitBuildLegs": { - "type": "object", - "description": "Split-build legs payload built by BuildService.getSplitBuildDetails (a ProjectDto subset). Only the fields this render actually populates are described.", - "properties": { - "id": { - "type": "integer", - "description": "Observability project id the split build belongs to." - }, - "name": { - "type": "string", - "description": "Project name." - }, - "builds": { - "type": "array", - "description": "The child legs, each a full build-summary object (same shape as BuildDetails).", - "items": { - "$schema": "BuildDetails" - } - }, - "pagingParams": { - "$schema": "PagingParams" - } - } - }, - "PagingParams": { - "type": "object", - "description": "Cursor pagination envelope. `searchAfter` is a build uuid (or list) to pass back as the next cursor; `hasNext` says whether another page exists.", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "description": "Opaque next-root cursor map (composite-agg paging); may be null." - }, - "searchAfter": { - "description": "Either a list of cursor tokens (build uuids) or a single integer, depending on the endpoint." - } - } - }, - "BuildDetails": { - "type": "object", - "description": "Full single-build detail object (BuildSummary DTO). Large flat object (~80 keys); only the non-obvious / gating fields are declared. Nulls ARE serialised (derivedName, originalName, ciBuildData, versionControlInfo, ... often null) - the NON_NULL notes on the nested aggregates do not apply to this object. `vcsInfo` is write-only and never serialised.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Live/computed status; may reflect an in-flight aggregation mid-run." - }, - "displayStatus": { - "type": "string", - "description": "Settled pipeline verdict; prefer this over `status` for the final outcome." - }, - "duration": { - "type": "integer", - "description": "Consolidated duration when present, else raw duration (the getter resolves this)." - }, - "statusStats": { - "$schema": "StatusAggregate" - }, - "issueTypeAggregate": { - "$schema": "IssueTypeAggregate", - "nullable": true, - "description": "Null when no failures have been categorised yet." - }, - "historyAggregate": { - "$schema": "HistoryAggregate", - "description": "Smart-tag roll-up; all zeros (not null) when nothing is tagged." - }, - "showHealingReport": { - "type": "boolean", - "description": "UI gate: whether a self-healing report exists to show (S3 key-existence check)." - }, - "showAuthoringReport": { - "type": "boolean", - "description": "UI gate: whether an authoring-agent report exists (S3 check); forced true on demo-sandbox envs." - }, - "appAccessibilityEnabled": { - "type": "boolean", - "description": "Per-group feature flag; fails CLOSED (false) if the flag lookup errors." - }, - "qualityGateStatus": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "tcmTestPlans": { - "type": "array", - "description": "Cross-product test-management plan links, if any.", - "items": { - "type": "object" - } - }, - "workflowStatusStats": { - "type": "object", - "description": "Workflow-status roll-up." - }, - "runInfos": { - "type": "array", - "items": { - "type": "object" - } - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "isLocked": { - "type": "boolean", - "nullable": true - }, - "buildNumber": { - "type": "integer", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true, - "description": "TestHub project id." - }, - "tcmTestRunIdentifier": { - "type": "string", - "nullable": true, - "description": "Linked Test Management run (TR-...), when any." - }, - "issueTypes": { - "type": "array", - "nullable": true, - "items": { - "type": "object" - } - }, - "aiRcaCompletedCount": { - "type": "integer", - "nullable": true - }, - "selfHealedCount": { - "type": "integer", - "nullable": true - }, - "testCaseCount": { - "type": "integer", - "nullable": true - }, - "state": { - "type": "string", - "nullable": true, - "description": "Lifecycle state, e.g. NEW or CLOSED." - } - } - }, - "BuildNotificationSummary": { - "type": "object", - "description": "Compact build-summary email/notification card (BuildSummaryEmail DTO). `startedAt` is @JsonIgnore; the payload exposes formatted date/time/duration helpers instead. `accessibility` is present only for APP_ALLY_AUT builds.", - "properties": { - "uuid": { - "type": "string" - }, - "name": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "projectNormalisedName": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "displayStatus": { - "type": "string", - "description": "Computed display status derived from status + statusStats." - }, - "duration": { - "type": "integer" - }, - "formattedDuration": { - "type": "string", - "description": "Human-readable duration helper (e.g. '2m 5s')." - }, - "startTime": { - "type": "string" - }, - "startDate": { - "type": "string" - }, - "qualityGateStatus": { - "type": "string" - }, - "totalCount": { - "type": "integer" - }, - "mutedCount": { - "type": "integer" - }, - "statusStats": { - "$schema": "StatusAggregate" - }, - "issueTypeAggregate": { - "$schema": "IssueTypeAggregate" - }, - "historyAggregate": { - "$schema": "HistoryAggregate" - }, - "alerts": { - "$schema": "BuildAlerts__w4_build2" - }, - "buildNumber": { - "type": "integer" - }, - "colorMapping": { - "type": "object", - "description": "Map of status/category -> hex color for rendering the card." - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "accessibility": { - "type": "object", - "description": "APP_ALLY_AUT builds only; omitted (NON_NULL) otherwise. Populated fail-open.", - "properties": { - "totalIssues": { - "type": "integer" - }, - "totalScans": { - "type": "integer" - }, - "a11yScore": { - "type": "number" - }, - "severityBreakdown": { - "type": "object", - "properties": { - "critical": { - "type": "integer" - }, - "serious": { - "type": "integer" - }, - "moderate": { - "type": "integer" - }, - "minor": { - "type": "integer" - } - } - } - } - } - } - }, - "BuildStabilityTile": { - "type": "object", - "description": "Stability tile for a build (BuildStability DTO). `count` on the DTO is @JsonIgnore; the surfaced number lives in `overview.count`.", - "properties": { - "data": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-run stability display series." - }, - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Bucket keys aligned to `data`." - }, - "median": { - "type": "number" - }, - "commentCount": { - "type": "integer" - }, - "overview": { - "type": "object", - "description": "Headline verdict for the tile.", - "properties": { - "count": { - "type": "string", - "description": "Current stability as a formatted percentage string (e.g. '92.3')." - }, - "inPercentage": { - "type": "boolean" - }, - "meta": { - "type": "string", - "description": "e.g. 'based on last 30 runs'." - }, - "insights": { - "type": "object", - "description": "Positive/negative/default verdict vs recent builds; only populated with >= 10 comparison runs.", - "properties": { - "type": { - "type": "string", - "values": [ - "positive", - "negative", - "default" - ] - }, - "text": { - "type": "string" - } - } - } - } - } - } - }, - "BuildSummaryStats": { - "type": "object", - "description": "Headline test-count strip for a build (BuildSummaryStats DTO).", - "properties": { - "data": { - "type": "array", - "description": "Colored metric tiles (total/passed/failed/skipped/pending roll-ups).", - "items": { - "$schema": "ColoredMetric" - } - }, - "aiRcaStats": { - "$schema": "AiRcaStats" - }, - "isArchived": { - "type": "boolean" - }, - "commentCount": { - "type": "integer" - }, - "hooksVisibility": { - "type": "string", - "description": "Whether hook rows are shown/hidden for this project." - }, - "manuallyOverriddenCount": { - "type": "integer" - }, - "sessionStats": { - "$schema": "SessionStats" - } - } - }, - "BuildTimelineStats": { - "type": "object", - "description": "Build execution timeline (BuildTimelineAggregate DTO).", - "properties": { - "data": { - "type": "array", - "description": "Three chart series with ids `passed`, `failed` and `pending` (pending = tests still running at that instant). Each point is [x, count] where x is MILLISECONDS since the build's first test started (relative, not epoch time).", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Series key: passed, failed or pending." - }, - "name": { - "type": "string" - }, - "color": { - "type": "string" - }, - "type": { - "type": "string", - "description": "Chart type, default 'column'." - }, - "data": { - "type": "array", - "description": "Array of [msSinceFirstTestStart, count] pairs.", - "items": { - "type": "array", - "items": { - "type": "integer" - } - } - } - } - } - } - } - }, - "BuildFailureCategoryStats": { - "type": "object", - "description": "Failure-category (defect) breakdown (DefectStats DTO). On a PENDING build, `data` is empty and `message` explains why (still HTTP 200).", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "DefectStatsData" - } - }, - "aiRcaStats": { - "$schema": "AiRcaStats" - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string", - "description": "Set to a 'available after your build finishes' notice while pending; otherwise typically absent." - } - } - }, - "DefectStatsData": { - "type": "object", - "description": "One failure-category bucket.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "value": { - "type": "integer", - "description": "Count of failures in this category." - }, - "color": { - "type": "string" - }, - "categories": { - "type": "array", - "description": "Optional nested sub-buckets.", - "items": { - "$schema": "ColoredMetric" - } - } - } - }, - "ColoredMetric": { - "type": "object", - "description": "A single colored metric tile / bucket.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "value": { - "type": "integer" - }, - "color": { - "type": "string" - }, - "filterCount": { - "type": "integer" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" - } - } - }, - "BuildFailuresByModule": { - "type": "object", - "description": "Failures grouped by test module / file path (FailureByModuleStats DTO).", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "A module/folder node.", - "properties": { - "id": { - "type": "string", - "description": "Full module/file path." - }, - "name": { - "type": "string" - }, - "value": { - "type": "integer", - "description": "Total tests in the module." - }, - "colorValue": { - "type": "integer" - }, - "failedTests": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "parent": { - "type": "string", - "description": "Parent node id for tree reconstruction." - }, - "customPathSplit": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Path split by the inferred separator." - }, - "separator": { - "type": "string" - }, - "joinSeparator": { - "type": "string" - } - } - } - }, - "commentCount": { - "type": "integer" - }, - "isConflicting": { - "type": "boolean" - }, - "sourceFencingFlags": { - "type": "object", - "description": "Present (NON_NULL) only when source-fencing excludes the build's product; names which products' tests were skipped.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "BuildFilterValues": { - "type": "object", - "description": "Older compact build-filter shape (FilterValues DTO): a single `filters` map keyed by facet name.", - "properties": { - "filters": { - "type": "object", - "description": "Map keyed by facet (browser, os, issueType, tags, status, folder, history, flaky). Each value is a list of options. `history` and `flaky` are static option lists always present.", - "properties": { - "browser": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "os": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "issueType": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "tags": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "status": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "folder": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "history": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "flaky": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - } - } - } - } - }, - "BuildTestFolders": { - "type": "object", - "description": "Build test-listing folder tree. Two shapes depending on folder_path: root-folders (RootFoldersResponse) or folder-detail (FolderDetailsResponse). Both are @JsonInclude(NON_NULL). Fields from both are shown; only one branch's fields are populated per call.", - "properties": { - "project_id": { - "type": "integer" - }, - "folders": { - "type": "array", - "description": "ROOT branch only: the root folders.", - "items": { - "$schema": "SubFolderDetails" - } - }, - "name": { - "type": "string", - "description": "DETAIL branch only: the folder's name (last path segment)." - }, - "folder_path": { - "type": "string", - "description": "DETAIL branch only: full folder path." - }, - "cases_count": { - "type": "integer", - "description": "DETAIL branch: unique cases directly in this folder." - }, - "total_cases_count": { - "type": "integer", - "description": "DETAIL branch: unique cases including nested subfolders." - }, - "sub_folders_count": { - "type": "integer", - "description": "DETAIL branch: immediate subfolder count." - }, - "sub_folders": { - "type": "array", - "description": "DETAIL branch only: immediate subfolders.", - "items": { - "$schema": "SubFolderDetails" - } - }, - "pagingParams": { - "$schema": "PagingParams" - }, - "after_key": { - "type": "string", - "description": "ROOT branch composite-agg cursor field; observed returned as null \u2014 page via pagingParams instead." - } - } - }, - "SubFolderDetails": { - "type": "object", - "description": "One folder node in the build's test-listing tree (NON_NULL).", - "properties": { - "name": { - "type": "string" - }, - "folder_path": { - "type": "string" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - } - } - }, - "BuildNonSdkTestCounts": { - "type": "string", - "description": "IMPORTANT: the body is a JSON *string* (Gson JsonObject.toString()), not a JSON object. After parsing the string it is `{untitled_test_count:int, unmarked_test_count:int, missing_failure_reason_count:int}`, counts spanning the parent build and its split child builds." - }, - "StatusAggregate": { - "type": "object", - "description": "Test-status roll-up (NON_NULL). Keys are lower-case status names.", - "properties": { - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "pending": { - "type": "integer", - "description": "Running/pending count (DTO field runningCount)." - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "aggregate": { - "type": "integer", - "nullable": true, - "description": "Aggregate (total) count across statuses." - } - } - }, - "IssueTypeAggregate": { - "type": "object", - "description": "Issue-type roll-up (NON_NULL). Keys are the human-readable issue-type labels, not camelCase.", - "properties": { - "To be Investigated": { - "type": "integer" - }, - "Automation Bug": { - "type": "integer" - }, - "Product Bug": { - "type": "integer" - }, - "No Defect": { - "type": "integer" - }, - "Environment Issue": { - "type": "integer" - } - } - }, - "HistoryAggregate": { - "type": "object", - "description": "Smart-tag history roll-up (NON_NULL).", - "properties": { - "isFlaky": { - "type": "integer" - }, - "isAlwaysFailing": { - "type": "integer" - }, - "isNewFailure": { - "type": "integer" - }, - "isPerformanceAnomaly": { - "type": "integer" - } - } - }, - "SessionStats": { - "type": "object", - "description": "Session-level counters (SessionStats DTO).", - "properties": { - "totalSessions": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "error": { - "type": "integer" - }, - "unmarked": { - "type": "integer" - }, - "idletimeout": { - "type": "integer" - }, - "running": { - "type": "integer" - }, - "done": { - "type": "integer" - }, - "timeout": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "AiRcaStats": { - "type": "object", - "description": "AI RCA availability counter.", - "properties": { - "completedCount": { - "type": "integer", - "description": "How many failures already have AI RCA completed." - } - } - }, - "FilterStruct__w4_build2": { - "type": "object", - "description": "A single filter dropdown option. Folder labels embed the count, e.g. 'auth.test.js(3)'.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Serialised from the DTO's `pid` field via @JsonProperty(\"pId\").", - "nullable": true - }, - "id": { - "type": "string", - "description": "Option id (same as value)." - } - } - }, - "SingleMetricSummary__w4_build2": { - "type": "object", - "description": "Shared single-metric tile (SingleMetricSummary DTO) used by the flaky / muted / new-failure tiles.", - "properties": { - "count": { - "type": "integer", - "description": "The metric count (flaky / muted / new-failure depending on endpoint)." - }, - "meta": { - "type": "string", - "description": "Context string, typically the 'out of N total' text." - }, - "commentCount": { - "type": "integer" - }, - "autoUnmuteCount": { - "type": "integer", - "description": "Most meaningful for the muted tile; how many are scheduled to auto-unmute." - } - } - }, - "BuildAlerts__w4_build2": { - "type": "object", - "description": "Build alerts panel (BuildAlerts DTO).", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Individual alert entries." - }, - "alertsConfigured": { - "type": "boolean" - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string" - } - } - }, - "ExternalBuildDetails": { - "type": "object", - "description": "Single external build-details object returned by get_latest_build (BuildDetails / BuildDetailsV2 by the new-build-listing flag). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are plan-gated by the external JsonView wrapper.", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "id": { - "type": "integer", - "description": "Integer build id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "status_stats": { - "type": "object", - "description": "Per-status counts (V2: workflow-status counts), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 shape only." - }, - "failure_categories": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "smart_tags": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "unique_errors": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "alerts": { - "type": "array", - "description": "Pro-only (JsonView)." - }, - "re_run": { - "type": "boolean" - }, - "build_error": { - "type": "object" - }, - "is_archived": { - "type": "boolean" - }, - "observability_version": { - "type": "object" - }, - "vcs_info": { - "type": "object" - }, - "ci_info": { - "type": "object" - }, - "host_info": { - "type": "object" - }, - "run_information": { - "type": "array" - }, - "observability_url": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "app_details": { - "type": "object" - } - } - }, - "ExternalProjectBuildListing": { - "type": "object", - "description": "Public-API build listing (BuildListingResponse / BuildListingResponseV2, selected by the new-build-listing group flag). Keys are snake_case except tcmTestRunIdentifier; the two versions differ only in what status_stats holds and V2's is_manually_overridden.", - "properties": { - "id": { - "type": "integer", - "description": "Project id." - }, - "name": { - "type": "string", - "description": "Project name." - }, - "builds": { - "type": "array", - "description": "The fetched page, reversed before returning (Lists.reverse), so oldest-first within a page.", - "items": { - "type": "object", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid (40 chars); use it for per-build calls. There is no `uuid` or integer `id` key (id is @JsonIgnore)." - }, - "name": { - "type": "string" - }, - "original_name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "build_number": { - "type": "integer" - }, - "is_archived": { - "type": "boolean" - }, - "observability_url": { - "type": "string", - "description": "Deep link to the build in the TRA UI." - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "status_stats": { - "type": "object", - "description": "Per-status counts. Same key on both shapes: v1 StatusAggregate, V2 WorkflowStatusAggregate." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 items only." - } - } - } - }, - "pagination": { - "type": "object", - "description": "snake_case keys (models/external/Pagination).", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." - } - } - } - } - }, - "ProjectBuildListing": { - "type": "object", - "description": "Internal build-listing payload (ProjectDto) returned by list_project_builds. builds[] are full BuildSummary objects.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "builds": { - "type": "array", - "description": "Rich BuildSummary rows (uuid, id, name, status, statusStats, duration, session/run counts, tags, qualityGateStatus, isArchived, displayStatus, parallelsUsed, tcmTestRunIdentifier, ...).", - "items": { - "type": "object" - } - }, - "pagingParams": { - "type": "object", - "description": "Cursor paging state (hasNext + searchAfter) for the next page." - }, - "activeCount": { - "type": "integer" - }, - "closedCount": { - "type": "integer" - }, - "manualBuildCount": { - "type": "integer" - }, - "automationBuildCount": { - "type": "integer" - }, - "isArchiving": { - "type": "boolean" - }, - "isClosing": { - "type": "boolean" - } - } - }, - "RunningBuildsOnDevice": { - "type": "object", - "description": "Builds currently queued/running on a device+OS combination (PQRunningBuildsResponse). Empty data list when nothing matches.", - "properties": { - "data": { - "type": "array", - "description": "BuildSummary rows for pending builds on the combination; each carries parallelsUsed (unique parallel sessions consumed).", - "items": { - "type": "object", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "parallelsUsed": { - "type": "integer", - "description": "Number of unique parallel sessions this build is consuming on the device+OS combination." - } - } - } - } - } - }, - "BuildInsights__w4_build4": { - "type": "object", - "description": "Agent-oriented build failure summary returned by get_build_insights (BuildInsightsResponse).", - "properties": { - "buildId": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer" - }, - "summary": { - "type": "object", - "properties": { - "statusMetrics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" - } - } - } - }, - "totalTests": { - "type": "integer" - }, - "totalHooks": { - "type": "integer" - } - } - }, - "failureCategorization": { - "type": "object", - "properties": { - "newFailures": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "flaky": { - "type": "integer" - } - } - }, - "failureContext": { - "type": "object", - "description": "ALWAYS null on this build-keyed route (no test run supplied). Present only on the test-run variant: { file:{path,testCount,failedCount,failureRate}, parentModule:{...} }.", - "nullable": true - }, - "topErrorsAnalysis": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty until COMPLETE." - }, - "uniqueErrorCount": { - "type": "integer" - }, - "uniqueErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "clusterId": { - "type": "string" - }, - "occurrences": { - "type": "integer" - }, - "truncatedError": { - "type": "string", - "description": "First ~200 chars of the error only." - } - } - } - } - } - }, - "defectStats": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issueType": { - "type": "string" - }, - "totalFailureTestsCount": { - "type": "integer" - }, - "totalFailureHooksCount": { - "type": "integer" - }, - "categories": { - "type": "array", - "items": { - "type": "object", - "properties": { - "category": { - "type": "string" - }, - "failureTestCount": { - "type": "integer" - }, - "failureHooksCount": { - "type": "integer" - } - } - } - } - } - } - } - } - }, - "ProjectWithAggregates": { - "type": "object", - "description": "A project enriched with Elasticsearch-derived build activity aggregates, as returned by the aggregate project-list routes.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "buildCount": { - "type": "integer", - "description": "Distinct build count for the project in the caller's product context." - }, - "projectStability": { - "type": "number", - "description": "Aggregate stability score for the project." - }, - "stabilityChartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Series of stability values for a mini chart." - }, - "lastRanAt": { - "type": "string", - "format": "date-time", - "description": "When the project's most recent build finished." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastRanBy": { - "type": "integer", - "description": "User id who triggered the most recent build." - }, - "thProjectId": { - "type": "integer" - } - } - }, - "SuiteHealthTestListItem": { - "type": "object", - "description": "One test row in the Stability & Performance / Suite/Test Health listing (com.browserstack.observability.models.response.SuiteHealth.TestStabilityAndPerformance).", - "properties": { - "id": { - "type": "string", - "description": "The test's stabilityHash \u2014 the identifier to carry into the detail endpoints (serialized from testHash via @JsonProperty(\"id\"))." - }, - "title": { - "type": "string", - "description": "Test name." - }, - "filePath": { - "type": "string" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "isPlatformError": { - "type": "boolean", - "description": "True when the sampled run's error was a platform (infra) error." - }, - "reliability": { - "type": "number", - "description": "Failure percentage (failed*100/total). HIGHER = less stable." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "totalFailures": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "browserList": { - "type": "array", - "description": "Browser/device combinations the test ran on.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "string" - } - } - } - }, - "platformList": { - "type": "array", - "description": "OS/platform combinations the test ran on.", - "items": { - "type": "object" - } - }, - "browsers": { - "type": "array", - "description": "Always EMPTY on this listing path (populated only when the internal browserString is set, which this path never sets). Read browserList instead.", - "items": { - "type": "object" - } - }, - "platforms": { - "type": "array", - "description": "Always EMPTY on this listing path. Read platformList instead.", - "items": { - "type": "object" - } - } - } - }, - "SuiteHealthPlatformBreakdownItem": { - "type": "object", - "description": "One OS/browser/device row of a test's platform breakdown (TestStabilityAndPerformanceBreakdown).", - "properties": { - "id": { - "type": "string", - "description": "Composite \"-\" key (lower-cased)." - }, - "reliability": { - "type": "number", - "description": "Failure percentage for this combination (higher = worse)." - }, - "duration": { - "type": "number", - "description": "Average run duration for this combination." - }, - "totalFailures": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "os": { - "type": "object", - "description": "Platform display object {name, version}." - }, - "browser": { - "type": "object", - "description": "Browser display object with device as a plain string.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "string" - } - } - }, - "browserDevice": { - "type": "object", - "description": "Browser display object with device as a nested object.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "object" - } - } - } - } - }, - "SuiteHealthTrendPoint": { - "type": "object", - "description": "One execution point in the Suite Health trend series (TrendDataPoint, @JsonInclude NON_NULL \u2014 null fields are omitted).", - "properties": { - "color": { - "type": "string", - "description": "Status colour for the run." - }, - "workflowStatus": { - "type": "string", - "description": "Workflow status (workflowStatusV2), or \"Unknown\"." - }, - "y": { - "type": "integer", - "description": "Run duration (ms); 0 when absent." - }, - "id": { - "type": "integer", - "description": "Test-run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id (display build number)." - }, - "buildName": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "flakyReason": { - "type": "integer", - "description": "Flaky-status code; 0 when absent." - }, - "creationType": { - "type": "string", - "description": "Upper-cased creation type (defaults to AUTOMATION)." - } - } - }, - "SuiteHealthTestRun": { - "type": "object", - "description": "One test execution in the Suite Health run-history table (TestStabilityAndPerformanceDetails). Smart-tag flags are under historyAggregate, not top-level.", - "properties": { - "id": { - "type": "integer", - "description": "Test-run id." - }, - "name": { - "type": "string", - "description": "Composed display name (build name + re-run/manual suffix + #serialId)." - }, - "originalName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "workflowStatusEditedBy": { - "type": "string" - }, - "state": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer", - "description": "Run duration (ms)." - }, - "buildNumber": { - "type": "integer" - }, - "normalisedName": { - "type": "string", - "description": "URL-encoded build name." - }, - "flakyReason": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "testCreationType": { - "type": "string" - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "isPlatformError": { - "type": "boolean" - }, - "thProjectId": { - "type": "integer" - }, - "tcmTestRunId": { - "type": "integer" - }, - "tcmTestRunIdentifier": { - "type": "string" - }, - "historyAggregate": { - "type": "object", - "description": "Smart-tag flags for the run.", - "properties": { - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - } - } - }, - "browser": { - "type": "object", - "description": "Browser display object." - }, - "os": { - "type": "object", - "description": "Platform display object." - }, - "device": { - "type": "string" - }, - "ciBuildData": { - "type": "object", - "description": "CI context (best-effort; empty when CI info absent).", - "properties": { - "buildUrl": { - "type": "string" - }, - "buildNumber": { - "type": "string" - }, - "jobName": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "versionControlInfo": { - "type": "object", - "description": "VCS context (best-effort).", - "properties": { - "url": { - "type": "string" - }, - "commitId": { - "type": "string" - } - } - }, - "pmToolDetails": { - "type": "array", - "description": "Linked PM-tool defects.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "url": { - "type": "string" - }, - "status": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - } - }, - "smartTagSettings": { - "type": "object", - "description": "The smart-tag settings resolved for this run from its build's setting version." - } - } - }, - "ObsSessionVideo": { - "type": "object", - "description": "Session video descriptor with playback offsets. Sourced from SessionVideo in observability-api. Offsets/flags can be null when the video is not yet processed; status_code 0 is normal.", - "properties": { - "status_code": { - "type": "integer" - }, - "url": { - "type": "string", - "nullable": true - }, - "offsetUrl": { - "type": "string", - "nullable": true - }, - "startTime": { - "type": "number", - "nullable": true - }, - "startTimeStamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "startOffset": { - "type": "integer", - "description": "ms offset of video start relative to the test/session, for aligning playback with the log timeline.", - "nullable": true - }, - "finishOffset": { - "type": "integer", - "nullable": true - }, - "launchingAppTime": { - "type": "integer", - "description": "VM/browser boot lead-in in seconds.", - "nullable": true - }, - "isIos": { - "type": "boolean", - "nullable": true - }, - "isV2": { - "type": "boolean", - "nullable": true - } - } - }, - "ObsHistoryTab": { - "type": "object", - "description": "One past execution of the same test across builds. Sourced from HistoryTab in observability-api; startedAt is used for ordering and is not serialized.", - "properties": { - "testRunId": { - "type": "integer", - "description": "Integer id of the historical run (serialized from the internal Id field)." - }, - "status": { - "type": "string" - }, - "serialId": { - "type": "integer" - }, - "isHittingRetentionQuota": { - "type": "boolean" - }, - "testRunUuid": { - "type": "string" - }, - "usageQuotaStatus": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "buildUuid": { - "type": "string" - }, - "thProjectId": { - "type": "integer" - } - } - }, - "TestRunHistoryEntry": { - "type": "object", - "description": "One test run's (or hook run's) execution-history entry from get_test_runs_history_details. Only the keys present for that run appear.", - "properties": { - "buildId": { - "type": "integer" - }, - "serialId": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "issueType": { - "$schema": "TestRunIssueType", - "nullable": true, - "description": "Null on passed runs." - }, - "duration": { - "type": "integer", - "description": "Run duration." - }, - "isFlaky": { - "type": "boolean", - "nullable": true - }, - "isAlwaysFailing": { - "type": "boolean", - "nullable": true - }, - "isNewlyFailed": { - "type": "boolean", - "nullable": true - }, - "isPerformanceAnomaly": { - "type": "boolean", - "nullable": true - }, - "flakyReason": { - "type": "integer", - "nullable": true - }, - "flakySetting": { - "type": "object", - "description": "Flaky configuration for the run (opaque).", - "nullable": true - }, - "creationType": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Test tags." - }, - "logs": { - "$schema": "TestRunRetryLogStruct" - }, - "workflowStatus": { - "type": "string", - "nullable": true - }, - "workflowStatusEditedBy": { - "type": "string", - "nullable": true - }, - "workflowStatusEditedAt": { - "type": "string", - "format": "date-time", - "nullable": true - } - } - }, - "TestRunRetryLogStruct": { - "type": "object", - "description": "Serialized log/attachment structure for a run or retry attempt. Each key is present only when that evidence exists.", - "properties": { - "TEST_FAILURE": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Failure text: the message followed by the stack, as an array of strings." - }, - "TEST_LOG": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Screenshot / attachment URLs; fetch each directly from storage (not proxied through this API)." - }, - "BROWSERSTACK_ERROR": { - "type": "object", - "description": "BrowserStack-side error log, when the run failed on infra." - } - } - }, - "TestRunSdkLogEntry": { - "type": "object", - "description": "One test run in a build that has SDK-captured logs/attachments.", - "properties": { - "logId": { - "type": "integer" - }, - "url": { - "type": "string", - "description": "Direct attachment/log URL, fetched from storage." - }, - "testRunId": { - "type": "integer" - }, - "testRunName": { - "type": "string" - }, - "attachmentsCount": { - "type": "integer" - }, - "screenshotsCount": { - "type": "integer" - } - } - }, - "TestRunCountEntry": { - "type": "object", - "description": "Per-unique-test rerun count within a build.", - "properties": { - "testHash": { - "type": "string", - "description": "Stable hash identifying the unique test across runs." - }, - "count": { - "type": "integer", - "description": "Number of times the test ran across the build and its re-runs." - }, - "isDifferentPlatformsUsed": { - "type": "boolean", - "description": "True when those runs spanned more than one platform." - } - } - }, - "TestRunIssueType": { - "type": "object", - "description": "A failure category (issue type or sub-type) as returned on test-run endpoints.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "locator": { - "type": "string", - "description": "Stable locator code for the issue type (e.g. a category code)." - }, - "abbreviation": { - "type": "string" - }, - "color": { - "type": "string" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "displayName": { - "type": "string", - "description": "Present only when set (JsonInclude NON_NULL)." - }, - "issueGroup": { - "type": "object", - "description": "Parent issue group (opaque; carries the group/enum this type sits under)." - } - } - }, - "BuildTopErrorStatsPanel": { - "type": "object", - "description": "InsightStatsPanel for a build's top unique errors.", - "properties": { - "overview": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "insights": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - }, - "description": "Defaults to the 'Analysis yet to run' overview when analysis has not completed." - }, - "data": { - "type": "array", - "description": "Error clusters sorted by count desc; each: id, title (the error), count, impactedTests, testRuns[], aiRcaTestRunIds[]. Empty while clustering is incomplete." - }, - "ready": { - "type": "boolean", - "description": "False while the auto-analyser is still running." - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string", - "description": "Set (e.g. 'Unique Errors are being detected') when not ready; the only meaningful field in the not-ready state.", - "nullable": true - } - } - }, - "ResolvedBuildUuid": { - "type": "object", - "description": "Build uuid resolved from identifiers.", - "properties": { - "uuid": { - "type": "string", - "description": "The resolved build uuid, or null (with HTTP 200) when nothing matched." - } - } - }, - "SidebarBuildRuns": { - "type": "object", - "description": "A page of unique build runs for the sidebar rail with bidirectional cursor paging.", - "properties": { - "data": { - "type": "array", - "description": "SidebarBuildSummary rows: uuid, id, name, originalName, normalisedName, status, displayStatus, startedAt, finishedAt, buildNumber, tags[], isArchived, usageQuotaStatus (retention vs pro-quota), qualityGateStatus, creationType, statusStats, source, selfHealedCount, etc." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasPrev": { - "type": "boolean" - }, - "hasNext": { - "type": "boolean" - }, - "beforeKey": { - "type": "string" - }, - "afterKey": { - "type": "string" - } - } - } - } - }, - "BuildQualityGateRecalcAck": { - "type": "object", - "description": "Acknowledgement that quality-gate recalculation was queued (QualityGateSummary with only message set).", - "properties": { - "message": { - "type": "string", - "description": "'Quality Gate result will be available after recalculation finishes'." - } - } - }, - "BuildReportPolling": { - "type": "object", - "description": "BuildReport polling envelope (@JsonInclude NON_NULL). May be a NULL body when the build cannot be resolved.", - "properties": { - "reportStatus": { - "type": "string", - "description": "IN_PROGRESS | TEST_AVAILABLE | COMPLETED | NOT_AVAILABLE | DATA_NOT_AVAILABLE." - }, - "retryCount": { - "type": "integer", - "description": "How many more times to poll (present with IN_PROGRESS)." - }, - "pollingInterval": { - "type": "integer", - "description": "Interval to wait before the next POLL request." - }, - "report": { - "type": "object", - "properties": { - "richHtml": { - "type": "string" - }, - "richCss": { - "type": "string" - }, - "basicHtml": { - "type": "string" - }, - "plainText": { - "type": "string" - } - }, - "description": "Populated only on terminal statuses." - }, - "errorReason": { - "type": "string", - "description": "BUILD_NOT_FOUND | MULTIPLE_BUILD_FOUND | DATA_NOT_AVAILABLE | SOMETHING_WENT_WRONG." - } - } - }, - "BuildHistoryWithDefectCounts": { - "type": "object", - "description": "Agent-facing build history: anchor build + prior same-name runs, each with status and defect count summaries.", - "properties": { - "builds": { - "type": "array", - "description": "Newest first; each entry: buildUuid, buildNumber (serialId), originalName, status, startedAt, finishedAt, durationMs, current (true for the anchor build), statusStats{passed,failed,skipped,total}, defectStats{automationBug,productBug,toBeInvestigated,notDetermined,environmentIssue}, flakyCount, alwaysFailingCount, newFailureCount." - } - } - }, - "PublicBuildDetails": { - "type": "object", - "description": "Public build record (models/external BuildDetails, or BuildDetailsV2 when new build listing is enabled). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are JsonView-gated, so the exact key set varies by flag and plan.", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "id": { - "type": "integer", - "description": "Integer build id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "status_stats": { - "type": "object", - "description": "Per-status counts (V2: workflow-status counts), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 shape only." - }, - "failure_categories": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "smart_tags": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "unique_errors": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "alerts": { - "type": "array", - "description": "Pro-only (JsonView)." - }, - "re_run": { - "type": "boolean" - }, - "build_error": { - "type": "object" - }, - "is_archived": { - "type": "boolean" - }, - "observability_version": { - "type": "object" - }, - "vcs_info": { - "type": "object" - }, - "ci_info": { - "type": "object" - }, - "host_info": { - "type": "object" - }, - "run_information": { - "type": "array" - }, - "observability_url": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "app_details": { - "type": "object" - } - } - } - }, - "responses": { - "Unauthorized": { - "description": "401 - no valid credential was accepted by the authenticator chain.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Forbidden": { - "description": "403 - authenticated but lacks the required permission/plan/role, or access-denied.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BadRequest": { - "description": "400 - invalid or missing parameters; also the catch-all for many client errors.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "NotFound": { - "description": "404 - the addressed resource does not exist or is not visible to this group.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "InternalServerError": { - "description": "500 - unhandled server error; carries only errorTraceId + message.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Conflict": { - "description": "409 - the request conflicts with current state, e.g. an operation already in progress.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BuildSummaryListResponse": { - "description": "Top-level array of build summaries.", - "schema": { - "type": "array", - "items": { - "$schema": "BuildSummary" - } - } - }, - "BuildAggregateMapResponse": { - "description": "Top-level object keyed by build name; each value is a BuildAggregate.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "BuildAggregate" - } - } - }, - "OverallDailySummaryResponse": { - "description": "Aggregated daily-summary report across projects.", - "schema": { - "$schema": "OverallDailySummary" - } - }, - "PublicLinkSettingResponse": { - "description": "Public-link on/off setting for a dashboard or project. Built inline by the handler; defaults to false when no token row exists yet.", - "schema": { - "type": "object", - "properties": { - "publicLink": { - "type": "boolean", - "description": "Whether public link sharing is enabled. false is the default for a dashboard/project that has never been shared." - } - } - } - }, - "QualityGateSettings": { - "description": "A project's quality-gate settings and mapped profiles.", - "schema": { - "$schema": "QualityGateResponse" - } - }, - "QualityProfileDetail": { - "description": "One quality profile in full (rules, filters, status). Null fields omitted.", - "schema": { - "$schema": "QualityProfile" - } - }, - "BuildQualityGateSummary": { - "description": "Readiness-aware build gate summary; exactly one of qgResult/message/state is present.", - "schema": { - "$schema": "QualityGateSummary" - } - }, - "BuildQualityProfileResults": { - "description": "Bare JSON array of per-profile result summaries for a build (no wrapper).", - "schema": { - "type": "array", - "items": { - "$schema": "QualityProfileResultLiteDto" - } - } - }, - "BuildQualityProfileResultDetail": { - "description": "One profile's per-rule results for a build.", - "schema": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "$schema": "QualityProfileRuleResult" - } - }, - "ruleStatus": { - "type": "string", - "enum": [ - "pass", - "fail" - ] - } - } - } - }, - "CiQualityGateResult": { - "description": "CI-facing pass/fail quality gate result for a build. Null fields omitted while running.", - "schema": { - "$schema": "ExtQualityGateResult" - } - }, - "QualityProfileFilterValues": { - "description": "Filter values for the quality-profile filter builder, with an afterKey cursor.", - "schema": { - "$schema": "QGFilterResponseData" - } - }, - "PercentTimelineCardResponse": { - "description": "A Testing Trends card: timeline series plus optional headline insight.", - "schema": { - "$schema": "PercentTimelineCard" - } - }, - "FailureCategoriesResponse": { - "description": "Failure counts broken down by defect category over time.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FailureCategorySeries" - } - } - } - } - }, - "CbtResponse": { - "description": "Cross-browser coverage: primary platform groups plus their drill-down breakdowns.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "CbtDataPoint" - } - }, - "drillDownData": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Primary group key this drill-down belongs to." - }, - "data": { - "type": "array", - "items": { - "$schema": "CbtDataPoint" - } - } - } - } - } - } - } - }, - "BuildFrequencyResponse": { - "description": "Run counts per build name for the window. sourceFencingFlags exists on the model but is not populated on this path.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildFrequencyStats" - } - } - } - } - }, - "BuildNamesResponse": { - "description": "Distinct build names within the plan retention window, wrapped under data.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "StabilityLeaderboardResponse": { - "description": "Paged stability leaderboard. Pagination is nested under `pagingParam` (singular).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "StabilityLeaderboardRow" - } - }, - "pagingParam": { - "$schema": "TrendPagingParams" - } - } - } - }, - "PerformanceLeaderboardResponse": { - "description": "Paged performance leaderboard. Pagination is nested under `pagingParam` (singular).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "PerformanceLeaderboardRow" - } - }, - "pagingParam": { - "$schema": "TrendPagingParams" - } - } - } - }, - "StabilityChartResponse": { - "description": "Stability-over-time series with the window median.", - "schema": { - "type": "object", - "properties": { - "median": { - "type": "number", - "description": "Overall pass-rate percentage for the window." - }, - "data": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - } - } - } - }, - "PerformanceChartResponse": { - "description": "Performance-over-time: barData is average duration per bucket, lineData is test count per bucket (lineData pointRange is null).", - "schema": { - "type": "object", - "properties": { - "barData": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - }, - "lineData": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - } - } - } - }, - "RecentRunsResponse": { - "description": "Recent builds picker. Pagination is nested under `pagingParams` (plural).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TrendBuildSummary" - } - }, - "pagingParams": { - "$schema": "TrendPagingParams" - }, - "currentQGOverrideStatus": { - "type": "boolean", - "description": "Project's quality-gate override flag." - } - } - } - }, - "WebhookProjectMappingResponse": { - "description": "Project picker / mapping list for the webhook editor.", - "schema": { - "type": "object", - "properties": { - "projectMapping": { - "$schema": "WebhookProjectMapping" - } - } - } - }, - "RcaRetriggerResponse": { - "description": "New RCA conversation turn id (and trial quota for trial users).", - "schema": { - "$schema": "RcaRetrigger" - } - }, - "BuildAccessibilityMetaResponse": { - "description": "Build accessibility summary header (web/Automate shape).", - "schema": { - "$schema": "BuildAccessibilityMeta" - } - }, - "SimilarTestRunsResponse": { - "description": "Reference run and the runs with a similar failure.", - "schema": { - "$schema": "SimilarTestRunRs" - } - }, - "SingleMetricSummaryResponse": { - "description": "A single build-insight tile metric.", - "schema": { - "$schema": "SingleMetricSummary" - } - }, - "BuildAISummaryResponse": { - "description": "AI-generated build failure summary; soft-fails with status NOT_AVAILABLE.", - "schema": { - "$schema": "BuildAISummary" - } - }, - "BuildAttemptRunStatsResponse": { - "description": "Per-attempt result breakdown plus an aggregate summary.", - "schema": { - "$schema": "BuildAttemptRunStatsResponse" - } - }, - "BuildHistoryResponse": { - "description": "Trend over the ~10 most recent builds of the same name.", - "schema": { - "$schema": "BuildHistory" - } - }, - "BuildIdFromCoordinatesResponse": { - "description": "The resolved build UUID for the given coordinates.", - "schema": { - "$schema": "BuildIdFromCoordinates" - } - }, - "BuildCoordinatesResponse": { - "description": "URL-safe coordinates and project ids for a build UUID.", - "schema": { - "$schema": "BuildCoordinates" - } - }, - "BuildReRunResponse": { - "description": "Flat (legacy) re-run summary for a build.", - "schema": { - "$schema": "BuildReRun" - } - }, - "BuildReRunV2Response": { - "description": "Split-build-aware grouped re-run summary with an aggregate.", - "schema": { - "$schema": "BuildReRunV2" - } - }, - "FieldSearchResponse": { - "description": "Type-ahead facet options for a per-project build filter. `data` is a set of {id, value, label} option objects.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterField" - } - } - } - } - }, - "SidebarSearchResponse": { - "description": "Sidebar facet options. `data` is a list of BARE VALUES \u2014 plain strings for branches, build names and tags. Capped at 100 entries by the underlying ES aggregation.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "BuildListFiltersResponse": { - "description": "Per-project build-listing filter bar: applied selection resolved plus the static option catalogue.", - "schema": { - "type": "object", - "properties": { - "applied": { - "$schema": "BuildFilterApplied" - }, - "staticFilters": { - "$schema": "BuildFilterStatic" - } - } - } - }, - "BuildSidebarFiltersResponse": { - "description": "Cross-project build-listing sidebar filters: applied selection resolved (with the projects it was scoped to) plus the static option catalogue. frameworkList here is the hardcoded master list, not a per-project presence list.", - "schema": { - "type": "object", - "properties": { - "applied": { - "allOf": [ - { - "$schema": "BuildFilterApplied" - } - ], - "properties": { - "projects": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "The project ids this filter view was scoped to." - } - } - }, - "staticFilters": { - "$schema": "BuildFilterStatic" - } - } - } - }, - "BuildTestFiltersResponse": { - "description": "Filter panel for a build's TEST listing. `staticFilters` is a map keyed by filter name to a list of {label, value} options. `proFilters` names plan-locked filter keys (non-empty only for freemium).", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "The caller's applied test filters, echoed back resolved (folders, testTags, status, priority, caseType, automationStatus, owner, lastExecutedBy, deviceList, osList, browserList, failureCategories, dateRange, and the boolean history/flaky flags)." - }, - "staticFilters": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "FilterStruct__w2_build_filter1" - } - }, - "description": "Catalogue of selectable test-filter values, keyed by filter name (browserList, osList, status, testTags, folders, runs, deviceList, failureCategories, history, flaky, ...)." - }, - "hooksVisibility": { - "type": "string", - "description": "One of all / failed / beforeFailed / none." - }, - "proFilters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Filter keys locked behind a paid plan; empty for a paid workspace, else names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted." - } - } - } - }, - "RunGroupFilterOptionsResponse": { - "description": "Run-group (build-group) sidebar facet. `data` is a list of slim build-group rows; `info` is page metadata but paging is fixed at page 1, size 100 and cannot be advanced through this endpoint.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Slim build-group row. Casing is mixed: `updated_at` is snake_case.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "isClosed": { - "type": "boolean" - }, - "state": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string" - } - } - } - }, - "info": { - "type": "object", - "description": "Page metadata (fixed page 1 / size 100).", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - } - } - } - }, - "DashboardResponse": { - "description": "A single dashboard, wrapped in `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Dashboard" - } - } - } - }, - "DashboardsListResponse": { - "description": "List of dashboards, wrapped in `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "Dashboard" - } - } - } - } - }, - "DashboardMetaResponse": { - "description": "Ids resolved to display names.", - "schema": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "$schema": "MetaItem" - } - }, - "users": { - "type": "array", - "items": { - "$schema": "MetaItem" - } - } - } - } - }, - "UniqueErrorSearchResponse": { - "description": "Top-level JSON array of unique-error search hits (max 10). There is no wrapper object.", - "schema": { - "type": "array", - "items": { - "$schema": "ErrorSearchResult" - } - } - }, - "WidgetCustomFieldFiltersResponse": { - "description": "Custom-field filters. Shape depends on the request: with NO page/per_page/search it is a bare JSON ARRAY of FilterDetails; with any of them it is the paginated object {data, info}.", - "schema": { - "oneOf": [ - { - "type": "array", - "items": { - "$schema": "FilterDetails" - }, - "description": "Unpaginated: returned when page/per_page/search are all omitted." - }, - { - "type": "object", - "description": "Paginated: returned when any of page/per_page/search is present.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterDetails" - } - }, - "info": { - "$schema": "DashboardPageInfo" - } - } - } - ] - } - }, - "ReportListResponse": { - "description": "Reports/dashboards listing. On an invalid `source` this same 200 carries a top-level `error` string and empty `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "ReportSummary" - } - }, - "my_report_count": { - "type": "integer" - }, - "total_report_count": { - "type": "integer" - }, - "info": { - "$schema": "ReportListPageInfo" - }, - "error": { - "type": "string", - "description": "Present only on the invalid-source 200 refusal." - } - } - } - }, - "ProjectAutomationCoverageResponse": { - "description": "All-time automation-coverage figures for one project. Null fields are omitted (NON_NULL).", - "schema": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "total_test_case_count": { - "type": "integer" - }, - "automated_test_case_count": { - "type": "integer" - }, - "automation_coverage_percent": { - "type": "number" - }, - "calculated_at": { - "type": "string", - "format": "date-time", - "description": "When this figure was computed." - } - } - } - }, - "ProjectListingExternalResponse": { - "description": "One page (up to 10) of the caller's projects, plus the cursor for the next page.", - "schema": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "$schema": "ProjectExternal" - } - }, - "pagination": { - "$schema": "ExternalPagination" - } - } - } - }, - "HomeUniqueBuildsResponse": { - "description": "Latest runs grouped by unique build name, with dual offset/cursor pagination params.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildSummary" - }, - "description": "One BuildSummary per unique build name (its most recent run)." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "pageNumber": { - "type": "integer" - }, - "afterKey": { - "type": "string", - "description": "search_after cursor to carry into the next request." - } - } - } - } - } - }, - "HomeRecentViewsResponse": { - "description": "The user's up-to-5 recently-viewed items.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "RecentView" - } - } - } - } - }, - "HomeTakeawaysResponse": { - "description": "Older Home takeaways variant: aggregate time-saved metric. The v2 timeSavingV2Metric key is present but null on this path.", - "schema": { - "type": "object", - "properties": { - "timeSavingsData": { - "$schema": "TimeSavingMetric" - }, - "takeAwayData": { - "$schema": "TakeAwayData" - }, - "timeSavingV2Metric": { - "description": "Always null on this older variant (the key is present, the value is not).", - "nullable": true - } - } - } - }, - "HomeTakeawaysNoiseBreakdownResponse": { - "description": "Newer Home takeaways variant: failure-noise breakdown. timeSavingsData is explicitly null on this path.", - "schema": { - "type": "object", - "properties": { - "timeSavingV2Metric": { - "$schema": "TimeSavingV2Metric" - }, - "timeSavingsData": { - "type": "null", - "description": "Always null through this endpoint." - }, - "takeAwayData": { - "$schema": "TakeAwayData" - } - } - } - }, - "ProjectBuildGroupListingResponse": { - "description": "A project's builds consolidated by build group, plus per-group aggregates and cursor pagination.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "isHittingRetentionQuota": { - "type": "boolean" - }, - "builds": { - "type": "array", - "items": { - "$schema": "BuildSummary" - }, - "nullable": true, - "description": "Never populated on this route (always null); the per-group rows are in buildGroupAggResult." - }, - "buildGroupAggResult": { - "type": "array", - "items": { - "$schema": "BuildGroupAggResult" - }, - "description": "Per-build-group rolled-up aggregates." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "description": "Cursor (array or integer) to carry into the next request." - } - }, - "nullable": true, - "description": "Never populated on this route (always null); all groups come back in one response." - }, - "activeCount": { - "type": "integer" - }, - "closedCount": { - "type": "integer" - }, - "isArchiving": { - "type": "boolean" - }, - "isClosing": { - "type": "boolean" - }, - "manualBuildCount": { - "type": "integer" - }, - "automationBuildCount": { - "type": "integer" - }, - "invalidQgBuildCount": { - "type": "integer" - }, - "currentQGOverrideStatus": { - "type": "boolean", - "nullable": true - } - } - } - }, - "SPTestFilterOptions": { - "description": "Suite Health test-list filter sidebar: the applied filters resolved into display fields, the static option catalogue, and the project default hooks visibility.", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "The currently applied filters echoed back: list filters resolved into Field objects (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList/osList/browserList, branches, pmToolStatus), scalar/tri-state flags (search, isFlaky, isNewFailure, isAlwaysFailing, hasPmToolDefects, isManuallyOverridden, isCiRun, isLatest), dateRange as {lowerBound, upperBound}, isMuted and hooksVisibility. Filters you did not send come back null or [] (inconsistently per key)." - }, - "staticFilters": { - "type": "object", - "description": "The choosable option catalogue: osList, browserList, deviceList, status, folders, failureCategories, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan - each a list of Field objects. The run-level lists (typeOfRun, assignedTo, state, testPlan) come from an upstream service and are null or [] when it has nothing or times out, rather than erroring." - }, - "hooksVisibility": { - "type": "string", - "description": "The project's default hooks visibility (all / failed / beforeFailed / none)." - } - } - } - }, - "FieldSearchResults": { - "description": "A set of filter suggestions/options. Shared by the branch, build-name, build-tag, host-name and test-tag type-ahead routes.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "Suggestion items (deduplicated set).", - "items": { - "$schema": "Field" - } - } - } - } - }, - "SPTestMetricsChart": { - "description": "Suite Health page charts. A top-level map keyed by chart name (no data wrapper).", - "schema": { - "type": "object", - "properties": { - "failureChart": { - "$schema": "SPChart" - }, - "durationChart": { - "$schema": "SPChart" - }, - "failureRateChart": { - "$schema": "SPChart" - } - } - } - }, - "SPTestBaseInfo": { - "description": "Identifying header info for one test in the Test Health detail view. Flat object, no data wrapper.", - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Test display name; empty string when the hash matches nothing in the window." - }, - "combinations": { - "type": "array", - "description": "OS/browser/device environment combinations the test ran on.", - "items": { - "type": "object", - "properties": { - "os": { - "type": "object", - "description": "Formatted platform descriptor." - }, - "browser": { - "type": "object", - "description": "Formatted browser descriptor, with a nested `device` field." - } - } - } - } - } - } - }, - "SPTestEnvironmentBreakdown": { - "description": "Per-environment stability breakdown for one test. A BARE JSON array (no object wrapper).", - "schema": { - "type": "array", - "items": { - "$schema": "SPTestBreakdownRow" - } - } - }, - "TestRunDetailResponse": { - "description": "Full detail of a single test run.", - "schema": { - "$schema": "TestRunDetail" - } - }, - "TestRunDetailMapResponse": { - "description": "A MAP keyed by test run id; each value is that run's detail ({ testCode, testMetadata }). Empty object when nothing matched \u2014 not an error.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestRunDetail" - } - } - }, - "BuildInsightsResponse": { - "description": "Build-level insights surrounding a test run.", - "schema": { - "$schema": "BuildInsights" - } - }, - "BuildTestRunListingResponse": { - "description": "A build's test runs as a nested hierarchy, plus pagination. Keys are snake_case (TestListingResponse / V2 by account flag); V2 adds is_manually_overridden and test_summary holds workflow-status counts.", - "schema": { - "type": "object", - "properties": { - "hierarchy": { - "type": "array", - "description": "Nested nodes (suites/folders/tests), each with details and children. On freemium/source-fenced builds analysis keys are stripped from node details.", - "items": { - "type": "object" - } - }, - "project_id": { - "type": "integer" - }, - "name": { - "type": "string", - "description": "Project name." - }, - "group_id": { - "type": "integer" - }, - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "build_name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "test_summary": { - "type": "object", - "description": "Per-status counts: StatusAggregate (older envelope) or WorkflowStatusAggregate (newer), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "Newer envelope only." - }, - "is_archived": { - "type": "boolean" - }, - "app_details": { - "type": "object" - }, - "pagination": { - "type": "object", - "description": "snake_case keys (models/external/Pagination).", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." - } - } - } - } - } - }, - "BuildTestRunsWithAttachmentsResponse": { - "description": "Trimmed test-run rows that have attachments/screenshots, with a numeric cursor.", - "schema": { - "type": "object", - "properties": { - "test_runs": { - "type": "array", - "items": { - "$schema": "BuildTestRunWithAttachments" - } - }, - "next_cursor": { - "type": "integer", - "description": "Numeric cursor; null on the last page. Pass back as `cursor`." - } - } - } - }, - "BugReportDraftResponse": { - "description": "Pre-filled bug-report draft composed from a failed run. Read-only; not a stored bug.", - "schema": { - "type": "object", - "properties": { - "buildName": { - "type": "string" - }, - "buildId": { - "type": "integer", - "description": "The build NUMBER (e.g. 1), not the numeric build_id." - }, - "ciInfo": { - "type": "object", - "nullable": true - }, - "testDetails": { - "type": "string", - "description": "Composed test hierarchy/description text for the ticket body." - }, - "testUrl": { - "type": "string", - "nullable": true - }, - "videoUrl": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - }, - "platform": { - "type": "string", - "nullable": true - }, - "browser": { - "type": "string", - "nullable": true - }, - "hostInfo": { - "type": "object", - "nullable": true - }, - "logStruct": { - "type": "object", - "description": "Image/log attachment references keyed by log type (e.g. TEST_FAILURE: array of strings); empty when the run has no screenshots." - }, - "retries": { - "type": "array", - "items": { - "type": "object" - } - }, - "issueType": { - "type": "string", - "nullable": true - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "duration": { - "type": "integer" - }, - "aiRca": { - "type": "object", - "description": "AI RCA snippet ({data: ...}); null when no RCA is available.", - "nullable": true - } - } - } - }, - "LinkBugToTestRunResponse": { - "description": "Result of linking a filed bug to a run. 200 carries the created integration id; a 204 (no body) means the bug key was already tracked.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Created integration id; null if none was recorded." - } - } - } - }, - "FlatTestRunListingResponse": { - "description": "Flat list of a build's test runs with search_after pagination.", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "$schema": "FlatTestRun" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object" - }, - "searchAfter": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Cursor to pass back as searchAfter for the next page." - } - } - } - } - } - }, - "BuildAIFailuresResponse": { - "description": "Build-level AI failure analysis: failure themes and workflows, sorted by failure share. Serialized from AIFailuresResponse; trialQuota is omitted (NON_NULL) unless the account is on the build-level trial.", - "schema": { - "type": "object", - "properties": { - "buildId": { - "type": "integer" - }, - "buildThemes": { - "type": "array", - "description": "Failure themes, sorted by testPercentage descending.", - "items": { - "type": "object", - "properties": { - "themeId": { - "type": "string" - }, - "buildFailureThemeId": { - "type": "integer", - "description": "Integer id used to drill into this theme's test runs." - }, - "themeData": { - "type": "object", - "description": "Opaque theme detail map." - }, - "affectedWorkflows": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "workflowData": { - "type": "object" - } - } - } - }, - "affectedWorkflowCount": { - "type": "integer" - }, - "testRunCount": { - "type": "integer" - }, - "testPercentage": { - "type": "string", - "description": "Share of failures as a string (may include a % sign)." - }, - "isNewTheme": { - "type": "boolean" - } - } - } - }, - "buildWorkflows": { - "type": "array", - "description": "Failure workflows, sorted by testPercentage descending.", - "items": { - "type": "object", - "properties": { - "workflowIdentifier": { - "type": "string" - }, - "buildFailureWorkflowId": { - "type": "integer", - "description": "Integer id used to drill into this workflow's test runs." - }, - "workflowData": { - "type": "object", - "description": "Opaque workflow detail map." - }, - "testRunCount": { - "type": "integer" - }, - "testPercentage": { - "type": "string" - }, - "themesIdentified": { - "type": "array", - "items": { - "type": "object", - "properties": { - "buildRunThemeId": { - "type": "integer" - }, - "themeData": { - "type": "object" - }, - "isNewTheme": { - "type": "boolean" - } - } - } - }, - "themeIdentifiedCount": { - "type": "integer" - } - } - } - }, - "buildThemeWorkflow": { - "type": "object", - "description": "Analysis-run status envelope.", - "properties": { - "status": { - "type": "string", - "description": "e.g. SUCCESS, PENDING, RETRIED_AND_FAILED. Non-terminal means results still being produced." - }, - "summary": { - "type": "string" - } - } - }, - "stats": { - "type": "object", - "properties": { - "totalThemes": { - "type": "integer" - }, - "newThemes": { - "type": "integer" - }, - "existingThemes": { - "type": "integer" - }, - "totalWorkflows": { - "type": "integer" - }, - "totalTestRuns": { - "type": "integer" - } - } - }, - "trialQuota": { - "type": "object", - "description": "Present only for build-level trial accounts.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - } - }, - "BuildAIFailureTriggerResponse": { - "description": "Get-or-trigger response for build-level AI failure analysis. The AIFailuresResponse fields are unwrapped at the TOP level (JsonUnwrapped) alongside triggered; when analysis was freshly triggered they are absent/null and only triggered (and trialQuota) are present.", - "schema": { - "type": "object", - "properties": { - "triggered": { - "type": "boolean", - "description": "true when analysis was enqueued (results not in this response); false when existing terminal analysis is returned inline." - }, - "buildId": { - "type": "integer", - "description": "Present only when existing analysis is returned; null/absent on a fresh trigger." - }, - "buildThemes": { - "type": "array", - "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildThemes.", - "items": { - "type": "object" - } - }, - "buildWorkflows": { - "type": "array", - "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildWorkflows.", - "items": { - "type": "object" - } - }, - "buildThemeWorkflow": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "Set to PENDING when a non-terminal analysis is retriggered." - }, - "summary": { - "type": "string" - } - } - }, - "stats": { - "type": "object", - "description": "Same shape as BuildAIFailuresResponse.stats." - }, - "trialQuota": { - "type": "object", - "description": "Present only for build-level trial accounts.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - } - }, - "FlatAIFailureTestRunsResponse": { - "description": "Flat, cursor-paginated list of the test runs behind an AI failure cluster (or an explicit id set). Serialized from FlatTestResponse. Detail fields are null when the run has no such data (os/browser/issueType on SDK-less runs, the analyser flags before analysis).", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "details": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "os": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - }, - "nullable": true - }, - "browser": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - }, - "nullable": true - }, - "device": { - "type": "string", - "nullable": true - }, - "isRealDevice": { - "type": "boolean", - "nullable": true - }, - "filePath": { - "type": "string", - "nullable": true - }, - "vcFileUrl": { - "type": "string", - "nullable": true - }, - "issueType": { - "type": "object", - "nullable": true - }, - "aiRcaState": { - "type": "string", - "nullable": true - }, - "isFlaky": { - "type": "boolean", - "nullable": true - }, - "isPerformanceAnomaly": { - "type": "boolean", - "nullable": true - }, - "isNewFailure": { - "type": "boolean", - "nullable": true - }, - "isAlwaysFailing": { - "type": "boolean", - "nullable": true - }, - "isMuted": { - "type": "boolean", - "nullable": true - }, - "isAutoAnalyzed": { - "type": "boolean", - "nullable": true - }, - "isAutoAnalyzerRunning": { - "type": "boolean", - "nullable": true - }, - "isUniqueErrorsRunning": { - "type": "boolean", - "nullable": true - }, - "isFailingLast5Builds": { - "type": "boolean", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "pagingParams": { - "type": "object", - "description": "Cursor paging state. Feed searchAfter back to fetch the next page; stop when hasNext is false.", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "description": "Opaque map, may be null.", - "nullable": true - }, - "searchAfter": { - "description": "Opaque cursor: an array (or integer) to pass back as the searchAfter query param." - } - } - } - } - } - }, - "QueuedSessionsResponse": { - "description": "Sessions currently sitting in the automation queue for a user and product.", - "schema": { - "type": "object", - "properties": { - "sessions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "buildUuid": { - "type": "string" - }, - "sessionName": { - "type": "string" - }, - "buildName": { - "type": "string", - "description": "Null when the build is outside the caller's group." - }, - "projectName": { - "type": "string", - "description": "Null when the build is outside the caller's group." - }, - "queueReason": { - "type": "string", - "description": "Why the session is queued, e.g. no_terminal_available." - }, - "capabilities": { - "type": "object", - "description": "Resolved caps; shape depends on caps_format (selenium / appium / default)." - }, - "duration": { - "type": "string", - "description": "How long the session has waited, computed at read time." - } - } - } - } - } - } - }, - "SessionAppProfilingResponse": { - "description": "Opaque upstream app-profiling JSON (CPU/memory/etc.). An empty object means the report is still generating or was never captured.", - "schema": { - "type": "object", - "description": "Opaque app-profiling payload from the App Automate product API; {} when not yet available." - } - }, - "SessionCapabilitiesResponse": { - "description": "Opaque session capabilities JSON. Normally the upstream sessionParams object; on upstream failure a fallback {outputCapabilities:{capabilities:...}} built from stored session caps.", - "schema": { - "type": "object", - "description": "Opaque capabilities object; key shape varies between the upstream sessionParams and the stored-caps fallback." - } - }, - "SessionDetailsResponse": { - "description": "Session header/metadata for the Test Details page (serialized from TestLogsInfo). customMetadata and buildId are always null through this path.", - "schema": { - "type": "object", - "properties": { - "videoLogs": { - "$schema": "ObsSessionVideo", - "nullable": true, - "description": "Session video handle (url, offsetUrl, status_code, startTime/startTimeStamp, startOffset/finishOffset, launchingAppTime, isIos, isV2); null if the media fetch failed." - }, - "audioLogs": { - "type": "object", - "description": "Opaque audio log node; null if unavailable.", - "nullable": true - }, - "browserstackSessionUrl": { - "type": "string", - "description": "Deep link to the session on the Automate/App-Automate dashboard." - }, - "percyBuildUrl": { - "type": "string", - "description": "Linked Percy build URL, extracted from build metadata; may be null.", - "nullable": true - }, - "isSdk": { - "type": "boolean", - "description": "Whether the build was produced by the observability SDK." - }, - "device": { - "type": "string", - "description": "Device name; an EMPTY string for desktop sessions (\"NA\" on some other routes)." - }, - "framework": { - "type": "string", - "description": "Short framework name, e.g. \"mocha\" (not the build's full framework/version string)." - }, - "customMetadata": { - "type": "object", - "description": "Always null through this path.", - "nullable": true - }, - "testStartedAt": { - "type": "string", - "description": "ISO-8601 session start time; drives the network-logs 30-day-expired empty state. May be null.", - "nullable": true - }, - "buildId": { - "type": "string", - "description": "Always null through this path despite the field name.", - "nullable": true - } - } - } - }, - "SessionTestCodeResponse": { - "description": "Test source code captured for an Automate session, one entry per test executed.", - "schema": { - "type": "object", - "properties": { - "session_id": { - "type": "string" - }, - "build_uuid": { - "type": "string" - }, - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "test_run_id": { - "type": "integer" - }, - "code": { - "type": "string", - "description": "Captured source; may be truncated server-side without any flag in the response." - }, - "language": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "type": { - "type": "string" - } - } - } - } - } - } - }, - "BuildLinkNotReady": { - "description": "Retryable 425: the session is not yet linked to its build. Retry shortly rather than treating as fatal.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "CheckConfigurationResponse": { - "description": "A repository's check-run configurations. On the internal empty-fallback path the repoName/ownerName fields may be populated in swapped order.", - "schema": { - "type": "object", - "properties": { - "repoName": { - "type": "string" - }, - "ownerName": { - "type": "string" - }, - "checks": { - "type": "array", - "items": { - "$schema": "CheckConfigurationItem" - } - } - } - } - }, - "CheckConfigurationItemResponseWrap": { - "description": "A single check-run configuration (returned by update and toggle on success).", - "schema": { - "$schema": "CheckConfigurationItem" - } - }, - "VcsCheckMappingResponse": { - "description": "A single registered PR-check mapping (vcs_ingestion row with its joined check configuration).", - "schema": { - "$schema": "VcsCheckMapping" - } - }, - "ViewResponse": { - "description": "A single saved view.", - "schema": { - "$schema": "View" - } - }, - "ViewListResponse": { - "description": "A bare JSON array of saved views (no envelope, not paginated).", - "schema": { - "type": "array", - "items": { - "$schema": "View" - } - } - }, - "DashboardViewResponse": { - "description": "Single dashboard view wrapped under `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "DashboardView" - } - } - } - }, - "WidgetFilterValuesResponse": { - "description": "Paginated widget filter-value picker results (WidgetEnhancedFilterResp).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "description": "Map keyed by bucket - `suggested` and `others` - each a list of value structs (not keyed by filter name).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - }, - "fullList": { - "type": "boolean", - "description": "True when the whole value set fit in this page.", - "nullable": true - }, - "info": { - "type": "object", - "description": "Page metadata (PageInfo).", - "nullable": true - }, - "suggestedAfterKey": { - "type": "string", - "nullable": true - }, - "othersAfterKey": { - "type": "string", - "nullable": true - } - } - } - }, - "IgnoredFilterValuesResponse": { - "description": "Report of which saved widget filter values are still valid vs ignored (TestHub IgnoredFilterValuesResponse).", - "schema": { - "type": "object", - "properties": { - "filterMatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "filterType": { - "type": "string" - }, - "matchedValuesCount": { - "type": "integer" - }, - "ignoredValuesCount": { - "type": "integer" - }, - "isMismatched": { - "type": "boolean" - } - } - } - }, - "ignoredFiltersCount": { - "type": "integer", - "description": "Count of filters completely ignored." - }, - "isMismatched": { - "type": "boolean" - } - } - } - }, - "WidgetDataResponse": { - "description": "Computed data for one dashboard-view widget. The concrete shape is untyped and depends entirely on the widgetType requested \u2014 inspect per widget type.", - "schema": { - "type": "object", - "description": "Widget-type-specific payload (charts, stat cards, trend series, drilldown data, etc.)." - } - }, - "ReportRecordResponse": { - "description": "A single report record wrapped under `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "ReportRecord" - } - } - } - }, - "ReportRecordListResponse": { - "description": "A flat list of report records under `data` (no pagination).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "ReportRecord" - } - } - } - } - }, - "ReportDatasetResponse": { - "description": "Report dataset rows (or static build rows) with a paging summary. Can be null when the report type is not TABULAR_TEST_RUNS.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunReportRow" - } - }, - "summary": { - "$schema": "PaginationSummary" - }, - "sourceFencingFlags": { - "type": "object" - } - } - } - }, - "ReportStatTilesResponse": { - "description": "Aggregate stat tiles for a report widget. `data` is null when the report type is not TABULAR_TEST_RUNS.", - "schema": { + "intent": "Return a project's quality-gate configuration over the public (access-key) API: whether gates are enabled, whether the gate overrides build status, and the profiles attached to the project. Use this from CI/integrations authenticated with a username+access-key.", + "returns": [ + "enabled", + "shouldOverrideBuildStatus", + "qualityProfiles" + ], + "responses": { + "200": { + "$response": "QualityGateSettings" + } + }, + "guidance": [ + "Same response as the in-product `get_project_quality_gate_settings`; this is the external surface that accepts a username+access-key (CI/integration).", + "The action name reads like a plain profile list but it also carries the project-level enabled / shouldOverrideBuildStatus settings.", + "Plan-gated: refused as feature-inaccessible when the plan lacks Quality Gates.", + "For service-account callers this enforces the quality-gate project permission (fallback modify_project_settings); ordinary user-key callers skip that check." + ] + }, + { + "name": "get_quality_profile", + "method": "GET", + "path": "/ext/v1/quality-gates/{project_name}/profiles/{profile_id}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project's normalised name." + }, + { + "name": "profile_id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Profile UUID. A non-UUID value 400s before the handler runs." + } + ], + "intent": "Fetch one quality-gate profile in full \u2014 rules, thresholds and build filters \u2014 over the public (access-key) API, with failure-category operands resolved to the project's display names.", + "returns": [ + "uuid", + "name", + "ruleStatus", + "rules", + "applicableBuilds", + "isGlobalProfile", + "hooksVisibility" + ], + "responses": { + "200": { + "$response": "QualityProfileDetail" + } + }, + "guidance": [ + "`displayWith` is populated only for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules. Null fields are omitted (JSON include NON_NULL).", + "The profile is loaded by UUID and is not itself re-checked against the named project.", + "Plan-gated (feature-inaccessible when the plan lacks Quality Gates).", + "External surface accepting a username+access-key; for service-account callers enforces the quality-gate project permission, user-key callers skip it." + ] + }, + { + "name": "get_build_quality_gate_result", + "method": "GET", + "path": "/ext/v1/quality-gates/{build_id}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "The build uuid (40-char hashed id, not an RFC-4122 UUID), despite the name `build_id`. Looked up by UUID alone, not scoped to the caller's group." + } + ], + "intent": "The CI-facing quality gate check: given a build UUID, return whether the build passed or failed its quality gate (or that it is still running), so a pipeline can decide to proceed or fail.", + "returns": [ + "build_uuid", + "build_url", + "status", + "quality_gate_result", + "quality_profiles" + ], + "responses": { + "200": { + "$response": "CiQualityGateResult" + } + }, + "guidance": [ + "While the build is unfinished (or gate analysis is still in process) it returns status 'running' with `quality_gate_result` set to a 'try again in a few minutes' message \u2014 CI callers must POLL, not treat the first response as final.", + "`status`/`quality_gate_result` resolve to 'passed', 'failed' or 'skipped' once evaluation completes; null fields are omitted (JSON include NON_NULL).", + "Errors: build-not-found for an unknown UUID; forbidden-operation for a locked build; profile-not-found when quality gates are disabled for the project OR no profile results exist for the build.", + "The build is looked up by UUID alone and NOT scoped to the caller's group, unlike the profile endpoints on this surface. For service-account callers it enforces the quality-gate project permission; ordinary user-key callers skip that check.", + "Triggers configured Quality-Gate category integrations as a side effect when a result is produced." + ] + }, + { + "name": "get_build_quality_gate_summary", + "method": "GET", + "path": "/api/v1/builds/{uuid}/v2/quality-gate", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + } + ], + "intent": "The readiness-aware quality-gate summary for a build: return the per-profile results, or instead a status message when the build is still pending, the auto-analyser is running, or an evaluation state is cached. This is the endpoint to poll after triggering a recalculation.", + "returns": [ + "qgResult", + "message", + "state" + ], + "responses": { + "200": { + "$response": "BuildQualityGateSummary" + } + }, + "guidance": [ + "The 200 is one of several shapes and a caller MUST branch on it: `qgResult` (the per-profile results list) when ready; otherwise `message` = 'Quality Gate result will be available after your build finishes' (build pending) or 'Quality Gate evaluation will begin soon' (auto-analyser running); or a bare `state` string read from cache. Only one of qgResult/message/state is present per response.", + "Poll this after a recalculate to watch the gate finish; the message/state responses mean 'not ready yet', not an error.", + "Behavioural counterpart of `list_build_quality_profile_results`: that one returns the results as a bare array with no readiness handling; this one wraps them and can report still-evaluating/pending states instead. (They are the v1/v2 pair on this route.)", + "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface.", + "Ignore the extracted 'Check project permission' comment associated with this route \u2014 it belongs to a neighbouring close-build method; there is no extra permission check here." + ] + }, + { + "name": "list_build_quality_profile_results", + "method": "GET", + "path": "/api/v1/builds/{uuid}/quality-gate", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + } + ], + "intent": "Return the per-quality-profile results for a build as a flat list: each profile with its name, uuid and per-status rule counts \u2014 the quality gate breakdown on the build page.", + "returns": [ + "uuid", + "name", + "status", + "statusStats" + ], + "responses": { + "200": { + "$response": "BuildQualityProfileResults" + } + }, + "guidance": [ + "The 200 body is a BARE JSON ARRAY of per-profile result objects (no wrapper, no `data`, no readiness field); each item is {uuid, name, status, statusStats:{passed,failed,skipped}}.", + "This returns the raw results with NO readiness handling: if the build is still evaluating you get an empty/partial list rather than a 'still evaluating' signal. Use `get_build_quality_gate_summary` (the v2 route) when you need to distinguish 'not ready' from 'no results'.", + "For a split build's child, results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." + ] + }, + { + "name": "get_build_quality_profile_result", + "method": "GET", + "path": "/api/v1/builds/{uuid}/quality-gate/profile/{profileUuid}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + }, + { + "name": "profileUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "UUID of the quality profile. A non-UUID value 400s before the handler runs." + } + ], + "intent": "Drill into a single quality profile's result for a build: the individual rules, their thresholds and rendered conditions, and each rule's pass/fail outcome \u2014 for the quality-gate detail drawer.", + "returns": [ + "rules", + "ruleStatus" + ], + "responses": { + "200": { + "$response": "BuildQualityProfileResultDetail" + } + }, + "guidance": [ + "The body is an object {rules, ruleStatus}, where each rule entry is {rule, ruleStatus, ruleResult} \u2014 the rule definition plus that rule's per-build status and result.", + "Failure-category rules have their `displayWith` resolved to the project's human display names; other rule types do not.", + "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." + ] + }, + { + "name": "get_flakiness_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/flakiness", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at the plan retention floor. Rejected with 400 only if it is negative or greater than upperBound." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now. Silently clamped down to the plan retention ceiling." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name. Omit for all builds." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's runs are counted; repeat the param for a set." + } + ], + "intent": "Get the flakiness card for a project's Testing Trends page - the percentage and count of flaky tests over the window plus the sparkline series, to track whether suite flakiness is improving.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ALWAYS pass lowerBound/upperBound: omitting them returns the full retention window (tens of thousands of characters) and blows the tool output limit. For a single build's flaky/new-failure/muted/re-run counts read the build row (historyAggregate) via list_project_builds_by_name or get_build_details instead; this card is per build NAME over a window, not per run.", + "Plan-feature-gated: if the flakiness widget is not in the caller's plan the call fails with 403 (FEATURE_INACCESSIBLE), NOT a 200 with empty data - do not treat that as 'no flaky tests'.", + "The window is clamped to plan retention silently, and the bucket interval is derived from the width of the window you asked for, so a wider range yields coarser buckets.", + "`insights.count` is the headline percentage as a STRING (one decimal), and `insights.meta` is a human sentence like 'N of M unique tests are flaky'; the per-bucket series is in `data`, each point's `y` being the flaky count for that bucket (null when no tests ran).", + "Both bounds are optional here; omit them to get the full retention window." + ] + }, + { + "name": "get_always_failing_tests_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/alwaysFailing", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor. 400 only if negative or greater than upperBound." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to the plan retention ceiling." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name. Omit for all builds." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the always-failing card for a project - the percentage and count of consistently broken (permanently red) tests over the window plus the sparkline series, to see whether they are growing.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-feature-gated: refused with 403 (FEATURE_INACCESSIBLE) when the always-failing widget is not in the caller's plan, rather than returning empty data.", + "Side effect: for non-superusers this records the Testing Trends view in the caller's recently-viewed list. It is harmless and does not change test data, but it means this read is not entirely free of writes.", + "Window is clamped to plan retention silently; bucket interval is auto-chosen from the width of the window. `insights.count` is a percentage string and `insights.meta` a summary sentence." + ] + }, + { + "name": "get_new_failures_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/newFailures", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the new-failures card for a project - the percentage and count of tests failing for the first time in the window plus the series, the 'what did we just break' regression signal.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-feature-gated: 403 (FEATURE_INACCESSIBLE) when the new-failures widget is not in the caller's plan; not a 200 with empty data.", + "Window clamped to plan retention silently; bucket interval auto-chosen from window width. `insights.count` is a percentage string; per-bucket new-failure counts are in `data[].y` (null for buckets with no runs)." + ] + }, + { + "name": "get_test_growth_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testGrowth", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the test-suite growth card for a project - how the number of unique test cases changed over the window, with the timeline series, i.e. whether the suite is actually growing.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the `testGrowth` path this counts DISTINCT TEST CASES (suite size), not test runs - use get_test_executions_trend for run volume.", + "Unlike flakiness / new-failures / always-failing this card is NOT feature-gated, so it works on any plan.", + "`insights` is null on this card (no headline metric); read the series in `data`, where each point's `y` is the distinct-test-case count for that bucket. Window clamped to plan retention; bucket interval auto from window width." + ] + }, + { + "name": "get_test_executions_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testExecutions", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the test-execution volume card for a project - how many tests ran over the window, bucketed over time, to correlate suite growth or CI changes with other trends.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Counts executions (how many tests ran), not distinct cases; for suite size use get_test_growth_trend.", + "`insights.count` here is the raw total execution count as a string (not a percentage), and `insights.metric`/`insights.meta` are null. Per-bucket counts are in `data[].y` (0 for empty buckets); `data[].t` is null on this card.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_failure_categories_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/failureCategories", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Break a project's failures down by failure category over time, so a team can see which classes of failure (defect types) are trending up and their share of the total.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FailureCategoriesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Response is `{data: [ {name, color, data:[{x,y}]} ]}` - one entry per defect/issue category, each with its own time series; `name` and `color` come from the group's defect-type mapping.", + "Within a category series, a point's `y` is null for a bucket in which no tests ran at all (as opposed to 0 failures) - distinguish 'no data' from 'no failures'.", + "Window clamped to plan retention; bucket granularity auto-chosen from the width of the window. A project with no failures in the window returns an empty `data` array, not an error." + ] + }, + { + "name": "get_cross_browser_coverage", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/cbt", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at epoch 0 rather than the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the cross-browser-testing coverage breakdown for a project - the window's executions aggregated by browser, OS and device and grouped into primary/secondary platform buckets with counts and percentages.", + "returns": [ + "data", + "drillDownData" + ], + "responses": { + "200": { + "$response": "CbtResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`data` holds the top-level (primary) platform groups; `drillDownData` holds each group's secondary breakdown, addressed by the primary group's key (`drillDownData[].id` matches `data[].drilldown`).", + "`percentage` fields are formatted STRINGS (two decimals), and `value` is the raw execution count. Primary points carry os/browser/device as null; those are set only on secondary (drill-down) points. Runs without platform info land in an 'Unknown' group with 'NA' secondaries.", + "A project with no runs in the window returns empty groups, not an error. Window clamped to plan retention." + ] + }, + { + "name": "get_build_frequency", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildFrequency", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds. REQUIRED on this card (unlike the others). Missing it is a 400." + }, + { + "name": "upperBound", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds. REQUIRED. lowerBound > upperBound, or a negative bound, is a 400." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns the run count for just this one build name; when absent, the count per distinct build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get how often builds ran over a period - run counts per build name (or for one build name) - so a team can see CI cadence and gaps.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildFrequencyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Unlike every other Testing Trends card, BOTH bounds are mandatory here and are validated as a range - omitting one, sending them reversed, or a negative value is a 400.", + "This card does NOT clamp the window to plan retention; the exact bounds you send are used against the relational build store, so counts can differ from the analytics-index cards.", + "Response is `{data: [ {id, buildName, runs} ]}`; `id` is just a positional index into the list, not a build id, and `runs` is the run count. There is no time series here - it is a per-build total for the window.", + "A `sourceFencingFlags` key exists on the model but is not populated on this path, so treat it as always absent." + ] + }, + { + "name": "list_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildNames", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's builds contribute names; repeat the param for a set." + } + ], + "intent": "List the distinct build names seen in a project within the plan's retention window - the source of the build-name values every other Testing Trends card filters by.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildNamesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes NO date range - it always scans the full plan-retention window, so build names from older builds simply disappear once they fall out of retention.", + "Response is wrapped as `{data: [ \"\", ... ]}` - a flat array of strings, not build objects.", + "An unknown project is a 404 (PROJECT_NOT_FOUND), unlike the analytics cards which resolve the project inside the aggregation." + ] + }, + { + "name": "list_stability_leaderboard", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the stability metric itself always uses the plan-retention window." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns just this one build's stability row (the 'All' aggregate row is then omitted)." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the stability leaderboard for a project's Testing Trends page - pass-rate / stability scoring per build name over the window, to rank the least reliable suites.", + "returns": [ + "data", + "pagingParam" + ], + "responses": { + "200": { + "$response": "StabilityLeaderboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The requested window is REPLACED by the plan-retention window for the stability computation, so an out-of-retention window silently returns retention-bounded scores; the bounds you send only affect which builds are listed on the page.", + "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false. Note the response key is `pagingParam` (SINGULAR), and `pagingParam.pageNumber` echoes back one-based (your pageNumber + 1).", + "On page 0 with no `buildName`, an extra project-wide row (`id:\"all\"`, buildName:\"All\") is prepended before the per-build rows; on later pages or with a build filter it is absent.", + "Each row's `percentage` is a formatted STRING and `chartData` is the per-bucket pass-rate series (entries may be null for empty buckets). For a full, higher-resolution series for one build use get_project_stability_chart / get_build_stability_chart." + ] + }, + { + "name": "get_project_stability_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the project-wide stability-over-time series for the Testing Trends stability chart - across all builds, or for one build selected by build name.", + "returns": [ + "median", + "data" + ], + "responses": { + "200": { + "$response": "StabilityChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the build-name-selected variant of the stability chart: it derives the series identifier from the `buildName` query param and falls back to the literal \"all\" when it is absent. To address a build by an identifier in the path instead, use get_build_stability_chart.", + "Response is `{median, data}`: `median` is the overall pass-rate percentage for the window (a number), and `data` is the per-bucket series where each point's `y` is a pass-rate percentage NUMBER (e.g. 55.5), null for empty buckets.", + "Window clamped to plan retention; bucket interval auto-chosen from the width of the window.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_build_stability_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/{identifier}/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + }, + { + "name": "identifier", + "type": "string", + "required": true, + "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the stability-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a stability leaderboard row is expanded.", + "returns": [ + "median", + "data" + ], + "responses": { + "200": { + "$response": "StabilityChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query instead, use get_project_stability_chart. Both return the identical `{median, data}` shape.", + "Window clamped to plan retention; bucket interval auto-chosen from window width. Point `y` values are pass-rate percentage strings, null for buckets with no runs." + ] + }, + { + "name": "list_performance_leaderboard", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the metric always uses the plan-retention window." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns just this one build's performance row." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the performance leaderboard for a project's Testing Trends page - per-build execution-duration metrics over the window, to find which suites are slowing down.", + "returns": [ + "data", + "pagingParam" + ], + "responses": { + "200": { + "$response": "PerformanceLeaderboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The requested window is REPLACED by the plan-retention window for the duration metrics; the bounds you send only affect which builds appear on the page. So a window outside retention silently returns retention-bounded numbers.", + "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false (note the response key is `pagingParam`, SINGULAR). Builds with zero matching executions in the window are dropped from the page, so a page can contain fewer than 8 rows even when hasNext is true.", + "Each row: `avgDuration` (number, avg execution duration), `testCount` (avg test-run count), `barChartData` (per-bucket average durations) and `lineChartData` (per-bucket test counts) - note barChartData is durations and lineChartData is counts. Per-row full series also available via get_project_performance_chart / get_build_performance_chart." + ] + }, + { + "name": "get_project_performance_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the project-wide duration-over-time series for the Testing Trends performance chart - across all builds, or for one build selected by build name.", + "returns": [ + "barData", + "lineData" + ], + "responses": { + "200": { + "$response": "PerformanceChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the build-name-selected variant: it derives the series identifier from the `buildName` query param, defaulting to \"all\" when absent. To address a build by an identifier in the path, use get_build_performance_chart.", + "Response is `{barData, lineData}`: `barData` is the per-bucket average duration series and `lineData` is the per-bucket test-count series. `lineData` points carry `pointRange: null` (only `x`/`y` are meaningful), whereas `barData` points carry the range.", + "Window clamped to plan retention; bucket interval auto-chosen from window width.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_build_performance_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/{identifier}/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + }, + { + "name": "identifier", + "type": "string", + "required": true, + "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the duration-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a performance leaderboard row is expanded.", + "returns": [ + "barData", + "lineData" + ], + "responses": { + "200": { + "$response": "PerformanceChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query, use get_project_performance_chart. Both return the identical `{barData, lineData}` shape.", + "Bucket interval auto-chosen from window width. `barData` is average duration per bucket, `lineData` is test count per bucket (lineData points carry pointRange:null and t:null).", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "list_recent_runs", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns", + "mode": "write", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 5 builds; there is no page-size param." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's runs are listed; repeat the param for a set." + } + ], + "intent": "List a project's most recent builds - one entry per build with its summary (name, number, status, counts) - the run picker / recent-runs list on the Testing Trends page.", + "returns": [ + "data", + "pagingParams", + "currentQGOverrideStatus" + ], + "responses": { + "200": { + "$response": "RecentRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SIDE EFFECT despite being a GET: this marks the project as subscribed for the calling user - a persisted user-preference write. Do not call it merely to peek at recent runs if you must avoid changing the user's subscriptions; there is no read-only variant on this route.", + "Takes NO date range; results are just the latest builds. Fixed page of 5 builds via the zero-based `pageNumber`; stop when `pagingParams.hasNext` is false. Here the response key is `pagingParams` (PLURAL) - unlike the leaderboards' singular `pagingParam`.", + "`data` is an array of full build summaries (not trend series), and `currentQGOverrideStatus` is the project's quality-gate override flag.", + "The subscription side effect is observable: when the caller has no email preference on the project, it creates their EMAIL notifier/preferences (daily summary ON by default), visible via list_project_notification_recipients type=EMAIL. Only fires once per user+project." + ] + }, + { + "name": "list_group_webhook_projects", + "method": "GET", + "path": "/api/v1/settings/webhooks/projects", + "mode": "read", + "entity": "webhook", + "intent": "List the group's TCM/TO projects for the webhook editor's project picker \u2014 a paginated, searchable, sortable list that marks the projects already linked to a given webhook.", + "returns": [ + "projectMapping" + ], + "query": [ + { + "name": "page", + "type": "integer", + "required": true, + "description": "Page number (required). Page size is fixed at 30 and is NOT caller-controllable \u2014 there is no perPage/pageSize parameter here." + }, + { + "name": "sortBy", + "type": "string", + "required": false, + "description": "Sort token for the project list." + }, + { + "name": "webhookId", + "type": "string", + "format": "uuid", + "required": false, + "description": "When supplied, each returned project's `isSelected` flags whether it is already linked to this rule; the rule must exist in the caller's group and product bucket." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Project name substring filter." + } + ], + "responses": { + "200": { + "$response": "WebhookProjectMappingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Subject to the controller-wide feature-entitlement check. When webhookId is passed, the rule must be in the caller's group/bucket or it is not found.", + "Page size is HARDCODED to 30 and the product source is HARDCODED to `TCM,TO` \u2014 neither can be changed by the caller; there is no perPage parameter. Walk pages via info.next.", + "Each project carries `isSelected`, true when it is already linked to the webhookId you passed \u2014 that is how the picker pre-checks linked projects.", + "Response is nested under projectMapping:{info, projects}; info here is the project-picker PaginationInfo (totalProjectsCount/linkedProjectsCount), distinct from the rule-list info block." + ] + }, + { + "name": "retrigger_rca_analysis", + "method": "POST", + "path": "/api/v1/ai/testRuns/{testRunId}/rcaContext/retrigger", + "mode": "write", + "entity": "ai_insight", + "intent": "'Improve analysis' \u2014 re-run the agentic AI RCA for a failed test with a user instruction and up to 3 previously presigned attachments, creating a new conversation turn. Returns the new message id (and trial quota used) so you can poll for the refreshed analysis.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "instruction", + "type": "string", + "required": false, + "description": "Free-text instruction for the improved analysis. Max 5000 characters." + }, + { + "name": "attachments", + "type": "array", + "required": false, + "description": "Up to 3 previously presigned attachments to include as context. Each item references a key returned by the RCA-context presign call. More than 3 is rejected.", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "description": "The key returned by the RCA-context presign call. Non-blank, max 512 chars." + }, + { + "name": "fileName", + "type": "string", + "required": true, + "description": "Non-blank, max 200 chars." + }, + { + "name": "contentType", + "type": "string", + "required": true, + "description": "Non-blank, max 100 chars." + }, + { + "name": "description", + "type": "string", + "required": false, + "description": "Optional note about the attachment. Max 500 chars." + } + ] + }, + { + "name": "usePreferences", + "type": "boolean", + "required": false, + "description": "Whether saved code-fix / analysis preferences should be applied to this run." + }, + { + "name": "webhook_url", + "type": "string", + "required": false, + "description": "Optional Atlas agent-callback URL. When present it rides the retrigger's metadata and the finished RCA is delivered to it. Max 2048 chars. Wire name is webhook_url.", + "json_path": "/webhook_url" + } + ], + "returns": [ + "messageId" + ], + "responses": { + "200": { + "$response": "RcaRetriggerResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: the response carries only messageId (and trialQuota.used for trial users) \u2014 the analysis itself arrives later when the AI service calls back. Poll the RCA conversation / that message id for the refreshed result.", + "trialQuota is present only for test-level trial users (their usage counter is incremented on success); it is omitted otherwise, so do not treat its absence as an error.", + "Attachments must first be presigned and uploaded via the RCA-context presign call \u2014 pass those returned s3Keys here; at most 3.", + "Only agentic RCAs on RCA-v2-eligible builds are accepted, the test run must be within the allowed age, and a run owned by the MCP-driven agent is refused.", + "There is no undo for a retrigger \u2014 it appends a new conversation turn; approve or discard the resulting turn separately." + ] + }, + { + "name": "get_build_accessibility_meta", + "method": "GET", + "path": "/api/v1/builds/{buildId}/a11y-meta", + "mode": "read", + "entity": "build", + "intent": "Get the build-level accessibility summary header \u2014 whether auto-scan was enabled, the severity breakdown (critical/serious/moderate/minor) and the total issue count \u2014 for the strip above a build's per-test accessibility listing.", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Build UUID. Can be silently overridden by the `run` query param \u2014 see guidance." + } + ], + "query": [ + { + "name": "run", + "type": "array", + "description": "Optional test-run ids (bound from `A11yFilterParams.run`). If supplied, the FIRST entry is treated as a test-run id and the build UUID is resolved from it, OVERRIDING the path `buildId`. Send this empty/absent to honour the path build.", + "fields": [ + { + "name": "run[]", + "type": "integer" + } + ] + }, + { + "name": "status", + "type": "string", + "description": "Accessibility status filter (bound from A11yFilterParams). Not used to compute the meta totals on this route." + }, + { + "name": "searchQuery", + "type": "string", + "description": "Search token (bound from A11yFilterParams). Not used to compute the meta totals on this route." + } + ], + "returns": [ + "totalIssue", + "severityBreakdown", + "autoEnabledBuild", + "autoEnabled", + "reportUrl" + ], + "guidance": [ + "This is the web/Automate accessibility shape. For App-Automate builds that have App Accessibility enabled for the group, the request is proxied to the App Accessibility service and returns a DIFFERENT body \u2014 do not assume the fields below when the build is an app build.", + "The `run` query param silently overrides the path `buildId`: if you pass a run id, the meta is for the build that run belongs to, not the build in the path.", + "The build must have finished (status FINISHED or TIMEOUT) and carry accessibility data; otherwise you get a classified accessibility error (build-not-completed / build-not-found / results-not-found), not an empty 200.", + "`autoEnabledBuild` and `autoEnabled` are the same value duplicated, read from the build's accessibility settings.auto_enable (the auto-scan config), so they can be false on a build whose SDK run did scan. `severityBreakdown` carries each severity the build summary reports (zero counts included), and `totalIssue` is their sum.", + "`reportUrl` is a weak deep link into the Accessibility dashboard: its path uses the literal placeholder segments Project/builds/Build/Number and only the thBuildId query is real. It is not an API endpoint.", + "The central-auth JWT (cookie or Bearer) is extracted and forwarded to the App-Accessibility proxy on the app path; a missing token there surfaces as an accessibility-service auth error." + ], + "responses": { + "200": { + "$response": "BuildAccessibilityMetaResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_similar_test_runs", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/analyzer-similar", + "mode": "read", + "entity": "build", + "intent": "For the auto-analyser / unique-errors view: given a failed test run (or a set of error-cluster ids) in a build, list the other test runs with a similar failure so you can judge blast radius and treat the cluster as one issue.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "mode", + "type": "string", + "required": true, + "description": "Scope of the similarity search. Required \u2014 a missing/invalid value is a 400.", + "values": [ + "CURRENT_BUILD", + "LAST_10_BUILDS", + "LAST_10_DAYS" + ] + }, + { + "name": "testRunId", + "type": "integer", + "description": "Numeric test-run id to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present." + }, + { + "name": "clusterIds", + "type": "array", + "description": "Error-cluster ids to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present.", + "fields": [ + { + "name": "clusterIds[]", + "type": "string" + } + ] + } + ], + "returns": [ + "similar", + "current", + "message" + ], + "guidance": [ + "Send EITHER `testRunId` OR `clusterIds` (or both) \u2014 if neither is provided the call is a 400 (\"Either clusterIds or TestRunId is required\").", + "403 (plan-feature-not-allowed) when the build is locked by plan fencing; this is a plan-fenced analyser feature.", + "`current` describes the reference run and `similar` the matched runs; each carries the run uuid plus project/build normalised names and build number so you can deep-link. `message` may explain why the list is empty.", + "Available on public-link-token auth (public dashboards), unlike most write routes." + ], + "responses": { + "200": { + "$response": "SimilarTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_always_failing_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/alwaysFailingStats", + "mode": "read", + "entity": "build", + "intent": "Get the 'always failing' tile for a build \u2014 how many of the build's tests have been failing consistently (out of the total), plus the tile's comment count \u2014 for the build insights page.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text filter over the build's tests (part of the standard build filter set). The whole FilterParamsV2 filter set is accepted here; it narrows which tests the metric counts." + }, + { + "name": "status", + "type": "array", + "description": "Test status filter (standard build filter set).", + "fields": [ + { + "name": "status[]", + "type": "string" + } + ] + }, + { + "name": "folders", + "type": "array", + "description": "Folder-path filter (standard build filter set).", + "fields": [ + { + "name": "folders[]", + "type": "string" + } + ] + }, + { + "name": "testTags", + "type": "array", + "description": "Tag filter (standard build filter set)." + }, + { + "name": "customParams", + "type": "object", + "description": "Any extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded. Unknown keys are not rejected." + } + ], + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "guidance": [ + "`count` is the always-failing test count and `meta` typically carries the total it is out of; `commentCount` is the number of comments on this tile.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "One of a family of near-identical build tiles (flaky, new-failure, muted, defect, stability) that share this shape and differ only in the metric computed \u2014 pick the endpoint for the metric you want rather than re-filtering this one.", + "This is the standard build filter set: the same query params work across the tiles." + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_ai_failure_summary", + "method": "GET", + "path": "/api/v1/builds/{id}/aiSummary", + "mode": "read", + "entity": "build", + "intent": "Get the AI-generated narrative of why a build failed \u2014 a summary paragraph plus the top failure themes (name, description, affected test count, failure %) and the top affected workflows.", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The NUMERIC internal build id \u2014 NOT the UUID used by most other build routes. A UUID here will not resolve." + } + ], + "returns": [ + "summary", + "topFailureThemes", + "topAffectedWorkflows", + "status" + ], + "guidance": [ + "Takes the numeric build id, so it will NOT accept the UUID the sibling build endpoints use.", + "Fails soft: any upstream error or missing theme data returns 200 with `status: \"NOT_AVAILABLE\"`, `summary: null` and empty theme/workflow lists \u2014 check `status`, do not treat it as an error.", + "`topFailureThemes` and `topAffectedWorkflows` are each capped at 5 items.", + "Ownership-checked: if the build's group does not match the caller it reads as build-not-found." + ], + "responses": { + "200": { + "$response": "BuildAISummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_attempt_run_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildAttemptRunStats", + "mode": "read", + "entity": "build", + "intent": "Break a build's test results down per re-run attempt \u2014 pass/fail/skipped/unknown and workflow-status counts for attempt 1, 2, 3\u2026 (including manual runs) \u2014 so you can see how results changed across retries.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "attempts", + "summary", + "count", + "commentCount" + ], + "guidance": [ + "`attempts` is one entry per attempt (label, attemptNumber, totalAttempts and status counts); `summary` is the aggregate across attempts.", + "The project's hooks visibility is resolved first and changes which test-run documents are counted \u2014 the same build can report different totals depending on hooks visibility.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID." + ], + "responses": { + "200": { + "$response": "BuildAttemptRunStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_history", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildHistory", + "mode": "read", + "entity": "build", + "intent": "Get the trend of the recent builds of the same build name leading up to this one (per-build pass/fail counts, keys, lock state) so the build page can show how this build compares with previous runs.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text filter (standard build filter set). The whole FilterParamsV2 filter set is accepted and narrows what the trend counts." + }, + { + "name": "status", + "type": "array", + "description": "Test status filter (standard build filter set).", + "fields": [ + { + "name": "status[]", + "type": "string" + } + ] + }, + { + "name": "customParams", + "type": "object", + "description": "Extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded; unknown keys are not rejected." + } + ], + "returns": [ + "data", + "keys", + "commentCount" + ], + "guidance": [ + "The window is hard-coded to the ~10 most recent builds of the same build name \u2014 there is no page size or count parameter to widen it.", + "`data` is a per-status series and `keys` the build labels/order along the x-axis; builds locked by plan fencing are flagged within the mapping.", + "403 (plan-feature-not-allowed) when the CURRENT build is plan-locked; build-not-found for an unknown UUID." + ], + "responses": { + "200": { + "$response": "BuildHistoryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_id_from_build_coordinates", + "method": "GET", + "path": "/api/v1/builds/getBuildIdFromBuildInfo", + "mode": "read", + "entity": "build", + "intent": "Resolve a human-readable build coordinate (project name + build name + build serial number, i.e. what appears in a build URL) into the internal build id/UUID, so deep links and CI references can be turned into an API-usable handle.", + "query": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name as it appears in the dashboard URL." + }, + { + "name": "buildNormalisedName", + "type": "string", + "required": true, + "description": "Build normalised name as it appears in the dashboard URL." + }, + { + "name": "buildSerialId", + "type": "integer", + "required": true, + "description": "Build serial number (the #N shown in the UI)." + } + ], + "returns": [ + "buildId" + ], + "guidance": [ + "All three query params are required.", + "When no build matches you get a bare HTTP 404 with an EMPTY body (not a structured error object) \u2014 handle the status code, there is nothing to parse.", + "The lookup is scoped to the caller's group, so a build in another group reads as not-found.", + "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse.", + "`buildSerialId` is the UI #N only. If the user's number does not match (404) it may be the CI run id (`buildRunId`): resolve that with search_entities (entity Build) or list_project_builds_by_name, which return both ids." + ], + "responses": { + "200": { + "$response": "BuildIdFromCoordinatesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No build matched the coordinates. Empty response body.", + "schema": { + "type": "object" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_coordinates_from_uuid", + "method": "GET", + "path": "/api/v1/builds/getBuildInfoFromUuid/{uuid}", + "mode": "read", + "entity": "build", + "intent": "The inverse of the coordinate lookup: given a build UUID, get the URL-safe coordinates for it (project & build normalised names, serial id, project id/name, finished/created timestamps) so a caller holding only a UUID can construct the build's dashboard URL.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "buildNormalisedName", + "buildSerialId", + "projectNormalisedName", + "userId", + "groupId", + "projectName", + "projectFinishedAt", + "projectCreatedAt", + "toProjectId", + "thProjectId" + ], + "guidance": [ + "The normalised names are URI-encoded with spaces turned into '+', so use them verbatim when building a URL \u2014 do not re-encode.", + "If the build's group does not match the caller the call is refused with a forbidden-operation error.", + "An unknown UUID returns a build-not-found error (note: an older description claimed an empty JSON object here; the current handler throws not-found \u2014 do not rely on an empty-object sentinel).", + "`toProjectId` is the Observability project id and `thProjectId` the TestHub project id \u2014 they are different id spaces." + ], + "responses": { + "200": { + "$response": "BuildCoordinatesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_rerun_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildRerunStats", + "mode": "read", + "entity": "build", + "intent": "Get the flat re-run summary for a build \u2014 a list of re-run legs with per-leg pass/fail/skipped/unknown counts and totals \u2014 for the re-run section of the build page.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "retryData", + "count", + "commentCount" + ], + "guidance": [ + "This is the flat/legacy shape, computed straight from the DB by build id + group id; `retryData` is a flat list of legs with no split-build grouping and there is no aggregate `summary`.", + "For split or parent/child builds, or when you need the aggregate summary, use `get_split_build_rerun_stats` instead \u2014 its response shape is different and NOT a drop-in swap.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "Hooks visibility for the project is resolved first and affects which runs are counted." + ], + "responses": { + "200": { + "$response": "BuildReRunResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_split_build_rerun_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildRerunStats/v2", + "mode": "read", + "entity": "build", + "intent": "Get the split-build-aware re-run summary for a build \u2014 re-run legs grouped (with per-split breakdowns) plus an overall aggregate summary \u2014 resolved against the whole build and the Observability project id so split/parent-child builds are handled correctly.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "retryData", + "count", + "commentCount", + "summary" + ], + "guidance": [ + "Use this for split or parent/child builds and project-scoped configuration \u2014 it resolves against the whole build object and the Observability project id.", + "The response shape differs from `get_build_rerun_stats`: `retryData` is a list of GROUPS (each with `splits` and its own `summary` and build metadata) and there is a top-level aggregate `summary`. It is NOT a drop-in swap for the flat version.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "Hooks visibility for the project is resolved first and affects which runs are counted." + ], + "responses": { + "200": { + "$response": "BuildReRunV2Response" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_test_filters", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/filters", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "The build's UUID. If it is a leg of a split build, the parent is resolved automatically and the filter set is aggregated over the parent plus every child leg." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text search echoed back into the resolved `applied` block; part of the currently-applied test filter, not a facet type-ahead." + }, + { + "name": "status", + "type": "array", + "description": "Currently-applied test status values, echoed back resolved. All the FilterParamsV2 test-listing fields are accepted here (status, priority, caseType, automationStatus, testTags, folders, owner, assignee, deviceList, osList, browserList, failureCategories, dateRange, isFlaky, isNewFailure, ...) and are returned normalised under `applied`." + }, + { + "name": "priority", + "type": "array", + "description": "Applied priority ids. When any of priority / caseType / automationStatus is present the response rewrites their labels in `applied` to match the static catalogue, so read labels from the response rather than assuming them." + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Test-management user ids; max 500 entries (enforced server-side)." + } + ], + "intent": "Get the filter panel for a build's TEST listing \u2014 the currently applied test filters resolved with labels, the static catalogue of every selectable test filter, hooks visibility, and which filters are pro-only for this workspace.", + "returns": [ + "applied", + "staticFilters", + "hooksVisibility", + "proFilters" + ], + "guidance": [ + "This is the filter set for the TESTS inside one build, not for the build listing \u2014 use get_build_list_filters / get_build_sidebar_filters for the builds-list facets.", + "`applied` is your own query echoed back in resolved form (ids turned into user/label objects); `staticFilters` is the full catalogue of selectable values keyed by filter name. Read available option values from `staticFilters`, not from `applied`.", + "`proFilters` lists the filter keys locked behind a paid plan; it is NON-EMPTY only for a freemium workspace (then it names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted) and empty otherwise. Treat a key that appears here as disabled, not missing.", + "`hooksVisibility` is one of all / failed / beforeFailed / none and tells you how before/after hook tests are surfaced for this project.", + "For a split build you may pass any leg's UUID; the parent is resolved and the static filters are unioned across all legs, so the result is the same whichever leg you name." + ], + "responses": { + "200": { + "$response": "BuildTestFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_list_filters", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/filters", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised-name slug. Resolved within the caller's group first, so a project in another group (or an unknown slug) returns 404." + } + ], + "query": [ + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Applied build-name selection, echoed back resolved. Any of the GetBuildFilterRqV2 build-list fields may be supplied as query params." + }, + { + "name": "users", + "type": "array", + "description": "Applied build-owner user ids (integers), echoed back as resolved user objects (id + name)." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team ids (integers), echoed back as resolved team objects." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values \u2014 branded SDK names and pass-through values like 'appium'/'selenium-4'. Sent as strings (not an enum), matched case-insensitively; use the exact value the static catalogue offers." + }, + { + "name": "statuses", + "type": "array", + "description": "Applied build display-status values." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag values (build-level tags)." + }, + { + "name": "branches", + "type": "array", + "description": "Applied git branch values." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end] \u2014 a size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names (distinct from buildTags); max 500 entries, and gated by a per-group flag \u2014 ignored downstream when the flag is off.", + "maxItems": 500 + }, + { + "name": "state", + "type": "string", + "description": "Build state scope; defaults to `active` when omitted.", + "default": "active" + } + ], + "intent": "Populate the build-listing filter bar for a project in one call \u2014 the static catalogue of selectable build filters (statuses, frameworks, build types, product sources, creation types) plus the caller's currently applied filter echoed back resolved.", + "returns": [ + "applied", + "staticFilters" + ], + "guidance": [ + "`staticFilters` carries the option lists: statusList, frameworkList, types, sources, creationType \u2014 each a list of {id, value, label} option objects. `applied` is your query resolved (user/team ids to names, date range, tags, branches).", + "This is the PER-PROJECT filter bar (scoped by the path project) \u2014 it returns the catalogue for that project. For a cross-project, filter-narrowed facet list use the sidebar capabilities instead.", + "On a demo-sandbox tenant this short-circuits before any project lookup and returns a hardcoded framework/static list, so the option set there is the global master list rather than what the project actually contains.", + "An unknown or foreign project slug is a 404 \u2014 the project is resolved inside the caller's group." + ], + "responses": { + "200": { + "$response": "BuildListFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_branch_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/branches", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the branch name. Optional \u2014 omit to get the leading matches." + } + ], + "intent": "Type-ahead options for the 'Branch' filter on a project's build listing \u2014 git branch names seen in the project's builds, as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Per-project, prefix-search only \u2014 it does not narrow by any other currently-applied filter. For branches present under a full applied filter across a project set, use list_sidebar_branch_filter_options.", + "Each option's `id`/`value` IS the branch string; send that value straight back as the branch filter selection." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_tag_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/tags", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the tag value. Optional." + } + ], + "intent": "Type-ahead options for the 'Build tag' filter on a project's build listing \u2014 matching build-tag values as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Per-project, prefix-search only \u2014 no other applied filter narrows the result. This handler always does a real project lookup (there is no demo-sandbox short-circuit here).", + "These are build-level tags. For test-execution tags, or for a tag list narrowed by an applied cross-project filter, use the sidebar tag facet." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_team_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/teams", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the team name. Optional." + } + ], + "intent": "Type-ahead options for the 'Team' filter on a project's build listing \u2014 BrowserStack teams that have builds in the project, as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Each option's `id` is the numeric team id (as a string) and `value`/`label` are the team name \u2014 send the id back as the team filter selection, not the name.", + "Per-project only; it is not narrowed by any other applied filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_user_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/users", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the user name. Optional." + } + ], + "intent": "Type-ahead options for the 'User' (build owner) filter on a project's build listing \u2014 matching users as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Each option's `id` is the numeric user id (as a string) and `value`/`label` are the user's name \u2014 send the id back as the user/owner filter selection.", + "Per-project only; it is not narrowed by any other applied filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_sidebar_filters", + "method": "POST", + "path": "/api/v2/builds/filters", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids the filter spans. When omitted the server falls back to a default project set for the group, so send it explicitly to scope the view." + }, + { + "name": "query", + "type": "string", + "description": "Type-ahead text for the sidebar sub-searches (users/tags/runGroups). NOT applied or echoed by this call - use `search` for the free-text build search." + }, + { + "name": "users", + "type": "array", + "description": "Applied build-owner user ids (integers), returned resolved to {id, name}." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team ids (integers), returned resolved to team objects." + }, + { + "name": "statuses", + "type": "array", + "description": "Applied build display-status values." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag values." + }, + { + "name": "branches", + "type": "array", + "description": "Applied git branch values." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values (branded SDK names and pass-through values like 'appium'/'selenium-4'); strings, matched case-insensitively." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end]; a size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + }, + { + "name": "assignees", + "type": "array", + "description": "Assignee tokens, each matching ^(\\$none|[0-9]+)$ \u2014 the literal '$none' selects unassigned, otherwise a numeric user id. A value that does not match the pattern is a 400.", + "pattern": "^(\\$none|[0-9]+)$" + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names; max 500 entries, gated by a per-group flag (ignored when off).", + "maxItems": 500 + }, + { + "name": "state", + "type": "string", + "description": "Build state scope; defaults to `active`.", + "default": "active" + }, + { + "name": "search", + "type": "string", + "description": "Free-text build search; echoed back as applied.search." + } + ], + "intent": "Hydrate the build-listing sidebar filter panel across a set of projects \u2014 the caller's applied selection resolved into display objects (user/team ids to names, date range, tags, branches, statuses, build names) alongside the static option catalogue.", + "returns": [ + "applied", + "staticFilters" + ], + "guidance": [ + "POST but purely read \u2014 the body is only a filter payload and nothing is written.", + "`applied` (a SidebarFilter: the resolved build filter plus the `projects` it was scoped to) echoes your selection resolved; `staticFilters` is the option catalogue (statusList, frameworkList, types, sources, creationType).", + "This is the GROUP-WIDE variant: the static framework list is the hardcoded master list, NOT the frameworks actually present in the selected projects. If you need the frameworks that really occur, treat frameworkList here as the full menu, not a presence list.", + "Scope with `projects`; if you leave it out you get the group's default project set rather than an error.", + "`applied` echoes users (resolved), teams, dateRange, statuses, buildTags (as `tags`), frameworks, branches, search and the boolean/enum toggles; `assignees` and `query` are NOT echoed, so do not read their absence as rejection." + ], + "responses": { + "200": { + "$response": "BuildSidebarFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_branch_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/branches", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive search text for the branch name. Optional." + }, + { + "name": "statuses", + "type": "array", + "description": "Part of the currently-applied build filter that narrows which builds are considered; the full GetBuildFilterRqV2 build-list field set is accepted (users, teams, buildTags, frameworks, dateRange, state, ...)." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end]; size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + } + ], + "intent": "Type-ahead options for the 'Branch' facet of the build-list sidebar \u2014 git branch names present in the builds matching the rest of the currently applied filter, optionally narrowed by a search string.", + "returns": [ + "data" + ], + "guidance": [ + "POST but read-only \u2014 the body is a filter, no state change.", + "Unlike the per-project branch list, this narrows to branches that actually occur under the whole applied filter across `projects`, and returns BARE STRINGS in `data` (not {id,value,label} objects).", + "Values come from an Elasticsearch terms aggregation capped at 100, so the list is a suggestion set, not a complete enumeration \u2014 narrow with `query` rather than expecting every branch back." + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_build_name_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/buildNames", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive search text for the build name. Optional." + }, + { + "name": "statuses", + "type": "array", + "description": "Part of the applied build filter that narrows which builds are considered; the full build-list field set (users, teams, buildTags, branches, frameworks, dateRange, state, ...) is accepted." + } + ], + "intent": "Type-ahead options for the 'Build name' facet of the build-list sidebar \u2014 the distinct build names present under the current filter/projects, optionally matched against a search string.", + "returns": [ + "data" + ], + "guidance": [ + "POST but read-only \u2014 body is a filter payload only.", + "Returns BARE STRINGS in `data`. Distinct from the shipped list_build_names capability, which is a different (paged) listing \u2014 this one is the sidebar facet's aggregation.", + "ES terms aggregation capped at 100 distinct names, so results are bounded, not exhaustive \u2014 use `query` to find a specific name." + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_run_group_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/runGroups", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids whose run groups to list. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive name search for the run group. Optional." + } + ], + "intent": "Type-ahead options for the 'Run group' (build group) facet of the build-list sidebar \u2014 the first 100 run groups of the selected projects, name-searchable.", + "returns": [ + "data", + "info" + ], + "guidance": [ + "POST but read-only. ONLY `projects` and `query` are read from the body \u2014 every other build-filter field is ignored here, so do not expect statuses/tags/dateRange to narrow the result.", + "Pagination is hardcoded to page 1, page size 100 and CANNOT be driven from the request: you always get at most the first 100 run groups. `info` reports paging but there is no way to fetch page 2 through this endpoint \u2014 use the per-project build-groups listing if you need to page.", + "`data` items are SLIM build-group rows {id, name, description, isClosed, state, updated_at} (note snake_case updated_at), not bare strings. For projectId/createdBy/closedAt use the per-project build-groups listing.", + "This facet is not available on a public dashboard link (no public-link-token auth); it needs an authenticated session or service key." + ], + "responses": { + "200": { + "$response": "RunGroupFilterOptionsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_widget_custom_field_filters", + "method": "GET", + "path": "/api/v1/dashboards/filters/{widgetType}", + "mode": "read", + "entity": "dashboard", + "intent": "List the Test Management custom fields usable as extra filters on a test-case widget, so the widget-config panel can offer them.", + "path_params": [ + { + "name": "widgetType", + "type": "string", + "required": true, + "values": [ + "TEST_CASE_TREND", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "AUTOMATION_COVERAGE" + ], + "description": "Widget type. ONLY these four are valid; any other widget type returns a 400 (BAD_REQUEST_ERROR)." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number. Presence of ANY of page/per_page/search switches the response to the paginated shape (see guidance)." + }, + { + "name": "per_page", + "type": "integer", + "required": false, + "description": "Page size. Passed through to Test Management's custom-fields API unchanged \u2014 no ceiling is enforced by this service." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Substring filter over custom-field names." + } + ], + "returns": [ + "data", + "info" + ], + "responses": { + "200": { + "$response": "WidgetCustomFieldFiltersResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "TWO response shapes from one route: omit all of page/per_page/search and you get a bare JSON ARRAY of {id, label, type, fieldType}; pass any one of them and you get the paginated object {data:[...], info:{...}} instead.", + "Only the four listed widget types are accepted; anything else is a 400, not an empty list.", + "This is a pass-through to Test Management's custom-fields API; `per_page` is forwarded without clamping, so a page ceiling cannot be assumed." + ] + }, + { + "name": "resolve_dashboard_filter_names", + "method": "POST", + "path": "/api/v1/dashboards/meta", + "mode": "read", + "entity": "dashboard", + "intent": "Batch-resolve project ids and user ids to their display names, so a saved dashboard's stored filter ids can be rendered as readable chips.", + "body": [ + { + "name": "projectIds", + "type": "array", + "required": true, + "description": "TRA project ids (Long) to resolve to names. Required key, but may be an empty array. Converted to Test Management project ids before lookup." + }, + { + "name": "userIds", + "type": "array", + "required": true, + "description": "User ids (Long) to resolve to names. Required key, but may be an empty array." + } + ], + "returns": [ + "projects", + "users" + ], + "responses": { + "200": { + "$response": "DashboardMetaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only \u2014 it changes nothing, it only looks names up.", + "Both `projectIds` and `userIds` are required keys (may be empty arrays); omitting either fails validation.", + "Returns {projects, users}, each a list of {id, name}. Project names come from the project repository (ids are mapped to Test Management project ids first); user names come from the user-details service." + ] + }, + { + "name": "search_unique_errors", + "method": "GET", + "path": "/api/v1/dashboards/errors", + "mode": "read", + "entity": "dashboard", + "intent": "Autocomplete over unique error signatures \u2014 given a partial error string, return the top matching unique errors so a user can pick one while configuring an error-focused dashboard widget.", + "query": [ + { + "name": "q", + "type": "string", + "required": false, + "description": "Partial error string to search for. Matched against the errors search index." + } + ], + "returns": [ + "errorClusterId", + "error" + ], + "responses": { + "200": { + "$response": "UniqueErrorSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is a top-level JSON ARRAY of {errorClusterId, error} \u2014 there is no wrapper object.", + "The result count is hardcoded to 10 and cannot be paged or raised; refine `q` to narrow rather than expecting more rows.", + "Backed by an Elasticsearch aggregation over the errors index; `errorClusterId` is the stable id of the unique-error cluster." + ] + }, + { + "name": "list_reports", + "method": "GET", + "path": "/api/v1/reports/list", + "mode": "read", + "entity": "dashboard", + "intent": "Power the Reports listing page: a paginated, searchable, sortable list of saved dashboards/reports across Test Observability and Test Management, each with owner, linked projects, type and schedule frequency, plus 'my reports' and total counts.", + "query": [ + { + "name": "projectIds", + "type": "array", + "required": false, + "description": "TestHub project ids (a project's thProjectId, e.g. from list_projects), NOT the TRA project id - a TRA id matches nothing and returns an empty 200. UDAC project filtering is applied on top; if UDAC blocks every project the page comes back empty." + }, + { + "name": "source", + "type": "string", + "required": false, + "values": [ + "TO", + "TCM" + ], + "description": "Product source. An INVALID value does not 4xx \u2014 it returns HTTP 200 with an `error` string and empty data." + }, + { + "name": "type", + "type": "string", + "required": false, + "description": "Report/dashboard type filter." + }, + { + "name": "userId", + "type": "integer", + "required": false, + "description": "Filter to a specific owner's reports." + }, + { + "name": "frequency", + "type": "string", + "required": false, + "description": "Schedule frequency filter." + }, + { + "name": "p", + "type": "integer", + "required": false, + "default": 1, + "description": "Page number (1-based)." + }, + { + "name": "size", + "type": "integer", + "required": false, + "default": 30, + "maximum": 100, + "description": "Page size. Clamped server-side to the range 1-100." + }, + { + "name": "sortBy", + "type": "string", + "required": false, + "default": "modifiedAt", + "description": "Sort column. Defaults to modifiedAt." + }, + { + "name": "sortOrder", + "type": "string", + "required": false, + "default": "desc", + "description": "Sort direction. Defaults to desc." + }, + { + "name": "q[query]", + "type": "string", + "required": false, + "description": "Free-text search. The query-parameter name is literally `q[query]`." + } + ], + "returns": [ + "data", + "my_report_count", + "total_report_count", + "info", + "error" + ], + "responses": { + "200": { + "$response": "ReportListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An invalid `source` returns HTTP 200 with a top-level `error` string and empty `data` \u2014 check `error`, not the status code.", + "TRA report types (Build Comparison, Tabular Test Runs) are hidden unless the group has the Custom Dashboard entitlement AND the TM-TO-reports rollout flag is on; other TM reports stay visible.", + "`size` is clamped to a maximum of 100 (and a minimum of 1); asking for more returns at most 100 rows.", + "The search parameter is spelled `q[query]`, not `q` or `query`.", + "`my_report_count` / `total_report_count` are separate top-level counts; pagination cursors are under `info` (page/next/prev/count/page_size)." + ] + }, + { + "name": "list_dashboards", + "method": "GET", + "path": "/ext/v1/dashboards", + "mode": "read", + "entity": "dashboard", + "intent": "Public-API listing of the custom dashboards visible to the caller's group (used by the Atlas AI dashboard builder and API clients) to discover dashboard ids/names/widgets before reading or cloning one.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source to list. Defaults to TO." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardsListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Same underlying service as the internal listing: scheduled-report dashboards are excluded and there is no pagination. Rows are summaries - widgets, filters and reports are null (totalWidget counts them); read one dashboard by id for its definition." + ] + }, + { + "name": "get_dashboard", + "method": "GET", + "path": "/ext/v1/dashboards/{id}", + "mode": "read", + "entity": "dashboard", + "intent": "Fetch one dashboard's full definition (name, type, level and the widget list with each widget's config) via the public API, so a client can render or edit it.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Dashboard UUID (from list_dashboards_via_public_api)." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source. Defaults to TO." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Resolves the id as a dashboard (not a scheduled report)." + ] + }, + { + "name": "create_dashboard", + "method": "POST", + "path": "/ext/v1/dashboards", + "mode": "write", + "entity": "dashboard", + "intent": "Create a new custom dashboard with its widget layout via the public API, so a client (or the AI dashboard builder) can provision a dashboard programmatically instead of building it in the UI.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source for the new dashboard. Defaults to TO." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 100, + "description": "Dashboard name, 1-100 chars, unique in the group." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "values": [ + "Dashboard", + "View", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON" + ], + "description": "Dashboard type; use `Dashboard` for an ordinary dashboard." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Optional description, max 100 chars." + }, + { + "name": "filters", + "type": "object", + "description": "Global filters (same shape as the internal create; note buildName / branches / tcmTestPlanIds wire names)." + }, + { + "name": "widgets", + "type": "array", + "description": "Widget definitions at the body root. Each widget is {id, name, type, config, layout, globalFilters}. See guidance for the treemap normalization this surface applies.", + "fields": [ + { + "name": "id", + "type": "string", + "description": "Client widget id." + }, + { + "name": "name", + "type": "string", + "description": "Widget title." + }, + { + "name": "type", + "type": "string", + "description": "Widget type enum." + }, + { + "name": "config", + "type": "object", + "description": "Widget config; validated for render-safety before persist." + }, + { + "name": "layout", + "type": "object", + "description": "Grid layout." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "This surface silently DROPS a `visualization` override on a treemap widget (FAILURE_RATE_BY_FOLDERS) rather than rejecting it \u2014 don't rely on that override being honoured.", + "It also runs render-safety validation up front, so a widget config that would 5xx at render is rejected here. The internal create does neither of these." + ] + }, + { + "name": "update_dashboard", + "method": "PUT", + "path": "/ext/v1/dashboards/{id}", + "mode": "write", + "entity": "dashboard", + "intent": "Update an existing dashboard's name/type/level and replace its widget set via the public API.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Dashboard UUID to update." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "New name, 1-100 chars. May be omitted \u2014 it is backfilled from the stored dashboard (see guidance)." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2. Backfilled from the stored dashboard if omitted." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "values": [ + "Dashboard", + "View", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON" + ], + "description": "Dashboard type. Backfilled from the stored dashboard if omitted." + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget set at the body root. Same treemap-visualization stripping and render-safety validation as create." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters (same shape as create)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Unlike the internal PUT this TOLERATES a partial body: omitted name/type/level are backfilled from the stored dashboard (an extra read) so a null is not mistaken for a change. The widget set, however, still fully replaces the stored one \u2014 there is no undo.", + "Same normalization as create: a treemap `visualization` override is silently dropped and widget configs are validated up front.", + "In demo-sandbox environments updating the default dashboard is rejected with 400." + ] + }, + { + "name": "clone_dashboard", + "method": "POST", + "path": "/ext/v1/dashboards/{id}/clone", + "mode": "write", + "entity": "dashboard", + "intent": "Copy an existing dashboard (its persisted widgets) into a new dashboard via the public API \u2014 typically to fork a template dashboard under a new name/level.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "SOURCE dashboard UUID to copy from." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 100, + "description": "Name for the copy, 1-100 chars, unique in the group." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2 for the copy." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "description": "Dashboard type for the copy." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "The widgets in the request body are IGNORED \u2014 the clone copies the SOURCE dashboard's stored widgets; only name/type/level are taken from the body.", + "Render-safety validation runs against the resolved source dashboard (inside the service), not the request body, so a broken source can fail the clone." + ] + }, + { + "name": "get_project_automation_coverage", + "method": "GET", + "path": "/ext/v1/projects/automation-coverage", + "mode": "read", + "entity": "project", + "intent": "Get one project's automation-coverage figures - total test cases, automated count and coverage percent over all time - to chart automation progress from outside the UI.", + "query": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Exact project display NAME (not the normalised slug and not the id), matched within the caller's group. Blank or missing is a 400; a name that does not resolve in the group is a 404." + } + ], + "returns": [ + "project_id", + "project_name", + "total_test_case_count", + "automated_test_case_count", + "automation_coverage_percent", + "calculated_at" + ], + "responses": { + "200": { + "$response": "ProjectAutomationCoverageResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public REST API route - callable with a username + access key as well as service credentials; scoping is by the authenticated group only, with no project-level permission check on this route.", + "Lookup is by EXACT project name, case- and space-sensitive, within your group. The normalised slug used elsewhere and the numeric id are both rejected here - use the display name.", + "This reuses the Automation Coverage dashboard widget over an all-time window, so it is an aggregation query, not a cheap key lookup - expect it to be slower than a plain read.", + "Fields are serialised only when non-null (NON_NULL). `calculated_at` is when this figure was computed, not a project timestamp; treat it as the freshness of the number." + ] + }, + { + "name": "list_projects", + "method": "GET", + "path": "/ext/v1/projects", + "mode": "read", + "entity": "project", + "intent": "List the calling account's Test Observability projects (public REST API) - the entry point for scripted/CI consumers that then drill into a project's builds.", + "query": [ + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 cursor from the previous response's pagination.next_page (it encodes a page index, not a row offset). Omit for the first page. A malformed value is a 400 'Incorrect pagination params'." + } + ], + "returns": [ + "projects", + "pagination" + ], + "responses": { + "200": { + "$response": "ProjectListingExternalResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pages are tiny and uneven in practice (2, 5, 6 rows) and there is no name filter, so this is the WRONG route for 'find project X' or 'what projects do I have' on a large group: use list_projects_lite_paginated (`search`, 100 per page, newest activity first) or list_projects_full (whole group, capped at 30 rows).", + "Requires the PROJECT_VIEW permission; a credential without it is refused. A user OAuth credential that holds it is accepted - a service account is not required.", + "Up to 10 projects per page, and a page can hold fewer while has_next is still true - a short page is not the last page. There is no page-size parameter. Paginate only with the returned base64 next_page cursor, not a numeric page.", + "pagination.has_next is computed as 'this page was non-empty', so the last full page still reports has_next=true and you get one extra EMPTY page. Stop when projects comes back empty, not when has_next turns false.", + "There is no total count in the response. Each row carries the project's id, name and a ready-made observability_url into its builds page." + ] + }, + { + "name": "list_home_unique_builds", + "method": "GET", + "path": "/api/v1/home/uniqueBuilds", + "mode": "read", + "entity": "project", + "intent": "List the Home page's latest builds across the caller's visible projects - one row per build with its status and counts - optionally narrowed to 'my builds' and quick filters.", + "query": [ + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Only affects has_next (true while pageNumber*10 <= 99); it does NOT offset the search, so pageNumber=1 returns the same first page. Page with afterKey instead. The response's pagingParams.pageNumber echoes pageNumber+1." + }, + { + "name": "afterKey", + "type": "string", + "description": "Cursor (the last row's build id) for the next page. This is the ONLY real pagination: carry pagingParams.afterKey from the previous response." + }, + { + "name": "myBuilds", + "type": "boolean", + "default": false, + "description": "Restrict to builds owned by the calling user." + }, + { + "name": "applyQuickFilters", + "type": "boolean", + "description": "Turn the per-request quick filters below ON. When false (default) the endpoint applies only the user's persisted Home filters and ignores the quick-filter params." + }, + { + "name": "statuses", + "type": "array", + "description": "Quick-filter build statuses. Only applied when applyQuickFilters=true; invalid values are skipped rather than 400ing." + }, + { + "name": "users", + "type": "array", + "description": "Quick-filter user ids. Only applied when applyQuickFilters=true." + }, + { + "name": "frameworks", + "type": "array", + "description": "Quick-filter framework values. Only applied when applyQuickFilters=true." + }, + { + "name": "buildTags", + "type": "array", + "description": "Quick-filter build tags. Only applied when applyQuickFilters=true." + }, + { + "name": "projects", + "type": "array", + "description": "Quick-filter project ids. Only applied when applyQuickFilters=true." + }, + { + "name": "isRealDevice", + "type": "array", + "description": "Quick-filter device type (string, not boolean, so a stale value degrades instead of erroring). Only applied when applyQuickFilters=true." + } + ], + "returns": [ + "data", + "pagingParams" + ], + "responses": { + "200": { + "$response": "HomeUniqueBuildsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The user's saved Home filters (build tags, projects, users, teams, unique build names, statuses) are merged into the query server-side even when applyQuickFilters is off - results are always scoped by the caller's persisted personalisation and sub-group.", + "Pagination: pageNumber is not applied to the search (only to the has_next cap); page with afterKey = pagingParams.afterKey. pagingParams.hasNext is 'this page was full AND pageNumber*10 <= 99'.", + "Quick-filter params are inert unless applyQuickFilters=true. Bad/stale quick-filter values are silently dropped (mapSkippingInvalid), never 400.", + "Heavy on large groups (emits per-stage timing telemetry); each row is a BuildSummary - use displayStatus for the pass/fail verdict (status is the lifecycle state, e.g. finished).", + "Rows are the most recent builds, one row PER BUILD (not deduplicated by build name: several runs of one pipeline appear as separate rows), in the caller's Home scope. Page size is 10." + ] + }, + { + "name": "list_home_recent_views", + "method": "GET", + "path": "/api/v1/home/recents", + "mode": "read", + "entity": "project", + "intent": "Get the user's last few visited items (dashboards, builds, views) for the Home page 'Recently viewed' strip.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "HomeRecentViewsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read from a per-user Redis sorted set, capped at the 5 most recent entries; there are no parameters and no pagination.", + "Entries are written as a side effect of the user opening a dashboard - this endpoint never writes. A fresh user gets an empty data array.", + "Unparseable stored entries are silently skipped, so the list can be shorter than 5 even when 5 were recorded. Response is {\"data\": [ ... ]}; each item has type, link, id, name." + ] + }, + { + "name": "get_home_takeaways", + "method": "GET", + "path": "/api/v1/home/takeaway", + "mode": "read", + "entity": "project", + "intent": "Get the Home page 'key takeaways' cards with the aggregate time-saved metric - stability, pass-rate, always-failing/flaky/new-failure counts and a single time-savings percentage, versus the previous 7-day window.", + "returns": [ + "timeSavingsData", + "takeAwayData" + ], + "responses": { + "200": { + "$response": "HomeTakeawaysResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the older takeaways variant. It returns the aggregate `timeSavingsData` object (timeSavings %, testToVerify, uniqueErrors, flakiness, alwaysFailing, autoTagged) and sends the v2 `timeSavingV2Metric` key as null. For the shape the current Home page uses, pick get_home_takeaways_with_noise_breakdown instead.", + "Scoped implicitly by the caller's saved Home filters, sub-group and visible projects; no parameters.", + "Cached in Redis per USER id for ~1 hour, so a filter change is not reflected until the cache expires. All metrics compare the current 7 days to the prior 7 days (each TakeAwayMetric carries value and prev)." + ] + }, + { + "name": "get_home_takeaways_with_noise_breakdown", + "method": "GET", + "path": "/api/v1/home/v2/takeaway", + "mode": "read", + "entity": "project", + "intent": "Get the Home page 'key takeaways' cards with the failure-noise breakdown - the same stability/pass-rate story plus per-category (flaky, always-failing, auto-analyzed, unique-error) failure counts and their percentage reductions.", + "returns": [ + "timeSavingV2Metric", + "takeAwayData" + ], + "responses": { + "200": { + "$response": "HomeTakeawaysNoiseBreakdownResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the variant the current Home page uses. It returns the `timeSavingV2Metric` object (totalFailures plus flakyFailing/alwaysFailing/autoAnalyzedFailure/uniqueErrors, each a count + reduction%) and explicitly sets the legacy `timeSavingsData` to null - do not expect a single time-savings percentage here.", + "Same takeAwayData block (stability, unique executions, new failures, always-failing, flakiness, failures; each value vs prev 7-day) as the older variant.", + "Cached in Redis per (filter-hash + CAD product + sub-group), so the cache is SHARED across users with identical filters - unlike the per-user cache of the older variant. TTL ~1 hour.", + "When there are zero failures in the window, timeSavingV2Metric collapses to just {totalFailures: 0} with the per-category objects omitted." + ] + }, + { + "name": "list_project_builds_by_group", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/build-groups-listing", + "mode": "read", + "entity": "project", + "intent": "List a project's builds rolled up by build group (consolidated re-runs/shards shown as one row), with the full v2 build-listing filter set.", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL-safe slug), not its numeric id." + } + ], + "query": [ + { + "name": "buildNameIds", + "type": "array", + "description": "Filter by build name ids." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Filter by unique build names." + }, + { + "name": "users", + "type": "array", + "description": "Filter by user ids (numeric)." + }, + { + "name": "assignees", + "type": "array", + "description": "Assignee ids; each must match ^(\\$none|[0-9]+)$ ($none = unassigned)." + }, + { + "name": "teams", + "type": "array", + "description": "Filter by team ids." + }, + { + "name": "buildGroupIds", + "type": "array", + "description": "Filter by build group ids." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values (branded enum names and pass-through values like 'appium'); matched case-insensitively." + }, + { + "name": "statuses", + "type": "array", + "description": "Build display statuses." + }, + { + "name": "sources", + "type": "array", + "description": "Product sources." + }, + { + "name": "types", + "type": "array", + "description": "Build types." + }, + { + "name": "buildTags", + "type": "array", + "description": "Filter by build tags." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names (max 500); AND/OR selected via `match`." + }, + { + "name": "branches", + "type": "array", + "description": "Filter by branch names." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millis longs [from, to] (size must be 2)." + }, + { + "name": "search", + "type": "string", + "description": "Free-text build search." + }, + { + "name": "searchAfter", + "type": "string", + "description": "ES search_after cursor; must match ^[0-9a-zA-Z]{40}$ (a 40-char id)." + }, + { + "name": "state", + "type": "string", + "values": [ + "active", + "closed" + ], + "description": "Build state; defaults to active." + }, + { + "name": "isArchived", + "type": "boolean", + "description": "Restrict to archived builds." + }, + { + "name": "selfHealed", + "type": "boolean", + "description": "Only self-healed builds." + }, + { + "name": "aiAuthored", + "type": "boolean", + "description": "Only AI-authored builds." + }, + { + "name": "isRealDevice", + "type": "array", + "description": "Device-type multi-select (true=real, false=virtual); empty/both = no filter." + } + ], + "returns": [ + "id", + "name", + "buildGroupAggResult", + "activeCount", + "closedCount" + ], + "responses": { + "200": { + "$response": "ProjectBuildGroupListingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Sibling of the ungrouped v2 build listing: this one consolidates re-runs/shards into build groups (see buildGroupAggResult). Filters are the same rich v2 set, passed as query params (there is no request body).", + "Returns per-group AGGREGATES only (buildGroupAggResult: counts plus the TestHub group record) - no build rows: `builds` and `pagingParams` are always null here, and there is no pagination. To list the builds themselves use the ungrouped v2 build listing.", + "Side effect: reading this marks the project as SUBSCRIBED for the caller (a user-preference write) - a benign, recoverable effect, but it is not a pure read.", + "Unlike the ungrouped v2 listing, this route does NOT apply the group's custom-retention window, so it can surface builds older than that window." + ] + }, + { + "name": "get_test_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug), not its numeric id." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to], for the window the filters resolve within. Required; a null or non-2-element range is a 400." + }, + { + "name": "search", + "type": "string", + "description": "Free-text test-name search that is echoed back under `applied`." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag filter values (echoed back resolved under `applied.buildTags`)." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Applied unique-build-name filter values." + }, + { + "name": "testTags", + "type": "array", + "description": "Applied test-tag filter values." + }, + { + "name": "folders", + "type": "array", + "description": "Applied folder filter values." + }, + { + "name": "hostNames", + "type": "array", + "description": "Applied host-name (machine/agent) filter values." + }, + { + "name": "status", + "type": "array", + "description": "Applied test-status filter values." + }, + { + "name": "sources", + "type": "array", + "description": "Applied product-source filter values." + }, + { + "name": "users", + "type": "array", + "description": "Applied user-id filter values (longs)." + }, + { + "name": "lastExecutedBy", + "type": "array", + "maxItems": 500, + "description": "User ids (longs) whose executions to include. Capped at 500 by validation; exceeding it is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team-id filter values (longs)." + }, + { + "name": "failureCategories", + "type": "array", + "description": "Applied failure-category filter values." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state: set true/false to filter by flakiness, omit to not filter." + }, + { + "name": "isNewFailure", + "type": "boolean", + "description": "Tri-state new-failure filter; omit to not filter." + }, + { + "name": "isAlwaysFailing", + "type": "boolean", + "description": "Tri-state always-failing filter; omit to not filter." + }, + { + "name": "hasPmToolDefects", + "type": "boolean", + "description": "Tri-state filter for tests that have linked PM-tool defects; omit to not filter." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden-status filter; omit to not filter." + }, + { + "name": "osList", + "type": "array", + "description": "Applied OS filter values." + }, + { + "name": "browserList", + "type": "array", + "description": "Applied browser filter values." + }, + { + "name": "deviceList", + "type": "array", + "description": "Applied device filter values." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-test visibility. Accepts all / true (muted only) / false (unmuted only); the identifiers ALL / MUTED / UNMUTED are also accepted, case-insensitively." + }, + { + "name": "hooksVisibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Applied hooks visibility. The response also returns the project's own default hooks visibility separately under `hooksVisibility`." + } + ], + "intent": "Get the whole filter sidebar for the Suite Health test list: the currently applied filters resolved into display fields, the available static filter options (failure categories, OS/browser/device, sources, run-level options, etc.), and the project's default hooks visibility.", + "returns": [ + "applied", + "staticFilters", + "hooksVisibility" + ], + "guidance": [ + "This echoes back the filters you sent (resolved into {id,value,label} fields under `applied`) AND the catalogue of choosable options under `staticFilters` \u2014 send an empty query (just dateRange) to fetch the full option catalogue for building a filter UI.", + "It degrades open: the run-level option lists inside `staticFilters` (typeOfRun, assignedTo, state, testPlan) are fetched from an upstream service under a timeout, and on timeout/failure (or when there is nothing) they come back null or [] rather than erroring. An empty or null run-level dropdown does not mean there are no options.", + "`dateRange` is mandatory and must be exactly two millisecond timestamps; there are no lowerBound/upperBound scalars on this route.", + "Scoped to project visibility only; no plan gate." + ], + "responses": { + "200": { + "$response": "SPTestFilterOptions" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_branches", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/branches", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wrapped in wildcards for the Elasticsearch match, and additionally applied as a case-SENSITIVE literal `contains` post-filter \u2014 a lowercase query will not surface an upper-case branch. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 it is only consulted by the build-names suggestion route, not this one." + } + ], + "intent": "Type-ahead the branch filter on the Suite Health test list: get git branch names seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the Elasticsearch terms aggregation is capped at 100 branches, so it is not exhaustive; narrow with `query` rather than expecting the full set.", + "The `query` match is applied twice: a wildcard ES match AND a case-sensitive literal `contains` filter on the returned values, so casing matters when narrowing.", + "Each suggestion is a Field object; use its `value` (the branch name) when applying the branch filter elsewhere.", + "Omitting `query` returns an EMPTY `data` list, not the top branches - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildNames", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match against unique build names. Omit/blank to list the top build names in the window." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Whether manual runs are included. Unlike the sibling type-aheads this route honours it (it is carried into the build-level filter request)." + } + ], + "intent": "Type-ahead the build-name filter on the Suite Health test list: get unique build names for the project in the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "This route is the odd one out: it converts the request into a build-sidebar filter and resolves against the build-level index (BuildFilterServiceV2), NOT the test-level filter service the other type-aheads use \u2014 so its matching and result limits can differ from the branches/tags/hostNames suggestions.", + "Because it runs through the build path it honours `includeManualRuns`, which the other suggestion routes ignore.", + "Each suggestion is a Field object; use its `value` (the build name) when applying the uniqueBuildNames filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_build_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildTags", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the build-tag filter on the Suite Health test list: get build tags used in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results, so it is not exhaustive; narrow with `query`.", + "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", + "Each suggestion is a Field object; use its `value` (the tag) when applying the buildTags filter.", + "Omitting `query` returns an EMPTY `data` list, not the top build tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_host_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/hostNames", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match against host names, wrapped in wildcards for the ES match. Omit/blank to list the top host names in the window." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the host-name (machine/agent) filter on the Suite Health test list: get host names seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", + "Unlike the other type-aheads this route does NOT accept `isManuallyOverridden` (that filter is not applied here), and it does not apply a client-side literal `contains` filter \u2014 it relies purely on the wildcard ES match.", + "Each suggestion is a Field object; use its `value` (the host name) when applying the hostNames filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_test_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/testTags", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the test-tag filter on the Suite Health test list: get test tags seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", + "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", + "Each suggestion is a Field object; use its `value` (the tag) when applying the testTags filter.", + "Omitting `query` returns an EMPTY `data` list, not the top test tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_metrics_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/tests/metrics/chart", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required \u2014 the chart interval is derived from it; a null or non-2-element range is a 400." + }, + { + "name": "search", + "type": "string", + "description": "Free-text test-name search to scope the charts." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build-tag filter values." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Unique-build-name filter values." + }, + { + "name": "testTags", + "type": "array", + "description": "Test-tag filter values." + }, + { + "name": "folders", + "type": "array", + "description": "Folder filter values." + }, + { + "name": "hostNames", + "type": "array", + "description": "Host-name filter values." + }, + { + "name": "status", + "type": "array", + "description": "Test-status filter values." + }, + { + "name": "sources", + "type": "array", + "description": "Product-source filter values." + }, + { + "name": "users", + "type": "array", + "description": "User-id filter values (longs)." + }, + { + "name": "lastExecutedBy", + "type": "array", + "maxItems": 500, + "description": "User ids (longs). Capped at 500 by validation; exceeding it is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Team-id filter values (longs)." + }, + { + "name": "failureCategories", + "type": "array", + "description": "Failure-category filter values." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state flakiness filter; omit to not filter." + }, + { + "name": "isNewFailure", + "type": "boolean", + "description": "Tri-state new-failure filter; omit to not filter." + }, + { + "name": "isAlwaysFailing", + "type": "boolean", + "description": "Tri-state always-failing filter; omit to not filter." + }, + { + "name": "osList", + "type": "array", + "description": "OS filter values." + }, + { + "name": "browserList", + "type": "array", + "description": "Browser filter values." + }, + { + "name": "deviceList", + "type": "array", + "description": "Device filter values." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-test visibility (ALL / MUTED / UNMUTED identifiers also accepted, case-insensitively)." + }, + { + "name": "hooksVisibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Hooks visibility scope." + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, failure counts are sourced from the recorded status keyword (SDK-recorded automation status). When not true AND the account has the new build listing flag on, counts instead come from workflow status, which can change the numbers for the same window." + } + ], + "intent": "Get the Suite Health page chart data in one call: time-bucketed failures-over-executions, average duration, and average failure-rate series for the project's tests over a window, filtered by the full Suite Health filter set.", + "returns": [ + "failureChart", + "durationChart", + "failureRateChart" + ], + "guidance": [ + "Response is a top-level map of three charts \u2014 `failureChart`, `durationChart`, `failureRateChart` \u2014 each an SPChart with a `data` map of named series (arrays of {x,t,y,pointRange} points) and an `insights` summary. There is no `data` envelope wrapper.", + "Series keys inside `data`: failureChart carries `totalExecutions` and `totalFailures`; durationChart carries `averageDuration`; failureRateChart carries `averageFailureRates`.", + "Archived builds are excluded from every counter.", + "The same query can return DIFFERENT failure numbers depending on account config: with the new build listing flag on and `isAutomationLatest` not true, failures are counted from workflow status rather than the raw status keyword. Set `isAutomationLatest=true` to force the raw-status counts.", + "The failure series can come back with fewer buckets than the executions series when a slice has zero matches; the failure-rate series is computed by aligning on the `x` timestamp, so do not assume the two arrays are index-aligned." + ], + "responses": { + "200": { + "$response": "SPTestMetricsChart" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_base_info", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/info", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash, discovered from the Suite Health test listing." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ], + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start as an epoch-millisecond long. Optional on this route (there is no dateRange array here)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end as an epoch-millisecond long. Optional." + }, + { + "name": "buildName", + "type": "string", + "description": "Single build name to scope to (wrapped into a one-element list server-side); this route takes at most one, not a list." + } + ], + "intent": "Get the identifying header info for one test in the Test Health detail view \u2014 its name and the environment (OS/browser/device) combinations it ran on \u2014 for the selected window.", + "returns": [ + "name", + "combinations" + ], + "guidance": [ + "Response is a flat object `{name, combinations}` with NO data wrapper. `name` is the test's display name (empty string when the hash matches nothing in the window, still 200), and `combinations` is the list of OS/browser/device environments it ran on.", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Narrower filter set than the test listing: no browser/os/device/tag filters, and only a single `buildName`.", + "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 (no structured error body), so treat a 500 as opaque rather than parsing it." + ], + "responses": { + "200": { + "$response": "SPTestBaseInfo" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_environment_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/breakdown", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash, discovered from the Suite Health test listing." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ], + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start as an epoch-millisecond long. Optional." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end as an epoch-millisecond long. Optional." + }, + { + "name": "buildName", + "type": "string", + "description": "Single build name to scope to (wrapped into a one-element list server-side); at most one, not a list." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state flakiness filter; omit to not filter." + }, + { + "name": "orderKey", + "type": "string", + "values": [ + "reliability", + "average", + "totalFailures" + ], + "description": "Sort key for the returned rows. Unrecognised/absent falls back to reliability." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ], + "description": "Sort direction. Unrecognised/absent falls back to descending." + } + ], + "intent": "Break one test's results down by environment (OS / browser / device) over the window, so you can see whether its instability is platform-specific.", + "returns": [ + "id", + "reliability", + "duration", + "os", + "browser", + "browserDevice", + "totalFailures" + ], + "guidance": [ + "The body is a BARE JSON ARRAY of environment rows (no object wrapper, no `data` key). Each row: `id` (browser-platform key), `reliability` (fail %), `duration` (avg), `os`/`browser`/`browserDevice` (formatted platform objects), and `totalFailures` {failed,total}.", + "Uses the older explicit-argument service overload, so it accepts only a SINGLE `buildName` and a much smaller filter set than the test listing (no os/browser/device/tag filters).", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Rows are sorted server-side (default reliability, descending) and platform rows that carry no displayable browser or device are dropped, so the array may be shorter than the number of distinct environments.", + "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 with no structured body." + ], + "responses": { + "200": { + "$response": "SPTestEnvironmentBreakdown" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_app_logs", + "method": "GET", + "path": "/api/v1/appLogs/query", + "mode": "read", + "entity": "test_detail", + "intent": "Get a test run's application (app-under-test / SDK) log lines, parsed and flattened for display \u2014 use this for the app-logs tab when you want normalised rows rather than raw Loki JSON.", + "query": [ + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "The test run's UUID (not its integer id and not the TR-NNN identifier). A missing or foreign UUID collapses into an empty 500 with no body." + } + ], + "returns": [ + "status", + "appLogQuery", + "logs" + ], + "responses": { + "200": { + "description": "Parsed app logs. `status` is 'success', or 'failure' with an empty `logs` list (still HTTP 200) when Loki reports a non-success status.", + "schema": { + "$schema": "AppLogResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns ONLY abnormal (error-level) app-log lines \u2014 the Loki query hard-codes an abnormal filter \u2014 capped at 5000 lines and bounded to the run's own start/finish window (\u00b15s). A passing run with no error logs legitimately comes back with an empty `logs` list.", + "Two failure shapes, neither is an ordinary HTTP error: when Loki answers non-success you get HTTP 200 with `status:\"failure\"` and `logs:[]`; when anything throws you get a bare HTTP 500 with no body. Check `status`, and do not read an empty 500 as a specific cause \u2014 a missing/foreign run and a Loki outage look identical.", + "Each log line is split on a ' cSep=S ' separator and only the text after it is kept; a line that lacks the separator is returned raw and unsplit.", + "For the untouched Loki payload use the raw variant instead; this one is the parsed, UI-normalised form.", + "`level` on each line falls back to 'unknown' when Loki did not tag the stream with a level." + ] + }, + { + "name": "get_test_run_app_logs_raw", + "method": "GET", + "path": "/api/v1/appLogs/nativeQuery", + "mode": "read", + "entity": "test_detail", + "intent": "Get a test run's application logs as Loki's raw, unparsed JSON response \u2014 use this only when you need the native Loki shape; for displayable rows use the parsed app-logs capability instead.", + "query": [ + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "The test run's UUID (not its integer id and not the TR-NNN identifier)." + } + ], + "returns": [ + "status", + "data" + ], + "responses": { + "200": { + "description": "Loki's native query_range JSON body, relayed verbatim as an opaque JSON document (top-level `status` and `data.result[]` streams). This is NOT an app model \u2014 the shape is Loki's, not the service's.", + "schema": { + "type": "object", + "description": "Opaque pass-through of Loki's /query_range response. Typically `{status, data:{resultType, result:[{stream, values:[[epochNano, line]]}]}}`, but treat it as an untyped Loki document." + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pure pass-through of Loki's body \u2014 the same abnormal-only filter, 5000-line cap and run-time-window bounding as the parsed variant apply, but nothing is normalised. Log lines still carry the ' cSep=S ' separator and their level pattern; you must split them yourself.", + "The handler swallows every exception into a bare HTTP 500 with no body, so a missing or foreign testRunUuid is indistinguishable from a Loki outage.", + "Prefer the parsed app-logs capability unless you specifically need Loki's native envelope; this one returns a JSON string, not the service's AppLogResponse." + ] + }, + { + "name": "get_test_run_hooks", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/hooks", + "mode": "read", + "entity": "test_detail", + "intent": "Public API: list the before/after setup and teardown hook executions relevant to a test run, so a failure can be attributed to a hook rather than the test body.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [ + "testCode", + "testMetadata", + "hooksCode" + ], + "responses": { + "200": { + "description": "A MAP keyed by hook test-run id (numeric string keys), each value a TestDetailResponse for that hook. An empty object means no hooks matched.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestDetailResponse" + }, + "description": "Keyed by matched hook run id." + } + }, + "404": { + "$response": "NotFound" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", + "The result is a MAP keyed by hook test-run id, not a list; iterate the values.", + "Matching is heuristic, not a stored parent/child link: hooks are matched on the SAME build id + filename + OS + browser + device, latest only. A hook that ran in a differently-configured session will not appear here even though it belongs to the same build.", + "At most 100 hook runs are returned (the underlying search is size-capped); there is no paging to fetch more.", + "An unknown test_run_id returns TEST_RUN_NOT_FOUND (404). A run with no matching hooks returns an empty object with 200." + ] + }, + { + "name": "get_test_run_log_bundle", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/logs", + "mode": "read", + "entity": "test_detail", + "intent": "Public API: get the full log bundle for a test run \u2014 BrowserStack session logs (console/network/selenium/appium/video), SDK logs, hook-run logs, failure and step logs \u2014 as URLs plus the test start/finish timestamps and framework.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [ + "testStartedAt", + "testFinishedAt", + "browserstackLogs", + "sdkLogs", + "hookRunLogs", + "failureLogs", + "stepLogs", + "framework", + "sessionId" + ], + "responses": { + "200": { + "description": "Log URLs and metadata. `browserstackLogs` is a SessionLogs object of per-type log URLs; `sdkLogs` and `hookRunLogs` are lists of URL strings; `failureLogs` and `stepLogs` are inline log-entry objects.", + "schema": { + "$schema": "LogUrls" + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", + "Returns log URLs to fetch separately, NOT log content. The caller downloads each artefact from the URLs in `browserstackLogs`, `sdkLogs` and `hookRunLogs`; only `failureLogs` and `stepLogs` are inline.", + "The logs are always the TRIMMED variant \u2014 the trim flag is hard-coded true on this route, so there is no way to ask for the untrimmed logs here.", + "Plan-locked / retention-expired builds are rejected with FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", + "Several log sources are fetched concurrently, so latency tracks the slowest source; expect a slow response for large sessions." + ] + }, + { + "name": "get_session_network_log_url", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "intent": "Get the URL of a session's HAR network log for the Test Details network tab \u2014 returns a link to the HAR file plus its fetch status, not the parsed entries.", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "The session's HASHED id (not a numeric id)." + } + ], + "returns": [ + "networkLogs", + "status_code", + "message" + ], + "responses": { + "200": { + "description": "The network-log URL envelope. `networkLogs` is the HAR file URL, `status_code` is the upstream fetch status, `message` any upstream message.", + "schema": { + "$schema": "SessionNetworkLogUrlResponse" + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the name, this returns the HAR file URL in `networkLogs`, NOT the parsed HAR entries \u2014 the handler requests the unparsed form. Fetch the HAR yourself from that URL; it can be large.", + "On the demo-sandbox environment it short-circuits to an empty HAR (`{log:{entries:[]}, responseOrigin:\"demo\"}`) before any lookup.", + "An unknown sessionId returns SESSION_NOT_FOUND. Note the id here is the session's hashed id, the same id the sibling session log-URLs capability takes." + ] + }, + { + "name": "get_test_run_behaviour", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/behaviour", + "mode": "read", + "entity": "test_detail", + "intent": "Get the BDD/behaviour payload stored on a test run (the Gherkin feature/scenario structure in the run's meta) that backs the Behaviour tab for Cucumber-style frameworks.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [], + "responses": { + "200": { + "description": "The run's meta JSON (the Gherkin/behaviour structure) as an opaque object, or an empty object `{}` when the run has no meta.", + "schema": { + "type": "object", + "description": "Opaque BDD/behaviour meta object stored on the run. Empty object when no meta is present." + } + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns the run's raw meta object; only meaningful for BDD (Cucumber-style) builds. A run with no meta returns an empty object `{}` with HTTP 200, not a 404.", + "A MISSING test run does not give a typed error \u2014 the handler checks lock status before the null check, so a bad id throws a NullPointerException (HTTP 500), not TEST_RUN_NOT_FOUND.", + "Locked (retention-expired) runs are rejected with FORBIDDEN_OPERATION.", + "The Behaviour tab is only offered for BDD builds; for a non-BDD run this payload is not useful even when present." + ] + }, + { + "name": "get_test_run_merged_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/consolidatedLogs", + "mode": "read", + "entity": "test_detail", + "intent": "Get a single merged, time-ordered log stream for a test run \u2014 BrowserStack session logs, SDK logs, hook-run logs, failure and step logs combined into one list, with content merged server-side \u2014 for the Logs tab.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "required": false, + "default": false, + "description": "When true, restrict the merged logs to the test's own time window. Defaults to false (full session)." + } + ], + "returns": [ + "logLevel", + "logType", + "timeStamp", + "content", + "url", + "duration" + ], + "responses": { + "200": { + "description": "A top-level JSON ARRAY of merged, time-ordered log entries (GenericLog objects). Content is downloaded and merged server-side, so entries carry inline `content`, not just URLs.", + "schema": { + "type": "array", + "items": { + "$schema": "GenericLog" + } + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This form downloads and MERGES every log source server-side and returns the content inline as one time-ordered array, so it is slow for long sessions. A sibling capability returns log URLs instead (content fetched client-side) \u2014 prefer that one for large sessions.", + "The body is a bare JSON array of log entries, not an object wrapper \u2014 iterate it directly.", + "`trim=true` narrows the stream to the test's own time window; the default (false) includes the whole session.", + "Bad ids give TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND (404); locked builds give FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", + "On the demo-sandbox environment canned logs are returned, chosen by the test's name.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ] + }, + { + "name": "get_test_run_details", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/details", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test run id." + } + ], + "intent": "Get the full detail of one test run \u2014 its metadata plus the captured test code and the code of the before/after hooks that ran around it, i.e. everything needed to understand what the test did.", + "guidance": [ + "This is the heaviest of the detail reads: it assembles the run, its captured code, its meta and the related hook code from several sources, so prefer a lighter read when you only need status or metadata.", + "testCode (and each entry of hooksCode) can have null code/language when no code was captured for that run \u2014 the row is still returned; do not treat a null code as an error.", + "A run that does not belong to the caller's group, or does not exist, comes back as TEST_RUN_NOT_FOUND \u2014 the same answer for 'wrong group' and 'no such run'.", + "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ], + "returns": [ + "testCode", + "testMetadata", + "hooksCode" + ], + "responses": { + "200": { + "$response": "TestRunDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_baseline", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/baseLine", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric id of the (typically failing) run whose baseline you want." + } + ], + "intent": "Find a failing run's baseline \u2014 the most recent passing run of the same test in the last 30 days \u2014 returned with full test detail, so you can diff a failure against the last known-good execution.", + "guidance": [ + "Returns a MAP keyed by test run id, not a single object. Priority 1 is the newest passing run with the same test hash (one entry); if there is none it falls back to the newest passing run per device/os/browser combination (several entries). An EMPTY map means no baseline was found, not an error.", + "The look-back window is hardcoded to 30 days before this run's start time \u2014 an older last-good run will not appear, so an empty result does not prove the test has never passed.", + "Each map value is { testCode, testMetadata, hooksCode } - hooksCode is present but null on this route, and testCode is null when no code was captured. Read the passing run's numeric id from the map key." + ], + "returns": [ + " (map key)", + "testCode", + "testMetadata" + ], + "responses": { + "200": { + "$response": "TestRunDetailMapResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_build_insights", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/buildInsights", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test run id; its parent build is resolved from it." + } + ], + "intent": "Get the build-level insights that surround a failing run \u2014 the parent build's failure/flakiness summary, failure categorisation and top-errors analysis, plus this run's own file/module failure context \u2014 so a failure can be reasoned about in context.", + "guidance": [ + "The run's parent build is resolved first; a run whose build cannot be found returns BUILD_NOT_FOUND (distinct from TEST_RUN_NOT_FOUND when the run itself is missing).", + "topErrorsAnalysis.status is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED \u2014 when not COMPLETE, uniqueErrors may be empty or partial, so check the status before treating the error list as final.", + "failureContext (file and parentModule failure rates) is the per-run slice; the rest of the payload is aggregated across the whole build. A build-scoped variant that omits this per-run context exists internally but is not this capability." + ], + "returns": [ + "buildId", + "buildName", + "status", + "summary", + "failureCategorization", + "failureContext", + "topErrorsAnalysis", + "defectStats" + ], + "responses": { + "200": { + "$response": "BuildInsightsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_runs_sharing_error", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/similarError/{clusterId}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric id of the failing run whose error cluster you are matching against. Excluded from the returned set." + }, + { + "name": "clusterId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). Unique-error cluster id. It sits in the PATH but is bound onto the filter object's clusterId property by Spring's URI-template data binding \u2014 you do NOT also pass it as a query param.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Project the error breakdown is scoped to. Required \u2014 omitting it is a binding error, not an empty result." + }, + { + "name": "dateRange", + "type": "array", + "description": "Two epoch-millisecond bounds [from, to] scoping the error search. Bound onto the filter but NOT validated at request binding on this route (no @Valid), so a call without it will not 400 here yet may return nothing \u2014 supply it to get a meaningful window.", + "fields": [ + { + "name": "from", + "type": "integer" + }, + { + "name": "to", + "type": "integer" + } + ] + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true, search the error across all builds rather than only the run's own build." + }, + { + "name": "searchAfter", + "type": "array", + "description": "Elasticsearch search_after cursor echoed back from a previous page to continue paging the affected-runs scan." + } + ], + "intent": "List the other test runs that failed with the same unique-error cluster as this run \u2014 the 'tests affected by this error' list, each returned with full test detail.", + "guidance": [ + "Returns a MAP keyed by test run id (each value is { testCode, testMetadata }); the querying run itself is always excluded, and an EMPTY map means no other run shares the cluster.", + "projectId is a REQUIRED query param; the cluster is taken from the {clusterId} path segment via data binding, so do not duplicate it as a query param.", + "The affected-run scan also accepts the wider GetSPError filter set (test tags, statuses, devices/os/browsers, folders, users, etc.) as query params bound onto the filter object \u2014 use them to narrow which affected runs come back." + ], + "returns": [ + " (map key)", + "testCode", + "testMetadata" + ], + "responses": { + "200": { + "$response": "TestRunDetailMapResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/testRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id). The listing is scoped to this build." + } + ], + "query": [ + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 pagination cursor. Echo back the pagination.nextPage from the previous response verbatim; do not construct it. Stop when pagination.hasNext is false." + }, + { + "name": "sort", + "type": "string", + "values": [ + "EXECUTION_ORDER", + "TOP_LEVEL_NAME", + "DURATION", + "FAILED_TEST", + "PLATFORM", + "STATUS" + ], + "description": "Sort column." + }, + { + "name": "order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "description": "Sort direction. Required alongside sort \u2014 the two are read together." + }, + { + "name": "test_statuses", + "type": "array", + "description": "Filter by status strings (e.g. passed, failed, skipped)." + }, + { + "name": "re_runs", + "type": "array", + "description": "Filter by re-run numbers (list of integers)." + }, + { + "name": "ci_build_numbers", + "type": "array" + }, + { + "name": "test_tags", + "type": "array" + }, + { + "name": "host_names", + "type": "array" + }, + { + "name": "is_flaky", + "type": "boolean", + "description": "Smart-tag filter. On a freemium or source-fenced build this (and is_always_failing / is_new_failure / has_performance_anomaly) is REFUSED with a plan-restriction 403, not silently ignored." + }, + { + "name": "is_always_failing", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "is_new_failure", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "has_performance_anomaly", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "failure_categories", + "type": "array" + }, + { + "name": "devices", + "type": "array" + }, + { + "name": "os", + "type": "array" + }, + { + "name": "browsers", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hooks_visibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Which hooks to include in the tree. Defaults to failed." + }, + { + "name": "is_muted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-tests visibility: all, true (only muted), false (only unmuted). Also accepts ALL/MUTED/UNMUTED. An unrecognised value is rejected." + } + ], + "intent": "List a build's test runs as the UI suite/folder tree, with the full filter set (status, flaky, muted, re-runs, tags, failure category, device/os/browser, folders, performance anomaly, new/always-failing), sorted and cursor-paginated, with deep-link URLs back to the BrowserStack UI.", + "guidance": [ + "The response is a NESTED hierarchy (suites/folders/tests), each node carrying an external deep-link URL \u2014 it is not a flat array. For a flat list use the flat build listing instead.", + "Plan- and source-aware: on a freemium or source-fenced build the analysis keys are stripped from every node, and using any smart-tag filter (is_flaky / is_always_failing / is_new_failure / has_performance_anomaly) is refused with a 403 telling you to upgrade \u2014 so gate those filters on plan.", + "Service-account callers are additionally checked for the TEST_RUN_AUTOMATED_VIEW permission on the build's project; lacking it is a 403.", + "Pagination is an opaque base64 next_page token: pass `pagination.next_page` back unchanged and stop when `pagination.has_next` is false. Keys are snake_case. The envelope differs by an account flag: newer accounts add `is_manually_overridden` and `test_summary` carries workflow-status counts, older ones plain status counts." + ], + "returns": [ + "hierarchy", + "is_archived", + "build_name", + "build_id", + "original_name", + "build_number", + "project_id", + "name", + "group_id", + "test_summary", + "app_details", + "pagination" + ], + "responses": { + "200": { + "$response": "BuildTestRunListingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_with_attachments", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/testRuns/withAttachments", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id)." + } + ], + "query": [ + { + "name": "page_size", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Page size. Must be 1..100; outside that range is a 400 ('Limit should be between 1 and 100')." + }, + { + "name": "cursor", + "type": "integer", + "description": "NUMERIC cursor from the previous response's next_cursor (this route uses a numeric cursor, unlike the base64 cursor on the plain build test-run listing). Omit on the first page." + } + ], + "intent": "List only the test runs in a build that actually have attachments or screenshots, with per-run counts \u2014 the discovery step before downloading artefacts run by run.", + "guidance": [ + "Returns a TRIMMED row per run \u2014 id, name, uuid, build_id, attachments_count, screenshots_count \u2014 not the full test-run object; use the per-run attachments read to get the actual download URLs.", + "Pagination is a numeric cursor: pass the previous response's next_cursor back as cursor; a null next_cursor means the last page.", + "The build is resolved by (uuid, group); an unknown uuid for the caller's group is BUILD_NOT_FOUND." + ], + "returns": [ + "test_runs", + "next_cursor" + ], + "responses": { + "200": { + "$response": "BuildTestRunsWithAttachmentsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_bug_report_draft", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetails", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler \u2014 the draft is composed from testRunId alone (group-scoped). Any placeholder value is accepted." + }, + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Numeric test run id whose failure the draft describes." + } + ], + "intent": "Build the pre-filled bug-report draft for a failed test \u2014 test name/status/duration, platform/browser, failure category, CI and host info, build name, retries, screenshot/log attachments and the AI RCA snippet \u2014 i.e. everything the 'Report a bug to Jira/Azure' modal needs to compose a ticket.", + "guidance": [ + "Read-only despite the name: it COMPOSES a new draft description, it does not fetch a bug that was already filed. To record a filed bug use link_bug_to_test_run.", + "It requires screenshot/retry data to exist for the run \u2014 a run with no such data throws 'retry doesn't exist' (a 4xx), so treat that as 'nothing to report on yet', not an outage.", + "logStruct carries image/log attachment references and aiRca carries the AI RCA snippet; both can be empty/absent when the run has no screenshots or no RCA." + ], + "returns": [ + "buildName", + "buildId", + "ciInfo", + "testDetails", + "testUrl", + "videoUrl", + "status", + "platform", + "browser", + "hostInfo", + "logStruct", + "retries", + "issueType", + "isAutoAnalyzed", + "duration", + "aiRca" + ], + "responses": { + "200": { + "$response": "BugReportDraftResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "link_bug_to_test_run", + "method": "POST", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetailsCallback", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler. Any placeholder value is accepted; the run is resolved from testRunId." + }, + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Numeric test run id to attach the bug to." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "The external issue KEY (e.g. the Jira/Azure ticket id). Used for the idempotency check \u2014 a key already tracked on the run makes the call a no-op." + }, + { + "name": "type", + "type": "string", + "description": "The PM-tool name the issue belongs to (e.g. Jira, Azure Boards)." + }, + { + "name": "url", + "type": "string", + "description": "Deep link to the created issue, shown in the UI." + }, + { + "name": "status", + "type": "string", + "description": "The issue's status to display against the run." + }, + { + "name": "uniqueUserId", + "type": "integer", + "description": "Id of the user credited with filing the bug." + } + ], + "intent": "After a bug has been created in a PM tool (Jira, Azure Boards, \u2026), link that external issue to the test run \u2014 recording it in the run's integration tables and marking the test as bug-reported so the UI shows the issue key/link.", + "guidance": [ + "Idempotent on the issue key: if that bug name is already tracked on the run (checked in both quoted-legacy and unquoted forms) you get 204 No Content with no body \u2014 treat that as 'already linked', not a failure.", + "On a fresh link it returns 200 with { id } \u2014 the created integration id. The caller must have permission to create defects on the run's project (403 otherwise).", + "Linking also fires the PM-Tools integration trigger as a side effect; if that trigger fails it is only logged and does not fail the link.", + "Reversible: an unlink route removes the association, so this is a write, not a destructive op. Send the issue KEY in `name` (not in `url`).", + "Read-back: the link is written to the test run's search document and appears as details.pm_tool_details[] ({id,name,url,type,status}) in list_build_test_runs \u2014 the returned id is that entry's id." + ], + "returns": [ + "id" + ], + "responses": { + "200": { + "$response": "LinkBugToTestRunResponse" + }, + "204": { + "description": "The bug key is already tracked on this run; nothing was changed and there is no body." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_flat", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/testRuns/flat", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id). Resolved by (uuid, group); unknown \u2192 BUILD_NOT_FOUND." + } + ], + "query": [ + { + "name": "searchAfter", + "type": "string", + "description": "Elasticsearch search_after cursor \u2014 pass the previous page's cursor back to continue. Omit on the first page." + }, + { + "name": "testRunIds", + "type": "array", + "description": "Restrict to specific numeric run ids. Send this OR clusterIds: with neither the handler 500s. Takes precedence over clusterIds." + }, + { + "name": "clusterIds", + "type": "array", + "description": "Restrict to runs in these unique-error clusters (latest run per test). Send this OR testRunIds: with neither the handler 500s." + }, + { + "name": "status", + "type": "array" + }, + { + "name": "issueType", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "browser", + "type": "array" + }, + { + "name": "os", + "type": "array" + }, + { + "name": "folder", + "type": "array" + }, + { + "name": "flaky", + "type": "string" + }, + { + "name": "isMuted", + "type": "string" + }, + { + "name": "search", + "type": "array" + }, + { + "name": "virtualDevice", + "type": "boolean", + "description": "true restricts to virtual-device runs; false includes explicit-false AND runs missing the field." + } + ], + "intent": "List a build's test runs as a flat, filterable list (rather than the nested suite/folder hierarchy) \u2014 the data source for the flat test-listing view of a build.", + "guidance": [ + "Send testRunIds or clusterIds. With neither, the handler builds an Elasticsearch terms filter from a null clusterIds list and returns 500 Internal System Exception. The other filter params (status, issueType, tags, browser, os, folder, flaky, isMuted, search) are bound but NOT applied by this handler; only testRunIds, clusterIds and virtualDevice narrow the result.", + "Flat counterpart of the hierarchical build listing \u2014 returns a plain array under testRuns, no tree. Filters arrive as QUERY params bound onto a POJO, not as a JSON body.", + "In each row the top-level `id` is the test's HASH (a string); the numeric run id you need for other run reads is `details.id`. Do not pass the top-level id where a numeric run id is expected.", + "Pagination is Elasticsearch search_after: pass the prior page's cursor back as `searchAfter`. Note the underlying query fetches up to 10,000 hits, so on very large builds narrow with filters rather than relying on paging alone.", + "The ES indices queried are chosen from the build, so very old builds may resolve to different indices; results are limited to the latest run per test unless you pass explicit testRunIds." + ], + "returns": [ + "testRuns", + "pagingParams" + ], + "responses": { + "200": { + "$response": "FlatTestRunListingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_ai_failures", + "method": "GET", + "path": "/ext/v1/ai/failures/{buildUUID}", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "intent": "Public API: get a build's AI failure analysis (clustered failure themes and their workflows, sorted by failure share), the summary a build-RCA agent uses to explain a build failure.", + "returns": [ + "buildId", + "buildThemes", + "buildWorkflows", + "buildThemeWorkflow", + "stats", + "trialQuota" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Read-only: returns whatever has already been computed and never triggers analysis; use trigger_build_ai_failure_analysis to start it.", + "Themes and workflows are sorted by failure share, highest first.", + "trialQuota appears only for build-level trial accounts.", + "The build is resolved within the caller's group; a build in another group reads as not found." + ], + "responses": { + "200": { + "$response": "BuildAIFailuresResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "trigger_build_ai_failure_analysis", + "method": "POST", + "path": "/ext/v1/ai/failures/{buildUUID}", + "mode": "write", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "intent": "Public API: start build-level AI failure clustering for a build (get-or-trigger). Returns existing analysis if ready, otherwise enqueues it and flags that it was triggered.", + "returns": [ + "triggered", + "buildId", + "buildThemes", + "buildWorkflows", + "buildThemeWorkflow", + "stats", + "trialQuota" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Get-or-trigger and asynchronous: triggered=true means analysis was enqueued and results are NOT in the response yet; poll get_build_ai_failures.", + "Gated by the AI-TFA plan fence plus AI consent, the build-level RCA feature, source-fencing and a trial quota; a blocked account gets a 403 plan-not-allowed error.", + "Runs only the theme/workflow clustering pipeline; this is NOT per-test RCA.", + "Takes no request body.", + "Produces data only, never removes any; there is no undo for the analysis run it starts." + ], + "responses": { + "200": { + "$response": "BuildAIFailureTriggerResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_ai_failure_theme_test_runs", + "method": "GET", + "path": "/ext/v1/ai/failures/{buildUUID}/flat", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "query": [ + { + "name": "buildFailureThemeId", + "type": "integer", + "required": false, + "description": "Integer id of the failure theme to drill into, from a public AI-failures read. Give this OR buildFailureWorkflowId to scope to one cluster. One of theme/workflow id is required." + }, + { + "name": "buildFailureWorkflowId", + "type": "integer", + "required": false, + "description": "Integer id of the failure workflow to drill into. One of theme/workflow id is required." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend default; use searchAfter to page." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Opaque search-after cursor from the previous response's pagingParams.searchAfter." + } + ], + "intent": "Drill into one AI failure theme or workflow of a build and list the flat, paginated test runs behind it. Takes a theme or workflow id only \u2014 to fetch an explicit set of test-run ids instead, use list_build_ai_failure_cluster_test_runs, which accepts testRunIds.", + "returns": [ + "testRuns", + "pagingParams" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Send buildFailureThemeId OR buildFailureWorkflowId (integer ids from get_build_ai_failures). One of them is REQUIRED: with neither, the TestHub lookup 400s 'At least one of build_failure_theme_id or build_failure_workflow_id must be provided' (there is no whole-build mode).", + "There is no explicit-testRunIds variant on the public flat endpoint.", + "limit is ignored; page with searchAfter until pagingParams.hasNext is false." + ], + "responses": { + "200": { + "$response": "FlatAIFailureTestRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_queued_sessions", + "method": "GET", + "path": "/api/v1/queued-sessions", + "mode": "read", + "entity": "session", + "query": [ + { + "name": "userId", + "type": "integer", + "required": true, + "description": "The user whose queued sessions to list. Taken from the query, NOT the caller's identity, so any user id can be asked about." + }, + { + "name": "product", + "type": "string", + "required": true, + "description": "Product to filter the queue by (e.g. automate / app-automate)." + } + ], + "intent": "List the automation sessions currently queued for a user and product, each with session/build/project name, resolved capabilities, why it is queued and how long it has waited.", + "returns": [ + "sessions" + ], + "guidance": [ + "Both userId and product are required (400 otherwise).", + "userId comes from the query, so it is NOT scoped to the caller; only the build/project name enrichment is group-scoped (names resolve to null for builds outside your group).", + "queueReason explains the wait (e.g. no_terminal_available); duration is computed at read time.", + "The capabilities field's shape depends on the session's caps format (selenium / appium / default)." + ], + "responses": { + "200": { + "$response": "QueuedSessionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_app_profiling", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/appProfiling", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get app-profiling data (CPU/memory/etc. captured during an App Automate session) for the Test Details app-performance tab.", + "returns": [], + "guidance": [ + "App profiling is App Automate only; on an Automate session this is a 400 (\"App Profiling is only supported for App Automate sessions\").", + "An empty object ({}) is a normal 200 meaning the report is still generating (upstream 202) or was never captured (upstream 404); it is NOT an error, so re-poll rather than treating it as failure.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "The body is the opaque upstream app-profiling JSON.", + "On the demo sandbox environment this returns an empty object." + ], + "responses": { + "200": { + "$response": "SessionAppProfilingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_capabilities", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/capabilities", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get the raw desired/actual capabilities a session was started with (Automate or App Automate), so a user can see exactly how the run was configured.", + "returns": [], + "guidance": [ + "Product-dependent: the session's origin (automate / app-automate) is resolved and the caps are fetched from that product's API.", + "The body is opaque JSON: normally the upstream sessionParams object, but if the upstream fetch fails it falls back to {outputCapabilities:{capabilities:...}} built from the stored session capabilities. Do not assume a fixed key.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "On the demo sandbox environment this returns dummy capabilities." + ], + "responses": { + "200": { + "$response": "SessionCapabilitiesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_details", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/sessionDetails", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get the header/metadata block for a session on the Test Details page: video/audio log handles, session URL, platform/device, framework and timings the tabs are built from.", + "returns": [ + "videoLogs", + "audioLogs", + "browserstackSessionUrl", + "percyBuildUrl", + "isSdk", + "device", + "framework", + "customMetadata", + "testStartedAt", + "buildId" + ], + "guidance": [ + "While the session is still being linked to its build the call returns a RETRYABLE 425 (BUILD_LINK_NOT_READY); retry shortly rather than treating it as fatal.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "customMetadata and buildId are always null through this path today (buildId is not populated here despite the field name), so do not depend on them.", + "If the upstream media fetch fails, videoLogs/audioLogs come back null but the rest of the metadata is still returned with a 200.", + "On the demo sandbox environment this returns synthetic details with a sample video URL." + ], + "responses": { + "200": { + "$response": "SessionDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "425": { + "$response": "BuildLinkNotReady" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_test_code", + "method": "GET", + "path": "/ext/v1/sessions/{session_id}/testCode", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Public API: get the test source code captured for an Automate session, one entry per test executed in that session, so external tooling (e.g. AI agents) can read the code behind a session.", + "returns": [ + "session_id", + "build_uuid", + "tests" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Cross-group safe: the resolved build's group is re-checked against the caller's group and a mismatch is reported as SESSION_NOT_FOUND (not a distinct 403).", + "A session that exists but captured NO code is reported as SESSION_NOT_FOUND, indistinguishable from a bad id, so not-found does not prove the id is wrong.", + "TestHub caps and truncates very large responses server-side; the truncation is only logged, never surfaced, so a returned code body may be incomplete without any flag.", + "A plan-locked build returns a forbidden-operation error." + ], + "responses": { + "200": { + "$response": "SessionTestCodeResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_checks_mapping", + "entity": "check_config", + "method": "POST", + "path": "/api/v1/checks-mapping", + "mode": "write", + "intent": "Register a pull request's VCS checks (e.g. GitHub check runs) against existing BrowserStack check configurations, so the PR-checks / quality-gate pipeline can evaluate them and call back with results.", + "path_params": [], + "body": [ + { + "name": "event_name", + "type": "string", + "required": true, + "json_path": "/event_name", + "description": "VCS event name that triggered the mapping. Mandatory and must be non-blank." + }, + { + "name": "tool", + "type": "string", + "required": true, + "json_path": "/tool", + "description": "Source tool (e.g. the VCS provider). Mandatory and must be non-blank." + }, + { + "name": "callback_url", + "type": "string", + "required": true, + "json_path": "/callback_url", + "description": "URL the evaluation pipeline calls back once each mapped check is evaluated. Mandatory and must be non-blank. Stored verbatim on every created ingestion row." + }, + { + "name": "pr_data", + "type": "object", + "required": true, + "json_path": "/pr_data", + "description": "Pull-request metadata. Mandatory. The whole object is also stored verbatim as the ingestion row's PR metadata blob (with githubCheckId merged in per check).", + "fields": [ + { + "name": "organisationId", + "type": "string", + "description": "VCS organisation/owner NAME as stored on the check configuration (vcs_org_name, e.g. 'my-org'), NOT GitHub's numeric org id. Matched with repositoryId + group; a mismatch means no config is found and the whole call is a 400." + }, + { + "name": "repositoryId", + "type": "string", + "description": "Repository NAME as stored on the check configuration (repo_name, e.g. 'my-repo'), NOT GitHub's numeric repository id. Matched with organisationId + group." + }, + { + "name": "number", + "type": "integer", + "description": "Pull request number, stored as pr_number on every created row." + }, + { + "name": "commitSHA", + "type": "string", + "description": "Head commit SHA, stored as commit_sha on every created row." + }, + { + "name": "date", + "type": "string", + "format": "date-time", + "description": "Commit timestamp as an ISO-8601 instant (parsed with Instant.parse). An unparseable value fails the whole request with a 400 naming the check; when omitted, commit_timestamp is left unset." + }, + { + "name": "githubCheckId", + "type": "string", + "description": "Optional; note the per-check GitHub id is normally supplied inside pr_checks, not here." + }, + { + "name": "ticketId", + "type": "string" + }, + { + "name": "installationId", + "type": "string" + }, + { + "name": "autoMerge", + "type": "boolean" + }, + { + "name": "fullName", + "type": "string" + } + ] + }, + { + "name": "pr_checks", + "type": "array", + "required": true, + "json_path": "/pr_checks", + "description": "Non-empty list of the PR's checks to register. Each item's id is the CHECK CONFIGURATION id, and it must match an existing check configuration for the caller's group + this repository + organisation, or the whole call returns 400 'Check Configurations not found...'.", + "fields": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Check CONFIGURATION id as a string. Parsed with Long.parseLong, so a non-numeric value throws before validation and fails the call. NOT the GitHub check id." + }, + { + "name": "githubCheckId", + "type": "integer", + "required": true, + "description": "The GitHub check-run id for this check. Mandatory. Merged into the stored PR metadata blob for the matching ingestion row." + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "Check name. Mandatory and must be non-blank." + } + ] + } + ], + "returns": [ + "id", + "prNumber", + "commitSha", + "callbackUrl", + "checkStates", + "checkType", + "retryCount", + "splitRepos", + "prMetadataJson", + "thProjectId", + "checkConfiguration" + ], + "responses": { + "201": { + "description": "Array of the created vcs_ingestion rows (one per matched check configuration), each in state 'Not Started'. On success but with no matching configs the flow instead returns 400.", + "schema": { + "type": "array", + "items": { + "$schema": "VcsCheckMapping" + } + } + }, + "400": { + "description": "Plain-text (not JSON) body. Covers validation failures, the CHECKS_EVALUATION-not-accessible case, an unparseable commit date, a non-empty-but-unmatched pr_checks set ('Check Configurations not found...'), and any other exception whose message is echoed as the body.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement; when it is off the call still comes back as a 400 with the exception message as a PLAIN-TEXT body, so errors here are untyped strings, not JSON.", + "pr_checks[].id is the BrowserStack check-CONFIGURATION id, not the GitHub check id and not the ingestion id. Send the GitHub id separately as pr_checks[].githubCheckId.", + "A non-numeric pr_checks[].id throws (Long.parseLong) before bean validation, so it fails the whole request rather than returning a clean per-field error.", + "If none of the pr_checks ids match an existing check configuration for the caller's group + repositoryId + organisationId, the whole call is 400 'Check Configurations not found...' and nothing is created; register the check configuration first.", + "One vcs_ingestion row is created per MATCHED configuration; ids that match nothing are silently skipped rather than erroring individually. Read the returned array to see exactly which were registered, and take each row's integer id to look the mapping up later.", + "pr_data.date must be an ISO-8601 instant; an unparseable value fails the call with a 400 naming the check, and omitting it just leaves the commit timestamp unset.", + "Live 2026-09-30: organisationId/repositoryId are compared to the configuration's stored owner/repo NAMES (VcsIngestionService findByIdInAndRepoNameAndVcsOrgNameAndGroupId); numeric GitHub ids never match. Creating a mapping only stores a 'Not Started' ingestion row \u2014 it posts nothing to GitHub itself." + ] + }, + { + "name": "get_checks_mapping", + "entity": "check_config", + "method": "GET", + "path": "/api/v1/checks-mapping/{id}", + "mode": "read", + "intent": "Fetch a single previously-registered PR-check mapping (a vcs_ingestion row plus its joined check configuration) to see its current evaluation state, retry count, PR metadata and callback URL.", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The vcs_ingestion row id returned by create_checks_mapping. NOT the GitHub check id and NOT the check-configuration id (the service calls it checkId, which is misleading)." + } + ], + "returns": [ + "id", + "prNumber", + "commitSha", + "callbackUrl", + "checkStates", + "checkType", + "retryCount", + "splitRepos", + "prMetadataJson", + "thProjectId", + "checkConfiguration" + ], + "responses": { + "200": { + "$response": "VcsCheckMappingResponse" + }, + "404": { + "description": "Plain-text (not JSON) body: 'No check mapping found for checkId: N'. Returned when no row matches the id within the caller's group (another group's mapping reads as not-found).", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Plain-text (not JSON) body: 'Error fetching check mapping: ...'.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "The path id is the INGESTION row id from create_checks_mapping's response, not the githubCheckId or the check-configuration id.", + "Scoped to the caller's group: a mapping owned by another group returns 404, so a 404 does not prove the id never existed.", + "The 404 and 500 bodies are PLAIN TEXT, not JSON error objects, so do not try to parse them as {error:...}.", + "checkStates carries the evaluation status (created rows start at 'Not Started'); the joined checkConfiguration object gives the underlying check's name, project and type." + ] + }, + { + "name": "list_check_configurations", + "entity": "check_config", + "method": "GET", + "path": "/api/v1/repos/{owner}/{repo}/check-runs", + "mode": "read", + "intent": "List the SCM check-run configurations set up for a repository \u2014 the rules deciding which BrowserStack build/project a commit or PR check reports on, and whether it posts quality-gate or build status.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner. Matched against the stored vcs_org_name." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name. Matched against the stored repo_name." + } + ], + "query": [ + { + "name": "filter", + "type": "string", + "required": false, + "description": "Only the value 'enabled' (case-insensitive) is honoured: it restricts the listing to enabled checks. ANY other value is silently ignored and all checks are returned \u2014 this is not a general-purpose filter.", + "values": [ + "enabled" + ] + } + ], + "returns": [ + "repoName", + "ownerName", + "checks" + ], + "responses": { + "200": { + "$response": "CheckConfigurationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement (contrary to what one might assume for a read); without it the call is refused as feature-inaccessible.", + "Rows are scoped to the caller's group AND user, so you only see check configs you created \u2014 not the whole repository's.", + "When nothing is configured you get HTTP 200 with an empty checks list, not a 404; and if the lookup throws internally it also returns 200 with an EMPTY response object (null repoName/ownerName and no checks), so treat an all-null body as an error, not as 'no checks'.", + "filter accepts only 'enabled'; any other token is ignored rather than rejected, so a mistyped filter silently returns everything.", + "PRODUCT BUG (live 2026-09-30): when the repository has NO checks, the empty response swaps the envelope \u2014 repoName carries the OWNER and ownerName carries the REPO (controller builds CheckConfigurationResponse(owner, repo, \u2026) against a (repoName, ownerName, \u2026) constructor). Non-empty listings are correct. Trust the path you sent, not the envelope, on an empty list." + ] + }, + { + "name": "create_check_configurations", + "entity": "check_config", + "method": "POST", + "path": "/api/v1/repos/{owner}/{repo}/check-runs", + "mode": "write", + "intent": "Register up to 10 check-run configurations on a repository so BrowserStack posts a named check (quality-gate or build status) back to the VCS for commits/PRs, bound to a given project, build name and branch.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner; stored as the check's vcs_org_name." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name; stored as the check's repo_name." + } + ], + "body": [ + { + "name": "checks", + "type": "array", + "required": true, + "json_path": "/checks", + "minItems": 1, + "maxItems": 10, + "description": "The check configurations to create (1..10). Empty is rejected (400 'Checks list cannot be empty'); more than 10 is rejected. This is also capped against the repository's EXISTING count \u2014 max 10 per repository in total.", + "fields": [ + { + "name": "checkName", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Display name of the check. Mandatory, non-blank. Part of the uniqueness key (checkName + repo + group + buildName + project)." + }, + { + "name": "checkType", + "type": "string", + "required": true, + "values": [ + "QG_STATUS", + "BUILD_STATUS" + ], + "description": "What the check reports: QG_STATUS (quality-gate status) or BUILD_STATUS (build status). Mandatory." + }, + { + "name": "tool", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Source tool / VCS provider. Mandatory, non-blank." + }, + { + "name": "splitRepos", + "type": "boolean", + "required": true, + "json_path": "/checks/splitRepos", + "description": "Split-repos flag. Mandatory (@NotNull) \u2014 omitting it is a 400 even though it looks optional." + }, + { + "name": "buildName", + "type": "string", + "required": true, + "json_path": "/checks/buildName", + "maxLength": 1024, + "description": "Target build name the check reports on. Mandatory, non-blank. Part of the uniqueness key." + }, + { + "name": "projectName", + "type": "string", + "required": true, + "json_path": "/checks/projectName", + "maxLength": 256, + "description": "Target project name. Mandatory, non-blank. Must resolve to an EXISTING project in the workspace, or the write is refused as project-not-found." + }, + { + "name": "branchName", + "type": "string", + "required": false, + "json_path": "/checks/branchName", + "maxLength": 256, + "description": "Branch to scope the check to (optional)." + }, + { + "name": "buildTags", + "type": "string", + "required": false, + "json_path": "/checks/buildTags", + "maxLength": 256, + "description": "Build tags used to select the build (optional)." + }, + { + "name": "commitCheck", + "type": "boolean", + "required": false, + "json_path": "/checks/commitCheck", + "description": "Whether the check reports per-commit (optional)." + }, + { + "name": "isEnabled", + "type": "boolean", + "required": false, + "json_path": "/checks/isEnabled", + "description": "Whether the check is active (optional)." + } + ] + } + ], + "returns": [ + "repoName", + "ownerName", + "checks" + ], + "responses": { + "201": { + "$response": "CheckConfigurationResponse" + }, + "200": { + "$response": "CheckConfigurationResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement; without it the write is refused as feature-inaccessible.", + "Success is HTTP 201, but the SAME route returns HTTP 200 when nothing was actually saved (empty or all-dropped) \u2014 check the returned checks list, not just the status code.", + "At most 10 checks per repository IN TOTAL (existing + new), not just 10 per request; exceeding it is a 400 'Maximum 10 checks per repository'.", + "splitRepos is required despite reading like a flag \u2014 omit it and the call is a 400.", + "projectName must name a project that already exists in the workspace, otherwise the whole request fails with project-not-found.", + "Uniqueness is (checkName, repo, group, buildName, project); a duplicate collides on that key.", + "Checks are stored against the caller's group and user, so only you will see and be able to manage them afterwards." + ] + }, + { + "name": "update_check_configuration", + "entity": "check_config", + "method": "PUT", + "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}", + "mode": "write", + "intent": "Edit an existing check-run configuration \u2014 its name, check type, tool, target project/build name, branch, tags, commit-check or enabled flag.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name." + }, + { + "name": "checkId", + "type": "string", + "required": true, + "description": "Numeric check-configuration id (as a string in the path). A non-numeric value is a 400 'Invalid check ID format'." + } + ], + "body": [ + { + "name": "checkName", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Check name. Mandatory, non-blank." + }, + { + "name": "checkType", + "type": "string", + "required": true, + "values": [ + "QG_STATUS", + "BUILD_STATUS" + ], + "description": "QG_STATUS or BUILD_STATUS. Mandatory." + }, + { + "name": "tool", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Source tool. Mandatory, non-blank." + }, + { + "name": "splitRepos", + "type": "boolean", + "required": true, + "json_path": "/splitRepos", + "description": "Split-repos flag. Mandatory (@NotNull)." + }, + { + "name": "buildName", + "type": "string", + "required": true, + "json_path": "/buildName", + "maxLength": 1024, + "description": "Target build name. Mandatory, non-blank." + }, + { + "name": "projectName", + "type": "string", + "required": true, + "json_path": "/projectName", + "maxLength": 256, + "description": "Target project name. Mandatory, non-blank; must resolve to an existing project or the update is refused as project-not-found." + }, + { + "name": "branchName", + "type": "string", + "required": false, + "json_path": "/branchName", + "maxLength": 256, + "description": "Branch scope (optional)." + }, + { + "name": "buildTags", + "type": "string", + "required": false, + "json_path": "/buildTags", + "maxLength": 256, + "description": "Build tags (optional)." + }, + { + "name": "commitCheck", + "type": "boolean", + "required": false, + "json_path": "/commitCheck", + "description": "Per-commit reporting flag (optional)." + }, + { + "name": "isEnabled", + "type": "boolean", + "required": false, + "json_path": "/isEnabled", + "description": "Enabled flag (optional)." + } + ], + "returns": [ + "checkId", + "checkName", + "repoName", + "vcsOrgName", + "vcsName", + "checkType", + "splitRepos", + "projectId", + "projectName", + "branchName", + "buildName", + "buildTags", + "groupId", + "userId", + "createdAt", + "updatedAt", + "commitCheck", + "isEnabled" + ], + "responses": { + "200": { + "$response": "CheckConfigurationItemResponseWrap" + }, + "204": { + "description": "Empty body. Returned when the service resolves no updatable check (used INSTEAD of a 404) \u2014 the caller cannot tell 'not found' from 'no-op' by status alone." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This is a FULL replace, not a patch: all mandatory fields (checkName, checkType, tool, splitRepos, buildName, projectName) must be present on every update, so read the check first and echo unchanged values back.", + "Gated by CHECKS_EVALUATION; scoped to the caller's group and user \u2014 you cannot edit a teammate's check.", + "A missing/foreign check comes back as HTTP 204 with an empty body, NOT a 404 \u2014 do not read 204 as success without re-reading.", + "A non-numeric checkId is a 400 'Invalid check ID format'; projectName must resolve to an existing project.", + "To only flip the enabled flag, prefer toggle_check_configuration \u2014 it takes a single query param instead of the whole body." + ] + }, + { + "name": "toggle_check_configuration", + "entity": "check_config", + "method": "PATCH", + "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}/toggle", + "mode": "write", + "intent": "Enable or disable a single check-run configuration without editing the rest of it \u2014 the quick on/off switch in the repo's checks settings.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name." + }, + { + "name": "checkId", + "type": "string", + "required": true, + "description": "Numeric check-configuration id (string in path); non-numeric is a 400 'Invalid check ID format'." + } + ], + "query": [ + { + "name": "isEnabled", + "type": "boolean", + "required": true, + "description": "New enabled state. This is a REQUIRED QUERY parameter, not a body field \u2014 omitting it is a 400 for a missing parameter." + } + ], + "returns": [ + "checkId", + "checkName", + "repoName", + "vcsOrgName", + "vcsName", + "checkType", + "splitRepos", + "projectId", + "projectName", + "branchName", + "buildName", + "buildTags", + "groupId", + "userId", + "createdAt", + "updatedAt", + "commitCheck", + "isEnabled" + ], + "responses": { + "200": { + "$response": "CheckConfigurationItemResponseWrap" + }, + "404": { + "description": "Empty body. Returned when no check matches the id for the caller's group/user." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "isEnabled is a REQUIRED QUERY parameter (?isEnabled=true|false), not a body field \u2014 this is the most common mistake on this route.", + "Gated by CHECKS_EVALUATION and scoped to the caller's group and user.", + "A check that is not found comes back as HTTP 404 with an EMPTY body (unlike update, which uses 204 for the same case).", + "On success it returns the full updated check configuration, so you can confirm the new isEnabled value from the body." + ] + }, + { + "name": "update_view", + "entity": "view", + "method": "PUT", + "path": "/api/v1/views/{viewType}/{id}", + "mode": "write", + "intent": "Rename a saved view, replace its stored filter query, or change its private/public visibility.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view belongs to; sets the type used for the name-uniqueness check. An unrecognised value is a 400." + }, + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "View UUID. A view that does not exist is a 400 'View does not exists', not a 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "json_path": "/name", + "description": "New name. Omit to keep the stored name. Despite @NotNull on the model, an omitted field falls back to the stored value (partial update). A clash returns VIEW_NAME_EXISTS." + }, + { + "name": "query", + "type": "string", + "required": false, + "json_path": "/query", + "description": "New serialised filter. Omit to keep the stored query." + }, + { + "name": "level", + "type": "integer", + "required": false, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "New visibility (1 = private, 2 = public). Omit to keep the stored level." + } + ], + "returns": [], + "responses": { + "200": { + "description": "Empty body \u2014 the updated view is NOT echoed back (unlike create_view). Re-list to see the result." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Requires the manage-custom-views permission (BUILD_MANAGE_VIEWS).", + "PARTIAL update despite being a PUT: omitted name/query/level fall back to the view's stored values.", + "For a PRIVATE view the caller must be the owner ('Not allowed to update this view'); the SQL also restricts updates to your own views OR public views in your group. A non-existent id is a 400 'View does not exists', not a 404.", + "A name clash returns VIEW_NAME_EXISTS (per screen type: within group for public, within creator for private).", + "Returns an empty 200 body on success \u2014 do not expect the updated view back; re-list to confirm.", + "There is no revision history: an overwritten name, query or level cannot be restored." + ] + }, + { + "name": "list_views", + "entity": "view", + "method": "GET", + "path": "/ext/v1/views/{viewType}", + "mode": "read", + "intent": "External/public API (used by the Atlas agent) to list the caller's saved views for a screen, so an integration can pick a saved view instead of rebuilding a filter query.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the views belong to. An unrecognised value is a 400." + } + ], + "query": [], + "returns": [ + "id", + "type", + "query", + "level", + "name", + "description", + "createdBy", + "groupId" + ], + "responses": { + "200": { + "$response": "ViewListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: authenticated with a BrowserStack username + access key over HTTP Basic, not the internal session token.", + "Returns a bare JSON ARRAY of view objects, scoped to the caller's user/group; not paginated. No public-link-token path here (unlike the internal list).", + "Thin adapter over the same view service as the internal list \u2014 results are the same set for the same caller. There is intentionally NO external delete operation.", + "An unrecognised viewType is a 400; valid values are builds, testRuns, testHealth, uniqueErrors, buildInsights." + ] + }, + { + "name": "create_view", + "entity": "view", + "method": "POST", + "path": "/ext/v1/views/{viewType}", + "mode": "write", + "intent": "External/public API to save a filter query as a named view (private or shared with the org) for a screen.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view is for; sets the view's type. An unrecognised value is a 400." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/name", + "minLength": 1, + "maxLength": 100, + "description": "View name. Enforced at this public boundary to be non-blank and at most 100 characters (stricter than the internal surface). Must be unique per screen type within the group (public) or creator (private)." + }, + { + "name": "query", + "type": "string", + "required": true, + "json_path": "/query", + "minLength": 1, + "maxLength": 4000, + "description": "Serialised filter to store, as a string. Enforced non-blank and at most 4000 characters here." + }, + { + "name": "level", + "type": "integer", + "required": true, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = shared with the org. Required; any other value is rejected with 'level must be 1 (private) or 2 (shared)'." + } + ], + "returns": [ + "id", + "type", + "query", + "level", + "name", + "description", + "createdBy", + "groupId" + ], + "responses": { + "200": { + "$response": "ViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: username + access-key HTTP Basic auth. Requires the manage-custom-views permission (works for both globalRBAC-on and -off orgs).", + "Stricter validation than the internal create: name must be non-blank and <=100 chars, query non-blank and <=4000 chars, level exactly 1 or 2 \u2014 each violation is its own 400 message.", + "type is set from the path and id/createdBy/groupId in the body are ignored; the creator and group come from the authenticated caller.", + "Name uniqueness is per screen type (group for public, creator for private); a clash returns VIEW_NAME_EXISTS.", + "Returns the stored view (with its UUID id) as a single object." + ] + }, + { + "name": "replace_view", + "entity": "view", + "method": "PUT", + "path": "/ext/v1/views/{viewType}/{id}", + "mode": "write", + "intent": "Rename a saved view or replace its stored filter query and visibility level. Name, query and level are all REQUIRED here \u2014 this replaces the view rather than patching it, so send the whole object; update_view is the partial-update route.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view belongs to; used for the name-uniqueness check and the ownership guard. An unrecognised value is a 400." + }, + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "View UUID. A non-UUID is a 400 'Invalid view id.'; an id not in the caller's own scoped view list is a 400 'View does not exist.' (closes a cross-tenant existence oracle)." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/name", + "minLength": 1, + "maxLength": 100, + "description": "View name. Validated non-blank and <=100 chars at this boundary." + }, + { + "name": "query", + "type": "string", + "required": true, + "json_path": "/query", + "minLength": 1, + "maxLength": 4000, + "description": "Serialised filter to store. Validated non-blank and <=4000 chars." + }, + { + "name": "level", + "type": "integer", + "required": true, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = shared. Required; other values are a 400." + } + ], + "returns": [], + "responses": { + "200": { + "description": "Empty body on success \u2014 the updated view is not echoed back." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: username + access-key HTTP Basic auth, plus the manage-custom-views permission.", + "Unlike the internal update, this validates the payload the same way create does (name <=100, query <=4000, level 1|2) \u2014 all three are effectively required here.", + "Explicit ownership guard: the id must appear in the caller's own scoped view list, otherwise a 400 'View does not exist.' \u2014 this deliberately hides whether some other tenant's view id exists. A non-UUID is a 400 'Invalid view id.'.", + "Returns an empty 200 body; re-list to confirm. No revision history, so overwritten values cannot be restored." + ] + }, + { + "name": "list_dashboard_views", + "method": "GET", + "path": "/api/v1/dashboards/views", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the dashboard views the current user can see (their own private ones plus group/shared ones) with name, visibility level, widget count and owner \u2014 the dashboard picker / dashboard list.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED even though this listing does not use the project to filter results \u2014 the controller resolves a project before every handler and 400s with 'One of `project_id`, `project_name` or `project_identifier` is required' when none is given." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which dashboard surface to resolve the project against. TO = Test Observability, TCM = Test Management." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "Dashboard views the caller can see, as a flat list under `data` (no success flag on this route).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "DashboardView" + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A project query param is mandatory here purely to satisfy the controller-wide project resolver; the returned list is not scoped by it, so passing any project the caller can access is enough.", + "The response is a bare `{data: [...]}` envelope with no `success` flag and no pagination \u2014 the whole visible set comes back in one call.", + "Each row carries the dashboard UUID under `data[].id`; carry that forward to get/update/delete/clone. The built-in views may or may not appear here, so address those by their `default` / `automation-trend` aliases on the read call rather than expecting them in this list." + ] + }, + { + "name": "create_dashboard_view", + "method": "POST", + "path": "/api/v1/dashboards/views", + "mode": "write", + "entity": "dashboard_view", + "intent": "Create a new custom dashboard view \u2014 its name, visibility level, global filters and widget layout \u2014 for the Test Observability (TO) or Test Management (TCM) dashboard page.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which surface the dashboard belongs to. TO = Test Observability, TCM = Test Management." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it both resolves the project and is the resource the DASHBOARD_CREATE permission check runs against." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "Dashboard name, 1-100 characters. Must be unique within the project \u2014 a duplicate name is rejected. Not annotated @NotNull, but a dashboard is expected to carry a name." + }, + { + "name": "level", + "type": "integer", + "default": 2, + "description": "Visibility level. Defaults to 2 (group / shared). Controls who else can see the dashboard." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Optional description. Capped at 100 characters (the validation message misstates the cap as 200)." + }, + { + "name": "filters", + "type": "object", + "description": "Global dashboard filters (DashboardFilters): dateRange, buildTags, tags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields. Applied across all widgets on the dashboard.", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Default time window for the dashboard (a DateRangeFilter with a `key` such as days30, or a custom range)." + }, + { + "name": "projects", + "type": "array", + "description": "Project ids the dashboard spans." + }, + { + "name": "buildName", + "type": "array", + "description": "Build-name filter values (wire key is `buildName`, not `buildNames`)." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names." + }, + { + "name": "tcmTestPlanIds", + "type": "array", + "description": "Test Plan ids (wire key `tcmTestPlanIds`; legacy `testPlanIds` is accepted as an alias)." + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "The widget layout to save. Each item is a Widgets object; see the widget-data capability for the per-widget field shape (id, name, type, globalFilters, desc, config, layout).", + "fields": [ + { + "name": "id", + "type": "string", + "description": "Client widget id, up to 100 chars." + }, + { + "name": "name", + "type": "string", + "description": "Widget title, 1-100 chars." + }, + { + "name": "type", + "type": "string", + "description": "Widget type (WidgetTypeEnum)." + }, + { + "name": "globalFilters", + "type": "object", + "description": "Per-widget filter overrides (DashboardFilters shape)." + }, + { + "name": "config", + "type": "object", + "description": "Widget configuration (display type, segments, filters, etc.)." + }, + { + "name": "layout", + "type": "object", + "description": "Grid position/size of the widget." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The created dashboard is returned under `data`, with the assigned UUID at `data.id` \u2014 take that for subsequent get/update/delete/clone.", + "Name must be unique in the project; a clash is rejected rather than silently de-duplicated.", + "`level` defaults to 2 (group/shared); set it explicitly if you need a private dashboard.", + "`source` (TO vs TCM) decides which dashboard surface the view belongs to and is not part of the body \u2014 pass it as a query param (defaults to TO)." + ] + }, + { + "name": "get_dashboard_view", + "method": "GET", + "path": "/api/v1/dashboards/views/{id}", + "mode": "read", + "entity": "dashboard_view", + "intent": "Load one dashboard view for rendering \u2014 its name, global filters and full widget configuration, with filter values injected for the built-in dashboards.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "A dashboard UUID, OR one of the read-only aliases `default` / `automation-trend`. `default` resolves to the TCM view when source=TCM and to the TO 'testing trends' view when source=TO; `automation-trend` resolves to the automation-trend view. Custom (non-built-in) UUIDs are scoped to the caller's user/group and read as not-found if they belong to someone else." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "TO or TCM. Also disambiguates which view the `default` alias resolves to." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide project resolver)." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SIDE EFFECT: reading a dashboard pushes it onto the caller's Redis 'recent views' list (capped at the most recent few), which is what the home recent-views listing returns. This write happens for ordinary users and is skipped only for super-users (except super-users in group 2). There is no read that avoids it.", + "For the built-in default dashboard the response is doctored: its date range is forced to the last 30 days regardless of what was stored, and widget filter values are injected \u2014 so what you read back is not necessarily what was persisted.", + "The aliases `default` and `automation-trend` work ONLY here (and other reads). Update, delete and clone-source require the raw UUID and reject the aliases.", + "The dashboard is under `data`; `data.isDefault` / `data.isAutomation` flag the built-in views, and `data.totalWidget` is the widget count." + ] + }, + { + "name": "update_dashboard_view", + "method": "PUT", + "path": "/api/v1/dashboards/views/{id}", + "mode": "write", + "entity": "dashboard_view", + "intent": "Save changes to an existing dashboard view \u2014 rename it, change its visibility level, change its global filters, or persist an edited widget layout.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The dashboard UUID (raw UUID only \u2014 the `default` / `automation-trend` aliases are NOT accepted on this route). The update is additionally scoped to the caller's user/group." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_UPDATE permission check." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "New name, 1-100 characters. Must stay unique within the project." + }, + { + "name": "level", + "type": "integer", + "description": "New visibility level." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "New description (capped at 100 characters)." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters (DashboardFilters shape \u2014 same as create)." + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget layout (array of Widgets objects \u2014 same shape as create)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "NO UNDO: dashboards keep no version history, so an overwritten name, filter set or widget layout is gone for good \u2014 read the current dashboard first if you need to preserve anything.", + "The three built-in dashboards (default TCM, default TO/testing-trends, automation-trend) cannot be updated \u2014 the call returns 400 'Cannot update default dashboard'.", + "The body replaces the fields it carries; send the full intended state (e.g. the complete widgets array) rather than a partial patch.", + "The path takes a raw UUID only. Unlike the read, `default` / `automation-trend` are not accepted here." + ] + }, + { + "name": "clone_dashboard_view", + "method": "POST", + "path": "/api/v1/dashboards/views/{id}/clone", + "mode": "write", + "entity": "dashboard_view", + "intent": "Duplicate an existing dashboard \u2014 copy its filters, widgets, source and type into a new dashboard owned by the caller, under a new name.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "UUID of the SOURCE dashboard to copy (raw UUID only \u2014 aliases not accepted). The source is read with the caller's permission, so you can only clone a dashboard you can see." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_CREATE permission check." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "Name for the NEW cloned dashboard, 1-100 chars, unique within the project. This is the only field of the body that is used \u2014 level, filters and widgets are copied verbatim from the source, not taken from the body." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only `name` from the body matters. Any level/filters/widgets you send are ignored \u2014 the clone copies the source dashboard's filters, widgets, source and type verbatim, so edit the copy afterwards to change them.", + "The new name must be unique in the project; a duplicate is rejected.", + "The clone is owned by the calling user; the new dashboard (with its own UUID at `data.id`) is returned synchronously in `data`.", + "The source id must be a raw UUID \u2014 the `default` / `automation-trend` aliases are not accepted here even though the read accepts them." + ] + }, + { + "name": "list_dashboard_view_widget_types", + "method": "GET", + "path": "/api/v1/dashboards/views/widgets", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the catalogue of widget types that can be added to a dashboard view (the 'add widget' palette) with their display names.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED by the controller-wide resolver even though this static catalogue does not use it." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "name", + "key" + ], + "responses": { + "200": { + "description": "Static catalogue of addable widget types, returned as a BARE JSON ARRAY (no envelope).", + "schema": { + "type": "array", + "items": { + "$schema": "WidgetCatalogueEntry" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a bare array `[{name, key}]`, not wrapped in `data` or `success`.", + "This is static, hard-coded data (Test Case Statistics, Test Case Type, Test Case Trend) \u2014 it makes no DB or downstream call and does not vary by project, despite the mandatory project query param.", + "`key` is the WidgetTypeEnum value to send as a widget's `type` when building a dashboard layout or requesting widget data." + ] + }, + { + "name": "list_dashboard_view_custom_field_filters", + "method": "GET", + "path": "/api/v1/dashboards/views/filters/{widgetType}", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the project's custom (user-defined) test-case fields that can be used as filters when configuring a test-case dashboard widget.", + "path_params": [ + { + "name": "widgetType", + "type": "string", + "required": true, + "values": [ + "TEST_CASE_TREND", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE" + ], + "description": "A WidgetTypeEnum value. Only TEST_CASE_TREND, TEST_CASE_STATISTICS_CARD and TEST_CASE_TYPE are valid here; any other widget type returns 400 'Custom Filters are not available for widget type: X'." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide resolver); the custom fields returned are this project's." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "id", + "label", + "type", + "fieldType" + ], + "responses": { + "200": { + "description": "Custom-field filters for the project, as a BARE JSON ARRAY (no envelope).", + "schema": { + "type": "array", + "items": { + "$schema": "CustomFieldFilterDetail" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only the three test-case widget types are accepted; anything else is a 400 naming the widget type, not an empty list.", + "The response is a bare array `[{id, label, type, fieldType}]` \u2014 each entry is a custom field usable as a widget filter (type is 'Custom').", + "Distinct from the group-level `/api/v1/dashboards/filters/{widgetType}` route; this one is scoped to a single dashboard-view project." + ] + }, + { + "name": "list_dashboard_widget_filter_values", + "method": "POST", + "path": "/api/v1/dashboards/views/widgets/filters", + "mode": "read", + "entity": "dashboard_view", + "intent": "Fetch the selectable values for one filter inside a dashboard widget's filter editor (the type-ahead / value picker) \u2014 e.g. the build names, tags, users, teams or test-case field values available for a project, given the currently applied filters.", + "query": [ + { + "name": "page", + "type": "integer", + "required": false, + "default": 1, + "description": "Page number for the value list. Note the param is `page` (not `p`)." + }, + { + "name": "per_page", + "type": "integer", + "required": false, + "default": 30, + "description": "Values per page (default 30). No server-side upper bound was found in the resolved code \u2014 see guidance." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Type-ahead substring to narrow the returned values." + }, + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "TO or TCM. With source=TCM the request is forced to includeManualAndAutomated=true." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED. The resolved project OVERWRITES whatever `filters.projects` is in the body." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "widgetKey", + "type": "string", + "required": true, + "description": "REQUIRED (@NotNull). The widget type (WidgetTypeEnum) the filter belongs to. Note the wire key is `widgetKey`, not `widget` or `type`. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "json_path": "/widgetKey", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + }, + { + "name": "filterKey", + "type": "string", + "required": true, + "values": [ + "buildStatus", + "buildName", + "users", + "teams", + "frameworks", + "folders", + "buildTags", + "hostName", + "os", + "browser", + "device", + "tags", + "branches", + "projects", + "testCaseRequirements", + "testCasePriorities", + "testCaseStatuses", + "testCaseTypes", + "testCaseTags", + "testCaseOwners", + "testCaseAutomationStatuses", + "testCaseFolders", + "buildTypes", + "buildOwners", + "workflowStatus", + "custom", + "customFields", + "issueType", + "testCaseAutomationStates" + ], + "description": "REQUIRED (@NotNull). Which filter's values to fetch. This is the field that selects the value set (the server switches on filterKey, not on widgetKey). filterKey=buildName additionally turns on selective project filtering.", + "json_path": "/filterKey" + }, + { + "name": "filters", + "type": "object", + "description": "The currently applied filters (WidgetFiltersValue) used to scope the value list. Optional \u2014 an omitted/null value is treated as empty. Its `projects` is always overwritten by the project resolved from the query params, so setting it in the body is pointless.", + "json_path": "/filters" + }, + { + "name": "filterId", + "type": "integer", + "required": false, + "description": "Optional custom-field id, used when filterKey targets a specific custom field.", + "json_path": "/filterId" + }, + { + "name": "applyDateRange", + "type": "boolean", + "required": false, + "default": false, + "description": "Opt in to bounding the value list by `filters.dateRange`. Defaults to false, i.e. the full value list is returned regardless of date range.", + "json_path": "/applyDateRange" + } + ], + "returns": [ + "data", + "fullList", + "info", + "suggestedAfterKey", + "othersAfterKey" + ], + "responses": { + "200": { + "$response": "WidgetFilterValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but READ-ONLY (@ReadOnly) \u2014 it fetches filter values and changes nothing.", + "The value set is chosen by `filterKey`, and `widgetKey` must also be present (both are @NotNull); the two are separate fields and the server dispatches on filterKey.", + "Pagination params are `page` and `per_page` (default 30), NOT `p`/`count`. `data` is a map keyed by bucket (`suggested`, `others`), each a list of value structs - the same shape as search_widget_filter_values. `fullList` and `info` can be null; continue with suggestedAfterKey / othersAfterKey.", + "`filters.projects` is always replaced by the project from the query params, so passing project ids in the body is ignored \u2014 set the project via project_id/project_name/project_identifier.", + "A public-link token may be supplied via the `Public-Link-Token` (or the observability public-link-token) header to run in shared read-only dashboard mode.", + "With source=TCM the request is forced to include both manual and automated runs." + ] + }, + { + "name": "check_dashboard_widget_ignored_filters", + "method": "POST", + "path": "/api/v1/dashboards/views/widgets/filters/ignored", + "mode": "read", + "entity": "dashboard_view", + "intent": "Validate a widget's saved test-case filter values against the current project and report which are no longer valid \u2014 so stale filter chips (a deleted tag, priority, status, type or linked requirement) can be flagged or dropped.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED; the saved filter values are validated against this project." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "testCaseRequirements", + "type": "object", + "description": "Linked-requirement filter to validate. Only `issueIds` (array) and `issueType` are read.", + "fields": [ + { + "name": "issueIds", + "type": "array", + "description": "Requirement/issue ids to validate." + }, + { + "name": "issueType", + "type": "string", + "description": "Requirement issue type." + } + ] + }, + { + "name": "testCasePriorities", + "type": "array", + "description": "Saved test-case priority ids to validate." + }, + { + "name": "testCaseTags", + "type": "array", + "description": "Saved test-case tag values to validate." + }, + { + "name": "testCaseStatuses", + "type": "array", + "description": "Saved test-case status ids to validate." + }, + { + "name": "testCaseTypes", + "type": "array", + "description": "Saved test-case type ids to validate." + } + ], + "returns": [ + "filterMatches", + "ignoredFiltersCount", + "isMismatched" + ], + "responses": { + "200": { + "$response": "IgnoredFilterValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only \u2014 a pass-through to TestHub that computes validity and stores nothing.", + "The body is a full WidgetFiltersValue object, but ONLY five fields are actually read: testCaseRequirements (issueIds + issueType), testCasePriorities, testCaseTags, testCaseStatuses and testCaseTypes. Every other filter field you send is silently ignored here.", + "The response reports, per filter type, how many values still match vs are ignored (`filterMatches[]` with matchedValuesCount / ignoredValuesCount / isMismatched), a total `ignoredFiltersCount`, and an overall `isMismatched` flag \u2014 use these to flag or drop stale chips." + ] + }, + { + "name": "get_dashboard_view_widget_data", + "method": "POST", + "path": "/api/v1/dashboards/views/{dashboardId}/widgets/{widgetType}", + "mode": "read", + "entity": "dashboard_view", + "intent": "Compute the data for one widget on a project-scoped dashboard view \u2014 applying the caller's global filters and time window \u2014 to render that widget (charts, stat cards, trends, etc.).", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "The dashboard UUID. Present in the path for routing but NOT read by the handler \u2014 the widget is computed purely from the body and query params." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "A WidgetTypeEnum value identifying which widget to compute. FLAKINESS, ALWAYS_FAILING and NEW_FAILURES are plan-gated and return FEATURE_INACCESSIBLE if the group lacks the feature. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": true, + "values": [ + "TO", + "TCM" + ], + "description": "REQUIRED on this route (unlike the controller default): omitting it 400s with '`source` is required'. With source=TCM, TCM build widgets are forced to include both manual and automated runs." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED on this route. The resolved project id is injected into the widget's filters automatically (the TestHub project id for ClickHouse-powered widgets, the observability project id otherwise) \u2014 do not pass project ids in the body." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "isThProject", + "type": "boolean", + "required": false, + "default": false, + "description": "Treat the supplied project identifier as a TestHub project id." + }, + { + "name": "lowerBound", + "type": "integer", + "required": false, + "description": "Start of the time window, epoch milliseconds. Optional \u2014 when omitted the window is derived from the body's dateRange." + }, + { + "name": "upperBound", + "type": "integer", + "required": false, + "description": "End of the time window, epoch milliseconds. Optional \u2014 derived from the body's dateRange when omitted." + }, + { + "name": "pageNumber", + "type": "integer", + "required": false, + "description": "Optional page number for paginated widget data; copied into the widget's global filters." + } + ], + "body": [ + { + "name": "type", + "type": "string", + "description": "Widget type (WidgetTypeEnum). Describes the widget being computed." + }, + { + "name": "globalFilters", + "type": "object", + "description": "The applied dashboard filters (DashboardFilters): dateRange (drives the time window unless lowerBound/upperBound are given), tags, buildName, folders, tcmTestPlanIds, match, etc. Its `projects` is overwritten server-side with the resolved project.", + "json_path": "/globalFilters", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Time window used when lowerBound/upperBound query params are absent." + } + ] + }, + { + "name": "id", + "type": "string", + "description": "Widget id (up to 100 chars)." + }, + { + "name": "name", + "type": "string", + "description": "Widget name (1-100 chars)." + }, + { + "name": "desc", + "type": "string", + "description": "Widget description (up to 200 chars)." + }, + { + "name": "config", + "type": "object", + "description": "Widget configuration (display type, segments, per-widget filters, stats options, etc.) that shapes the computed data." + }, + { + "name": "layout", + "type": "object", + "description": "Grid layout of the widget (not needed to compute data)." + } + ], + "returns": [ + "(widget-type-specific)" + ], + "responses": { + "200": { + "$response": "WidgetDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but READ-ONLY (@ReadOnly, routed to a read replica) \u2014 it computes and returns widget data and persists nothing.", + "The response SHAPE depends entirely on `widgetType` (the handler returns an untyped object built per widget) \u2014 inspect it per widget type rather than assuming a fixed schema.", + "Unlike the other dashboard routes, BOTH `source` and a project identifier are mandatory here; omitting either 400s from the request binders.", + "The path `dashboardId` is not read \u2014 the widget is defined by the request body and `widgetType`. Do not rely on it selecting a saved widget.", + "Do not put project ids in `globalFilters.projects`; the resolved project (TestHub id for ClickHouse-powered widgets, observability id otherwise) is injected automatically and overrides the body.", + "FLAKINESS, ALWAYS_FAILING and NEW_FAILURES widgets are plan-gated \u2014 a group without the feature gets FEATURE_INACCESSIBLE, not empty data." + ] + }, + { + "name": "create_report", + "method": "POST", + "path": "/api/v1/reports", + "mode": "write", + "entity": "report", + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Report source. Defaults to TO (the end-user builder). TCM is the Test-Management-mirror source and is normally set by the sync path, not here." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Report name. Must be unique for the report's (type, level) combination or the create is rejected." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = group. Omit and the report is created with no level set." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "description": "Report type. Decides which widget list is persisted: a report-type value (in the backend's REPORT_TYPES set) persists reports[]; otherwise widgets[] is persisted. Defaults to 'Dashboard' when omitted, which would persist widgets[] instead." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Description. Max length is 100 characters (the validation message misstates it as 200)." + }, + { + "name": "filters", + "type": "object", + "description": "Global filters saved on the report.", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Date-range filter." + }, + { + "name": "projects", + "type": "array", + "description": "Observability project ids (List)." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build tag names." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names; AND/OR controlled via match['tags']." + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "buildNames", + "type": "array", + "description": "Wire key is buildName (singular).", + "json_path": "/filters/buildName" + }, + { + "name": "branchList", + "type": "array", + "description": "Wire key is branches.", + "json_path": "/filters/branches" + }, + { + "name": "testPlanIds", + "type": "array", + "description": "Wire key is tcmTestPlanIds; the legacy alias testPlanIds is also accepted for old persisted blobs.", + "json_path": "/filters/tcmTestPlanIds" + }, + { + "name": "order", + "type": "array", + "description": "Filter display order. Wire key is filterOrder.", + "json_path": "/filters/filterOrder" + }, + { + "name": "match", + "type": "object", + "description": "Per-filter AND/OR match map." + } + ] + }, + { + "name": "reports", + "type": "array", + "description": "The report widgets, persisted when type is a report-type value. Each item is a widget (see 'widgets' for its shape).", + "fields": [ + { + "name": "id", + "type": "string", + "maxLength": 100 + }, + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + { + "name": "type", + "type": "string", + "description": "WidgetTypeEnum." + }, + { + "name": "reportType", + "type": "string", + "description": "e.g. TABULAR_TEST_RUNS." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget-level global filters. Wire key is globalFilters.", + "json_path": "/reports/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig \u2014 datasets, segments, display type, columns, etc." + }, + { + "name": "layout", + "type": "object" + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "Dashboard-style widgets, persisted only when type is NOT a report-type value. Send reports[] for a report-typed record; send the wrong list for the type and the record saves empty. Same item shape as reports[].", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/widgets/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + } + ], + "intent": "Create a new saved report \u2014 its name, visibility level, global filters and the list of report widgets \u2014 and get back the persisted record.", + "returns": [ + "data" + ], + "guidance": [ + "Name must be unique for the given (type, level); a clash is rejected.", + "Whether the saved widgets come from reports[] or widgets[] depends on type: a report-type value persists reports[], anything else (including the default 'Dashboard') persists widgets[] \u2014 send the wrong list for the type and the report saves with no widgets.", + "Widget-level global filters ride under the key globalFilters (not dashboardFilters); the report-level filter list uses filterOrder / buildName / branches / tcmTestPlanIds on the wire (not their Java names).", + "source defaults to TO; TCM is the Test-Management-mirror source and normally set by the sync path, not here.", + "The new record is stamped with your user, group and team and returned under data with a fresh UUID id.", + "The response echoes the saved record, but `desc` is not echoed back, `widgets` is null for a report-typed record (not an empty array), and filters come back normalised to their wire keys (users, lastExecutedBy, teams, ... are added as empty/null)." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_saved_reports", + "method": "GET", + "path": "/api/v1/reports", + "mode": "read", + "entity": "report", + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which report source to list. TO = end-user reports (default); TCM = reports mirrored from Test Management." + } + ], + "intent": "List the reports visible to you \u2014 your own plus group-level public ones \u2014 for the Reports landing page, with each report's name, level, type, filters and widget config.", + "returns": [ + "data" + ], + "guidance": [ + "Requires the custom-dashboard plan feature; without it the call is refused (feature inaccessible) rather than returning an empty list.", + "This is the report-typed view of the shared report/dashboard store, so dashboard-only filter rewriting is skipped.", + "Returns a flat list under data with NO pagination \u2014 there is no page / per_page on this listing.", + "The report listing is the source of the UUIDs the single-report read, update, delete, clone, dataset and tiles calls need.", + "Rows are summaries: `reports`, `widgets` and `filters` come back null even when totalWidget > 0, and source may be null. Read the report by id (get_report) for its widgets and filters." + ], + "responses": { + "200": { + "$response": "ReportRecordListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report", + "method": "GET", + "path": "/api/v1/reports/{id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The report UUID." + } + ], + "intent": "Load one saved report's full definition \u2014 name, level, filters and widget/report configuration \u2014 to render or edit it.", + "returns": [ + "data" + ], + "guidance": [ + "Scoped to your own group; a report id belonging to another group reads as not found (DASHBOARD_NOT_FOUND).", + "The dashboard-only filter rewrites and the automation-state widget transform are deliberately skipped for reports, so you read back the raw saved definition.", + "The report sits under data; its widgets are under data.reports for a report-typed record (data.widgets for a dashboard-typed one)." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_report", + "method": "PUT", + "path": "/api/v1/reports/{id}", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The report UUID." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "New name; re-checked for (type, level) uniqueness when it changes. Omit to keep the current name." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility 1 = private, 2 = group." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100 + }, + { + "name": "desc", + "type": "string", + "maxLength": 100 + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters. Same shape/wire-key quirks as create (globalFilters, filterOrder, buildName, branches, tcmTestPlanIds). Omit to keep existing filters." + }, + { + "name": "reports", + "type": "array", + "description": "Replacement widget set for a report-typed record. Omit to keep the existing widgets. Item shape as in create.", + "fields": [ + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/reports/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget set for a dashboard-typed record. Omit to keep the existing widgets.", + "fields": [ + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/widgets/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + } + ], + "intent": "Update an existing saved report in place \u2014 rename, change visibility level, change global filters, or replace its widget set.", + "returns": [ + "data" + ], + "guidance": [ + "No version history \u2014 the previous name, filters and widgets are overwritten with no way to restore them (there is no undo).", + "Name uniqueness is re-checked only when the name, type or level changes.", + "Widget replacement is whole-set: report-typed records replace from reports[], dashboard-typed from widgets[]; omit the list and the existing widgets are kept.", + "For a GCD-disabled user, persisted ClickHouse widgets omitted from the payload are merged back in rather than dropped.", + "In the demo-sandbox environment the built-in default report cannot be updated (400).", + "The lookup is scoped to your user and group; a report you cannot see is reported as not found." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "clone_report", + "method": "POST", + "path": "/api/v1/reports/{id}/clone", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The SOURCE report UUID to duplicate." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Name for the copy. Must be unique for the (type, level)." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility for the copy: 1 = private, 2 = group." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100 + }, + { + "name": "desc", + "type": "string", + "maxLength": 100 + } + ], + "intent": "Duplicate an existing report under a new name \u2014 'Make a copy' in the Reports UI \u2014 copying the source report's saved filters and widgets.", + "returns": [ + "data" + ], + "guidance": [ + "The copy takes its filters and widgets from the SOURCE report, not from your body \u2014 the body essentially only supplies the new name and level. A filters/widgets payload here is ignored.", + "Give a name that is unique for the report's (type, level), or the clone is rejected.", + "The source report's widget config is validated first, so a corrupt source is refused rather than copied through.", + "The new report comes back under data with its own new UUID id." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_dataset_rows", + "method": "POST", + "path": "/api/v1/reports/{id}/datasets", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Rows per page." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Bound but not used for dataset computation." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body, not an error. Missing reportType is 400 'Report type is required'." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget global filters. Wire key is globalFilters.", + "json_path": "/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "type", + "type": "string", + "description": "WidgetTypeEnum." + }, + { + "name": "layout", + "type": "object" + }, + { + "name": "id", + "type": "string", + "maxLength": 100 + }, + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100 + } + ], + "intent": "Run a report widget's query and get its paginated result rows \u2014 the actual table data behind a tabular test-runs report tile.", + "returns": [ + "data", + "summary", + "sourceFencingFlags" + ], + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", + "Only reportType TABULAR_TEST_RUNS is implemented; any other type returns a 200 with a null body rather than erroring.", + "The {id} in the path is ignored entirely \u2014 everything comes from the body, so the path id is just a placeholder.", + "Widget global filters are sent under the key globalFilters, NOT dashboardFilters.", + "Rows come back under data with a summary; summary.totalDatasets is -1 (not a real total), so page through using page / per_page and summary.hasNext rather than trusting a count.", + "A validation failure other than a known ObservabilityException is only logged, not surfaced \u2014 a 200 with empty data can mask a silently dropped dataset." + ], + "responses": { + "200": { + "$response": "ReportDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_stat_tiles", + "method": "POST", + "path": "/api/v1/reports/{id}/tiles", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "description": "Bound but IGNORED \u2014 tiles are not paginated." + }, + { + "name": "per_page", + "type": "integer", + "description": "Bound but IGNORED \u2014 tiles are not paginated." + }, + { + "name": "source", + "type": "string", + "values": [ + "TO", + "TCM" + ], + "description": "Bound but not used for tiles." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body. Missing reportType is 400 'Report type is required'." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget global filters. Wire key is globalFilters.", + "json_path": "/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + } + ], + "intent": "Get the summary stat tiles (aggregate KPI numbers) for a report widget \u2014 the headline figures shown above the report table.", + "returns": [ + "data", + "sourceFencingFlags" + ], + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", + "Same body as the dataset call but returns aggregates instead of rows; only TABULAR_TEST_RUNS is implemented, other types return a 200 with a null body.", + "page / per_page / source are accepted and ignored here \u2014 tiles are not paginated.", + "The {id} path variable is unused; the widget definition comes entirely from the body.", + "Tiles come back under data as {meta, value} pairs (totalUniqueTests, flakyTestsCount, newFailureTestsCount, executions counts, \u2026); totalIssuesCount is added only when configuredColumns includes 'openIssues', and activeRunCount / closedRunCount / totalTestCasesCount appear only when present." + ], + "responses": { + "200": { + "$response": "ReportStatTilesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_report_custom_fields", + "method": "GET", + "path": "/api/v1/reports/custom-fields", + "mode": "read", + "entity": "report", + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "default": 300, + "description": "Page size, default 300 (the upstream max, i.e. the whole list in one call). The picker is not meant to paginate; values above 300 are clamped by the upstream, not rejected." + }, + { + "name": "search", + "type": "string", + "description": "Substring filter on field name." + }, + { + "name": "project_ids", + "type": "array", + "description": "Observability project ids (List, converted to TestHub ids for you). Omit for the whole group. Ids that resolve to nothing return an empty list, not an error." + } + ], + "intent": "List the custom-field / column definitions usable as report columns and filters, optionally narrowed to given projects \u2014 feeds the report builder's column picker and resolves stored field ids back to display names.", + "returns": [ + "customFields", + "info" + ], + "guidance": [ + "per_page defaults to 300 (the upstream max) because the picker is meant to load the whole list in one call; requests above 300 are clamped upstream, not rejected.", + "project_ids are observability project ids and are converted to TestHub project ids for you; omit them for the whole group.", + "A saved report stores columns by numeric fieldId \u2014 use this to map those ids back to fieldName. The same field name can appear twice, distinguished only by entityType (TestCase vs TestExecution)." + ], + "responses": { + "200": { + "$response": "ReportCustomFieldsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_report_static_builds", + "method": "GET", + "path": "/api/v1/reports/{projectNormalisedName}/builds", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised NAME (not its id). A report UUID and a project name share the /reports/{...} space, so this route matches only when the segment is a project name." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "default": 10 + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to]. Required (@NotNull @Size(2)); omitted -> 400 'dateRange must not be null'." + }, + { + "name": "buildStatuses", + "type": "array", + "description": "Explicit build display statuses. When present, wins outright (the inherited 'status' is not unioned in)." + }, + { + "name": "status", + "type": "array", + "description": "Interpreted here as build DISPLAY statuses: used only when buildStatuses is empty, and only values that case-insensitively map to a build display status are kept \u2014 the rest are silently ignored." + }, + { + "name": "frameworks", + "type": "array" + }, + { + "name": "buildOwners", + "type": "array", + "description": "User ids (List)." + }, + { + "name": "buildStates", + "type": "array" + }, + { + "name": "buildGroupIds", + "type": "array" + }, + { + "name": "testPlanIds", + "type": "array" + }, + { + "name": "requirements", + "type": "array" + }, + { + "name": "buildName", + "type": "array" + }, + { + "name": "buildIds", + "type": "array" + }, + { + "name": "projects", + "type": "array" + }, + { + "name": "creationType", + "type": "string" + }, + { + "name": "isArchived", + "type": "boolean" + }, + { + "name": "excludeLocked", + "type": "string" + }, + { + "name": "search", + "type": "string" + }, + { + "name": "ordering", + "type": "string" + } + ], + "intent": "List a project's builds that match a report's build-level filter set \u2014 used to populate the build picker / static build scope when building a report against a fixed set of builds.", + "returns": [ + "data", + "summary", + "sourceFencingFlags" + ], + "guidance": [ + "The path segment is the project's normalised NAME, not its id.", + "status is treated as a build DISPLAY status here (the filter object is shared with the stability/performance request): explicit buildStatuses wins; otherwise status values that map to a build display status are back-filled and non-matching values are ignored.", + "Builds come back under data as build rows (not test-run rows), with a summary whose totalDatasets is -1 \u2014 page with page / per_page and read summary.hasNext rather than a total.", + "An empty match returns a 200 with empty data (plus sourceFencingFlags), not a 404." + ], + "responses": { + "200": { + "$response": "ReportDatasetResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_test_stats", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/stats", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, taken from a row of the report table." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence. Defaults to TO (Test Observability); TCM narrows to test-management data." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params: project ids to scope the window." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter: build ids to scope the window." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq filter. On this build-level report, status values that map to a build-display-status are used and non-matching values are ignored; an explicit buildStatuses set takes precedence and is not widened by status." + } + ], + "intent": "Get the headline stats for one test case in the tabular test-runs report - its reliability (failure ratio), average duration, and failed/total occurrence counts over the filtered window - for the detail drawer.", + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "responses": { + "200": { + "description": "Headline stats for the test, or an empty object when the test has no matching runs in the window.", + "schema": { + "$schema": "ReportTestStats" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "When the test has zero matching runs in the window the body is an empty JSON object `{}` - not zeroed stats - so branch on emptiness before reading `reliability`.", + "`reliability` is the failure ratio as a percentage (failed*100/total), `duration` is the average duration, and `failureOccurrence` carries the raw `{failed, total}` counts.", + "Requires the REPORT_VIEW project permission. Filters are read from GetReportsFilterRq query params, not a body, even though sibling report calls post a body.", + "The `globalTestHash` is the test's cross-run hash from a report-table row, not a numeric test id." + ] + }, + { + "name": "list_report_test_runs", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/testsLists", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, from a report-table row." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number. Defaults to 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Page size. Defaults to 10. No server-enforced ceiling was found in the handler - the value is passed through to the backend unclamped." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq status filter (build-display-status resolution as on the stats call)." + } + ], + "intent": "List the individual test runs behind one row of the tabular test-runs report - every execution of that test case in the window, with status, duration, build and settings-version detail - for the drill-down view.", + "returns": [ + "data", + "summary" + ], + "responses": { + "200": { + "description": "The runs for this test, plus a pagination summary. An empty result returns an object with null data and summary.", + "schema": { + "$schema": "TabularTestRunsReportResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are under `data` (each a report row: test/build ids, workflow status, avg duration in ms, build name/tags/number, project id, started-at, smart-tag settings). Paging state is under `summary` - `summary.hasNext` is true when the page came back full, and `summary.totalDatasets` is -1 (not a real total) on this route.", + "When there are no matching runs the body is an object with `data` and `summary` both null, rather than an empty array - check for null before iterating.", + "This call enriches each row with a build settings-version lookup, so it is heavier than a plain list; keep `per_page` modest.", + "`workflowStatus`/`status` are normalised to a known status or the literal 'unknown'.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "get_report_test_trend", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/trend", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, from a report-table row." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq status filter." + } + ], + "intent": "Get the per-run trend series for one test case in the tabular test-runs report - duration per execution plus each point's workflow status and colour, keyed by build - to draw the test's history chart.", + "returns": [ + "keys", + "data" + ], + "responses": { + "200": { + "description": "Trend series for the test.", + "schema": { + "$schema": "ReportTestTrendResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`keys` is the x-axis: build started-at epochs, index-aligned to `data`. `data` is the point series.", + "Each point's `y` is the run duration in MILLISECONDS, `color` is derived from the workflow status, and `buildId`/`buildName`/`creationType` let the chart link a point back to its run.", + "Point fields that are null are omitted (the point serialises non-null only), so do not assume every optional flag is present.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "run_tabular_test_runs_report", + "method": "POST", + "path": "/api/v1/reports/view/{id}/search", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Ignored by the handler - the report is defined by the body, not this id. Any value satisfies the route; a placeholder is fine." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, default 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Page size, default 10. No server-enforced ceiling found in the handler." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "values": [ + "TABULAR_TEST_RUNS" + ], + "description": "Report type. Required (missing/null reportType is a 400 'Report type is required'). Only TABULAR_TEST_RUNS is implemented; any other value falls through the switch and returns a null body with 200." + }, + { + "name": "config", + "type": "object", + "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "reportGlobalFilters", + "type": "object", + "description": "Global filters (WidgetFiltersValue) merged into each dataset before the query runs." + }, + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Dashboard filters block (the DTO field `dashboardFilters` serialises as `globalFilters`); projects/date-range etc." + }, + { + "name": "configuredColumns", + "type": "array", + "description": "Column keys to include; e.g. adding 'openIssues' also surfaces an issues count in the tiles sibling call." + }, + { + "name": "id", + "type": "string", + "maxLength": 100, + "description": "Optional widget/report id echoed for telemetry." + } + ], + "intent": "Run a configured report and return its paginated table - post the full report definition (datasets, segments, global filters) and get back the tabular test-runs result set.", + "returns": [ + "data", + "summary" + ], + "responses": { + "200": { + "description": "The report table page. Null body when reportType is not TABULAR_TEST_RUNS; an object with null data when the definition yields nothing or all datasets conflict.", + "schema": { + "$schema": "TabularTestRunsReportResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", + "This is a read despite being a write-shaped call: the body is the query, not new state, and the route is marked read-only.", + "The `{id}` in the address is never read - the report is defined entirely by the body, so pass any placeholder.", + "`reportType` is required and only TABULAR_TEST_RUNS works; any other type returns a 200 with a NULL body (not an error), so a null response can mean 'unsupported report type', not 'no data'.", + "When every dataset mutually conflicts the body comes back empty rather than erroring; distinct from the null-body case above.", + "Rows are under `data`, paging under `summary`; `summary.totalDatasets` is -1 on this route, use `summary.hasNext` to page.", + "Requires the REPORT_VIEW project permission (public-link-token callers are also accepted for shared reports)." + ] + }, + { + "name": "get_tabular_test_runs_report_tiles", + "method": "POST", + "path": "/api/v1/reports/view/{id}/search/tiles", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Ignored by the handler; any value satisfies the route." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "values": [ + "TABULAR_TEST_RUNS" + ], + "description": "Required (missing is a 400). Only TABULAR_TEST_RUNS is implemented; other types return a null body with 200." + }, + { + "name": "config", + "type": "object", + "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "reportGlobalFilters", + "type": "object", + "description": "Global filters merged into each dataset." + }, + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Dashboard filters block (DTO field `dashboardFilters`)." + }, + { + "name": "configuredColumns", + "type": "array", + "description": "Including 'openIssues' adds a totalIssuesCount tile." + } + ], + "intent": "Get the summary stat tiles that sit above a report's table (totals and aggregates for the same report definition) so the header cards can load independently of the paginated rows.", + "returns": [ + "data", + "sourceFencingFlags" + ], + "responses": { + "200": { + "description": "Stat tiles for the report. When all datasets conflict, or no stats are returned, `data` is empty and only the source-fencing flags are populated.", + "schema": { + "$schema": "ReportStatTilesResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", + "Read-only despite being write-shaped; same body as the table call, but no pagination.", + "Each tile in `data` is `{meta, value}` where `meta` is the tile name (totalUniqueTests, flakyTestsCount, newFailureTestsCount, alwaysFailingTestsCount, totalPassedExecutions, totalFailedExecutions, totalSkippedExecutions, totalUnknownExecutions, grandTotalExecutions) and `value` is a Long count. Note the key is `meta`, not `name`.", + "Extra tiles appear only conditionally: totalIssuesCount only when `configuredColumns` contains 'openIssues'; activeRunCount, closedRunCount and totalTestCasesCount only when the backend returns them.", + "When all datasets conflict, or the backend has no statistics, `data` is empty/absent and `sourceFencingFlags` (all false) is still returned - an empty tiles body is not an error.", + "Non-TABULAR_TEST_RUNS report types return a null body with 200.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "list_widget_filter_values", + "method": "GET", + "path": "/api/v1/dashboards/widgets/filters", + "mode": "read", + "entity": "widget", + "query": [ + { + "name": "key", + "type": "string", + "required": true, + "description": "Which filter's values to list.", + "values": [ + "buildStatus", + "buildName", + "users", + "teams", + "frameworks", + "folders", + "buildTags", + "hostName", + "os", + "browser", + "device", + "tags", + "branches", + "projects", + "testCaseRequirements", + "testCasePriorities", + "testCaseStatuses", + "testCaseTypes", + "testCaseTags", + "testCaseOwners", + "testCaseAutomationStatuses", + "testCaseFolders", + "buildTypes", + "buildOwners", + "workflowStatus", + "custom", + "customFields", + "issueType", + "testCaseAutomationStates" + ] + } + ], + "intent": "Look up the available values for a single widget filter key (build status, users, teams, frameworks, build tags, build name, branches, ...) as a flat list for a widget configuration dropdown.", + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "Flat list of filter values for the key.", + "schema": { + "$schema": "WidgetFilterValueList" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the simple/legacy flat-list variant. For search, paging, and suggested-vs-others buckets, use `search_widget_filter_values` (same path, richer request/response) instead.", + "`data` is a flat array of `{label, value, ...}` structs; there is no paging on this route.", + "Values come from Elasticsearch aggregations with deleted projects excluded.", + "The request binds `key` from query params only - there is no body.", + "Values are account-wide (every visible project), not scoped to one project. Each item is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null for most keys." + ] + }, + { + "name": "search_widget_filter_values", + "method": "POST", + "path": "/api/v1/dashboards/widgets/filters", + "mode": "read", + "entity": "widget", + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, default 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Page size, default 30. No server-enforced ceiling found in the handler." + }, + { + "name": "search", + "type": "string", + "description": "Free-text filter over the candidate values." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO. TCM forces manual+automated inclusion." + } + ], + "body": [ + { + "name": "widgetKey", + "type": "string", + "required": true, + "json_path": "/widgetKey", + "description": "The widget type the filter is being configured for (WidgetTypeEnum). REQUIRED. NOTE the JSON key is `widgetKey`, not `widget` - the DTO field `widget` is renamed on the wire; sending `widget` is dropped and the request fails @NotNull validation. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + }, + { + "name": "filterKey", + "type": "string", + "required": true, + "description": "Which filter's values to fetch (same value set as the GET `key`). REQUIRED (@NotNull)." + }, + { + "name": "filters", + "type": "object", + "description": "Already-applied filters (WidgetFiltersValue: projects, dateRange, ...) so the returned values are context-dependent. Defaults to empty when omitted.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids; for ClickHouse-powered widgets these are rewritten to TestHub project ids server-side." + }, + { + "name": "dateRange", + "type": "object", + "description": "Optional date-range scope; only applied when the widget/caller opts in." + } + ] + } + ], + "intent": "Fetch matching values for a widget filter, split into suggested and others with search and paging - the lookup that powers the dependent dropdowns in a widget's configuration panel.", + "returns": [ + "data", + "fullList", + "info", + "suggestedAfterKey", + "othersAfterKey" + ], + "responses": { + "200": { + "description": "Filter values bucketed by suggested/others, with paging cursors.", + "schema": { + "$schema": "WidgetEnhancedFilterResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read-only despite being a POST; same path as the flat GET variant but a different request and response shape - do not treat them as interchangeable.", + "The widget field is sent as `widgetKey` on the wire, NOT `widget`; both `widgetKey` and `filterKey` are required or the request 400s on validation.", + "`data` is a map keyed by bucket (suggested / others), each a list of value structs; page onward with `suggestedAfterKey` / `othersAfterKey` cursors rather than by page number alone.", + "Behaviour is context-dependent: the TEST_EXECUTION_DISTRIBUTION widget forces only-manual-builds, source=TCM forces manual+automated, and ClickHouse-powered widgets have their project ids translated to TestHub ids first.", + "`customFields` values are restricted to a hardcoded allow-list of group ids per environment, so a custom-field filter may return nothing for a group not on that list.", + "`fullList` and `info` can come back null; rely on suggestedAfterKey / othersAfterKey. Valid widgetKey values are the WidgetTypeEnum constants; filterKey names follow the widget's filter keys (e.g. projects) - an unknown pair is an opaque 400." + ] + }, + { + "name": "get_org_dashboard_widget_data", + "method": "POST", + "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}", + "mode": "read", + "entity": "widget", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "Not read by the handler on this route; the projects come from the body. Any value satisfies the route." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "The widget to compute (WidgetTypeEnum). Determines the response shape. Bound by exact enum constant name; any other value is a 400.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Optional window start (epoch ms). If omitted, the window is resolved from the widget's own date-range filter." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Optional window end (epoch ms)." + } + ], + "body": [ + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Filters block (DTO field `dashboardFilters`, serialised as `globalFilters`). Carries `projects[]` - the org/multi-project scope for the widget. For ClickHouse-powered widgets these project ids are translated to TestHub ids.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids scoping this org-level widget." + }, + { + "name": "dateRange", + "type": "object", + "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." + } + ] + }, + { + "name": "config", + "type": "object", + "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", + "fields": [ + { + "name": "FILTERS", + "type": "object", + "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." + } + ] + }, + { + "name": "type", + "type": "string", + "description": "Widget type enum on the body; the path widgetType is what actually selects the computation." + } + ], + "intent": "Compute the data for one widget on a multi-project (org-level) dashboard, from the widget definition plus the global filters and time window supplied in the body.", + "returns": [ + "(widget-type-specific)" + ], + "responses": { + "200": { + "description": "Widget data. The shape is polymorphic per widget type (line chart, stat card, distribution, table, ...), so it is not a single fixed envelope.", + "schema": { + "type": "object", + "description": "Widget-type-specific payload; read the widgetType you requested to know the shape. Common CH-powered widgets embed a `sourceFencingFlags` object." + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", + "Projects come from the body's `globalFilters.projects` (this is the org-level route), NOT from the address - the `{dashboardId}` is ignored here.", + "The response envelope depends on `widgetType`; there is no single fixed shape, so switch on the type you asked for.", + "For ClickHouse-powered widget types the project ids you send are translated to TestHub ids before the query runs.", + "Special case: group 2 requesting the ACTIVE_BUILDS widget is forced to a 7-day window unless it explicitly asked for 1 day.", + "Unlike the project-view variant of this route, this org-level call does NOT apply the FLAKINESS / ALWAYS_FAILING / NEW_FAILURES feature gate.", + "Read-only despite being a POST." + ] + }, + { + "name": "get_widget_drilldown_records", + "method": "POST", + "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}/drilldown", + "mode": "read", + "entity": "widget", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "Not read by the handler; any value satisfies the route." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "The widget whose data point is being drilled into (WidgetTypeEnum). Bound by exact enum constant name; any other value is a 400.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Optional window start (epoch ms)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Optional window end (epoch ms)." + } + ], + "body": [ + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Filters block (DTO field `dashboardFilters`) carrying the drill-down selection: projects, date range, page number, and drill-down date type.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids for the selection (translated to TestHub ids for CH-powered widgets)." + }, + { + "name": "pageNumber", + "type": "integer", + "description": "Page of drill-down rows." + }, + { + "name": "dateRange", + "type": "object", + "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." + } + ] + }, + { + "name": "config", + "type": "object", + "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", + "fields": [ + { + "name": "FILTERS", + "type": "object", + "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." + } + ] + } + ], + "intent": "Return the underlying row-level records behind a widget data point - the 'click a bar to see the builds/tests it represents' drill-down for a dashboard widget.", + "returns": [ + "data", + "insights", + "isConflicting", + "isEmptyNumerator", + "isEmptyDenominator" + ], + "responses": { + "200": { + "description": "Drill-down rows plus insight and conflict flags.", + "schema": { + "$schema": "WidgetDrillDownData" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", + "There is no separate project-view drill-down route: drilling down from a project view uses THIS route with the projects supplied in the body.", + "`data` rows carry project identity (projectId/thProjectId/projectName/normalisedName) plus widget-specific fields; `insights` is a `{value, meta}` summary and the boolean flags report a conflicting or empty numerator/denominator selection.", + "Same project-id translation for ClickHouse-powered widgets and same TCM manual+automated behaviour as the widget-data call.", + "Read-only despite being a POST; the dashboard id in the address is ignored." + ] + }, + { + "name": "get_parallel_queue_usage_summary", + "method": "GET", + "path": "/api/v1/parallels/queues/usageSummary", + "mode": "read", + "entity": "parallel_queue", + "query": [ + { + "name": "product", + "type": "string", + "description": "Product to report live queue usage for (e.g. automate / app-automate). OPTIONAL: omit it and only the test-execution usage block is returned, with no live parallel-queue fetch." + } + ], + "intent": "Get the top-level parallel-queue summary for the signed-in user's group: plan test-execution usage plus current parallels-in-use and queued sessions for the requested product.", + "returns": [ + "summary", + "testExecutionsUsage" + ], + "responses": { + "200": { + "description": "Group-level queue summary; `summary` present only when a product was supplied.", + "schema": { + "$schema": "ParallelQueueUsageSummary" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only works for JWT/session-token callers - it reads the authenticated user straight from the security context, not from a supplied credential.", + "Omitting `product` returns just the `testExecutionsUsage` block; the live `summary` (parallels/queued) is populated only when a product is given.", + "The internal userDetails block is stripped out before the response is returned.", + "`summary` is a list of per-product SummaryDetails (totalParallelsUsed, maximumParallelsAllowed, totalQueuedSessions, maximumQueueAllowed, with per-pool breakdowns)." + ] + }, + { + "name": "list_parallel_queue_team_usage", + "method": "GET", + "path": "/api/v1/parallels/queues/teams", + "mode": "read", + "entity": "parallel_queue", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product to break down (e.g. automate / app-automate). Required and non-empty, else 400; resolved to a per-product backend endpoint, so an unknown product fails downstream." + } + ], + "intent": "Break the group's parallel-session usage down by team and member - parallels used vs allowed, queued vs queue limit, with per-pool breakdowns - so an admin can see who is consuming the plan's concurrency.", + "returns": [ + "summary", + "teams" + ], + "responses": { + "200": { + "description": "Per-team and per-member concurrency usage, plus a group summary.", + "schema": { + "$schema": "ParallelQueueTeamUsage" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`product` is required and non-empty (empty/missing throws a 400); it is resolved to a per-product backend endpoint, so an unrecognised product fails there, not here.", + "Passes through to the billing backend with the caller's own BrowserStack username/access key, then reshapes and enriches members with user details.", + "`teams[].members[]` carry per-user parallelsUsed / sessionQueued and pool breakdowns; `showRunningBuildsButton` indicates whether a member has running builds to drill into (via `list_user_running_builds`)." + ] + }, + { + "name": "list_user_running_builds", + "method": "GET", + "path": "/api/v1/parallels/queues/running/builds/{userId}", + "mode": "read", + "entity": "parallel_queue", + "path_params": [ + { + "name": "userId", + "type": "integer", + "required": true, + "description": "The user whose currently-running builds to list. Taken from the path and NOT checked against the caller's own group." + } + ], + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "values": [ + "automate", + "app-automate" + ], + "description": "Product to query. Required and non-empty, else 400. Any value other than 'automate' is treated as App Automate." + } + ], + "intent": "Show which builds a given user currently has running for Automate or App Automate, with their projects and how long the oldest has been going - the drill-down from the parallel-queue view to who is holding the parallels.", + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "The user's currently-running builds.", + "schema": { + "$schema": "RunningBuildsResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`userId` is read from the path and is NOT checked against the caller's group, so this can surface another user's running builds.", + "`product` is required; anything other than the exact string 'automate' is treated as App Automate rather than rejected.", + "Backed by an Elasticsearch query (size 10000, locked builds excluded), so it returns up to that many rows without paging.", + "`data` is a list of build summaries (the standard build-summary shape)." + ] + }, + { + "name": "get_test_execution_usage_count", + "method": "GET", + "path": "/api/v1/usage", + "mode": "read", + "entity": "usage", + "query": [ + { + "name": "lowerBound", + "type": "integer", + "required": true, + "description": "Window start (epoch). Required." + }, + { + "name": "upperBound", + "type": "integer", + "required": true, + "description": "Window end (epoch). Required." + }, + { + "name": "userId", + "type": "integer", + "description": "User to count for. Optional. At least one of userId/groupId must be supplied; if both are given, userId wins." + }, + { + "name": "groupId", + "type": "integer", + "description": "Group to count for. Optional. Used only when userId is absent." + } + ], + "intent": "Count test executions reported to Observability in a time window for a user or group, split into total tests and tests that ran on BrowserStack infrastructure - the raw numbers behind usage/consumption reporting.", + "returns": [ + "totalTestCount", + "browserstackTestCount" + ], + "responses": { + "200": { + "description": "Execution counts for the window. When BOTH userId and groupId are missing, the body is instead the plain string 'Missing userId or groupId' with a 200.", + "schema": { + "$schema": "TestExecutionUsageCount" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`userId`/`groupId` are taken straight from the query with NO check that they belong to the caller - any authenticated caller can read another user's or group's counts.", + "Supply at least one of `userId`/`groupId`; if both are present `userId` takes precedence and `groupId` is ignored.", + "When BOTH are omitted the call returns HTTP 200 with the plain-text body `Missing userId or groupId` (not JSON, not a 400) - handle that string before parsing JSON.", + "`totalTestCount` is all tests reported; `browserstackTestCount` is the subset that carry a sessionId (ran on BrowserStack infra).", + "The action name behind this route mentions always-failing tests but that is a copy-paste artefact - this endpoint has nothing to do with always-failing tests." + ] + }, + { + "name": "get_group_plan_usage_details", + "method": "GET", + "path": "/api/v1/usageDetails", + "mode": "read", + "entity": "usage", + "intent": "Load the plan/usage panel: the group's plan info and plan details plus non-BrowserStack test-execution consumption - used vs total executions for the team and the whole group, usage percentages, BrowserStack-run count, and the billing reset date.", + "returns": [ + "planInfo", + "planDetails", + "nonBrowserstackTotalTestExecutions", + "nonBrowserstackUsedTestExecutions", + "nonBrowserstackPercentageOfTestExecutionUsage", + "billingResetDate", + "browserstackUsedTestExecutions", + "totalTestExecutions", + "nonBrowserstackPercentageOfGroupTestExecutionUsage", + "nonBrowserstackGroupTotalTestExecutions" + ], + "responses": { + "200": { + "description": "Plan info/details plus usage figures, all counts as compacted strings. Usage fields are null when the group has no execution limit.", + "schema": { + "$schema": "GroupPlanUsageDetails" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes no parameters; scoping comes from the authenticated session, so this needs a session/JWT caller (it reads the UserDetail from the security context).", + "All the execution counts come back as compacted DISPLAY STRINGS (e.g. '1.2K'), not numbers - do not do arithmetic on them.", + "This returns `planDetails` (not `userDetails`); it is the same underlying data as the usage block of the init call but requested with plan details included.", + "When the group has no execution limit configured, every usage figure is nulled out. Note the null-branch uses the key `nonBrowserstackTestExecutions` where the populated branch uses `nonBrowserstackUsedTestExecutions` - the key set differs between the two shapes.", + "`billingResetDate` is an epoch-millis timestamp." + ] + }, + { + "name": "get_device_tier_usage", + "method": "GET", + "path": "/api/v1/tier/info", + "mode": "read", + "entity": "usage", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product whose device tiers to report (e.g. automate / app-automate). Required and non-empty, else IllegalArgumentException (400)." + } + ], + "intent": "Get the group's mobile device-tier entitlement picture for a product - per tier, the devices in it, the device/parallel limits and how much of each is in use - for the device-tier/queue capacity view.", + "returns": [ + "tierInfo", + "devices" + ], + "responses": { + "200": { + "description": "Aggregated tier usage and device list for the product.", + "schema": { + "$schema": "DeviceTierUsageInfo" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only MOBILE tiers are returned; non-mobile tiers are filtered out.", + "`product` is required and non-empty; missing/empty is a 400 (IllegalArgumentException).", + "Server-side the handler resolves the caller's own BrowserStack username + selenium access key and calls the public backend with them - no extra caller-supplied credential is needed.", + "This is a pass-through aggregation over two backend endpoints (tier usage + devices); `tierInfo` and `devices` are raw JSON trees, so read them defensively." + ] + }, + { + "name": "search_entities", + "method": "POST", + "path": "/api/v1/search", + "mode": "read", + "entity": "search", + "body": [ + { + "name": "query", + "type": "string", + "required": true, + "minLength": 3, + "description": "Free-text query. Must be non-blank and at least 3 characters or the request 400s. ClickHouse dollar-quote tokens ($q$) are stripped from it server-side." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "Build", + "BuildTestCaseExecutionResult", + "Project", + "All" + ], + "description": "Entity type to search. Must be exactly one of the supported values (CASE-SENSITIVE); any other value 400s." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "1-based page. Values <= 0 or absent default to 1." + }, + { + "name": "days", + "type": "integer", + "default": 30, + "description": "Look-back window in days. Defaults to 30." + }, + { + "name": "filters", + "type": "object", + "description": "Per-entity filter block (build / buildTestCaseExecutionResult / project / all). Only recognised sub-fields are forwarded; e.g. build.onlyLocked, build.isArchived, build.userId, test.isFlaky/isMuted/failureCategories, project.userId.", + "fields": [ + { + "name": "build", + "type": "object", + "description": "Build filters: onlyLocked, isCIRun, isArchived, userId, status." + }, + { + "name": "buildTestCaseExecutionResult", + "type": "object", + "description": "Test-run filters: isMuted, isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly, failureCategories[], userId, status." + } + ] + }, + { + "name": "overlay", + "type": "boolean", + "description": "When true, returns the quick-overlay page (size 10) and OMITS pagingParams and totalCount. When false/absent, returns the full page (size 30) with paging and total count." + } + ], + "intent": "Global search across the product: given a free-text query and an entity type, return matching entities (builds, tests, projects) for the search bar or quick overlay, paginated and enriched with user names.", + "returns": [ + "response", + "pagingParams", + "totalCount" + ], + "responses": { + "200": { + "description": "Search results. `pagingParams` and `totalCount` are present only when overlay is off.", + "schema": { + "$schema": "GlobalSearchResult" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`entity` is case-sensitive and must be exactly Build, BuildTestCaseExecutionResult, Project, or All; anything else 400s. `query` must be >= 3 non-blank characters.", + "Sort is fixed to updated_at-desc and cannot be changed by the caller.", + "Page size is NOT caller-controlled: overlay=true fixes it to 10 and omits `pagingParams`/`totalCount`; overlay off fixes it to 30 and includes them. Page through with `pageNumber` and `pagingParams.has_next`.", + "This is a thin pass-through to the backend global-search API; TRA only validates/sanitises the query, fixes the sort, and sets the page size.", + "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id.", + "Keep `days` at or below 90: on a large group, windows of 365 or more returned HTTP 500 'Internal System Exception' instead of results.", + "entity=\"Build\" is the only cross-project way to find a build by NAME or by CI run id: each hit carries `projectName`/`projectNormalizedName`, `uuid`, `serialId` (the #N) and `buildRunId` (the CI run id). Use it when a user names a build or a run number without a project.", + "entity=\"Project\" is unreliable for name lookup (an existing project can return zero hits); find projects by name with list_projects_lite_paginated `search` instead." + ] + }, + { + "name": "create_self_healing_pr", + "entity": "self_healing", + "method": "POST", + "path": "/api/v1/aiHealing/builds/{buildUuid}/createPR", + "mode": "write", + "intent": "Open a GitHub pull request that rewrites broken selectors to their AI-healed values \u2014 pick the healed locators you accept from a build's healing report and ask BrowserStack to raise the PR in the customer's repo.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$", + "description": "The build whose healing report the selectors were chosen from." + } + ], + "body": [ + { + "name": "selectors", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 200, + "description": "The healed selectors to apply. 1..200 items. Each carries `using`/`value` (NOT `type`/`value`) for both the original and healed locator \u2014 note this differs from the verdict endpoint, which uses `type`. There is no `elementIdentifier` here.", + "fields": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The healed-selector id from the healing report; must be non-blank. Used to group files and to derive the head branch name." + }, + { + "name": "originalLocator", + "type": "object", + "required": true, + "description": "The broken locator to find and replace in source.", + "fields": [ + { + "name": "using", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Locator strategy, e.g. xpath / css. Non-blank, max 64 chars." + }, + { + "name": "value", + "type": "string", + "required": true, + "maxLength": 4000, + "description": "The original locator string. Non-blank, max 4000 chars." + } + ] + }, + { + "name": "healedLocator", + "type": "object", + "required": true, + "description": "The replacement locator to write in.", + "fields": [ + { + "name": "using", + "type": "string", + "required": true, + "maxLength": 64 + }, + { + "name": "value", + "type": "string", + "required": true, + "maxLength": 4000 + } + ] + }, + { + "name": "sessionId", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Session the selector broke in. Non-blank, max 64. Distinct sessionIds across the batch must be <= 25 (each triggers one source-file lookup); over that the whole request is rejected." + } + ] + }, + { + "name": "baseBranch", + "type": "string", + "required": false, + "description": "Branch to open the PR against. Optional \u2014 defaults to the build's recorded VCS branch; if neither is present the call fails as missing VCS info." + }, + { + "name": "headBranch", + "type": "string", + "required": false, + "maxLength": 200, + "pattern": "^(?!/)(?!.*//)(?!.*\\.\\.)[A-Za-z0-9._/-]+(?- when blank. Must be a valid git ref: no '..', no leading/trailing '/', no '//'." + }, + { + "name": "repository", + "type": "string", + "required": false, + "maxLength": 200, + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", + "description": "Target repo in `owner/repo` form. Optional \u2014 auto-derived from the build's GitHub origin remote when blank. Auto-derivation is GitHub-only; a GitHub Enterprise / non-github.com host MUST pass this explicitly or the call fails as unparseable remote." + }, + { + "name": "title", + "type": "string", + "required": false, + "minLength": 10, + "maxLength": 200, + "description": "PR title. Optional \u2014 a default is generated. If supplied it must be 10..200 chars; it is prefixed with '[BrowserStack Heal] ' unless it already starts with that." + }, + { + "name": "description", + "type": "string", + "required": false, + "maxLength": 4000, + "description": "PR body. Optional \u2014 a default is generated. Control characters are stripped (newlines/tabs kept)." + } + ], + "returns": [ + "healingPrId", + "status" + ], + "guidance": [ + "Asynchronous: returns HTTP 202 with only `{healingPrId, status:\"IN_PROGRESS\"}`. The PR is NOT open yet \u2014 there is no prUrl in this response. Capture healingPrId and poll get_self_healing_pr_status for the outcome and the PR URL.", + "The job id lives only in Redis with a short TTL and is not listed anywhere, so if you lose it or stop polling in time it is gone \u2014 store it from this response immediately.", + "Preconditions that reject synchronously before any work: an operational kill switch may disable the feature (global or per-group); the AI feature must be within plan/entitlement and the build must not be plan-locked; the build must carry VCS info and resolve to a GitHub owner/repo; at least one selector; <= 25 distinct sessions; and after source-file resolution <= 100 files.", + "Rate limited per user (25/hr) and per group (100/hr). Over-limit calls are rejected; a transient downstream failure refunds the token rather than burning your quota.", + "This is GitHub-only. A non-GitHub origin remote is silently skipped during auto-derivation (you then get an unparseable-remote error) \u2014 pass `repository` explicitly for those.", + "Each selector's locator uses `using`/`value`. Do not send `type` here (that is the verdict endpoint's field); an object missing `using` or `value` fails validation.", + "No API undo: this opens a real PR in the customer's repo. Closing/reverting is done in GitHub, not here.", + "Live 2026-09-30: the healing worker's integration key 'github-scm' is the GitHub 'Source Code View' integration (integrations page \u2192 Source Code View \u2192 GitHub), connected per user. Without it the job FAILS at fetch_files with 401 'Invalid credentials for the integration'. The account-wide GitHub (issues) connection does not satisfy it." + ], + "responses": { + "202": { + "$response": "CreateHealingPrAccepted" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "429": { + "description": "Rate limited (per-user 25/hr or per-group 100/hr) via HEALING_PR_RATE_LIMITED.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_self_healing_pr_status", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/healingPrs/{healingPrId}/status", + "mode": "read", + "intent": "Poll a self-healing PR job started by create_self_healing_pr \u2014 get its in-progress/terminal state and, once ready, the created pull request URL.", + "path_params": [ + { + "name": "healingPrId", + "type": "string", + "required": true, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", + "description": "The job id returned by create_self_healing_pr (strict lowercase 8-4-4-4-12 hex UUID)." + } + ], + "returns": [ + "healingPrId", + "status", + "prUrl", + "prNumber", + "headSha", + "perFileReport", + "errorMessage" + ], + "guidance": [ + "`status` is one of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED. Treat PARTIAL and NO_CHANGES_NEEDED as successful terminal outcomes, not failures.", + "prUrl / prNumber / headSha are null until a terminal, PR-producing state; perFileReport (per-file applied/skipped detail) and errorMessage appear depending on the outcome. Do not expect a PR URL while IN_PROGRESS.", + "Status is stored only in Redis under a group-scoped key with a TTL. An unknown or EXPIRED id returns HEALING_PR_NOT_FOUND \u2014 that is not a terminal failure state, just a lapsed/foreign id.", + "Only visible to the group that initiated the PR." + ], + "responses": { + "200": { + "$response": "HealingPrStatusResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "HEALING_PR_NOT_FOUND \u2014 the id is unknown or its Redis entry expired, or it belongs to another group.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "submit_self_healing_verdict", + "entity": "self_healing", + "method": "POST", + "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", + "mode": "write", + "intent": "Record a reviewer's verdict on one AI-healed locator ('accurate' or 'inaccurate', with optional feedback) to feed the human-in-the-loop healing loop.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$" + } + ], + "body": [ + { + "name": "healedLocator", + "type": "object", + "required": true, + "description": "The healed locator being judged. Note the field is `type` here (xpath|cssPath), NOT `using` as in create_self_healing_pr.", + "fields": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "xpath", + "cssPath" + ], + "description": "Locator strategy; must be exactly 'xpath' or 'cssPath'." + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The healed locator string. Non-blank." + } + ] + }, + { + "name": "verdict", + "type": "string", + "required": true, + "values": [ + "accurate", + "inaccurate" + ], + "description": "The review outcome; must be exactly 'accurate' or 'inaccurate'." + }, + { + "name": "elementIdentifier", + "type": "string", + "required": true, + "description": "Identifier of the element the locator targets; part of the hash that keys the verdict in TCG. Non-blank." + }, + { + "name": "baseUrl", + "type": "string", + "required": false, + "description": "Web automate: the page URL; required for the web hash (baseUrl + elementIdentifier + groupId). Leave null/empty for app automate." + }, + { + "name": "screenIdentifier", + "type": "string", + "required": false, + "description": "App automate only: native screen name used in the app hash (elementIdentifier + groupId + screenIdentifier). Null for web automate." + }, + { + "name": "client", + "type": "string", + "required": false, + "description": "Platform client type: 'app-automate' selects the app hash formula; empty/null means web automate." + }, + { + "name": "feedback", + "type": "object", + "required": false, + "description": "Optional free-form feedback (arbitrary JSON)." + }, + { + "name": "region", + "type": "string", + "required": false, + "maxLength": 64, + "description": "Optional session region code; only a lookup key into the TCG region-URL map (a miss is skipped, not an error). Max 64." + } + ], + "returns": [ + "elementHash", + "healedLocator", + "verdict", + "feedback", + "reviewedByUserId", + "reviewedAt", + "isCarryover", + "carryoverBuildSerialId" + ], + "guidance": [ + "Gated on the human-in-the-loop healing feature per group: when it is OFF this is refused outright (FORBIDDEN_OPERATION) \u2014 unlike list_self_healing_verdicts, which silently returns []. Check the feature before submitting.", + "Requires a full product role (Product Admin, Product User, Admin or User); Viewer / Read Only is rejected.", + "Dual-writes to observability storage AND the TCG service (keyed by a hash of the element identifier), so a TCG outage can fail the call even though it looks like a local write.", + "Unknown body keys are ignored. For web tests supply baseUrl; for app tests supply screenIdentifier and client='app-automate' \u2014 the wrong pairing computes a hash that won't match the element." + ], + "responses": { + "200": { + "$response": "SubmitHealingVerdictResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_self_healing_verdicts", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", + "mode": "read", + "intent": "List the human-in-the-loop verdicts (accurate/inaccurate) already recorded against a build's AI-healed locators, to show which healed selectors have been reviewed.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$" + } + ], + "returns": [ + "elementHash", + "healedLocator", + "verdict", + "feedback", + "reviewedByUserId", + "reviewedAt", + "isCarryover", + "carryoverBuildSerialId" + ], + "guidance": [ + "Returns a bare JSON array of verdict objects (no wrapper envelope).", + "Feature-flagged: when the human-in-the-loop healing feature is OFF for the group this returns an empty array with HTTP 200 \u2014 NOT an error. Do not read [] as 'no verdicts recorded' without knowing the feature is on.", + "`isCarryover` is true when the verdict was first recorded against a different (earlier) build; `carryoverBuildSerialId` names that build. Join `elementHash` with the healing report to line each verdict up with its selector.", + "A build UUID outside the caller's group is rejected as build-not-found." + ], + "responses": { + "200": { + "$response": "HealingVerdictListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_self_heal_summary", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/builds/{uuid}/selfHealSummary", + "mode": "read", + "intent": "Get a build's self-healing headline numbers for the build-insights page \u2014 how many tests and locators were healed, with counts and percentages.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$", + "description": "Build UUID." + } + ], + "returns": [ + "testSummary", + "locatorSummary", + "buildSummary" + ], + "guidance": [ + "Numbers come from search aggregations over the build's runs: testSummary (totalTests, healedTests, percentHealedTests), locatorSummary (totalLocators, healedLocators, percentHealedLocators) and buildSummary (totalBuild, healedBuilds, percentHealedBuilds) - buildSummary can be null.", + "Refused with FORBIDDEN_OPERATION / plan-feature-not-allowed when the build is locked by plan quota \u2014 you get an error, not zeroes.", + "In the demo-sandbox environment this returns hardcoded dummy stats, not real data." + ], + "responses": { + "200": { + "$response": "SelfHealSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_comparison_aggregation", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/aggregation", + "mode": "read", + "intent": "Get the headline counts for a base-vs-target build comparison \u2014 additional, dropped, no-change, newly failed, started passing, and failed-with-a-different-reason \u2014 the summary strip at the top of the Build Comparison page.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID (top-level parent build). Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID (top-level parent build). Empty/missing \u2192 build-not-found." + }, + { + "name": "filterParams", + "type": "object", + "required": false, + "description": "Standard V2 test filters bound from the query string (same filter keys as the build test listing), applied to both builds before comparing." + } + ], + "returns": [ + "newlyFailed", + "failedWithDifferentReason", + "startedPassing", + "additionalTests", + "droppedTests", + "noChange", + "sourceFencingFlags" + ], + "guidance": [ + "Plan-gated on the build-comparison feature: FORBIDDEN_OPERATION / plan-feature-not-allowed otherwise. A successful call also bumps feature-usage counters.", + "Both builds must be top-level parents and supported: a child/archived/locked build is rejected as unsupported (BUILD_COMPARISON_BUILD_NOT_SUPPORTED). Normalise UUIDs with resolve_build_comparison_parent_uuids first.", + "A cross-source (incompatible) pair is NOT an error: you get 200 with all six counts at 0 and `sourceFencingFlags` populated. Check sourceFencingFlags before trusting the zeroes.", + "Expensive: resolves both builds' child builds and runs search + aggregation across them.", + "The controller method is internally named getBuildUuid but it returns counts, not a UUID \u2014 ignore the name." + ], + "responses": { + "200": { + "$response": "BuildComparisonAggregationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "check_build_comparison_available", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/checkComparison", + "mode": "read", + "intent": "Pre-flight check of whether two builds can actually be compared (and why not), so the UI can show a 'comparison unavailable' state before firing the expensive aggregation.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "status", + "message", + "sourceFencingFlags" + ], + "guidance": [ + "Same build-comparison plan gate as the aggregation \u2014 refused when the plan lacks the feature.", + "For an incompatible-source pair it returns `sourceFencingFlags` and NO availability payload (status/message null); otherwise status/message describe whether the comparison is available. Check the flags first.", + "Same unsupported-build rejection as the other comparison endpoints for child/archived/locked builds.", + "Use this before get_build_comparison_aggregation / list_build_comparison_bucket_tests to avoid the heavy call when the pair can't be compared." + ], + "responses": { + "200": { + "$response": "BuildComparisonCheckResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "resolve_build_comparison_parent_uuids", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/getActual", + "mode": "read", + "intent": "Normalise two build UUIDs (which may be re-run/child builds) to their top-level parent build UUIDs \u2014 call this first so a rerun UUID pasted into the comparison picker is compared at the parent level.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID to resolve (may be a child/rerun UUID). Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID to resolve. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "base", + "target", + "sourceFencingFlags" + ], + "guidance": [ + "Returns `{base, target}` where each is the resolved top-level PARENT build UUID. Feed these into the other comparison calls.", + "NOT plan-gated (unlike the other four comparison endpoints) \u2014 works even without the build-comparison feature.", + "Any resolution failure is collapsed into a single BUILD_NOT_FOUND naming whichever of the two UUIDs appeared in the underlying error message; you cannot tell from the error which side genuinely failed.", + "sourceFencingFlags is part of the envelope but is not set on the normal success path here." + ], + "responses": { + "200": { + "$response": "BuildComparisonActualResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_comparison_bucket_tests", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/list", + "mode": "read", + "intent": "List the actual tests inside one bucket of a build comparison (e.g. newly-failed, started-passing, dropped, additional), grouped for display \u2014 the drill-down when a user clicks an aggregation tile.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "bucket", + "type": "string", + "required": true, + "description": "Comparison bucket, as the exact BucketEnum constant name (case-sensitive). The aggregation's camelCase keys (newlyFailed ...) are NOT accepted: any other value -> 500 'No enum constant'.", + "values": [ + "NEWLY_FAILED_TEST", + "STARTED_PASSING_TEST", + "DROPPED_TEST", + "ADDITIONAL_TEST", + "NO_CHANGE", + "FAILED_WITH_DIFFERENT_REASON" + ] + }, + { + "name": "fileOffset", + "type": "integer", + "required": true, + "description": "Pagination cursor (Long) over the grouped result; start at 0 and advance it to page through groups. Required." + }, + { + "name": "filterParams", + "type": "object", + "required": false, + "description": "Standard V2 test filters bound from the query string, applied before matching." + } + ], + "returns": [ + "bucketName", + "groups", + "sourceFencingFlags" + ], + "guidance": [ + "Plan-gated on the build-comparison feature.", + "Returns `{bucketName, groups}` where each group bundles a file/browser/device/os and its `scopes`, each scope holding base/target element pairs. A cross-source pair returns an empty `groups` with `sourceFencingFlags` set rather than an error.", + "Expensive: pulls test-run ids for both builds (and their child builds) from search with a large per-build ceiling (~10k) before matching.", + "`fileOffset` is a cursor, not a page number; carry the last value forward to continue. The page size / total is not returned as a simple count \u2014 walk until a page comes back empty." + ], + "responses": { + "200": { + "$response": "BuildComparisonListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_comparison_meta", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/meta", + "mode": "read", + "intent": "Get the two build summary headers shown side by side on the Build Comparison page \u2014 name, status, timings, framework and status counts for both the base and the target build.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "base", + "target", + "sourceFencingFlags" + ], + "guidance": [ + "Returns `{base, target}`, each a full build summary object. A cross-source pair returns the envelope with `sourceFencingFlags` set and base/target null.", + "NOT explicitly plan-gated (unlike aggregation/list/checkComparison), but it DOES apply the same unsupported-build rejection and source-fencing behaviour.", + "The status counts are AMR-corrected: it re-runs the same dedup/coalesce aggregation the buckets use so the header agrees with them, and silently falls back to raw automation counts if that recompute fails. This makes it heavier than a plain build-summary fetch." + ], + "responses": { + "200": { + "$response": "BuildComparisonMetaResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_comments", + "entity": "comment", + "method": "GET", + "path": "/api/v1/projects/{normalizedName}/comments", + "mode": "read", + "intent": "Fetch the comments to render inline on a build-insights or test listing page \u2014 comments for a build (and a window of preceding build numbers), optionally narrowed to one test, to your own comments/mentions, and optionally excluding re-runs.", + "path_params": [ + { + "name": "normalizedName", + "type": "string", + "required": true, + "description": "Project normalised name (resolved within the caller's group)." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "description": "Comment page type (same enum as create_comment, by NAME). REQUIRED in practice \u2014 omitting it returns [] with 200.", + "values": [ + "TESTS", + "ALWAYS_FAILING", + "FLAKINESS", + "NEW_FAILURES", + "MUTED", + "BUILD_SUMMARY", + "BUILD_HISTORY", + "UNIQUE_ERRORS", + "BUILD_STABILITY", + "RUN_SUMMARY", + "ALERTS", + "FAILURE_CATEGORIES", + "DEFECT_STATS", + "BUILD_RERUN_STATS", + "TOP_ERROR_STATS", + "QUALITY_GATES", + "FAILURES_BY_FOLDERS", + "ATTACHMENTS", + "BUILD_OPTIMISATION", + "SELF_HEALING" + ] + }, + { + "name": "buildSerialId", + "type": "integer", + "description": "The build's numeric id (build_id, e.g. 160459727) - NOT its build_number. REQUIRED in practice \u2014 omitting it returns [] with 200 (not an error). The read looks back over a fixed window of preceding ids from this value." + }, + { + "name": "buildUuid", + "type": "string", + "description": "Build UUID to scope to when no runUuid is given." + }, + { + "name": "runUuid", + "type": "string", + "description": "When set (and not in re-run mode), scopes to this run instead of the whole build." + }, + { + "name": "testHash", + "type": "string", + "description": "Required when type=TESTS \u2014 type=TESTS without a testHash returns [] with 200. Also used to scope re-run/expand-all test queries." + }, + { + "name": "expandAll", + "type": "boolean", + "description": "Expand the full set for the context rather than a collapsed view; changes how buildUuid/testHash filtering is applied." + }, + { + "name": "showMyComments", + "type": "boolean", + "default": false, + "description": "Restrict to comments you authored or were mentioned in." + }, + { + "name": "showReRunComments", + "type": "boolean", + "default": false, + "description": "Include comments across re-run builds. REQUIRES parentBuildUuid \u2014 set without it throws INVALID_PARAMETERS_FOR_COMMENTS." + }, + { + "name": "parentBuildUuid", + "type": "string", + "description": "Parent build UUID; mandatory whenever showReRunComments=true." + } + ], + "returns": [ + "id", + "type", + "threadId", + "userId", + "groupId", + "commentNode", + "projectId", + "buildUuid", + "buildSerialId", + "runUuid", + "testHash", + "mentions", + "createdAt", + "modifiedAt" + ], + "guidance": [ + "Returns a bare JSON array of comment objects (no envelope). Note the array items carry `commentNode` but NOT the `content` HTML.", + "Silent empties: missing buildSerialId, missing type, or type=TESTS without testHash all return [] with HTTP 200 \u2014 easy to misread as 'no comments'. Supply buildSerialId and type (and testHash for TESTS).", + "showReRunComments=true without parentBuildUuid is a hard error (INVALID_PARAMETERS_FOR_COMMENTS), not an empty list.", + "Only looks back a FIXED number of preceding build serial ids from buildSerialId (so recurring discussion carries over), not all history, and is further bounded by the plan's data-retention window." + ], + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_comment_threads", + "entity": "comment", + "method": "GET", + "path": "/api/v1/projects/{normalizedName}/builds/{buildId}/comments", + "mode": "read", + "intent": "Power the build-level comments side panel \u2014 list the comment threads across a build and all its re-runs, grouped by thread and ordered by most recent activity, with each thread's page type and test-run uuid for deep-linking.", + "path_params": [ + { + "name": "normalizedName", + "type": "string", + "required": true, + "description": "Project normalised name (resolved within the caller's group)." + }, + { + "name": "buildId", + "type": "string", + "required": true, + "description": "PARENT build UUID. Threads are aggregated across all builds sharing this parent uuid \u2014 passing a re-run's own uuid returns nothing." + } + ], + "query": [ + { + "name": "showMyComments", + "type": "boolean", + "description": "Restrict to threads you authored or were mentioned in. REQUIRED in practice: omitted -> 500 (null Boolean unboxed). Send false for all threads.", + "required": true + } + ], + "returns": [ + "threadId", + "name", + "browser", + "os", + "device", + "type", + "testRunUuid", + "commentCount", + "testHash" + ], + "guidance": [ + "Returns a bare JSON array of thread summaries (threadId, commentCount, the thread's page type and testRunUuid for deep-linking), not the individual comments \u2014 fetch a thread's comments via list_comments.", + "`buildId` must be the PARENT build uuid; a re-run's uuid yields an empty result, not an error.", + "Thread aggregation is capped at 1000 threads, and results are bounded by the plan's data-retention window so older threads silently drop out.", + "Always send showMyComments (false for every thread): the server unboxes it without a null check, so omitting it returns 500 Internal System Exception." + ], + "responses": { + "200": { + "$response": "CommentThreadsSidePanelResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_comments", + "entity": "comment", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/comments", + "mode": "read", + "intent": "Public API: fetch the discussion thread on a test run \u2014 the comments testers left against that failure, with author info.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test-run id (Long)." + } + ], + "returns": [ + "id", + "type", + "threadId", + "userId", + "groupId", + "commentNode", + "projectId", + "buildUuid", + "buildSerialId", + "runUuid", + "testHash", + "mentions", + "createdAt", + "modifiedAt" + ], + "guidance": [ + "Public /ext/v1 route: authenticate with the customer username + access key, not the internal session.", + "Read-only \u2014 there is NO external endpoint to post a comment; use the internal create_comment for that.", + "A run not in the caller's group returns TEST_RUN_NOT_FOUND.", + "Returns a bare JSON array of comment objects (same shape as list_comments items)." + ], + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_unique_errors", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors", + "mode": "read", + "entity": "sp_error", + "intent": "List the Suite Health unique errors for a project \u2014 test failures clustered by error signature over a date window, each cluster with its occurrence count, impacted-test count and a sample of the error text \u2014 so a team can attack the few root errors behind many failing tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], sent as a repeated query param (dateRange=&dateRange=). Required \u2014 missing, or not exactly two values, is a 400. lowerBound + upperBound singles are the only alternative." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match against the error signature / sample text." + }, + { + "name": "status", + "type": "array", + "description": "Test-status filter values. Note the list is failed-only regardless." + }, + { + "name": "sources", + "type": "array", + "description": "Product-source filter.", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array", + "description": "Failure-category ids to include." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Restrict to these unique build names." + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array", + "description": "Owner user ids (integers)." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (integers)." + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "branches", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "description": "Muted-tests visibility. Unmuted is the default; an unrecognised value is a 400 (IllegalArgumentException).", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the cluster-id constraint is dropped, so results span all builds of a test rather than one error cluster." + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column. Ignored (forced to errorCount) for free / non-pro accounts.", + "values": [ + "errorCount", + "testCount" + ] + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Zero-based page index into the clustered result. Page size is fixed at 15. The response's pagingParams.pageNumber is the NEXT index to request (requested + 1), not the one you sent." + } + ], + "returns": [ + "pagingParams", + "errors", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "responses": { + "200": { + "$response": "UniqueErrorListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Take each row's `id` (a numeric string, the errorClusterId) \u2014 that is the cluster id every per-error detail view wants in its path.", + "Page with `pageNumber` (0-based) and a fixed size of 15. Walk pages while the response `pagingParams.hasNext` is true; note `pagingParams.pageNumber` is already the NEXT page to request, not the one you asked for.", + "An empty `errors` with a 200 is the honest answer for a demo tenant or a project with no builds \u2014 it does NOT mean the project is missing. A truly absent project is a 404.", + "Before concluding coverage is complete, check automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 source fencing may have silently dropped whole product families from the clustering.", + "Free / non-pro accounts get exactly one error back (the top one) and the sort is forced to errorCount desc; do not read that single row as the only error that exists.", + "Heavy filter combinations run under an Elasticsearch time budget and can be cut short, so a smaller-than-expected result on an expensive query may be a budget cut rather than the true count.", + "Any unrecognised extra query params are collected into customParams rather than rejected, so a mis-typed filter name is silently ignored, not errored." + ] + }, + { + "name": "get_unique_error_summary", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/info", + "mode": "read", + "entity": "sp_error", + "intent": "Get the header/summary block for one unique error \u2014 its test name, the sample error text (split into lines), and the OS/browser/device combinations it was seen on \u2014 to describe what the error actually is on the detail page.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the summary aggregates across all failed runs of the test (by stability hash) rather than this one cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "browser", + "type": "string", + "description": "Optional single-value browser narrowing (pro accounts only)." + }, + { + "name": "os", + "type": "string", + "description": "Optional single-value OS narrowing (pro accounts only)." + }, + { + "name": "device", + "type": "string", + "description": "Optional single-value device narrowing (pro accounts only)." + } + ], + "returns": [ + "name", + "error", + "combinations" + ], + "responses": { + "200": { + "$response": "UniqueErrorSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Pass the cluster id as a numeric `errorId`. A non-numeric id is NOT a 400 here \u2014 it is a raw 500 from Long.parseLong, because this handler does not validate or wrap exceptions the way its siblings do.", + "If no failing run matches the filters+window, the handler dereferences the first (absent) hit and 500s rather than returning an empty summary \u2014 treat a 500 here as possibly 'no sample found', and widen the window.", + "`error` is the sample text already split on newlines into an array of lines, not a single string.", + "`combinations` lists the platform combos the error appeared on ({os, browser} where browser also carries a `device`); combos that are NA on both os and browser are dropped from the list." + ] + }, + { + "name": "get_unique_error_occurrence_count", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/errorCount", + "mode": "read", + "entity": "sp_error", + "intent": "Get the total number of times one unique error occurred in the window, plus a per-day count series \u2014 the headline count and sparkline for the error detail panel.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the count spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "errorCount", + "chartData" + ], + "responses": { + "200": { + "$response": "UniqueErrorOccurrenceCountResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "`chartData` is a flat array of per-day occurrence counts with NO timestamps \u2014 reconstruct the x-axis yourself from the requested window (one bucket per calendar day, extended-bounds padded to the window).", + "`errorCount` is the total matching-run count for the window, independent of the bucket array length.", + "Buckets are fixed at one calendar day; the interval is not requestable." + ] + }, + { + "name": "get_unique_error_build_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/trend", + "mode": "read", + "entity": "sp_error", + "intent": "Get the occurrence trend of one unique error across builds over time \u2014 matching runs (timestamp, status, duration) grouped by status series and enriched with build info \u2014 to plot when and in which builds the error appeared.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the trend spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "data", + "keys" + ], + "responses": { + "200": { + "$response": "UniqueErrorBuildTrendResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Hard-capped at 5000 matching runs: for a very common error the trend is silently truncated to the first 5000 runs by start time, so totals here can understate the real count \u2014 use get_unique_error_occurrence_count for the true total.", + "Heavier than the other detail calls \u2014 it does a second relational lookup to enrich each run with build info \u2014 so expect higher latency.", + "`data` is a list of status series ({name, color, data[]}); `keys` is the parallel list of run start timestamps (epoch). A truly missing project is re-thrown as a 404; other failures surface as 500." + ] + }, + { + "name": "list_unique_error_builds", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds", + "mode": "read", + "entity": "sp_error", + "intent": "List the builds in which one unique error occurred, cursor-paginated, so a user can jump from an error cluster to the actual runs and builds where it appeared.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "searchAfter", + "type": "array", + "description": "Cursor: pass back the `pagingParams.searchAfter` array from the previous page verbatim. Follow this cursor rather than a page number; page size is fixed at 15." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped so all failed runs of the test are listed, not just this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "pagingParams", + "builds" + ], + "responses": { + "200": { + "$response": "UniqueErrorBuildsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Cursor pagination only \u2014 there is no page number. Take `pagingParams.searchAfter` from the response and send it back as the `searchAfter` query array for the next page; page size is fixed at 15.", + "Stop when the response has no further rows: `pagingParams.hasNext` is set to false only on an empty page, so an empty `builds` (or hasNext=false) is the end of the list.", + "Each build carries isFailedWithSameError, saying whether that build's run failed with THIS exact cluster or merely contains the test; use it to distinguish same-error recurrences from unrelated runs.", + "Some fields are exposed only through computed getters, not raw columns: versionControlInfo ({url, commitId}), issueType ({isAutoAnalyzed, value}, null unless the run is failed) and historyAggregate ({isAlwaysFailing, isFlaky, isNewlyFailed})." + ] + }, + { + "name": "get_unique_error_platform_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/platforms", + "mode": "read", + "entity": "sp_error", + "intent": "Break one unique error down by environment \u2014 which OS / browser / device combinations it occurred on and how often \u2014 so a team can tell whether the error is platform-specific.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped so the breakdown spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "browser", + "os", + "chartData", + "totalFailure", + "errorCount" + ], + "responses": { + "200": { + "$response": "UniqueErrorPlatformBreakdownResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Returns a FLAT array of platform buckets, not a nested os->browser->device tree; each entry is one {browser (with a `device`), os} combination.", + "Per entry, `totalFailure` is the occurrence doc count and `errorCount` is the distinct impacted-test count; `chartData` is per-day occurrence counts with no timestamps (reconstruct from the window).", + "Combos that are NA on both os and browser are dropped from the list.", + "The breakdown is a single composite aggregation page (up to 1000 combinations) and is not paginated, so a project with a very large platform matrix could be truncated." + ] + }, + { + "name": "get_unique_error_impacted_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{clusterId}/breakdown", + "mode": "read", + "entity": "sp_error", + "intent": "Expand one unique-error cluster into the individual tests that hit it \u2014 each with the browsers/devices/platforms it failed on and an occurrence series \u2014 the timeline/detail view behind a row in the unique-errors list.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project name path variable, resolved within the caller's group." + }, + { + "name": "clusterId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). The error cluster id. It reaches the query only because Spring binds the URI variable onto the command object's `clusterId` setter \u2014 a query param of the same name would be overridden by this path value, so set it here.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Restrict the breakdown to these unique build names (pro accounts only)." + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "orderValue", + "type": "string", + "description": "Sorts the returned tests by occurrence count.", + "values": [ + "asc", + "desc" + ] + } + ], + "returns": [ + "id", + "title", + "filePath", + "scopeList", + "browsers", + "platforms", + "device", + "errorCount", + "testRunId", + "chartData" + ], + "responses": { + "200": { + "$response": "UniqueErrorImpactedTestsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a bare JSON array of impacted-test objects (no envelope). Each test's `id` is a stability hash (the test signature), not a numeric id; `testRunId` is one representative failing run.", + "Set the cluster id in the PATH \u2014 the {clusterId} path variable overrides any clusterId query param of the same name, because the handler never reads the path variable itself and relies on request binding.", + "The full Suite Health filter surface is accepted and narrows the matched runs, but pro-account filters (build names, flaky, etc.) are skipped for free / non-pro users.", + "It pages internally through all composite buckets under a loop deadline, so for a very large cluster the returned list can be truncated when the deadline is hit rather than erroring.", + "A genuinely absent project is re-thrown as a 404; other failures surface as a 500. `chartData` is per-day occurrence counts with no timestamps." + ] + }, + { + "name": "get_unique_error_timeline_charts", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/errors/metrics/chart", + "mode": "read", + "entity": "sp_error", + "intent": "Get the Suite Health unique-errors timeline in one call \u2014 three day-bucketed time series over the window (unique errors, unique impacted tests, total impacted executions), each with its headline total \u2014 for the project's failed tests.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group. No permission check beyond project visibility." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "search", + "type": "string" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array" + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "allBuilds", + "type": "boolean" + } + ], + "returns": [ + "uniqueErrorChart", + "uniqueTestChart", + "impactedTestChart" + ], + "responses": { + "200": { + "$response": "UniqueErrorTimelineChartsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Response is a map with exactly three keys: uniqueErrorChart, uniqueTestChart, impactedTestChart. Each is {data, insights}, where data is a single-entry map whose series key differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", + "Read the headline number from each chart's `insights.value` (a STRING, e.g. '42') with `insights.metric` as its label \u2014 not by summing the points.", + "Each series point carries x (bucket epoch), y (value, may be null), t and pointRange; the bucket interval is derived from the window, not requestable.", + "Only failed tests are counted; archived and usage-locked builds are excluded, and the failed-status field used depends on the group's new-build-listing flag, so totals can shift when that flag changes." + ] + }, + { + "name": "get_unique_error_filters", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters", + "mode": "read", + "entity": "sp_error", + "intent": "Build the whole filter sidebar for a project's Unique Errors page in one call \u2014 the currently applied selections echoed back as display fields, the static dropdown option sets (failure categories, statuses, platforms, users, teams, sources, folders...), and the project's default hooks visibility.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. Scopes which option values exist. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "search", + "type": "string" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array" + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "branches", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "applied", + "staticFilters", + "hooksVisibility" + ], + "responses": { + "200": { + "$response": "UniqueErrorFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`applied` echoes the caller's current selections resolved into display objects (each option is {id, value, label, count, pId}); `staticFilters` is the full set of selectable option lists for the page.", + "Options are fetched from Elasticsearch and TestHub concurrently and degrade to EMPTY dropdowns on timeout or rejection rather than failing the request \u2014 an empty option list does NOT mean there is no data.", + "This is the errors-page filter surface; do not confuse it with the general build/test sidebar filters. Use the sibling typeahead capabilities (branches / build names / build tags / host names / test tags) for search-as-you-type option lookups." + ] + }, + { + "name": "search_unique_error_filter_branches", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/branches", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the branch filter on the Unique Errors page \u2014 return git branch names in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; applied as a leading+trailing Elasticsearch wildcard." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (this is the QueryRq shape, NOT the full error filter). lowerBound/upperBound singles are NOT accepted here." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Whether manual (Test Management) runs are considered. Defaults to true on this endpoint (unlike build-names, which forces it false)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Uses the compact QueryRq body (query + dateRange + isManuallyOverridden + includeManualRuns) \u2014 NOT the full Unique Errors filter set. dateRange must be exactly two bounds; there is no lowerBound/upperBound fallback.", + "Returns a BOUNDED set under `data` (each {id, value, label, count, pId}), matched by wildcard on `query`; it is a typeahead, not the full branch list.", + "Source fencing is applied, so branches from fenced-off product families may not appear." + ] + }, + { + "name": "search_unique_error_filter_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildNames", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the build-name filter on the Unique Errors page \u2014 return unique build names in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "IGNORED / silently forced to false on this endpoint \u2014 manual (Test Management) runs never appear in build-name suggestions whatever you send." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Unlike its sibling typeahead endpoints this one FORCES includeManualRuns=false server-side, so manual (Test Management) build names are always excluded regardless of the value you pass.", + "It delegates to the shared build-filter service rather than the suite-health filter service, so its result set can differ subtly from the other error-filter typeaheads.", + "Returns a bounded set under `data` (each {id, value, label, count, pId}); it is a typeahead, not the full list. Uses the compact QueryRq shape (no lowerBound/upperBound fallback)." + ] + }, + { + "name": "search_unique_error_filter_build_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildTags", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the build-tag filter on the Unique Errors page \u2014 return build tags used in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; applied as an Elasticsearch wildcard terms lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true; this endpoint does NOT force it false (only build-names does)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard terms lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape (query + dateRange + isManuallyOverridden + includeManualRuns); dateRange must be exactly two bounds with no lowerBound/upperBound fallback." + ] + }, + { + "name": "search_unique_error_filter_host_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/hostNames", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the host-name (CI machine / runner) filter on the Unique Errors page \u2014 return matching host names within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; Elasticsearch wildcard lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false).", + "Runs without a recorded host name aggregate into one option with empty id/value/label (and a count) - skip it when building a host filter." + ] + }, + { + "name": "search_unique_error_filter_test_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/testTags", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the test-tag filter on the Unique Errors page \u2014 return tags attached to tests in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; Elasticsearch wildcard lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false)." + ] + }, + { + "name": "trigger_bulk_test_rca", + "method": "POST", + "path": "/api/v1/ai/builds/{buildUuid}/bulkTestRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "The build's UUID. All failed tests of this build (optionally narrowed by the filter body) are queued for AI RCA." + } + ], + "body": [ + { + "name": "filters", + "type": "object", + "description": "Optional. Same test-listing filter shape (FilterParamsV2) the build's test list uses \u2014 narrows which failed tests are analysed. Omit to target the build's failures as a whole.", + "fields": [ + { + "name": "status", + "type": "array", + "description": "Test statuses to include (e.g. failed). Part of the shared build test-listing filter." + } + ] + }, + { + "name": "limit", + "type": "integer", + "description": "Optional cap on how many failed runs are queued in this request." + } + ], + "intent": "Kick off AI root-cause analysis for all failed tests of a build in one go (the 'Analyse all failures' action) \u2014 optionally narrowed by the test-listing filters and capped by a limit.", + "returns": [ + "state", + "buildUuid", + "bulkRcaUuid", + "trialQuota" + ], + "responses": { + "200": { + "$response": "BulkTestRcaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: this only queues work. The reply's bulkRcaUuid is the handle for the batch and each run's finished RCA arrives later \u2014 poll the individual test runs' RCA (get_test_rca_data) rather than expecting analyses in this response.", + "Plan-fenced: a free group that is not test-level-trial eligible is refused with 403 (PLAN_FEATURE_NOT_ALLOWED). It also requires the group's AI opt-in consent and the test-runs-RCA feature on the AI token; a source-fenced build is refused with AI_FEATURE_FENCED.", + "Needs the caller's own BrowserStack username + Selenium access key, resolved server-side from the caller's user record; a caller whose record lacks them gets 400 'User details are not available'.", + "Disabled entirely in the demo-sandbox environment.", + "The body is optional \u2014 POST with no body queues the build's failures with no extra filtering." + ] + }, + { + "name": "get_test_rca_data", + "method": "GET", + "path": "/api/v1/ai/testRuns/{testRunId}/testRca/data", + "mode": "read", + "entity": "test_rca", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Read an already-generated AI RCA for a test run with NO side effects \u2014 the safe counterpart to get_test_rca that never creates a record or queues a generation.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "bulkRcaId", + "isAgentic", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pure read: no AI consent/feature checks and no generation queued. Use this to poll for a result after triggering one elsewhere.", + "When nothing has been generated yet this is a 400 (BAD_REQUEST, 'Test Run RCA not found for the given test run ID'), NOT a 404 or an empty 200 \u2014 treat that 400 as 'not generated yet', not as a bad request.", + "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found.", + "404 'RCA not found' means no RCA has been generated for this test run yet (not a bad id): generating one is a write (get_test_rca / trigger_test_rca) and needs user confirmation." + ] + }, + { + "name": "get_test_rca", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/testRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "peek", + "type": "boolean", + "default": false, + "description": "peek=true makes the call strictly read-only: it returns the current RCA state and never enqueues a generation. With the default peek=false the GET is get-or-create and CAN queue work \u2014 treat that form as a trigger, not a read." + } + ], + "intent": "Public-API fetch of a failed test run's AI root-cause analysis; by default get-or-create (queues a generation and returns the pending state if none exists), or a strict read with peek=true.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "isLive", + "pendingMessageId", + "alreadyTracked", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the public-API twin of get_test_rca and accepts username + access-key auth. Behaviour splits on peek: peek=true is safe (never enqueues); peek=false (the default) is get-or-create and has a side effect.", + "To poll without triggering, always pass peek=true. The peek-only fields isLive / pendingMessageId / alreadyTracked describe an in-flight generation; gate a re-trigger on isLive (alreadyTracked only reflects whether a chat row holds a handle and is null for web/MCP-started runs).", + "Requires AI opt-in consent and AI enablement; source-fenced groups get AI_FEATURE_FENCED. Resolves the caller's BrowserStack username/access key server-side and 400s if missing.", + "To register an https callback instead of polling, use the POST twin trigger_test_rca.", + "peek=true on a run with NO RCA returns 200 with every field null (id, state, isAgentic, ...), not a 404 - check id/state for null. isAgentic can read false on the first (Pending) response and true once generation starts." + ] + }, + { + "name": "trigger_test_rca", + "method": "POST", + "path": "/ext/v1/testRun/{test_run_id}/testRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "body": [ + { + "name": "webhook_url", + "type": "string", + "maxLength": 2048, + "description": "Optional https callback URL the pipeline POSTs the finished RCA to when a generation is actually queued. https only; localhost/.internal/.local/loopback/private/link-local/multicast hosts are rejected (SSRF-guarded at accept time and re-checked at dispatch). Absent/blank means no callback." + } + ], + "intent": "Public-API trigger for a test run's AI RCA with a fire-and-forget callback: same get-or-queue behaviour as the GET, plus an https webhook the pipeline notifies when the analysis is ready.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "isLive", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "With no body this behaves exactly like get_test_rca with peek=false (get-or-queue). The only added capability is registering webhook_url.", + "The callback fires ONLY when a generation is actually queued \u2014 if the RCA already exists (or is already in flight) no callback is sent, so still read the 200 body's state.", + "webhook_url must be https and pass the SSRF host checks both at accept time and again at dispatch; a URL that resolves to a blocked host is rejected with a 400 naming the reason.", + "The field is named webhook_url on the wire (snake_case), not webhookUrl." + ] + }, + { + "name": "list_build_groups", + "method": "GET", + "path": "/api/v1/projects/{projectId}/build-groups", + "mode": "read", + "entity": "build_group", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "TestHub project id (NOT the observability project id) used by the build-group backend." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "description": "1-based page number." + }, + { + "name": "pageSize", + "type": "integer", + "default": 30, + "description": "Rows per page (passed through to TestHub; server default 30)." + }, + { + "name": "name", + "type": "string", + "description": "Substring name search over run groups." + }, + { + "name": "ids", + "type": "string", + "description": "Comma-separated build-group ids to fetch a specific set instead of the full listing." + } + ], + "intent": "List a project's run groups (build groups) with paging and name search \u2014 the source of run-group ids for linking, closing, editing or deleting; can also fetch a specific set by ids.", + "returns": [ + "data", + "info" + ], + "responses": { + "200": { + "$response": "BuildGroupListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The groups are at the top-level `data` array (there is no `success` wrapper on this route); paging is under `info` (page, pageSize, count, prev, next).", + "By default closed groups are excluded from this listing (the controller requests excludeClosed=true).", + "projectId in the path is the TestHub project id, not the observability project id \u2014 using the wrong one returns an empty/foreign result.", + "Each row's `state` is derived: ACTIVE until closed, then CLOSED. Take the integer `id` for the link/close/edit/delete calls." + ] + }, + { + "name": "create_build_group", + "method": "POST", + "path": "/api/v1/projects/{projectId}/build-groups", + "mode": "write", + "entity": "build_group", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "TestHub project id." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "maxLength": 255, + "description": "Run-group name. Required and must not be blank; the reserved name 'Ungrouped' (any case) is rejected with 400 'Please enter a valid group name'." + }, + { + "name": "description", + "type": "string", + "maxLength": 1000, + "description": "Optional description." + } + ], + "intent": "Create a new run group (build group) in a project so builds can be grouped for a release or test cycle.", + "returns": [ + "success", + "data", + "message" + ], + "responses": { + "200": { + "$response": "BuildGroupOperationResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Requires the project's BUILD_ADD_TO_RUN_GROUP permission.", + "The created group is at `data` (a build-group object with its integer `id`); `success` is true and `message` is 'Build group created successfully'.", + "Creation actually happens in TestHub and is mirrored back \u2014 the returned id is the TestHub group id used by the other build-group calls.", + "'Ungrouped' is reserved and rejected even though it is otherwise a valid name." + ] + }, + { + "name": "list_build_accessibility_test_runs", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/a11y-testRuns", + "mode": "read", + "entity": "accessibility", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "status", + "type": "string", + "description": "Filter by test status." + }, + { + "name": "searchQuery", + "type": "string", + "description": "Substring search over test name." + }, + { + "name": "run", + "type": "array", + "description": "Restrict to specific run ids.", + "fields": [ + { + "name": "", + "type": "integer", + "description": "A run id." + } + ] + }, + { + "name": "page", + "type": "integer", + "description": "Page number (see guidance on 0/1 indexing)." + }, + { + "name": "limit", + "type": "integer", + "description": "Rows per page." + } + ], + "intent": "List the accessibility-scanned tests of one build for the build's Accessibility tab \u2014 per test: name, file, status, OS/browser, a11y score, page/component counts and a critical/serious/moderate/minor issue breakdown, with paging.", + "returns": [ + "testCases", + "pagingParams" + ], + "responses": { + "200": { + "$response": "AccessibilityTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The build must have FINISHED (or TIMEOUT) and carry accessibility data, else a typed 4xx: A11Y_BUILD_NOT_FOUND (no build/no a11y), A11Y_BUILD_STATUS_INVALID (no status), or A11Y_BUILD_NOT_COMPLETED (still running).", + "Feature-flag fork: when the App-Accessibility flag is on for the group AND the build's source is the App-Accessibility automation source, the whole call is proxied to a separate Accessibility service and the RESPONSE SHAPE IS THAT SERVICE'S, not the one described here. Do not assume this shape for App-Accessibility builds.", + "Tests with no a11y summary/severity breakdown are SILENTLY DROPPED from the list, so the row count can be lower than the build's total tests.", + "Paging is re-based to 0-index for the frontend: pagingParams.nextRoot.page is (upstream next page - 1), and pagingParams.hasNext says whether to continue.", + "Accepts a public-link token in addition to the normal auth types (public dashboards)." + ] + }, + { + "name": "get_test_run_accessibility_meta", + "method": "GET", + "path": "/api/v1/testRuns/{btcerId}/a11y-testMeta", + "mode": "read", + "entity": "accessibility", + "path_params": [ + { + "name": "btcerId", + "type": "integer", + "required": true, + "description": "TestHub build-test-case-execution-result (btcer) id \u2014 a plain integer, NOT a test-run TR-NNN identifier." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "description": "Page number. Page size is fixed server-side at 10 and cannot be changed by the caller." + } + ], + "intent": "Accessibility detail for a single test on the Test Details page \u2014 that test's a11y score, total issues and severity breakdown, and its report URL.", + "returns": [ + "totalIssues", + "severityBreakdown", + "reportURL", + "score", + "testCaseUuid" + ], + "responses": { + "200": { + "$response": "AccessibilityTestMetaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Error mapping is LOSSY: every failure except A11Y_SERVICE_USER_UNAUTHORIZED and A11Y_SERVICE_NOT_CONFIGURED is rewritten to A11Y_TESTCASE_RESULTS_NOT_FOUND \u2014 so a genuine upstream error surfaces as 'no results'. Do not read 'not found' as definitely empty.", + "App-Accessibility feature-flag fork again: when the flag is on and the run's source is the App-Accessibility automation source, the call is proxied to a separate Accessibility service and returns that service's shape, not the one described here.", + "Page size is fixed at 10 server-side; only `page` is caller-controllable.", + "reportURL points to the a11y report to fetch elsewhere; the response itself does not embed the full report." + ] + }, + { + "name": "update_code_fix_preference", + "method": "PUT", + "path": "/api/v1/ai/projects/{projectId}/codeFixPreferences/{id}", + "mode": "write", + "entity": "ai_insight", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id. Must belong to the caller's group (IDOR-checked) and must match the group+project the preference row was created under." + }, + { + "name": "id", + "type": "integer", + "required": true, + "description": "Integer id of the code-fix preference row to edit. The row is located by this id alone; buildName in the body does not select it." + } + ], + "body": [ + { + "name": "buildName", + "type": "string", + "required": true, + "description": "Required and must be non-blank (validation), but its value is IGNORED on update \u2014 the row is located by the path id and buildName is never re-written. Send any non-blank string (echo the preference's existing build name)." + }, + { + "name": "instruction", + "type": "string", + "maxLength": 5000, + "description": "Free-text code-fix guidance. Omit (null) to leave the stored instruction unchanged. There is no way to send null to clear it; to end up with no instruction you must keep at least one file attached, else the call is 400." + }, + { + "name": "fileAttachments", + "type": "array", + "maxLength": 1, + "description": "TRI-STATE. Omit (null) = leave existing attachments untouched. Send [] = remove ALL existing attachments. Send a non-empty array = REPLACE them. On replace or remove the previously attached S3 objects are permanently deleted (no undo, no version history). At most ONE attachment (MAX_PREFERENCE_FILES=1).", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "maxLength": 512, + "description": "S3 key of the already-uploaded file from a prior code-fix-preference presign+upload. Confirmed against this group/project; an s3Key that is not confirmable is rejected." + }, + { + "name": "fileName", + "type": "string", + "required": true, + "maxLength": 200 + }, + { + "name": "contentType", + "type": "string", + "required": true, + "maxLength": 100 + } + ] + } + ], + "intent": "Edit an existing AI code-fix preference for a project/build \u2014 change the instruction text and/or replace (or remove) its attached context file.", + "guidance": [ + "fileAttachments is tri-state: null (omit) leaves attachments untouched, [] removes all of them, a non-empty array replaces them. Replacing or removing permanently deletes the old S3 objects \u2014 there is no undo and preferences keep no version history, so an overwritten instruction or removed file is gone for good.", + "The final state must still carry either an instruction or at least one file: a request that would leave the row empty is rejected 400 'At least one of instruction or fileAttachments is required' before any S3 work runs.", + "buildName is required by validation on every update but its value is silently ignored \u2014 the update targets the row named by the path id and never rewrites buildName. Do not use this call to rename the build a preference is bound to.", + "instruction omitted (null) means 'unchanged'; only a non-null instruction overwrites it. Max 5000 characters.", + "Only one attachment is allowed (max 1). Each must reference an s3Key produced by the code-fix-preference presign+upload flow; a key not confirmable for this group/project fails the request.", + "The path id must belong to the SAME group and project as the path projectId, else 400 'Preference does not belong to this project' (not 404). A missing id is 404 code-fix-preferences-not-found.", + "Plan-gated: 403 plan-feature-not-allowed when the AI-TFA fence trips, and per-user AI must be enabled for the caller." + ], + "returns": [ + "id", + "projectId", + "buildName", + "instruction", + "fileAttachments", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "description": "Updated code-fix preference. fileAttachments[].downloadUrl is a freshly presigned GET URL, omitted when its generation failed.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "projectId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "instruction": { + "type": "string" + }, + "fileAttachments": { + "type": "array", + "items": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$schema": "StatTile" - } - }, - "sourceFencingFlags": { - "type": "object" - } + "s3Key": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "downloadUrl": { + "type": "string", + "description": "Presigned GET URL for the attachment. Present only when it could be generated (NON_NULL)." + } } + } + }, + "createdBy": { + "type": "integer" + }, + "updatedBy": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" } - }, - "ReportCustomFieldsResponse": { - "description": "Custom-field / column definitions plus page info.", - "schema": { - "type": "object", - "properties": { - "customFields": { - "type": "array", - "items": { - "$schema": "ReportCustomFieldItem" - } - }, - "info": { - "$schema": "ReportPageInfo" - } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "improve_test_rca", + "method": "POST", + "path": "/ext/v1/ai/testRuns/{test_run_id}/rcaContext/retrigger", + "mode": "write", + "entity": "ai_insight", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id. IDOR-scoped to the caller's group via its build." + } + ], + "body": [ + { + "name": "instruction", + "type": "string", + "maxLength": 5000, + "description": "Extra user instruction to steer the improved RCA. Optional." + }, + { + "name": "attachments", + "type": "array", + "maxLength": 3, + "description": "Up to 3 context files, each already uploaded via the RCA-context presign flow.", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "maxLength": 512 + }, + { + "name": "fileName", + "type": "string", + "required": true, + "maxLength": 200 + }, + { + "name": "contentType", + "type": "string", + "required": true, + "maxLength": 100 + }, + { + "name": "description", + "type": "string", + "maxLength": 500 + } + ] + }, + { + "name": "usePreferences", + "type": "boolean", + "description": "Whether to fold the project's stored code-fix preferences into the retrigger." + }, + { + "name": "webhook_url", + "type": "string", + "maxLength": 2048, + "description": "Optional https callback the pipeline POSTs the finished RCA to. SSRF-validated at accept time and again at dispatch: https only, and NOT localhost/private/link-local/.internal/.local hosts \u2014 a violation is 400. Absent/blank means no callback." + } + ], + "intent": "Re-run ('improve') a test's RCA with an extra instruction and optional attachments, on behalf of an agent acting for the customer \u2014 returns the new conversation message id to poll.", + "guidance": [ + "Precondition-heavy: requires an existing RCA row for the test run, AI opt-in consent, and RCA-v2 eligibility, and the test run must not exceed the age limit \u2014 otherwise it is refused before any generation.", + "Refused with FORBIDDEN_OPERATION when the run is already owned by the MCP-driven agent flow.", + "webhook_url must be https and must not target localhost/private/link-local/.internal/.local hosts; it is validated when accepted and re-validated at dispatch, so a bad URL is a 400 up front.", + "instruction is capped at 5000 chars and at most 3 attachments are accepted; each attachment needs an s3Key from a prior RCA-context presign+upload.", + "Asynchronous: the body carries only messageId (a UUID) \u2014 poll the RCA conversation/turn-status read for the outcome; the RCA itself is delivered elsewhere (the polled read, or the webhook when supplied). trialQuota.used appears only for trial tenants.", + "This is the external mirror of the session-auth internal retrigger; authenticate with the customer's BrowserStack username + access key." + ], + "returns": [ + "messageId", + "trialQuota" + ], + "responses": { + "200": { + "description": "Retrigger accepted; poll the returned messageId.", + "schema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "UUID of the new conversation turn to poll." + }, + "trialQuota": { + "type": "object", + "description": "Present (NON_NULL) only for trial tenants.", + "properties": { + "used": { + "type": "integer" } + } } - }, - "CreateHealingPrAccepted": { - "description": "Healing-PR job accepted (async). Only healingPrId + status (IN_PROGRESS) are set.", - "schema": { - "$schema": "CreateHealingPrResponse" - } - }, - "HealingPrStatusResponse": { - "description": "Current state of a healing-PR job; PR fields populated once terminal.", - "schema": { - "$schema": "CreateHealingPrResponse" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_split_build_legs", + "method": "GET", + "path": "/api/v1/builds/{uuid}/getSplitBuildDetails", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The PARENT build's 40-char uuid. The child legs are resolved as builds whose parentId is this build's id." + } + ], + "query": [ + { + "name": "pageLimit", + "type": "integer", + "default": 10, + "description": "Page size (child legs per page). Defaults to 10 when omitted. Bound from the query string despite the DTO marking it @JsonIgnore (that only affects JSON body binding, not query binding)." + }, + { + "name": "searchAfter", + "type": "string", + "pattern": "^[0-9a-zA-Z]{40}$", + "description": "Cursor for the NEXT page: the build UUID of the last leg you received, NOT an offset or page number. Must be a 40-char alphanumeric build uuid or the request is rejected. Echoed back to you in pagingParams.searchAfter." + } + ], + "intent": "List the individual legs (child builds) of a split / parallel build with their ids and uuids, so you can offer a leg switcher or aggregate stats across the whole split run.", + "returns": [ + "id", + "name", + "builds", + "pagingParams" + ], + "responses": { + "200": { + "$response": "SplitBuildLegsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pagination is CURSOR-based, keyed by build uuid: read `pagingParams.searchAfter` (the last leg's uuid) from the response and pass it as the next call's `searchAfter`. There is no offset/page-number paging here.", + "Walk pages while `pagingParams.hasNext` is true; hasNext is set from whether the page came back full (>= pageLimit), so a final short page ends the walk.", + "An unknown parent uuid is ObservabilityException(BUILD_NOT_FOUND), not a 200 with an empty list.", + "Each leg in `builds` is a full build-summary object (same shape as get_build_details), so you get every leg's status and counts without a per-leg call.", + "`builds` are the CHILD legs only; the parent build itself is addressed by get_build_details." + ] + }, + { + "name": "get_build_notification_summary", + "method": "GET", + "path": "/api/v1/builds/{id}/summaryLite", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The NUMERIC build id (not the 40-char uuid). This route takes the integer id; passing a uuid will not resolve." + } + ], + "intent": "Get the compact build summary used to render the build-summary email / notification card \u2014 name, project, run-by, status/issue-type/history aggregates, muted and total counts, build alerts and quality-gate status.", + "returns": [ + "uuid", + "name", + "projectName", + "status", + "totalCount", + "mutedCount", + "qualityGateStatus", + "statusStats", + "issueTypeAggregate", + "historyAggregate", + "alerts", + "buildNumber", + "colorMapping" + ], + "responses": { + "200": { + "$response": "BuildNotificationSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes the INTEGER build id, unlike almost every other build endpoint (which take the uuid). Get the integer id from a build read or listing first.", + "An unknown id returns HTTP 200 with a NULL body, not a 404 \u2014 treat an empty/null response as 'build not found', not as an empty summary.", + "This is NOT group-scoped in the lookup: it resolves the build by id alone. Do not rely on it to enforce workspace ownership.", + "This is a different payload from get_build_details / get_build_summary_stats \u2014 it is the trimmed 'email card' shape (BuildSummaryEmail) with formatted duration/date helpers, not the full detail object.", + "`accessibility` is present only for APP_ALLY_AUT builds (populated fail-open) and omitted entirely otherwise; do not treat its absence as a11y failure.", + "`startedAt` is not serialised as a raw timestamp \u2014 the payload exposes formatted date/time/duration helper fields instead." + ] + }, + { + "name": "get_build_stability_tile", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildStabilityStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Any extra query params not bound to the FilterParamsV2 fields are collected into a custom-params map and forwarded to the query. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the stability tile for a build \u2014 the share of tests that ran cleanly versus flaky / always-failing / new-failure noise, with a percentile insight versus recent builds \u2014 for the build insights page.", + "returns": [ + "data", + "keys", + "overview", + "median", + "commentCount" + ], + "responses": { + "200": { + "$response": "BuildStabilityTileResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403 (FORBIDDEN_OPERATION / plan feature not allowed) \u2014 the stability tile is a gated feature.", + "An unknown uuid is BUILD_NOT_FOUND, not an empty tile.", + "This is the stability TILE (single build, based on the last ~30 runs, `overview.meta` says how many) \u2014 not the historical stability chart (that is get_build_stability_chart).", + "This route accepts the full FilterParamsV2 filter set as query params plus arbitrary customParams; filters narrow which of the build's tests feed the stability number.", + "`overview.count` is a percentage string (e.g. \"92.3\") and `overview.insights` is a positive/negative/default verdict versus recent builds \u2014 it is only populated once there are >= 10 comparison runs." + ] + }, + { + "name": "get_build_summary_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildSummary", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the headline test-count strip for a build under the currently applied filters \u2014 total / passed / failed / skipped / pending roll-ups plus AI-RCA and session counts \u2014 the summary strip at the top of the build insights page.", + "returns": [ + "data", + "aiRcaStats", + "isArchived", + "commentCount", + "hooksVisibility", + "manuallyOverriddenCount", + "sessionStats" + ], + "responses": { + "200": { + "$response": "BuildSummaryStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Name-collision trap: this is `/{uuid}/buildSummary` (headline counters, action getBuildStats). It is NOT `/{uuid}` build metadata (get_build_details) and NOT `/{uuid}/getBuildStats` (get_build_non_sdk_test_counts). The similar names back completely different payloads.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "`data` is an array of colored metric tiles (each `{id, name, value, color, testsCount, hooksCount, ...}`), not a flat counts object \u2014 read each tile's `value`.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the counters reflect the applied filters, so an unfiltered total needs an unfiltered call." + ] + }, + { + "name": "get_build_timeline_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildTimelineStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "intent": "Get the build's execution timeline \u2014 per-status series (passed / failed / pending=running) over the build's lifetime \u2014 for the timeline visualisation on the build page.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildTimelineStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No filters apply on this route \u2014 it takes only the uuid; any query params are ignored.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "The payload is a single top-level `data` array of three series (ids passed, failed, pending - pending is the running count), each carrying `[x, value]` pairs where x is milliseconds since the first test started, not an epoch timestamp. Only passed/failed runs are counted." + ] + }, + { + "name": "get_build_details", + "method": "GET", + "path": "/api/v1/builds/{uuid}", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "run", + "type": "integer", + "required": false, + "description": "Optional attempt / run number to scope the summary to a specific re-run attempt. Omit for the latest." + } + ], + "intent": "Get the main build-detail payload for one build \u2014 status, run counts, framework/CI metadata, cross-product and test-plan info \u2014 plus the per-build UI gates (whether to show the Self-Healing tab, the Authoring Agent tab, and whether App Accessibility is enabled).", + "returns": [ + "uuid", + "id", + "name", + "status", + "displayStatus", + "duration", + "statusStats", + "issueTypeAggregate", + "historyAggregate", + "projectId", + "qualityGateStatus", + "showHealingReport", + "showAuthoringReport", + "appAccessibilityEnabled", + "tags", + "tcmTestPlans", + "workflowStatusStats" + ], + "responses": { + "200": { + "$response": "BuildDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An unknown uuid throws ObservabilityException(BUILD_NOT_FOUND) (a 404-equivalent), not a 200 with an empty body.", + "`showAuthoringReport` and `showHealingReport` are UI gates derived from an S3 key-existence check on the build metadata \u2014 they say whether the report EXISTS to show, not whether you may access it. On demo-sandbox environments `showAuthoringReport` is forced true regardless.", + "`appAccessibilityEnabled` is a per-group feature flag and fails CLOSED (false) if the flag lookup errors \u2014 do not read false as 'definitely no a11y data'.", + "`duration` is the consolidated duration when present, otherwise the raw duration \u2014 the field already resolves this, so use it directly.", + "`displayStatus` is the settled pipeline verdict and is preferred over `status` for the final outcome; `status` can still reflect a live aggregation mid-run.", + "Use the `run` query param to inspect a specific re-run attempt; omitting it returns the latest attempt's summary." + ] + }, + { + "name": "get_build_failure_category_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/defectStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the failure-category (defect) breakdown for a build \u2014 how many failures fall into each issue type/category, how many have AI RCA available, and the comment count \u2014 for the 'Failure Categories' panel.", + "returns": [ + "data", + "aiRcaStats", + "commentCount", + "message" + ], + "responses": { + "200": { + "$response": "BuildFailureCategoryStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "While the build is still PENDING (or has no status yet) this returns HTTP 200 with an EMPTY `data` array, zeroed `aiRcaStats`/`commentCount`, and `message` = 'Failure Categories will be available after your build finishes'. That is a valid 200, not an error \u2014 check `message`/`data` before concluding there are no defects.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Each `data` entry is a category with a `value` count and optional nested `categories` sub-buckets; `aiRcaStats.completedCount` is how many of these failures already have AI RCA.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the breakdown reflects the applied filters." + ] + }, + { + "name": "get_build_failures_by_module", + "method": "GET", + "path": "/api/v1/builds/{uuid}/failureByModules", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get a build's failures grouped by test module / file path, so you can see which modules or suites contribute most of the failures.", + "returns": [ + "data", + "commentCount", + "isConflicting", + "sourceFencingFlags" + ], + "responses": { + "200": { + "$response": "BuildFailuresByModuleResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Each `data` entry is a module node with a `value` (total) and `failedTests` count, plus `parent`/`customPathSplit` so you can rebuild the folder/module tree \u2014 the path separator is inferred per node (`/` or `\\`).", + "`sourceFencingFlags` is present only when the group's source-fencing feature excludes the build's product; when present it names which products' tests were skipped from the aggregation, so counts may be partial.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_filter_values", + "method": "GET", + "path": "/api/v1/builds/{buildId}/getFilters", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (despite the `buildId` name, this is the 40-char uuid, resolved via getBuildbyUuId)." + } + ], + "intent": "Get the distinct filter values available for one build's test listing (browsers, OSes, tags, issue types, statuses, plus static history/flaky options) so the UI can render the filter dropdowns.", + "returns": [ + "filters" + ], + "responses": { + "200": { + "$response": "BuildFilterValuesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the OLDER filter shape. The richer replacement is get_build_test_filters (route `/{buildUuid}/filters`), which additionally returns the currently-applied filters, hooks visibility, split-build children and freemium pro-gating. Prefer that one unless you specifically need this compact shape.", + "Not group-scoped in the lookup (resolves the build by uuid alone).", + "The payload is a single `filters` map keyed by facet name (browser, os, issueType, tags, status, folder, history, flaky), each a list of `{label, value}` options; `history` and `flaky` are static option lists always present.", + "Values are computed from the build's LATEST test runs, so a facet a filter narrowed away will not appear as an option here." + ] + }, + { + "name": "get_build_flaky_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/flakyStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the flaky-tests tile for a build \u2014 how many of the build's tests were detected as flaky (with a 'out of total' meta) plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary: count/meta/commentCount/autoUnmuteCount) as get_build_muted_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the flaky count; `meta` carries the 'out of N' context string.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "list_build_test_folders", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/folder", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (despite the `buildId` name it is the 40-char uuid). WARNING: there is no not-found guard \u2014 an unknown uuid NPEs into a 500, not a 404." + } + ], + "query": [ + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Absolute folder path within the build's test listing. OMIT it (or send blank) to get the paginated ROOT folders with pass/fail roll-ups; provide it to get that folder's details and its immediate sub-folders." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "description": "Page number, used ONLY on the root-folder (no folder_path) branch. Ignored on the sub-folder branch." + }, + { + "name": "size", + "type": "integer", + "default": 30, + "description": "Page size, used ONLY on the root-folder branch. The sub-folder branch ignores it and uses default paging." + }, + { + "name": "after_key", + "type": "string", + "required": false, + "description": "Composite-aggregation cursor for the root-folder branch. Ignored on the sub-folder branch. NOTE: the response's `after_key` is returned null even on the root branch (see guidance)." + }, + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded." + } + ], + "intent": "Browse the folder-tree view of a build's test listing: with no folder_path, list the paginated root folders (with pass/fail roll-ups); with a folder_path, get that folder's details and its sub-folders. All the build's test-listing filters apply.", + "returns": [ + "project_id", + "folders", + "name", + "folder_path", + "cases_count", + "total_cases_count", + "sub_folders_count", + "sub_folders", + "pagingParams", + "after_key" + ], + "responses": { + "200": { + "$response": "BuildTestFoldersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response SHAPE depends on the branch. No `folder_path` -> root shape `{project_id, folders[], pagingParams, after_key}`. With `folder_path` -> folder-detail shape `{project_id, name, folder_path, cases_count, total_cases_count, sub_folders_count, sub_folders[], pagingParams}`. Do not expect `folders` on the detail branch or `sub_folders` on the root branch.", + "There is NO build-not-found guard on this route: an unknown uuid throws an NPE that surfaces as a 500, not a clean 404. Validate the uuid (e.g. via get_build_details) first.", + "Root-folder pagination is composite-aggregation based; walk it with `pagingParams` (and `after_key`/`p`). Note the top-level `after_key` in the response is set to null even on the root branch \u2014 rely on `pagingParams` to page.", + "Passing a `folder_path` on this route ignores `p`/`size`/`after_key` entirely and returns that folder with default paging.", + "The full FilterParamsV2 test-listing filter set (status, tags, users, dateRange, isFlaky, ...) is bound from query params and applied to the folder roll-ups; delegates to TestHub's folder-hierarchy API.", + "A blank/whitespace `folder_path` is treated as 'no path' (root branch); a non-blank but unresolvable path on the detail branch is a 400 'Invalid folder_path'." + ] + }, + { + "name": "get_build_muted_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/mutedStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the muted-tests tile for a build \u2014 how many of the build's tests are muted (with a 'out of total' meta) plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the muted count; `autoUnmuteCount` is how many are scheduled to auto-unmute.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_new_failure_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/newFailureStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the new-failures tile for a build \u2014 how many tests failed in this build that were not failing before \u2014 plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_muted_stats \u2014 only the metric differs. `count` is the new-failure count; `meta` carries the 'out of N' context.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_non_sdk_test_counts", + "method": "GET", + "path": "/api/v1/builds/{uuid}/getBuildStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid. The counts include the build's split child builds." + } + ], + "intent": "Get data-hygiene counters for a build (including its split child builds) \u2014 how many test runs are untitled, how many are unmarked, and how many have no failure reason \u2014 used to nudge non-SDK / untitled instrumentation setup.", + "returns": [ + "untitled_test_count", + "unmarked_test_count", + "missing_failure_reason_count" + ], + "responses": { + "200": { + "$response": "BuildNonSdkTestCountsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Name-collision trap: this is `/{uuid}/getBuildStats` (data-hygiene counters, action getNonSdkUntitledTestCount). It is NOT the build headline counters \u2014 those are `/{uuid}/buildSummary` (get_build_summary_stats).", + "The body is a JSON *string* (a Gson JsonObject serialised via toString), not a JSON object \u2014 the caller must parse the string to read the three keys `untitled_test_count`, `unmarked_test_count`, `missing_failure_reason_count`.", + "Counts span the parent build AND its split child builds combined; there is no per-leg breakdown here." + ] + }, + { + "name": "get_build_insights", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/insights", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID. Looked up group-scoped; an unknown uuid is BUILD_NOT_FOUND (404)." + } + ], + "intent": "One-call failure summary for a build, built for agents and CI bots \u2014 status metrics (test vs hook counts), failure categorisation (new failures / always-failing / flaky), defect stats and a top-errors analysis for the run.", + "returns": [ + "buildId", + "buildName", + "status", + "startedAt", + "finishedAt", + "duration", + "summary", + "failureCategorization", + "failureContext", + "topErrorsAnalysis", + "defectStats" + ], + "guidance": [ + "This is the build-keyed sibling of the test-run buildInsights call. Because no test run is supplied, `failureContext` (the file / parent-module drill-down) is ALWAYS null on this route \u2014 use the test-run variant if you need it.", + "`topErrorsAnalysis.status` is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED. When it is not COMPLETE, `uniqueErrors` may be empty even though the build has failures \u2014 the clustering has not finished, so re-read rather than concluding there are no errors.", + "`summary.statusMetrics` splits every status into `testsCount` vs `hooksCount`; `summary.totalTests` and `summary.totalHooks` are the run-wide totals.", + "`topErrorsAnalysis.uniqueErrors[].truncatedError` is only the first ~200 chars of the error \u2014 an overview string, not the full message. Carry `clusterId` to the unique-errors endpoints for the full text.", + "`defectStats` is keyed by issueType, each with per-category failure test/hook counts." + ], + "responses": { + "200": { + "$schema": "BuildInsights__w4_build4" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_latest_build", + "method": "GET", + "path": "/ext/v1/builds/latest", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project name (normalised internally). Missing \u2192 400 'Parameter project_name is required'. Resolved within the caller's group." + }, + { + "name": "build_name", + "type": "string", + "description": "Exact original build name to match." + }, + { + "name": "user_name", + "type": "string", + "description": "Triggering user's username. If it resolves to no user id, the whole call is BUILD_NOT_FOUND (404) \u2014 not a validation error." + }, + { + "name": "build_tags", + "type": "string", + "description": "Comma-separated build tags; split and trimmed server-side into a tag set." + }, + { + "name": "framework", + "type": "string", + "description": "Test framework name; enum-validated. An unrecognised framework is reported as BUILD_NOT_FOUND (404), not a 400." + } + ], + "intent": "Find the most recent build in a project matching optional filters (build name, triggering user, build tags, framework) and get its full build-details payload \u2014 the entry point for 'the latest run of X' when you don't know the build uuid.", + "returns": [ + "build_id", + "id", + "name", + "build_number", + "status", + "started_at", + "finished_at", + "duration", + "user", + "tags", + "status_stats", + "observability_url", + "tcmTestRunIdentifier" + ], + "guidance": [ + "Always returns a SINGLE build (the page limit is fixed at 1), not a list \u2014 the newest match. No match for the given filters is BUILD_NOT_FOUND (404).", + "The requester must hold automated-test-run view permission on the project (service-account project permission is enforced); otherwise the call is refused.", + "The response shape is feature-flagged per group: the newer build-listing flag returns the V2 build-details envelope, otherwise the v1 one. Both are FURTHER field-gated by the account's plan through the external JsonView wrapper \u2014 a field absent from the body may be plan-gated rather than empty, so do not treat a missing field as 'no value'.", + "Carry the returned `build_id` (the hashed uuid; keys are snake_case and there is no `uuid` key) to the per-build endpoints (insights, reports, history); the display `tcmTestRunIdentifier` is not a build id." + ], + "responses": { + "200": { + "$schema": "ExternalBuildDetails" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_project_builds_by_project_id", + "method": "GET", + "path": "/ext/v1/projects/{project_id}/builds", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric TRA project id." + } + ], + "query": [ + { + "name": "unique_build_names", + "type": "array", + "description": "Filter to these build names." + }, + { + "name": "build_tags", + "type": "array", + "description": "Filter to builds carrying any of these tags." + }, + { + "name": "users", + "type": "array", + "description": "Triggering user ids (numeric)." + }, + { + "name": "frameworks", + "type": "array", + "description": "Test framework names; enum-validated. An invalid framework value is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (numeric)." + }, + { + "name": "build_status", + "type": "array", + "description": "Build display statuses; enum-validated. An invalid status value is a 400." + }, + { + "name": "branches", + "type": "array", + "description": "VCS branch names." + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Restrict to archived (true) or non-archived builds." + }, + { + "name": "is_ci_run_only", + "type": "boolean", + "description": "Restrict to CI-triggered builds." + }, + { + "name": "exclude_lock", + "type": "boolean", + "description": "Exclude locked builds." + }, + { + "name": "date_range", + "type": "array", + "description": "Exactly two epoch-millisecond values [start, end]. OPTIONAL despite the DTO's @NotNull \u2014 this route binds it without validation, so when omitted it DEFAULTS to the last 7 days. Provide both to widen the window.", + "fields": [ + { + "name": "(item)", + "type": "integer" + } + ] + }, + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 pagination cursor from the previous page's `pagination.next_page`. Omit for the first page." + } + ], + "intent": "List a project's builds over the public REST API with status, counts and a deep link back to the TRA UI \u2014 filterable like the UI build listing (names, users, teams, tags, branches, frameworks, statuses, date range) and cursor-paginated.", + "returns": [ + "id", + "name", + "builds", + "pagination" + ], + "guidance": [ + "Cursor pagination only: read `pagination.has_next` and pass `pagination.next_page` back as `next_page` (snake_case keys). There is NO caller-settable page size; walk the cursor until `has_next` is false.", + "The item shape is feature-flagged per group: with the 'new build listing' flag on, `status_stats` holds workflow-status counts and items add `is_manually_overridden`; otherwise v1 status counts. Same keys otherwise.", + "When `date_range` is omitted the window is the LAST 7 DAYS \u2014 older builds simply will not appear unless you pass an explicit [start,end] in epoch millis.", + "Within a page builds come back OLDEST-first: the handler reverses the fetched page (Lists.reverse) before returning.", + "Requires automated-test-run view permission on the project.", + "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls.", + "For 'the latest / last N builds' prefer list_project_builds_by_name (newest-first, `pageLimit`, project-scoped `search`): this route is oldest-first within a page, 10 per page and defaults to the last 7 days, so 'last 10 builds' can cost several calls.", + "Each item carries `build_number` (the serial #N) and, when the CI set one, the CI run id; a user's 'run ' may be the CI run id rather than `build_number`.", + "The filters are NOT the UI set: this route takes users, build_tags, unique_build_names, is_ci_run_only and exclude_lock, while list_project_builds_by_name takes search, sortOrder, statuses, sources and types. Pick the route by the filter you need." + ], + "responses": { + "200": { + "$schema": "ExternalProjectBuildListing" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_project_builds_by_name", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/builds/v2", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "Project's normalised name." + } + ], + "query": [ + { + "name": "statuses", + "type": "array", + "description": "Build display statuses to include (enum)." + }, + { + "name": "sources", + "type": "array", + "description": "Product sources (e.g. Automate / App Automate) (enum)." + }, + { + "name": "types", + "type": "array", + "description": "Build types (enum)." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (numeric)." + }, + { + "name": "frameworks", + "type": "array", + "description": "Test framework names (enum)." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build tags to filter on." + }, + { + "name": "branches", + "type": "array", + "description": "VCS branch names." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond values [start, end] (@Size min=2,max=2).", + "fields": [ + { + "name": "(item)", + "type": "integer" + } + ] + }, + { + "name": "search", + "type": "string", + "description": "Free-text search over builds." + }, + { + "name": "searchAfter", + "type": "string", + "description": "Cursor for the next page (from the previous response's paging params)." + }, + { + "name": "pageLimit", + "type": "integer", + "description": "Page size. Defaults to 10 when omitted. No hard maximum is enforced in this handler \u2014 see could_not_determine." + }, + { + "name": "sortOrder", + "type": "string", + "values": [ + "Asc", + "Desc" + ], + "description": "Sort direction on the build ordering." + }, + { + "name": "state", + "type": "string", + "description": "Build state filter (defaults to `active`)." + }, + { + "name": "isArchived", + "type": "boolean", + "description": "Restrict to archived / non-archived builds." + } + ], + "intent": "The main build-listing endpoint: a filtered, sorted, cursor-paginated page of a project's builds with their status and run counts, for the builds table.", + "returns": [ + "id", + "name", + "builds", + "pagingParams", + "activeCount", + "closedCount" + ], + "guidance": [ + "PREFERRED build listing for agents: newest-first, `pageLimit` sets the page size (keep it at or below 50 \u2014 each row is large and a big page overflows the tool output), `search` filters by build name, and rows carry buildNumber, `buildRunId` (CI run id), statusStats, historyAggregate (flaky / newFailure / alwaysFailing counts) and qualityGateStatus, so one call answers 'last N builds with status'.", + "Takes the project's NORMALISED NAME in the path, not a numeric id. Uses the same large filter object as the build-groups listing.", + "SIDE EFFECT: this call marks the project as subscribed for the calling user \u2014 a read that quietly changes the user's subscription state. It also applies the group's custom data-retention window for the duration of the request, so builds older than the retention window will not appear regardless of `dateRange`.", + "Cursor pagination via `pagingParams` / `searchAfter`; page through until the paging params report no next cursor. `pageLimit` defaults to 10.", + "Each build row is a rich BuildSummary (status, statusStats, run/session counts, quality-gate status, tags, etc.), not the trimmed external shape.", + "This route does NOT accept username+access-key auth (it is not on the /ext/v1 public surface)." + ], + "responses": { + "200": { + "$schema": "ProjectBuildListing" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_running_builds_on_device", + "method": "GET", + "path": "/api/v1/tier/running/builds", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product; mapped to Automate ('automate') vs App Automate (anything else). Missing/empty \u2192 400." + }, + { + "name": "os", + "type": "string", + "required": true, + "description": "Operating system. Missing/empty \u2192 400." + }, + { + "name": "osVersion", + "type": "string", + "required": true, + "description": "OS version. A trailing '.0' is also matched without it. Missing/empty \u2192 400." + }, + { + "name": "device", + "type": "string", + "required": true, + "description": "Device name. Missing/empty \u2192 400." + } + ], + "intent": "Show which of the group's builds are currently queued/running on a specific device+OS combination, with the number of parallel sessions each consumes \u2014 used to explain why a device tier is saturated.", + "returns": [ + "data" + ], + "guidance": [ + "All four query params (product, os, osVersion, device) are REQUIRED \u2014 any missing/empty one is a 400.", + "Only counts test runs in `pending` status for the caller's group; a build with no pending runs on that device+OS will not appear.", + "Returns `{data: []}` (empty list) when nothing matches \u2014 that is a normal 'tier not saturated' result, not an error.", + "Each build in `data` carries `parallelsUsed` \u2014 the count of unique parallel sessions that build is consuming on the combination.", + "This route does NOT accept username+access-key auth.", + "Live 2026-09-30: a running non-SDK App Automate (plain Appium) session on the queried device/OS did NOT appear (data:[]) \u2014 the listing counts TRA test runs in pending status, which non-SDK sessions do not create." + ], + "responses": { + "200": { + "$schema": "RunningBuildsOnDevice" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_user_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/users", + "mode": "read", + "entity": "build_filter", + "intent": "List the users to offer in the build-list sidebar's Users facet - the people who actually triggered builds under the current filter, returned as {id, name} and searchable by name.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the projects of the caller's sub-group.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Substring searched against each user's FULL NAME (wrapped as a *query* wildcard inside Elasticsearch); special characters are escaped. Blank/absent returns the top users by build count." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time. Only projects, query and dateRange from the body affect this route.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is the start, element 1 the end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarUserFilterResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Only `projects`, `query` and `dateRange` from the body affect the result on this route. Every other build-filter field you send (teams, statuses, buildTags, branches, sources, buildIds, ...) is accepted and SILENTLY IGNORED here - it does not narrow the users returned.", + "Each element is an object {id, name} where `name` is the user's full name - NOT a bare string. `id` is the user id you would pass back as a `users` filter value.", + "`query` matches the full name as a substring; ids are aggregated from the builds index, then names are resolved from the database.", + "Results are an unordered, de-duplicated set capped at 100 options. There is no paging on this route, so a filter matching more than 100 users returns only the first 100 the aggregation surfaces.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_tag_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/tags", + "mode": "read", + "entity": "build_filter", + "intent": "List build-tag options for the build-list sidebar's Tags facet - the top tags on builds under the current filter, or the tags matching a search string.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Tag search substring (wrapped as *query* wildcard, special chars escaped). Blank/absent switches to the top-tags-by-frequency path instead of a search." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is start, element 1 end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Two different code paths depending on `query`: a blank/absent query returns the top build tags by frequency (terms aggregation), a non-blank query runs a wildcard substring tag search with highlighting. Elements are bare tag strings either way.", + "Only `projects`, `query` and `dateRange` from the body affect the result. Every other build-filter field in the body is accepted and SILENTLY IGNORED on this route.", + "Results are an unordered, de-duplicated set capped at 100 options; there is no paging, so more than 100 matches returns only the first 100.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_team_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/teams", + "mode": "read", + "entity": "build_filter", + "intent": "List the team options for the build-list sidebar's Teams facet - the BrowserStack teams whose sub-groups own builds under the current filter, returned as {id, name} and optionally filtered by name.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Team-name search substring. NOTE it is applied by the BrowserStack teams service AFTER the sub-group aggregation, not inside Elasticsearch - see guidance." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is start, element 1 end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarTeamFilterResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A request body is REQUIRED even when every field is optional: calling with no body is a 400 'Invalid request payload'. Send at least {} (or {projects:[...]}).", + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Two-step and `query` does NOT filter at the Elasticsearch stage: the sub-group ids that own matching builds are aggregated first (capped at 100), then the BrowserStack teams service resolves those ids to team names and applies `query` to the names. So a very large group can have owning sub-groups beyond the 100 aggregated that never appear.", + "Each element is an object {id, name} (team id + team name) - NOT a bare string. Names come from central/rails team data, not the builds index.", + "Only `projects`, `query` and `dateRange` from the body affect the result; every other build-filter field is accepted and SILENTLY IGNORED here.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_test_case_status_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/testCaseStatuses", + "mode": "read", + "entity": "build_filter", + "intent": "List the manual test-case status options (Test Management's TestResult custom-field values) configured for the selected projects, to offer manual test-case statuses as a build-list sidebar filter.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids. The ONLY body field this route reads; they are translated to Test Management project ids before the lookup. Omit/empty to default to the sub-group projects, then to the group's first 100 projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + } + ], + "returns": [], + "responses": { + "200": { + "$response": "SidebarTestCaseStatusesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only. Only `projects` is read - `query`, `dateRange` and every other body field are ignored, and the result is NOT scoped by the current build filter at all; it returns the configured status options for the projects regardless of which builds exist.", + "Pure pass-through to Test Management's custom-fields API for entity `TestResult`: the response body is the RAW TestHub JSON, not the {data:[...]} envelope the other sidebar facets return. Do not assume a `data` array here.", + "Observability project ids are converted to Test Management project ids first; a project with no Test Management mapping contributes nothing.", + "Unlike the other sidebar facets this route is not exposed to public-link tokens." + ] + }, + { + "name": "list_home_filter_options", + "method": "GET", + "path": "/api/v1/home/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List the selectable values for one dropdown of the Observability home/overview page's personal filters (build status, product source, framework, build tag, unique build name, or folder).", + "query": [ + { + "name": "key", + "type": "string", + "description": "Which filter dropdown's values to return. Required - omitting it fails the call. One of the UserFilterKey values.", + "values": [ + "buildTags", + "uniqueBuildNames", + "folders", + "sources", + "buildStatus", + "frameworks" + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "HomeUserFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`key` is required and must be one of the six UserFilterKey values; a value outside the enum is a 400, and omitting `key` entirely fails the request.", + "`buildStatus` and `sources` are computed purely from enums with no backend call, so they always return the full option set.", + "`frameworks` FAILS OPEN: on an internal error (ES or the fence setup) it returns the full hardcoded framework list rather than erroring. On success it lists only frameworks seen in the caller's builds (plus the saved selection), which can be EMPTY.", + "The build-backed keys (`buildTags`, `uniqueBuildNames`, `folders`) aggregate Elasticsearch scoped to the caller's sub-group, and to visible projects only when UDAC is enabled, returning up to 10000 values. There is no paging - the whole set comes back in one call.", + "Each element is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null here. `folders` labels embed the count ('Language(45)') and can repeat a value across projects, and folders span every project in scope, not one." + ] + }, + { + "name": "list_project_webhook_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectName}/settings/webhooks/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List type-ahead/facet values for a project's webhook rule builder (build names, tags, test names, folders, and other trigger-condition fields), with search and cursor paging - the values behind 'fire only when is ...'.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project whose values to aggregate. Scope is this project only on this route." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", + "values": [ + "build_name", + "build_tag", + "qg_status", + "build_status", + "test_name", + "folder", + "alert_name", + "failure_category", + "smart_tags", + "browser", + "os", + "device", + "alert_severity", + "test_tag", + "test_status" + ] + }, + { + "name": "projectIds", + "type": "string", + "description": "IGNORED on this route - the scope is the path `projectName` only. Use the group-level webhook filter route to scope by projectIds." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response to get the next page." + } + ], + "returns": [ + "data", + "afterKey" + ], + "responses": { + "200": { + "$response": "WebhookFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The value set returned depends entirely on `type`. Only build_name, build_tag, test_name, test_tag, folder, failure_category, alert_name, os, browser, device and test_status are wired; the remaining enum values (qg_status, build_status, smart_tags, alert_severity) return empty `data`.", + "`projectIds` in the query is IGNORED here - scope is the path `projectName`. This is the opposite of the group-level route, which reads projectIds and ignores any project path.", + "Cursor-paged via a composite aggregation: pass the response's `afterKey` back as the `afterKey` query param for the next page. afterKey is set even on the final page, so stop when a page returns fewer rows than the page size or none.", + "Per-page size is 10 for most types and up to 1000 for `folder`. `query` and `afterKey` are each capped at 100 characters.", + "Each element is {label, value, pId, count}. For `folder`, `label` is 'name(count)', `value` is the full path, and `pId` is the parent path; for other types `pId` echoes the value." + ] + }, + { + "name": "list_group_webhook_filter_options", + "method": "GET", + "path": "/api/v1/settings/webhooks/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List type-ahead/facet values for the group-level webhook rule builder across selected projects (build names, tags, and the other supported condition fields), with search and cursor paging.", + "query": [ + { + "name": "type", + "type": "string", + "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", + "values": [ + "build_name", + "build_tag", + "qg_status", + "build_status", + "test_name", + "folder", + "alert_name", + "failure_category", + "smart_tags", + "browser", + "os", + "device", + "alert_severity", + "test_tag", + "test_status" + ] + }, + { + "name": "projectIds", + "type": "string", + "description": "Comma-separated TestHub/Test Management project ids (thProjectId, NOT the TRA project id - a TRA id gives an empty 200). Each is translated to a TO project id before the query. A non-numeric entry fails the request; blank resolves to no projects and empty `data`." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response for the next page." + } + ], + "returns": [ + "data", + "afterKey" + ], + "responses": { + "200": { + "$response": "WebhookFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Gated by a controller-wide entitlement: WEBHOOKS (non-TM groups) or GROUP_WEBHOOKS (TM groups) must be accessible, otherwise the whole controller is refused as feature-inaccessible.", + "`projectIds` is a comma-separated string of TestHub/Test Management project ids (a project's thProjectId, not the TRA id - a TRA id silently yields empty `data`), mapped to TO project ids before the aggregation. A non-numeric entry throws (parse failure) and fails the call; an empty/blank value yields no projects and empty `data`.", + "The value set returned depends entirely on `type`; qg_status, build_status, smart_tags and alert_severity return empty `data`.", + "Cursor-paged like the project route: pass the response's `afterKey` back for the next page. afterKey is set even on the last page, so stop when a page has fewer than the page size (10 for most types, 1000 for `folder`) or is empty. `query` and `afterKey` are capped at 100 characters.", + "Each element is {label, value, pId, count}; for `folder`, `pId` is the parent path and `label` is 'name(count)'." + ] + }, + { + "name": "list_project_build_names", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/buildNames", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug). It is URL-decoded and every '+' is turned back into a space before lookup, so a project named 'My App' is addressed as 'My+App'. A name that matches no project returns 404." + } + ], + "intent": "List the distinct build names seen in a project \u2014 the values that populate the build-name filter dropdown on the build listing.", + "returns": [], + "guidance": [ + "The 200 body is a bare JSON array of strings (the build names), with no wrapper object \u2014 not {success, ...}.", + "Names are drawn only from builds inside the account's retention window, so a very old build's name may not appear even though the build once existed.", + "No product-source filter is applied on this route, so names from every source (Automate, App Automate, SDK, etc.) are merged into one list.", + "The path takes the normalised name, not the numeric project id; '+' in the slug is decoded to a space." + ], + "responses": { + "200": { + "description": "Distinct build names for the project.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_non_sdk_summary", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/nonSdkSummary", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug); '+' is decoded to a space. Resolved to the project first, so an unknown name returns 404." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs, [lowerBound, upperBound]. OPTIONAL despite the DTO marking it required: when the range (or either bound) is absent the handler substitutes the last 7 days. If you do send it, send both bounds \u2014 a one-element range is rejected.", + "fields": [ + { + "name": "lowerBound", + "type": "integer", + "description": "dateRange[0], epoch millis, inclusive start." + }, + { + "name": "upperBound", + "type": "integer", + "description": "dateRange[1], epoch millis, inclusive end." + } + ] + } + ], + "intent": "Get, over a date range, how many of a project's builds were ingested WITHOUT the BrowserStack SDK plus the resulting data-quality gaps (untitled tests, unmarked tests, tests with no failure reason) \u2014 the numbers behind the 'switch to the SDK' nudge.", + "returns": [ + "nonSDKBuildCount", + "unnamedTestCount", + "unmarkedTestCount", + "missingFailureReasonCount" + ], + "guidance": [ + "Elasticsearch-backed and scoped to a date range: with no dateRange it reports the last 7 days, so a zero here can just mean 'nothing in the last week', not 'nothing ever' \u2014 use get_project_non_sdk_build_count for the unbounded count.", + "Deleted and archived builds (and their tests) are excluded from every count, so the numbers track the live project view rather than raw ingest.", + "The three test-quality counts (unnamedTestCount, unmarkedTestCount, missingFailureReasonCount) come from a separate aggregation over the test index; each is the count of non-SDK tests hitting that gap in the window.", + "The other QueryRq fields (query, isManuallyOverridden, includeManualRuns) are not consulted on this route \u2014 only the date range matters." + ], + "responses": { + "200": { + "description": "Non-SDK build count and data-quality gap counts for the window.", + "schema": { + "type": "object", + "properties": { + "nonSDKBuildCount": { + "type": "integer", + "description": "Builds in the window ingested without the SDK." + }, + "unnamedTestCount": { + "type": "integer", + "description": "Non-SDK tests with no title in the window." + }, + "unmarkedTestCount": { + "type": "integer", + "description": "Non-SDK tests left unmarked in the window." + }, + "missingFailureReasonCount": { + "type": "integer", + "description": "Non-SDK tests with no failure reason in the window." } - }, - "SubmitHealingVerdictResponse": { - "description": "The verdict as recorded.", - "schema": { - "$schema": "HealingVerdictResponse" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_non_sdk_build_count", + "method": "GET", + "path": "/api/v1/projects/{projectId}/nonSDKBuildCount", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The observability project id (numeric). Note this route takes the numeric id, not the normalised name that the summary/build-names routes take." + } + ], + "intent": "Get just the count of non-SDK builds in a project \u2014 a cheap, all-time yes/no signal for whether to show SDK-onboarding prompts.", + "returns": [ + "project_id", + "non_sdk_build_count" + ], + "guidance": [ + "The 200 body is a JSON STRING, not a JSON object: the payload {\"project_id\":..,\"non_sdk_build_count\":..} is serialised to a string and returned as-is, so you must JSON-parse the response body a second time before reading the fields.", + "Unbounded in time \u2014 unlike get_project_non_sdk_summary there is no date range, so this counts every non-SDK build ever (excluding deleted/archived builds).", + "Keys are snake_case here (project_id, non_sdk_build_count), unlike the camelCase summary route.", + "The count falls back to 0 rather than erroring when the search returns no total." + ], + "responses": { + "200": { + "description": "A JSON-encoded STRING of {project_id, non_sdk_build_count}. Parse the string to get the object.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_by_id_or_name", + "method": "GET", + "path": "/api/v1/projects/{idOrName}", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "idOrName", + "type": "string", + "required": true, + "description": "Either the numeric project id OR the (normalised) project name \u2014 the handler resolves both. Because this is a catch-all mapping, the literal segments 'lite', 'lite/v2', 'pusherAuth' and any other declared path win first, so you cannot address a project whose name collides with a reserved segment through this route." + } + ], + "intent": "Fetch one project's full resource representation by either numeric id or normalised name \u2014 the project header/settings context (id, name, entry type, configuration, permissions).", + "returns": [ + "projectId", + "projectName", + "entryType", + "configuration", + "organization", + "permissions" + ], + "guidance": [ + "Accepts either the numeric id or the normalised name in the one path segment \u2014 carry whichever you have, no separate lookup needed.", + "configuration.attributes is always present; configuration.subTypes and configuration.alertConfiguration are omitted when null (the DTO is NON_NULL), so treat their absence as 'none configured', not an error.", + "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer.", + "The numeric id must be the TRA project id (e.g. from list_projects_lite_paginated `id`), NOT the TestHub/Test Management id (`thProjectId`): a TestHub id gives 404 and an unknown id can give a bare 500 'Internal System Exception'.", + "`permissions` reflects the API role, which can be broader than what the UI shows for the same user; do not tell the user they can edit settings based on it alone." + ], + "responses": { + "200": { + "description": "Single project resource.", + "schema": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "entryType": { + "type": "string" + }, + "configuration": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "description": "Map of project attribute key -> value." + }, + "subTypes": { + "type": "object", + "description": "Map of issue type -> list of issue sub-type resources. Omitted when null." + }, + "alertConfiguration": { + "type": "object", + "description": "Project alert config. Omitted when null." + } + } + }, + "organization": { + "type": "string" + }, + "permissions": { + "type": "array", + "description": "Caller's permission entries for this project." } - }, - "HealingVerdictListResponse": { - "description": "Recorded verdicts for the build. Empty array (200) when the HITL healing feature is off for the group.", - "schema": { + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_full", + "method": "GET", + "path": "/api/v1/projects", + "mode": "read", + "entity": "project", + "intent": "List the caller's group's projects as full records with complete metadata (attributes, issue types, alerts) \u2014 the heavyweight variant to use when you need full project detail rather than a picker payload.", + "returns": [ + "id", + "name", + "userId", + "groupId", + "thProjectId", + "normalisedName" + ], + "guidance": [ + "This is the heaviest of the project-list variants (full entity records, no paging). For pickers, sidebars and dashboards prefer list_projects_lite, list_projects_lite_paginated or list_projects_for_sidebar instead.", + "The group is taken from the session \u2014 there are no parameters; you get exactly the projects the caller's group owns.", + "The 200 body is a bare JSON array of project objects, with no wrapper and no pagination envelope, so the whole group is returned in one response." + ], + "responses": { + "200": { + "description": "All of the group's projects as full records.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "normalisedName": { + "type": "string", + "description": "Derived slug: spaces become '+', '#' becomes '%23', but case and characters such as '&' are kept, so it is not URL-safe - encode it before putting it in a path." + }, + "projectAttributes": { "type": "array", - "items": { - "$schema": "HealingVerdictResponse" - } - } - }, - "SelfHealSummaryResponse": { - "description": "Build self-heal headline numbers.", - "schema": { - "$schema": "SelfHealSummaryStats" - } - }, - "BuildComparisonAggregationResponse": { - "description": "Build-comparison headline counts.", - "schema": { - "$schema": "BuildComparisonAggregation" - } - }, - "BuildComparisonCheckResponse": { - "description": "Comparison availability pre-check.", - "schema": { - "$schema": "BuildComparisonAvailable" - } - }, - "BuildComparisonActualResponse": { - "description": "base/target resolved to parent build UUIDs.", - "schema": { - "$schema": "BuildComparisonStringEnvelope" - } - }, - "BuildComparisonListResponse": { - "description": "One bucket's grouped tests.", - "schema": { - "$schema": "BuildComparisonList" - } - }, - "BuildComparisonMetaResponse": { - "description": "base/target build summary headers.", - "schema": { - "$schema": "BuildComparisonMetaEnvelope" - } - }, - "CommentListResponse": { - "description": "A bare array of comments (commentNode, no content HTML).", - "schema": { + "description": "Project attribute rows." + }, + "projectIssueTypes": { "type": "array", - "items": { - "$schema": "CommentRes" - } - } - }, - "CommentThreadsSidePanelResponse": { - "description": "A bare array of thread summaries for the build side panel.", - "schema": { + "description": "Issue-type rows configured on the project." + }, + "alerts": { "type": "array", - "items": { - "$schema": "BuildRunComment" - } - } - }, - "UniqueErrorListResponse": { - "description": "Suite Health unique-errors list. An empty `errors` with a 200 is the honest state for a demo tenant or a buildless project.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "pageNumber": { - "type": "integer", - "description": "The NEXT page index to request (requested pageNumber + 1)." - }, - "hasNext": { - "type": "boolean", - "description": "Whether the page just returned had rows; false marks the end." - } - } - }, - "errors": { - "type": "array", - "items": { - "$schema": "UniqueErrorClusterRow" - } - }, - "automateTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded Automate tests." - }, - "appAutomateTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded App Automate tests." - }, - "lcncTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded low-code / no-code tests." - } - } - } - }, - "UniqueErrorSummaryResponse": { - "description": "Header/summary block for one unique error.", - "schema": { + "description": "Alert rows attached to the project." + }, + "appLogFilter": { "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Sample failing test name." - }, - "error": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample error text split on newlines." - }, - "combinations": { - "type": "array", - "description": "Platform combos the error was seen on; combos NA on both os and browser are dropped.", - "items": { - "type": "object", - "properties": { - "os": { - "$schema": "SPFilterField" - }, - "browser": { - "type": "object", - "description": "Formatted browser object that also carries a `device`." - } - } - } - } - } + "description": "App-log filter settings: {apps, envs}, each null when unset." + }, + "latestAlerts": { + "type": "array", + "description": "Most recent alert rows for the project." + } } - }, - "UniqueErrorOccurrenceCountResponse": { - "description": "Total occurrences of one unique error plus a per-bucket count series.", - "schema": { - "type": "object", - "properties": { - "errorCount": { - "type": "integer", - "description": "Total matching-run count for the window." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence doc counts with NO timestamps \u2014 reconstruct the x-axis from the window." - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_default_project", + "method": "GET", + "path": "/api/v1/projects/lite/default", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "normalisedName", + "type": "string", + "description": "Optional. If given and it resolves, that project is returned; if it does not resolve the call silently falls back to the group's most recently updated project rather than 404-ing." + } + ], + "intent": "Resolve which project the UI should land on: the named project if normalisedName is given and exists, otherwise the group's most recently updated project \u2014 for app load and deep links where the named project may no longer exist.", + "returns": [ + "id", + "name", + "thProjectId", + "normalisedName", + "userId", + "groupId", + "createdAt", + "finishedAt" + ], + "guidance": [ + "Fault-tolerant by design: a normalisedName that does not resolve does NOT 404 \u2014 it falls back to the last-updated project. So a 200 does not confirm the name you asked for exists; compare the returned normalisedName if you need to know.", + "If the group has no projects at all, this returns HTTP 200 with a null body (not 404). Guard for a null/empty response.", + "Returns a single lightweight project object, not a list." + ], + "responses": { + "200": { + "description": "The resolved landing project (lightweight), or null when the group has no projects.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "normalisedName": { + "type": "string" } - }, - "UniqueErrorBuildTrendResponse": { - "description": "Occurrence trend of one unique error grouped into status series (hard-capped at 5000 runs).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Status series name." - }, - "color": { - "type": "string", - "description": "Series colour (defaults to #999999)." - }, - "data": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Enriched run/build info entries for that series." - } - } - } - }, - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Parallel list of run start timestamps (epoch)." - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "rename_project", + "method": "PUT", + "path": "/api/v1/projects/{projectId}", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The observability project id to rename." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "The new project name. Trimmed server-side; a blank or whitespace-only name is rejected with 400 ('Project name cannot be empty.'). Sent flat at the top level of the body." + } + ], + "intent": "Rename a project \u2014 use this to change a project's display name (and, as a side effect, its URL slug).", + "returns": [ + "message", + "normalisedName" + ], + "guidance": [ + "Renaming CHANGES THE URL SLUG: the new normalised name becomes the project's slug, so existing links and any saved normalisedName keyed on the old name stop resolving. There is no automatic redirect \u2014 update stored slugs from the returned normalisedName.", + "No true undo: to reverse it you must rename back to the old name, and links broken in the meantime are not restored \u2014 treat it as a write with no history.", + "Blocked in demo-sandbox environments: returns 400 'Cannot rename default project' there.", + "The rename happens in TestHub and is mirrored locally; TestHub's own 400/401/404 are translated into observability errors, so a 404 here can mean TestHub could not find the project.", + "The 200 body carries a human-readable message and the new normalisedName \u2014 read normalisedName, not the message, to rebuild links." + ], + "responses": { + "200": { + "description": "Rename succeeded.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "normalisedName": { + "type": "string", + "description": "The project's new URL slug." } - }, - "UniqueErrorBuildsResponse": { - "description": "Cursor-paginated list of builds in which a unique error occurred.", - "schema": { + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "assign_issue_types_to_test_runs", + "method": "PUT", + "path": "/api/v1/projects/{projectId}/updateIssueTypes", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Project id scoping the test runs. Despite the project-scoped path this call updates TEST RUNS, not the project. In TM the id is interpreted as a TestHub project id." + } + ], + "body": [ + { + "name": "issues", + "type": "array", + "required": true, + "description": "The list of (test run -> issue type) assignments to apply. Sent flat at the top level as 'issues'.", + "fields": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id to categorise. The wire key is 'testRunId' \u2014 a plain 'id' will not bind." + }, + { + "name": "issueTypeId", + "type": "integer", + "required": true, + "description": "The issue-type / failure-category id to assign. The wire key is 'issueTypeId' \u2014 not 'issueType'." + } + ] + } + ], + "intent": "Bulk-assign a failure category / issue type to a list of test runs (the 'mark failure reason' action on the failure-analysis screens) and get back how many rows were updated.", + "returns": [ + "updatedCount" + ], + "guidance": [ + "Despite the action's project-scoped path and 'updateProject'-style naming, this updates TEST RUNS \u2014 it does not modify the project itself.", + "Each item's wire keys are testRunId and issueTypeId; a body using id/issueType (the shape some callers assume) silently fails to bind those fields. Send exactly testRunId and issueTypeId.", + "Reversible: re-run with a different issueTypeId to re-categorise, so this is a write, not destructive. There is no per-call undo, though.", + "Rejected with 400 while a project archive is in progress \u2014 retry after the archive completes rather than treating it as bad input.", + "The 200 body is just {updatedCount}: the number of test-run rows actually changed. A count lower than the number you sent means some ids did not match \u2014 it is not an error." + ], + "responses": { + "200": { + "description": "Count of test runs updated.", + "schema": { + "type": "object", + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of test-run rows whose issue type was updated." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_for_sidebar", + "method": "POST", + "path": "/api/v2/projects/lite", + "mode": "read", + "entity": "project", + "body": [ + { + "name": "queryStr", + "type": "string", + "description": "Optional case-insensitive project-name substring (ILIKE)." + }, + { + "name": "projects", + "type": "array", + "description": "Optional list of project ids (Long) to EXCLUDE from the page - the already-selected projects the picker shows separately (SQL `p.id NOT IN (:selectedProjectIds)`). It does not pin them in. Nulls are dropped.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "A selected project id to leave out." + } + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 1, + "description": "1-based page number. Values <= 0 (and absent) default to 1." + } + ], + "intent": "Populate the build-list sidebar project picker: a page of lightweight project records for the group ordered by most recent build activity, with optional name search and the ability to exclude the already-selected project ids from the page.", + "returns": [ + "data", + "pagingParams" + ], + "guidance": [ + "POST but READ-ONLY \u2014 the body is only search/paging/selection state; nothing is mutated.", + "hasNext is inferred from 'the page came back non-empty', so the last full page still reports has_next=true and next_page = pageNumber+1; the empty page after it is how you learn you are past the end. Stop when data is empty, not when has_next flips.", + "Page size is fixed server-side at 10 records and cannot be changed; a short page (under 10 rows) is the last one, even though has_next is still true.", + "Pagination keys are snake_case here (has_next, next_page under pagingParams) \u2014 different from the GET lite/aggregate routes which use hasNext/searchAfter.", + "Each row is the sidebar-lite shape (thProjectId, id, displayName, normalisedName) \u2014 note displayName, not name. This is distinct from list_projects_lite_paginated (a GET with offset paging and no id-pinning).", + "`projects[]` EXCLUDES the listed ids from the result (the picker renders selected projects itself); pass it only to leave those out. Combining a search with a selected id that matches it returns an empty page." + ], + "responses": { + "200": { + "description": "A page of sidebar-lite projects plus pagination.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { "type": "object", "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean", - "description": "Set to false only on an empty page." - }, - "searchAfter": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Cursor to send back as the next request's searchAfter." - }, - "nextRoot": { - "type": "object", - "description": "Usually absent for this endpoint." - } - } - }, - "builds": { - "type": "array", - "items": { - "$schema": "UniqueErrorBuild" - } - } - } - } - }, - "UniqueErrorPlatformBreakdownResponse": { - "description": "Flat list of platform buckets for one unique error.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "browser": { - "type": "object", - "description": "Formatted browser object that also carries a `device`." - }, - "os": { - "type": "object", - "description": "Formatted platform object." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps)." - }, - "totalFailure": { - "type": "integer", - "description": "Occurrence doc count for this combination." - }, - "errorCount": { - "type": "integer", - "description": "Distinct impacted-test count for this combination." - } - } + "thProjectId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "displayName": { + "type": "string" + }, + "normalisedName": { + "type": "string" + } } - } - }, - "UniqueErrorImpactedTestsResponse": { - "description": "Bare array of impacted tests behind one error cluster (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "UniqueErrorImpactedTest" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean", + "description": "True whenever the page was non-empty; not a true 'more rows exist' signal." + }, + "next_page": { + "type": "string", + "description": "pageNumber+1 as a string, or null when has_next is false." } + } } - }, - "UniqueErrorTimelineChartsResponse": { - "description": "Three Suite Health charts keyed uniqueErrorChart / uniqueTestChart / impactedTestChart.", - "schema": { + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_lite_paginated", + "method": "GET", + "path": "/api/v2/projects/lite/v2", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "search", + "type": "string", + "description": "Optional project-name search." + }, + { + "name": "searchAfter", + "type": "integer", + "default": 1, + "description": "Despite the cursor-style name this is a PLAIN OFFSET (integer). Defaults to 1 when absent. In demo-sandbox environments it is forced to 0. Advance it using the value returned in pagingParams.searchAfter." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route \u2014 paging is driven by searchAfter." + } + ], + "intent": "Get a paginated, optionally name-searched list of the group's projects in lightweight form (id, name, normalised name, TestHub id) for pickers and listing pages that do not need per-project metrics.", + "returns": [ + "projectLiteList", + "pagingParams" + ], + "guidance": [ + "PREFERRED route to resolve a project by name: pass `search` with the user's words; the exact match is usually on the first page. The row carries the TRA `id` (for integer-id routes), `normalisedName` (for name routes) and `thProjectId` (TestHub/TM id, NOT accepted by TRA routes).", + "searchAfter is an OFFSET, not a cursor \u2014 the name is misleading. Page by reading pagingParams.searchAfter back into the next request; hasNext is computed by over-fetching one row.", + "Page size is hardcoded at 100 and cannot be changed.", + "In demo-sandbox environments the offset is forced to 0, so you always get the first page there regardless of searchAfter.", + "Ordered by most recent build activity (build_updated_at desc). The 200 wraps the rows under projectLiteList with pagingParams alongside \u2014 not a bare array.", + "Overlaps heavily with list_projects_for_sidebar (POST /api/v2/projects/lite): that one pins selected ids and pages at size 10; this one does neither and pages at 100." + ], + "responses": { + "200": { + "description": "A page of lite projects plus pagination.", + "schema": { + "type": "object", + "properties": { + "projectLiteList": { + "type": "array", + "items": { "type": "object", "properties": { - "uniqueErrorChart": { - "$schema": "SPChart__w3_sp_error" - }, - "uniqueTestChart": { - "$schema": "SPChart__w3_sp_error" - }, - "impactedTestChart": { - "$schema": "SPChart__w3_sp_error" - } + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "normalisedName": { + "type": "string" + } } - } - }, - "UniqueErrorFiltersResponse": { - "description": "The Unique Errors filter sidebar: applied selections, static option sets, and default hooks visibility.", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "Caller's current selections resolved into display objects. Scalar keys (search, isFlaky, isNewFailure, isAlwaysFailing, isManuallyOverridden, isMuted, hooksVisibility, dateRange) plus option-set keys (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList, osList, browserList, pmToolStatus, branches), each a list of {id, value, label, count, pId} option objects." - }, - "staticFilters": { - "type": "object", - "description": "Selectable option lists for the page: osList, browserList, status, folders, failureCategories, deviceList, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan \u2014 each a list of {id, value, label, count, pId} option objects. Any list can be empty when its upstream (ES / TestHub) times out." - }, - "hooksVisibility": { - "type": "string", - "description": "The project's default hooks-visibility setting." - } + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "The offset to send as searchAfter on the next request." + }, + "nextRoot": { + "type": "object" } + } } - }, - "FieldSearchResponse__w3_sp_error": { - "description": "Typeahead result: a bounded set of matching filter options.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "SPFilterField" - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_with_build_aggregates_paginated", + "method": "GET", + "path": "/api/v2/projects/v2", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "search", + "type": "string", + "description": "Optional project-name search." + }, + { + "name": "searchAfter", + "type": "integer", + "default": 1, + "description": "A PLAIN OFFSET (integer) despite the cursor-style name. Defaults to 1 when absent; forced to 0 in demo-sandbox. Advance using pagingParams.searchAfter from the response." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route." + } + ], + "intent": "Back the Projects landing page: the same paginated project list as the lite paginated variant but enriched with per-project build aggregates (build counts/status roll-ups, stability) from Elasticsearch, so each project card can show its health at a glance.", + "returns": [ + "projectWithAggregates", + "pagingParams" + ], + "guidance": [ + "Heavier than list_projects_lite_paginated because it runs an aggregate pass over builds for the page \u2014 use it only when you need the per-project numbers.", + "Same paging quirks as the lite paginated route: searchAfter is an offset (not a cursor), page size is hardcoded at 100, hasNext is computed by over-fetching one row, and the offset is forced to 0 in demo-sandbox.", + "When the project page is empty the response still returns 200 with an empty projectWithAggregates list (no aggregate pass is run).", + "The 200 wraps rows under projectWithAggregates with pagingParams alongside \u2014 not a bare array." + ], + "responses": { + "200": { + "description": "A page of projects with build aggregates plus pagination.", + "schema": { + "type": "object", + "properties": { + "projectWithAggregates": { + "type": "array", + "items": { + "$schema": "ProjectWithAggregates" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "The offset to send as searchAfter on the next request." + }, + "nextRoot": { + "type": "object" } + } } - }, - "TestRunsRcaDataResponse": { - "description": "A test run's AI RCA (state + analysis). Top-level object; there is NO `data` wrapper.", - "schema": { - "$schema": "TestRunsRcaData" - } - }, - "BulkTestRcaResponse": { - "description": "Acknowledgement that a bulk test-RCA batch was queued. bulkRcaUuid is the batch handle; the analyses arrive later.", - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string" - }, - "buildUuid": { - "type": "string" - }, - "bulkRcaUuid": { - "type": "string", - "description": "UUID identifying the queued bulk batch." - }, - "trialQuota": { - "type": "object", - "description": "Present only for trial users." - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_health_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests", + "mode": "read", + "entity": "sp_test", + "intent": "List a project's tests on the legacy Test Health surface \u2014 one row per unique test (stability hash) over a date range, with its reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find the least reliable tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "description": "Window as EXACTLY two epoch-millis values [start, end]. NOT validated on this legacy route (no @Valid), so omitting it does not 400 \u2014 the window then falls back to lowerBound/upperBound, and with neither the query is unbounded and slow. Prefer dateRange.", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis). Used only when dateRange is absent." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis). Used only when dateRange is absent." + }, + { + "name": "search", + "type": "string", + "description": "Substring match on test name." + }, + { + "name": "status", + "type": "array", + "description": "Test statuses to include." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array", + "description": "Numeric user ids." + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Numeric user ids of last executor. Capped at 500 entries (@Size max 500) \u2014 more is a 400.", + "maxItems": 500 + }, + { + "name": "teams", + "type": "array", + "description": "Numeric team ids." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "When true (pro accounts only), keeps only tests with a non-zero flaky count." + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "isMuted", + "type": "string", + "description": "Muted-tests visibility. One of the three tokens below; anything else is treated as ALL.", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column. Honoured only for pro accounts; free accounts are forced to reliability. Recognised: reliability, average (duration), totalFailures." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. 10 tests per page." + }, + { + "name": "offset", + "type": "integer", + "description": "Accepted but internally capped at 100 (getRestrictedOffset)." + } + ], + "intent_notes": "", + "returns": [ + "pagingParams", + "tests", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "guidance": [ + "This is the OLDER Test Health generation. For current behaviour (validated window, public-link sharing, workflow-status-aware failure counts) use list_suite_health_tests instead.", + "reliability is a FAILURE percentage (failed*100/total) \u2014 HIGHER means LESS stable. duration is the average run duration. Neither is a 0-100 stability score.", + "Free (non-pro) accounts get a truncated result: only the single least-reliable test is returned, and orderKey/orderValue are ignored (forced to reliability desc).", + "Paged by pageNumber at 10 per page; walk pages until pagingParams.hasNext is false. There is no per-page size control (offset is capped at 100 and does not set the page size).", + "isMuted is a plain query token, not a boolean: send \"true\"/\"false\"/\"all\".", + "Auth: central-auth, obs-token, service-key, rails-basic or speedboat-token. Unlike the Suite Health list this route is NOT reachable with a public-link token.", + "ES-aggregation backed and internally paginated (loops pages until the slice is filled), so wide date ranges are slow.", + "browsers and platforms in each row are empty on this listing path \u2014 read browserList and platformList for the platform combinations." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_suite_health_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests", + "mode": "read", + "entity": "sp_test", + "intent": "List a project's tests on the current Suite Health surface \u2014 one row per unique test (stability hash) over a required date range, with reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find flaky, always-failing or slow tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as EXACTLY two epoch-millis values [start, end]. REQUIRED and validated here (@NotNull @Size(min=2,max=2)) \u2014 missing it, or a length other than 2, is a 400.", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "search", + "type": "string", + "description": "Substring match on test name." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "description": "Product sources to include." + }, + { + "name": "users", + "type": "array" + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Numeric user ids; capped at 500 (@Size max 500).", + "maxItems": 500 + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "hooksVisibility", + "type": "string", + "description": "Whether test hooks are included." + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, failure counts are sourced from the recorded automation status rather than workflow status." + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column (pro only). Recognised: reliability, average (duration), totalFailures." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. 10 tests per page." + } + ], + "returns": [ + "pagingParams", + "tests", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "guidance": [ + "Any query key this route does not recognise is SWALLOWED into customParams, never rejected \u2014 so a misspelled filter name is silently ignored and widens the result instead of erroring. Check spelling against the parameter list.", + "reliability is a FAILURE percentage (failed*100/total), HIGHER = less stable; duration is the average run duration.", + "A build-less-but-existing project still resolves (it falls back to its Postgres id and returns an honest empty result). Only a genuinely absent project is a 404 PROJECT_NOT_FOUND.", + "When source feature-fencing hides tests, the response sets automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 an empty-looking list may just be fenced, not truly empty.", + "Free (non-pro) accounts get only the single least-reliable test, with sorting forced to reliability desc.", + "Paged by pageNumber at 10 per page; stop when pagingParams.hasNext is false. No per-page size control.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token, and (unlike the Test Health list) public-link-token \u2014 so this listing can back a shared public link.", + "browsers and platforms per row are empty on this path; use browserList and platformList." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_health_stats", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/stats", + "mode": "read", + "entity": "sp_test", + "intent": "Get the aggregate stat tiles for one test on the legacy Test Health detail view \u2014 reliability (failure %), average duration and failed/total run counts \u2014 optionally narrowed to one browser/OS/device.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (the id field from a test-listing row)." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ] + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis)." + }, + { + "name": "buildName", + "type": "string", + "description": "Restrict to a single build name." + } + ], + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "guidance": [ + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "reliability is a failure percentage (higher = worse); duration is the average run duration; failureOccurrence carries {failed, total} raw counts.", + "Failure count on this legacy route is always the raw status count. The Suite Health equivalent (get_suite_health_stats) instead uses the workflow-status count when new build listing is enabled, so the two can disagree for the same test.", + "Any backend error is rethrown as a bare RuntimeException and surfaces as HTTP 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." + ], + "responses": { + "200": { + "$response": "SuiteHealthStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_stats", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/stats", + "mode": "read", + "entity": "sp_test", + "intent": "Get the aggregate stat tiles for one test on the Suite Health detail page \u2014 reliability (failure %), average duration and failed/total run counts over the selected window.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash sent in the query." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "guidance": [ + "stabilityHash comes from the PATH and overrides any value in the query string.", + "Failure count uses the workflow-status aggregation when new build listing is enabled for the group, otherwise the raw status count \u2014 so it can differ from the legacy get_test_health_stats for the same test and window.", + "reliability is a failure percentage (higher = worse); duration is average run duration; failureOccurrence = {failed, total}.", + "Unknown query keys are swallowed into customParams rather than rejected.", + "Any backend error surfaces as a bare 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthStatsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_health_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/trend", + "mode": "read", + "entity": "sp_test", + "intent": "Get the per-execution trend line for one test on the legacy Test Health detail view \u2014 each run's status colour, duration and build context over the window \u2014 to plot stability and duration over time.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (read from the path on this route)." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ] + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis)." + }, + { + "name": "buildName", + "type": "string", + "description": "Restrict to one build name." + }, + { + "name": "browser", + "type": "string" + }, + { + "name": "os", + "type": "string" + }, + { + "name": "device", + "type": "string" + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." + } + ], + "returns": [ + "keys", + "data" + ], + "guidance": [ + "Response is {keys, data}: keys is the parallel array of startedAt epochs, data is the points. Each point is MINIMAL \u2014 {color, y (duration), id (test-run id), buildId (serial id), buildName}. There are NO smart-tag flags and no workflow status here; use get_suite_health_trend for those.", + "allBuilds and isFlaky are accepted by the endpoint but silently dropped (never passed to the service) \u2014 do not rely on them to filter.", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Points are ordered oldest-first (startedAt asc) and capped at 5000 executions with no paging.", + "Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." + ], + "responses": { + "200": { + "$response": "TestHealthTrendResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/trend", + "mode": "read", + "entity": "sp_test", + "intent": "Get the per-execution time series for one test on the Suite Health detail page \u2014 each run's status, workflow status, duration, build context and smart-tag flags (flaky / newly failed / always failing / performance anomaly) \u2014 for the trend chart.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. This handler declares no @PathVariable and never calls setStabilityHash \u2014 the {stabilityHash} path segment is bound into the filter object by ExtendedServletRequestDataBinder (it binds URI path vars onto the command object). Supply it in the path as the route requires." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "browser", + "type": "string" + }, + { + "name": "os", + "type": "string" + }, + { + "name": "device", + "type": "string" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, only the most recent run per build is kept in the series." + } + ], + "returns": [ + "keys", + "data" + ], + "guidance": [ + "Response is {keys, data}: keys is the parallel array of startedAt epochs; data points are RICH \u2014 {color, workflowStatus, y (duration), id (test-run id), buildId (serial id), buildName, isFlaky, isAlwaysFailing, isNewlyFailed, isPerformanceAnomaly, flakyReason, creationType}. Null-valued fields are omitted (JsonInclude NON_NULL).", + "Unlike get_test_health_trend, this carries the smart-tag flags and workflow status \u2014 prefer it when you need them.", + "This handler relies on the data binder for stabilityHash rather than an explicit @PathVariable; the source inventory's claim that the path segment is ignored does not hold under ExtendedServletRequestDataBinder. Pass stabilityHash in the path.", + "Points are ordered oldest-first and capped at 5000 executions with no paging. With isAutomationLatest=true the series is deduped to the latest run per build.", + "Unknown query keys are swallowed into customParams, not rejected. Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthTrendResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_test_info", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/info", + "mode": "read", + "entity": "sp_test", + "intent": "Get the header metadata for one test's Suite Health detail page \u2014 the test's display name plus the list of OS / browser / device combinations it has executed on \u2014 used to populate the platform filter.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "name", + "combinations" + ], + "guidance": [ + "Returns {name, combinations}. name is the test's display name from a sample run \u2014 an empty string \"\" when no run matches the window (not null, not an error).", + "combinations lists the platform combos the test ran on, each {os, browser} where browser also nests its device. Combos whose browser and device are both non-displayable are filtered out, so the list can be shorter than the raw platform set.", + "stabilityHash from the path overrides anything sent in the query. Unknown query keys are swallowed into customParams.", + "Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token.", + "This is the Suite Health platform/identity header \u2014 distinct from the get_test_base_info capability." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestInfoResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_test_platform_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/breakdown", + "mode": "read", + "entity": "sp_test", + "intent": "Break one test's stability and performance down per OS / browser / device combination, so you can see which platform is driving its flakiness or slowness.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "[]" + ], + "guidance": [ + "The 200 body is a BARE JSON ARRAY (no wrapping object), one row per OS/browser/device combination, sorted by reliability descending.", + "Each row's id is the composite \"-\" string, and carries reliability (failure %), duration (average), totalFailures {failed,total}, plus os / browser / browserDevice objects for display.", + "Rows whose browser and device are both non-displayable are dropped, so the array can be shorter than the raw combination set.", + "stabilityHash from the path overrides the query string. Unknown query keys are swallowed into customParams.", + "Any service-level error is rethrown as a bare RuntimeException \u2192 HTTP 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthPlatformBreakdownResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_suite_health_test_runs", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds", + "mode": "read", + "entity": "sp_test", + "intent": "List the individual executions of one test across builds \u2014 per-run status, duration, smart tags (flaky / newly failed / always failing / performance anomaly) and build context \u2014 for the run-history table on the Suite Health detail page.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (set from the path onto the filter object)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "start", + "type": "integer", + "description": "Pagination cursor (epoch millis). Pass the pagingParams.start returned by the previous page to fetch the next, older page; runs are returned newest-first and start acts as the exclusive upper time bound. Omit for the first page." + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, keeps only the latest run per build within the page." + } + ], + "returns": [ + "pagingParams", + "builds" + ], + "guidance": [ + "Cursor-paged, NOT page-numbered: 20 runs per page, newest-first. To get the next (older) page, pass the pagingParams.start from the previous response as the `start` query param.", + "The offset field inside pagingParams is a fixed placeholder (always 20) kept only for the FE contract \u2014 the backend does not read it; drive paging with start.", + "pagingParams.hasNext is true whenever the page returned any rows; a subsequent empty page is how you know you have reached the end.", + "Smart-tag flags for each run are NOT top-level \u2014 they are under the run's historyAggregate object {isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly}, resolved per run from that build's setting version.", + "Two-stage internally: Elasticsearch resolves the matching test-run ids, then per-run detail (status, duration, CI/VCS info, PM-tool links) is read from the test-run store.", + "Unknown query keys are swallowed into customParams. Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_overview", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testOverview", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id (the numeric BTCer id, not a TR-NNN string)." + } + ], + "intent": "Get the Overview-tab payload for a single test run \u2014 framework/SDK and platform context (browser, OS, device), the BrowserStack session id, user and BrowserStack tags, and the run's start time.", + "guidance": [ + "runInfo is a NESTED object ({duration, browser, os, device}); duration, browser, os and device do NOT appear at the top level even though they read like headline attributes \u2014 read them under runInfo.", + "sessionId is not always a plain BrowserStack Automate session id: for App Automate non-SDK frameworks it is resolved differently, so do not assume it addresses the Automate session API directly.", + "bsTags is the BrowserStack-side tag list (array of objects); userTags is the plain string array the user set \u2014 they are two different fields.", + "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ], + "returns": [ + "runInfo", + "sessionId", + "userTags", + "bsTags", + "isRealDevice", + "startedAt", + "unmuteConditionConfig" + ], + "responses": { + "200": { + "description": "Overview payload for the test run.", + "schema": { + "type": "object", + "properties": { + "runInfo": { + "type": "object", + "description": "Nested platform block; duration/browser/os/device live here, not at top level.", + "properties": { + "duration": { + "type": "integer", + "description": "Run duration in ms." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor." + }, + "os": { + "type": "object", + "description": "Formatted platform/OS descriptor." + }, + "device": { + "description": "Device name, or null for non-real-device runs." } + } + }, + "sessionId": { + "type": "string", + "description": "BrowserStack session id; resolved differently (not a plain Automate id) for App Automate non-SDK frameworks." + }, + "userTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User-set tags." + }, + "bsTags": { + "type": "array", + "items": { + "type": "object" + }, + "description": "BrowserStack-side tag objects." + }, + "isRealDevice": { + "type": "boolean" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "unmuteConditionConfig": { + "type": "object", + "description": "Auto-unmute condition config for the run, when set." } - }, - "BuildGroupListResponse": { - "description": "Paged run-group listing. Groups at top-level `data`; paging under `info`. No `success` wrapper.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildGroup" - } - }, - "info": { - "$schema": "PageInfo" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_network_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "default": false, + "description": "When true, keep only HAR entries whose startedDateTime falls within the test's start/finish window. Default false returns all session entries." + } + ], + "intent": "Get a test run's network (HAR) entries parsed server-side \u2014 obs-api downloads the HAR, filters it to the test window and returns the request entries so the Network tab renders without the browser downloading the raw file.", + "guidance": [ + "Returns {log:{entries:[\u2026HAR entries\u2026]}, responseOrigin} \u2014 NOT the raw HAR envelope. When the HAR exceeds the network-log size limit the download is skipped: entries comes back EMPTY and a meta:{isTruncated:true} flag is added \u2014 fall back to get_test_run_network_log_url for oversized logs.", + "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration \u2014 treat that as 'no logs', not an error.", + "This is the server-parsed counterpart of get_test_run_network_log_url; prefer the URL read when you only need the pointer or the file may be large.", + "In non-prod a Redis key can make it return mocked network logs." + ], + "returns": [ + "log", + "responseOrigin", + "meta" + ], + "responses": { + "200": { + "description": "Parsed network logs, or null when the run has no BrowserStack integration.", + "schema": { + "type": "object", + "properties": { + "log": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object" + }, + "description": "HAR entries; empty when the file was oversized (see meta.isTruncated) or absent." } - } - }, - "BuildGroupOperationResponse": { - "description": "Result of a create/update/delete/close on a run group. Serialized non-null only, so the exact key set depends on the operation: create/update carry {success, data, message}; delete carries {success, message}; close carries {success, message, build_group_id, closed_builds}.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$schema": "BuildGroup" - }, - "message": { - "type": "string" - }, - "build_group_id": { - "type": "integer", - "description": "Echoed on close." - }, - "closed_builds": { - "type": "integer", - "description": "Number of builds flipped to closed (close only)." - } + } + }, + "responseOrigin": { + "type": "string", + "description": "Where the logs were sourced from, e.g. 'observability' or 'bstack'." + }, + "meta": { + "type": "object", + "description": "Present ONLY when the HAR was too big to parse.", + "properties": { + "isTruncated": { + "type": "boolean" } + } } - }, - "AccessibilityTestRunsResponse": { - "description": "Build-level a11y test listing assembled by TRA. NOTE: for App-Accessibility groups whose build source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", - "schema": { - "type": "object", - "properties": { - "testCases": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Test case id." - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "file": { - "type": "string" - }, - "thBtcerId": { - "type": "string", - "description": "The btcer UUID for this test." - }, - "osData": { - "type": "object", - "description": "OS name/version/icon." - }, - "browserData": { - "type": "object", - "description": "Browser name/version/icon." - }, - "summary": { - "type": "object", - "properties": { - "pageCount": { - "type": "integer" - }, - "componentCount": { - "type": "integer" - }, - "score": { - "type": "number" - }, - "issueCount": { - "type": "integer" - }, - "severityBreakdown": { - "type": "object", - "description": "Counts keyed by critical/serious/moderate/minor." - } - } - } - } - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Next page, re-based to 0-index for the FE (upstream next page - 1)." - } - } - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_network_log_url", + "method": "GET", + "path": "/api/v1/testRuns/v2/{testRunId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "default": false, + "description": "When true, ALSO return testStartedAt/testFinishedAt so the client can window the HAR itself. It does NOT filter the file server-side (that is get_test_run_network_logs)." + } + ], + "intent": "Get the pointer to a test run's network (HAR) log rather than its content \u2014 the S3/BrowserStack URL plus status code and message, so the client fetches (or skips) the file itself.", + "guidance": [ + "Returns {networkLogs:, status_code, message}. The URL key is `networkLogs`, NOT `url`.", + "When the file is oversized a meta:{isTruncated:true, fileSize:} block is added so the UI can show the long-session notice instead of downloading \u2014 the URL is still returned alongside it.", + "trim=true adds testStartedAt/testFinishedAt to the body but does not trim the file; use get_test_run_network_logs when you want the entries filtered server-side.", + "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration. In non-prod a Redis key can make it return mocked network logs." + ], + "returns": [ + "networkLogs", + "status_code", + "message", + "testStartedAt", + "testFinishedAt", + "meta" + ], + "responses": { + "200": { + "description": "Network-log descriptor, or null when the run has no BrowserStack integration.", + "schema": { + "type": "object", + "properties": { + "networkLogs": { + "type": "string", + "description": "S3/BrowserStack URL of the raw HAR (the log content is fetched from here)." + }, + "status_code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "testStartedAt": { + "type": "string", + "nullable": true, + "description": "Present only when trim=true. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." + }, + "testFinishedAt": { + "type": "string", + "nullable": true, + "description": "Present only when trim=true; null when the run has not finished. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." + }, + "meta": { + "type": "object", + "description": "Present ONLY when the HAR is oversized.", + "properties": { + "isTruncated": { + "type": "boolean" + }, + "fileSize": { + "type": "integer", + "description": "HAR size in bytes." } + } } - }, - "AccessibilityTestMetaResponse": { - "description": "Per-test a11y detail. NOTE: for App-Accessibility groups whose run source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", - "schema": { - "type": "object", - "properties": { - "totalIssues": { - "type": "integer" - }, - "severityBreakdown": { - "type": "object", - "description": "Counts keyed by critical/serious/moderate/minor." - }, - "reportURL": { - "type": "string", - "description": "URL of the a11y report to fetch elsewhere." - }, - "score": { - "type": "number" - }, - "testCaseUuid": { - "type": "string" - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_detail_tabs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/tabs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "List which tabs the test-detail page should render for a run \u2014 Overview, Behaviour, Logs, Network, Test code, Test cases and so on \u2014 computed from the run's data, the build's framework and whether it is a BDD build.", + "guidance": [ + "The tab list is DERIVED per run, not stored; each element is {label, value} and the set varies from run to run.", + "A missing/unknown test run surfaces here as a 500 (an internal null-pointer), NOT a typed TEST_RUN_NOT_FOUND \u2014 do not read the 500 as an outage.", + "Use this as a UI hint: a tab's presence implies the corresponding data read is worth calling for this run, its absence that the run has nothing for it." + ], + "returns": [ + "label", + "value" + ], + "responses": { + "200": { + "description": "Ordered list of tabs to render.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display label for the tab." + }, + "value": { + "type": "string", + "description": "Tab key (Overview, Behaviour, Logs, Network, TestCode, TestCases, \u2026)." + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_linked_test_cases", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testCases", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "searchAfter", + "type": "integer", + "description": "Offset cursor. Pass the searchAfter value returned in the previous response's pagingParams. Defaults to 1 server-side when omitted; forced to 0 in the demo sandbox." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Opaque JSON paging token; echo back the previous response's pagingParams.nextRoot unchanged. Optional." + } + ], + "intent": "List the Test Management test cases linked to a test run (its BTCer test cases), paginated, for the Test cases tab of the test-detail page.", + "guidance": [ + "Page size is FIXED at 100 server-side \u2014 there is no page-size parameter. Walk pages while pagingParams.hasNext is true.", + "The cursor is an OFFSET that advances by 1 per page (the response's pagingParams.searchAfter = current offset + 1), NOT by the page size of 100 \u2014 echo the returned pagingParams rather than computing your own offset.", + "In the demo sandbox the offset is forced to 0, so paging is effectively disabled there.", + "The linked cases are under `testCases`; `pagingParams` carries hasNext, searchAfter and nextRoot for the next call." + ], + "returns": [ + "pagingParams", + "testCases" + ], + "responses": { + "200": { + "description": "A page of linked test cases plus the cursor for the next page.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "Offset for the next page (current offset + 1)." + }, + "nextRoot": { + "type": "object", + "description": "Opaque paging token map.", + "nullable": true } - } - }, - "SplitBuildLegsResponse": { - "description": "Paginated child legs of a split/parallel build (cursor by build uuid).", - "schema": { - "$schema": "SplitBuildLegs" - } - }, - "BuildNotificationSummaryResponse": { - "description": "Compact build notification/email summary. HTTP 200 with a NULL body when the build id is unknown.", - "schema": { - "$schema": "BuildNotificationSummary" - } - }, - "BuildStabilityTileResponse": { - "description": "Build stability tile.", - "schema": { - "$schema": "BuildStabilityTile" - } - }, - "BuildSummaryStatsResponse": { - "description": "Build headline test-count strip.", - "schema": { - "$schema": "BuildSummaryStats" - } - }, - "BuildTimelineStatsResponse": { - "description": "Build execution timeline series.", - "schema": { - "$schema": "BuildTimelineStats" - } - }, - "BuildDetailsResponse": { - "description": "Full single-build detail with UI gates.", - "schema": { - "$schema": "BuildDetails" - } - }, - "BuildFailureCategoryStatsResponse": { - "description": "Failure-category breakdown. Empty data + explanatory message (still 200) while the build is pending.", - "schema": { - "$schema": "BuildFailureCategoryStats" - } - }, - "BuildFailuresByModuleResponse": { - "description": "Failures grouped by module/file path.", - "schema": { - "$schema": "BuildFailuresByModule" - } - }, - "BuildFilterValuesResponse": { - "description": "Older compact build filter-values map.", - "schema": { - "$schema": "BuildFilterValues" - } - }, - "BuildTestFoldersResponse": { - "description": "Build test-listing folder tree; root-folders or folder-detail shape depending on folder_path.", - "schema": { - "$schema": "BuildTestFolders" - } - }, - "BuildNonSdkTestCountsResponse": { - "description": "Data-hygiene counters. The body is a JSON STRING that must be parsed to read the three count keys.", - "schema": { - "$schema": "BuildNonSdkTestCounts" - } - }, - "SingleMetricSummaryResponse__w4_build2": { - "description": "Single-metric tile (flaky / muted / new-failure).", - "schema": { - "$schema": "SingleMetricSummary__w4_build2" - } - }, - "SidebarUserFilterResponse": { - "description": "Users offered by the build-list sidebar Users facet. `data` is an unordered, de-duplicated set capped at 100.", - "schema": { + } + }, + "testCases": { + "type": "array", + "items": { "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "User id." - }, - "name": { - "type": "string", - "description": "User's full name (the wire field is `name`, populated from the user's full name)." - } - } - } - } - } + "description": "BtcerTestCases entry \u2014 a TM test case linked to the run (identifier, name, status and TM links; keys vary)." + } } - }, - "SidebarTeamFilterResponse": { - "description": "Teams offered by the build-list sidebar Teams facet, resolved via the BrowserStack teams service. `data` is unordered and capped at 100 owning sub-groups.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Team id (BrowserStack team / sub-group id)." - }, - "name": { - "type": "string", - "description": "Team name, from central/rails team data." - } - } - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_test_code", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testCode", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Get the source location of the test that produced a run \u2014 the file name and a deep link into the connected VCS (GitHub/GitLab/etc.) built from the build's VCS info \u2014 for the Test code tab.", + "guidance": [ + "The `url` deep link depends on VCS-integration data captured on the build; without it only `filename` is meaningful and `url` may be empty.", + "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build is refused with FORBIDDEN_OPERATION.", + "`code`, `language` and `type` are populated only when the source was captured; often only `filename` is set. `testRunId` comes back null - keep the id you asked with." + ], + "returns": [ + "language", + "code", + "filename", + "url", + "testRunId", + "type" + ], + "responses": { + "200": { + "description": "Test-code descriptor for the run.", + "schema": { + "type": "object", + "properties": { + "language": { + "type": "string", + "nullable": true + }, + "code": { + "type": "string", + "description": "Source of the test, when available.", + "nullable": true + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string", + "description": "VCS deep link; empty when the build has no VCS integration." + }, + "testRunId": { + "type": "integer", + "nullable": true, + "description": "Null on this route (not an echo of the requested id)." + }, + "type": { + "type": "string", + "nullable": true } - }, - "SidebarTestCaseStatusesResponse": { - "description": "Raw pass-through of Test Management's custom-fields API for entity `TestResult` (the configured manual test-result status options) for the selected projects. Returned verbatim, NOT wrapped in the {data:[...]} envelope the other sidebar facets use.", - "schema": { - "type": "object", - "description": "Opaque TestHub custom-fields JSON; keys and nesting are defined by Test Management, not by this service, and are returned as received." + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_media_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testDetails", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Get the media/session block for a test run's detail page \u2014 the session video (with playback offsets), audio log, the BrowserStack session and Percy build URLs, framework, whether the run is SDK-based, plus custom metadata.", + "guidance": [ + "Despite the /testDetails route this returns the MEDIA + session-link block (videoLogs, audioLogs, browserstackSessionUrl, percyBuildUrl, framework, isSdk, customMetadata), NOT the run's metadata \u2014 use get_test_run_meta for identity/status/history.", + "videoLogs carries startOffset/finishOffset/startTimeStamp for aligning playback with the log timeline; audioLogs and percyBuildUrl are present only when the run actually has them.", + "testStartedAt drives the FE's 30-day-expired empty state and buildId feeds the long-session cURL fallback \u2014 they are context flags, not log content. buildId can be null even for a real SDK run.", + "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build returns FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED rather than partial data." + ], + "returns": [ + "videoLogs", + "audioLogs", + "browserstackSessionUrl", + "percyBuildUrl", + "isSdk", + "device", + "framework", + "customMetadata", + "testStartedAt", + "buildId" + ], + "responses": { + "200": { + "description": "Media/session block for the test run.", + "schema": { + "type": "object", + "properties": { + "videoLogs": { + "$schema": "ObsSessionVideo" + }, + "audioLogs": { + "description": "Audio-log descriptor; null when absent.", + "nullable": true + }, + "browserstackSessionUrl": { + "type": "string" + }, + "percyBuildUrl": { + "type": "string", + "description": "Percy build URL; null when the run has no Percy build.", + "nullable": true + }, + "isSdk": { + "type": "boolean" + }, + "device": { + "type": "string" + }, + "framework": { + "type": "string" + }, + "customMetadata": { + "description": "Opaque custom metadata attached to the run.", + "nullable": true + }, + "testStartedAt": { + "type": "string", + "description": "Drives the FE 30-day-expired empty state for network logs.", + "nullable": true + }, + "buildId": { + "type": "string", + "description": "Hashed BrowserStack build id for the long-session cURL fallback; can be null even for an SDK Automate run with a session.", + "nullable": true } - }, - "HomeUserFilterOptionsResponse": { - "description": "Selectable values for one home/overview personal-filter dropdown, keyed by the requested `key`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true, - "description": "Null on this route." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true, - "description": "Occurrence count; null for buildStatus/sources." - }, - "pId": { - "type": "string", - "nullable": true - } - } - } - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_meta", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testMeta", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "excludeHistory", + "type": "boolean", + "default": false, + "description": "When true, skip execution-history assembly (much cheaper). Use when only the header is needed; fetch history separately via list_test_run_execution_history." + } + ], + "intent": "Get the primary metadata blob for a single test run \u2014 identity (name, file/test-case identifier), status, flakiness/mute flags, issue type and RCA data, retries, build context and, unless excluded, its recent execution history \u2014 feeding the header of the test-detail drawer.", + "guidance": [ + "excludeHistory=true skips history assembly and is much cheaper \u2014 use it when you only need the header; the history is separately available from list_test_run_execution_history.", + "The test-case identifier is OVERWRITTEN with the third-party (TCM/Jira) identifier when the case has one, so testCase.identifier may not be the native TC-NNN id.", + "`status` comes back NULL when the run is retention-locked (the run itself is still returned) \u2014 do not read a null status as 'no status recorded'.", + "`history` is [] when excludeHistory=true and `retries` is null when the test has no retries \u2014 both are absent-as-empty, not errors." + ], + "returns": [ + "testName", + "status", + "testHash", + "buildId", + "buildName", + "history", + "retries", + "issueType", + "testCase", + "buildDetails", + "observabilityVersion", + "testRunRcaData", + "isMuted", + "isFlaky" + ], + "responses": { + "200": { + "description": "Test-run metadata blob.", + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "'HOOK' or 'TEST'." + }, + "isHookAsTest": { + "type": "boolean" + }, + "errorClusterId": { + "type": "string", + "description": "Unique-error cluster id; only resolved for FAILED runs." + }, + "history": { + "type": "array", + "items": { + "$schema": "ObsHistoryTab" + }, + "description": "Recent executions across builds; [] when excludeHistory=true." + }, + "retries": { + "description": "List of retry-attempt metadata, or null when there are none." + }, + "testName": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Null when the run is retention-locked." + }, + "isFlaky": { + "type": "boolean" + }, + "testHash": { + "type": "string" + }, + "buildId": { + "type": "string", + "description": "Build UUID." + }, + "buildName": { + "type": "string" + }, + "commentCount": { + "type": "integer" + }, + "isMuted": { + "type": "boolean" + }, + "isLatest": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "issueType": { + "type": "object", + "description": "Failure issue type with resolved displayName." + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "isAutoAnalyzerRunning": { + "type": "boolean" + }, + "isUniqueErrorsRunning": { + "type": "boolean" + }, + "buildDetails": { + "type": "object", + "description": "Build context: status, state, reRun eligibility, issueTypes, ciBuildData, isArchived, finishedAt, id." + }, + "observabilityVersion": { + "type": "object", + "description": "Framework/SDK version info for the build." + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "workflowStatusEditedBy": { + "type": "integer" + }, + "workflowStatusEditedAt": { + "type": "string", + "format": "date-time" + }, + "testCaseVersionId": { + "type": "integer" + }, + "testCaseId": { + "type": "integer" + }, + "testCase": { + "type": "object", + "description": "Linked TM test case; identifier is the third-party (TCM/Jira) id when one exists." + }, + "thProjectId": { + "type": "integer" + }, + "testRunRcaData": { + "type": "object", + "description": "RCA data with resolved failureTypeDisplayName." + }, + "projectId": { + "type": "integer" + }, + "device": { + "type": "string" + }, + "cbtBrowser": { + "type": "string" + }, + "cbtPlatform": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "description": "Opaque JSON meta blob for the run." + }, + "source": { + "type": "string" + }, + "testCaseCount": { + "type": "integer" + }, + "note": { + "description": "Session note (banner parity); null when absent." + }, + "sdkNudge": { + "description": "SDK nudge; null when absent." + }, + "nudges": { + "description": "Mobile-CTM upsell nudges for the current viewer; {} when ineligible." } - }, - "WebhookFilterOptionsResponse": { - "description": "Webhook rule-builder filter options for the requested `type`, with a composite-aggregation paging cursor.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Display label; for `folder` it is 'name(count)'." - }, - "value": { - "type": "string", - "description": "The value to store in the rule; for `folder` it is the full path." - }, - "pId": { - "type": "string", - "description": "For `folder`, the parent folder path; for other types it echoes the value." - }, - "count": { - "type": "integer", - "description": "Matching document count for the value.", - "nullable": true - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - }, - "meta": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - } - } - } - }, - "afterKey": { - "type": "string", - "description": "The last key of this page. It is still set on the final page, so stop when a page returns fewer rows than the page size (10; 1000 for folder) or an empty `data`.", - "nullable": true - } - } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_execution_history", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testMetaHistory", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "List the recent execution history of the same test across previous builds (the history strip on the test-detail page) so you can tell whether a failure is new, flaky or long-standing.", + "guidance": [ + "The same data is embedded in get_test_run_meta unless excludeHistory=true \u2014 this endpoint exists so the header can load fast and the history be fetched separately.", + "Each row carries testRunId, status, serialId, testRunUuid, buildUuid, workflowStatus, creationType, usageQuotaStatus, isHittingRetentionQuota and thProjectId; startedAt is used for ordering but is not returned.", + "An unknown id returns TEST_RUN_NOT_FOUND." + ], + "returns": [ + "testRunId", + "status", + "serialId", + "testRunUuid", + "buildUuid", + "workflowStatus", + "creationType", + "isHittingRetentionQuota" + ], + "responses": { + "200": { + "description": "List of past executions of the same test.", + "schema": { + "type": "array", + "items": { + "$schema": "ObsHistoryTab" + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_runs_history_details", + "method": "POST", + "path": "/api/v1/builds/testRuns/historyDetails", + "mode": "read", + "entity": "test_run", + "intent": "For a batch of test runs (or hook runs), get each one's execution history across previous builds together with its logs and failure/issue-type info \u2014 the data behind the test details 'history' panel, used to judge flakiness and whether a failure is new.", + "body": [ + { + "name": "testRunIds", + "type": "array", + "required": true, + "description": "Integer ids of the test runs to fetch history for. When `isHook` is true these are HOOK run ids, resolved against a different backing store \u2014 mixing test-run and hook-run ids in one call returns history for whichever set matches the `isHook` flag.", + "fields": [ + { + "name": "item", + "type": "integer", + "description": "A test run id (or hook run id when isHook=true)." + } + ] + }, + { + "name": "isHook", + "type": "boolean", + "json_path": "/isHook", + "description": "Routes the whole request. false (default) treats `testRunIds` as test run ids via the test-run service; true reinterprets them as hook run ids and resolves them through the hook-run service instead. Bound from the JSON key `isHook` exactly (a differently cased key is ignored and defaults to false)." + } + ], + "guidance": [ + "This is a POST that only carries a filter body \u2014 it reads and changes nothing (routed to a read replica), so it is safe to call freely.", + "`isHook: true` switches the ENTIRE lookup to hook runs: the ids you send are then hook run ids, not test run ids. Sending test run ids with isHook=true returns history for the hook runs (if any) with those ids, not your tests.", + "The response is a JSON OBJECT keyed by the id you asked for (`{ \"\": {\u2026history\u2026} }`), not an array. An id with no history is simply absent from the map \u2014 iterate the ids you sent rather than assuming every one comes back.", + "Each entry's `logs` is an object with only the keys that exist for that run: `TEST_FAILURE` (an ARRAY of strings - message then stack), `TEST_LOG` (an array of screenshot/attachment URLs the caller fetches directly from storage), and `BROWSERSTACK_ERROR`. `logs` is {} for passed runs. Several flags (isNewlyFailed, isPerformanceAnomaly, flakySetting) can be null.", + "Scoped to the caller's group in the repository query \u2014 ids outside the caller's group are dropped from the result, not errored." + ], + "returns": [ + "buildId", + "serialId", + "status", + "issueType", + "duration", + "isFlaky", + "isAlwaysFailing", + "isNewlyFailed", + "isPerformanceAnomaly", + "flakyReason", + "flakySetting", + "creationType", + "tags", + "logs", + "workflowStatus", + "workflowStatusEditedBy", + "workflowStatusEditedAt" + ], + "responses": { + "200": { + "$response": "TestRunHistoryDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_retry_logs", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunUuid}/retryLogs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler \u2014 any value satisfies routing; the run is located by testRunUuid alone." + }, + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "UUID of the retry attempt / test run whose evidence to fetch. This route keys off the UUID, unlike the sibling /testRuns/{testRunId} routes which take the numeric id. A malformed UUID throws before any lookup." + } + ], + "intent": "Get the screenshot/log attachment structure captured for a single retry attempt of a test, so the retries tab can render that attempt's evidence.", + "guidance": [ + "Identify the run by UUID here \u2014 the numeric id that the other /testRuns/{\u2026} endpoints take will not resolve on this route.", + "The response is a log-structure object with only the keys that exist: `TEST_FAILURE` (failure backtrace), `TEST_LOG` (an array of screenshot URLs \u2014 fetch each from storage yourself; they are not proxied through this API), and `BROWSERSTACK_ERROR`. An attempt with no captured evidence throws `IllegalArgumentException(\"retry doesn't exist\")` rather than returning an empty object, so treat that error as 'no screenshot data', not a missing run.", + "Reads through the group-scoped repository factory, so a run in another group behaves as absent." + ], + "returns": [ + "TEST_FAILURE", + "TEST_LOG", + "BROWSERSTACK_ERROR" + ], + "responses": { + "200": { + "$response": "TestRunRetryLogsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_run_sdk_logs", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRunLogs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (NOT a numeric build id), resolved by (uuid, groupId). A UUID outside the caller's group 404s with BUILD_NOT_FOUND." + } + ], + "query": [ + { + "name": "cursor", + "type": "integer", + "required": false, + "description": "Opaque forward cursor \u2014 pass back `cursorInfo.nextCursor` from the previous page. Omit it for the first page; the per-build `totalAttachments` count is computed ONLY on that first (cursor-absent) page and is absent on later pages." + } + ], + "intent": "Page through the test runs in a build that have SDK-captured logs/attachments, with the per-build attachment total \u2014 backs the build's log/attachment viewer.", + "guidance": [ + "Page size is fixed at 15 and cannot be changed; walk pages by feeding `cursorInfo.nextCursor` back as `cursor` until `cursorInfo.hasMore` is false.", + "`totalAttachments` is returned only on the first page (when `cursor` is omitted). Read it there and carry it; do not expect it on subsequent pages.", + "Each row's `url` is a direct attachment/log URL fetched from storage, not through this API. `attachmentsCount` and `screenshotsCount` are per-test-run totals.", + "Path variable is named buildId but is bound to the build UUID string \u2014 sending a numeric id yields BUILD_NOT_FOUND." + ], + "returns": [ + "data", + "cursorInfo", + "totalAttachments" + ], + "responses": { + "200": { + "$response": "TestRunSdkLogsListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "assign_test_run_failure_categories", + "method": "PUT", + "path": "/api/v1/builds/{projectId}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "A PROJECT id, despite the /builds/ route prefix. Interpreted per product: when the caller's CAD product is TM it is a TestHub project id and mapped to the observability project; otherwise it is the observability project id directly." + } + ], + "body": [ + { + "name": "issues", + "type": "array", + "required": true, + "description": "The per-run category assignments to apply. Required and validated (@NotNull); each item is validated too.", + "fields": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Id of the test run to categorise. Bound from the JSON key `testRunId` (maps to the internal `id`); required per item." + }, + { + "name": "issueTypeId", + "type": "integer", + "required": true, + "description": "Id of the issue type / sub-type to assign to that run. Required per item." + } + ] + } + ], + "intent": "Bulk-assign failure categories (issue types / sub-types) to a set of test runs \u2014 the 'categorise these failures' action on the test listing; returns the resulting issue types.", + "guidance": [ + "Requires the BUILD_UPDATE_FAILURE_CATEGORIES permission on the (resolved) project; otherwise AccessDenied. The path id is a project id, not a build id \u2014 do not pass a build id.", + "This overwrites each run's current category; there is no version history, so the previous category is not recoverable through this API. Read the runs first if you need to record what they were.", + "A 409 (AUTO_ANALYSIS_IN_PROGRESS) means auto-analysis is running \u2014 it is SAFE to retry, because the whole batch is validated up front and nothing is written until it passes, so a 409 wrote nothing.", + "The whole batch is pre-checked before any write, so an invalid item fails the entire request rather than partially applying.", + "Response is a flat array of the resulting IssueType objects, not a per-run map." + ], + "returns": [ + "id", + "name", + "locator", + "abbreviation", + "color", + "groupId", + "userId", + "displayName", + "issueGroup" + ], + "responses": { + "200": { + "$response": "AssignTestRunFailureCategoriesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "409": { + "$response": "Conflict" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_bstack_error_sessions", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/bstackErrorSessions", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID. Looked up with findOneByUuid WITHOUT the group scoping the other endpoints on this controller apply \u2014 do not rely on cross-group isolation here." + } + ], + "intent": "List the sessions in a build that failed because of BrowserStack-side infrastructure errors, so the test listing can separate platform problems from genuine test failures.", + "guidance": [ + "SDK builds only. A non-SDK build \u2014 and the demo-sandbox environment \u2014 returns an EMPTY array, so `[]` does not mean 'no infra errors occurred'; confirm the build is an SDK build before concluding there were none.", + "The response is a raw JSON array of session objects straight from the session repository (no envelope, no fixed schema) \u2014 treat the element fields as opaque and read defensively.", + "Unlike the sibling listing endpoints, the build here is resolved without group scoping (findOneByUuid)." + ], + "returns": [], + "responses": { + "200": { + "$response": "BstackErrorSessionsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_flat_filtered", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/flatTestRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "headers": [ + { + "name": "Public-Link-Token", + "type": "string", + "required": false, + "description": "Public-link token; when present (or via X-Public-Link-Token) the response is restricted \u2014 pending/in-progress tests are excluded from shared reports." + }, + { + "name": "X-Public-Link-Token", + "type": "string", + "required": false, + "description": "Alternate public-link token header, same effect as Public-Link-Token." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "lastExecutedBy", + "type": "array", + "required": false, + "description": "User ids; capped at 500 (@Size max 500)." + }, + { + "name": "isFlaky", + "type": "boolean", + "required": false, + "description": "Pro filter. On a freemium account, applying any pro filter (isFlaky / hasPerformanceAnomaly / isNewFailure / isAlwaysFailing) returns an EMPTY list rather than erroring." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token (bound via a custom setter)." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level entries; echo it back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Elasticsearch search_after cursor; echo it back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Requested run-count flag, but FORCED to false outside the demo-sandbox environment regardless of what you send \u2014 do not rely on it in production." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as a flat, filterable, sortable, cursor-paged list (no suite/file nesting) \u2014 the build's test-listing table, with the full V2 filter surface and public-link sharing support.", + "guidance": [ + "This is the flat listing on the newer surface: it takes the full FilterParamsV2 filter set, sorting, folder scoping, cursor paging (nextRoot + searchAfter) and public-link tokens. The older flat listing (list_build_test_runs_flat) has none of that; pick this one when you need filtering or sorting.", + "Any unrecognised query params are collected and forwarded as custom filter params \u2014 a misspelled filter name is NOT rejected, it is silently passed through and ignored, so verify the filter took effect against the result.", + "`showRunCount` is forced to false outside the demo sandbox, so run counts will be absent in production even if you ask for them.", + "On a freemium account, applying a pro filter (isFlaky, hasPerformanceAnomaly, isNewFailure, isAlwaysFailing) returns an empty `testRuns` with `pagingParams.hasNext=false` \u2014 an empty result there is a plan gate, not 'no matches'.", + "Page by feeding `pagingParams` (nextRoot + searchAfter) back; the per-page size is a server default that is not caller-configurable here.", + "Send the public-link token via Public-Link-Token or X-Public-Link-Token to get the shared-report view (pending/in-progress hidden)." + ], + "returns": [ + "testRuns", + "pagingParams", + "isArchived", + "smartTagSettings", + "status", + "workflowStatus", + "folder_path" + ], + "responses": { + "200": { + "$response": "FlatTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_hierarchy", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "headers": [ + { + "name": "Public-Link-Token", + "type": "string", + "required": false, + "description": "Public-link token; when present (or via X-Public-Link-Token) pending/in-progress tests are hidden from the shared report." + }, + { + "name": "X-Public-Link-Token", + "type": "string", + "required": false, + "description": "Alternate public-link token header, same effect." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "lastExecutedBy", + "type": "array", + "required": false, + "description": "User ids; capped at 500." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level suite/file roots; echo back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "search_after cursor; echo back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Forced to false outside the demo-sandbox environment regardless of what you send." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as the hierarchical suite/file/test tree the build detail page renders, with full filtering, sorting and cursor paging; this is the variant that honours public-link sharing and forwards custom filter params.", + "guidance": [ + "This is the canonical hierarchical listing: use it for shared/public reports (it reads the Public-Link-Token / X-Public-Link-Token headers and hides pending/in-progress tests for public callers) and when you want arbitrary extra query params forwarded as custom filters. Its twin list_build_test_runs_hierarchy_without_public_link does neither.", + "Unrecognised query params are silently collected and passed through as custom filters \u2014 a misspelled filter name is not rejected; verify it took effect against the tree.", + "Paging is by top-level ROOT nodes, not individual tests: the page holds 5 roots (just 1 for builds over 2000 runs). Walk pages by feeding `pagingParams` (nextRoot + searchAfter) back until it reports no next.", + "`showRunCount` is forced false outside the demo sandbox.", + "The tree sits under the top-level `hierarchy` array (each element is a suite/file/test node); aggregates come back under `status` and `workflowStatus`." + ], + "returns": [ + "hierarchy", + "pagingParams", + "status", + "workflowStatus", + "isArchived", + "smartTagSettings", + "source", + "folder_path" + ], + "responses": { + "200": { + "$response": "HierarchyTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_hierarchy_without_public_link", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns/hierarchy", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level roots; echo back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "search_after cursor; echo back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Forced to false outside the demo-sandbox environment." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as the same hierarchical suite/file/test tree, for session-authenticated callers that do not need public-link sharing or custom-param pass-through.", + "guidance": [ + "Identical tree to list_build_test_runs_hierarchy (same underlying query) with two deliberate differences: this route does NOT read the Public-Link-Token / X-Public-Link-Token headers, and it does NOT collect arbitrary query params as custom filters. Prefer this only for internal/session views; use the public-link-aware twin for shared reports.", + "Because it ignores the public-link headers, a public-link caller hitting this route is NOT given the pending/in-progress suppression \u2014 do not use it to serve shared/public reports.", + "Only the declared query params are honoured here; unknown params are dropped rather than forwarded.", + "Paging is by top-level ROOT nodes (5 per page, or 1 for builds over 2000 runs); walk `pagingParams` (nextRoot + searchAfter). `showRunCount` is forced false outside the demo sandbox." + ], + "returns": [ + "hierarchy", + "pagingParams", + "status", + "workflowStatus", + "isArchived", + "smartTagSettings", + "source", + "folder_path" + ], + "responses": { + "200": { + "$response": "HierarchyTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_run_counts", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns/count", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "intent": "For every unique test in a build (keyed by test hash), get how many times it ran across the build and its re-runs, and whether those runs spanned different platforms \u2014 what the UI uses for rerun/retry badges.", + "guidance": [ + "Returns per-test rows under a top-level `data` array (`{testHash, count, isDifferentPlatformsUsed}`), NOT a single total. Sum `count` yourself if you need a build total.", + "Counts are aggregated over the PARENT build id, so a re-run chain (a build and its re-runs) is counted as one build, not per re-run.", + "`isDifferentPlatformsUsed` is true when the run count for that test hash spanned more than one platform." + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "TestRunCountListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_top_error_stats", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/topErrorStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build uuid." + } + ], + "query": [ + { + "name": "filter", + "type": "object", + "required": false, + "description": "FilterParamsV2 listing-filter set (statuses, frameworks, tags, date range, search, etc.) bound from flat query params; the panel is computed over the tests these match." + }, + { + "name": "customParams", + "type": "object", + "required": false, + "description": "Any extra query params are collected verbatim into a custom-params map and folded into the filter." + } + ], + "intent": "Get the 'Top unique errors' insight panel for a build \u2014 the most frequent error clusters with their test-run counts, respecting the active listing filters.", + "returns": [ + "overview", + "data", + "ready", + "commentCount", + "message" + ], + "responses": { + "200": { + "$response": "BuildTopErrorStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Handle the not-ready state: while the auto-analyser is still running you get ready:false with only a `message` ('Unique Errors are being detected') and the default 'Analysis yet to run' overview \u2014 not an error and not the final data.", + "While clustering is incomplete the panel comes back with an empty `data` set; poll again rather than concluding there are no errors.", + "`data` is sorted by count descending; each row carries the cluster id, title (the error, often a full stack trace), count, testRuns and aiRcaTestRunIds. impactedTests can be null and testRuns can be empty even for a ready panel - use aiRcaTestRunIds (populated) to reach the runs.", + "Build lookup is group-scoped, so a build uuid from another group reads as not found." + ] + }, + { + "name": "recalculate_build_quality_gate", + "method": "POST", + "path": "/api/v1/builds/{uuid}/recalculate-quality-gate", + "mode": "write", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid." + } + ], + "body": [ + { + "name": "close", + "type": "boolean", + "required": false, + "description": "Part of the RecalculateQGBody shape; effectively ignored on this route \u2014 the handler forces the recalculation branch regardless." + }, + { + "name": "reCalculateQG", + "type": "boolean", + "required": false, + "description": "Ignored \u2014 the handler forces reCalculateQG=true. The body must be PRESENT (a valid RecalculateQGBody), but its contents do not change behaviour; unknown keys are ignored." + } + ], + "intent": "Re-run the quality-gate evaluation for a build (e.g. after profile rules or a status override changed) \u2014 use after quality-gate inputs change to refresh the build's gate result.", + "returns": [ + "message" + ], + "responses": { + "200": { + "$response": "BuildQualityGateRecalcAckResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: responds immediately with 200 {message:'Quality Gate result will be available after recalculation finishes'} \u2014 the result is NOT ready yet. Poll the build's quality-gate summary read for the outcome.", + "This OVERWRITES the build's previous quality-gate result; there is no undo for the prior result.", + "A request body must be sent (an object; {} is fine) even though its fields are ignored \u2014 the handler forces the recalculation.", + "403 PLAN_FEATURE_NOT_ALLOWED when the build is plan-locked.", + "Live 2026-09-30: results can take a few minutes to reappear; if they stay empty, one enabled profile in the project may be un-evaluable (see create_quality_profile) \u2014 it blanks every profile's result, not just its own." + ] + }, + { + "name": "resolve_build_uuid_from_identifiers", + "method": "GET", + "path": "/api/v2/builds/buildUuid", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "creationType", + "type": "string", + "required": true, + "description": "Build.CreationTypeEnum. AUTOMATION uses the name/serial identifiers below; any other (manual/TCM) type uses ONLY tcmTestRunId and ignores the name/serial params." + }, + { + "name": "buildNormalisedName", + "type": "string", + "required": false, + "default": "", + "description": "Normalised build name (AUTOMATION path)." + }, + { + "name": "projectNormalisedName", + "type": "string", + "required": false, + "default": "", + "description": "Project normalised name (AUTOMATION path); pair with buildNormalisedName + buildSerialId." + }, + { + "name": "buildSerialId", + "type": "integer", + "required": false, + "default": 0, + "description": "Per-project sequential build number (AUTOMATION path). On a serial-id collision the first match wins (logged)." + }, + { + "name": "tcmTestRunId", + "type": "integer", + "required": false, + "default": 0, + "description": "TCM test-run id \u2014 the only identifier used for non-AUTOMATION creation types." + }, + { + "name": "thProjectId", + "type": "integer", + "required": false, + "default": 0, + "description": "TestHub project id, an alternative to projectNormalisedName for scoping the AUTOMATION lookup." + } + ], + "intent": "Resolve a build UUID from human/CI-facing identifiers \u2014 project name (or TestHub project id) + build name + serial id for automation builds, or a TCM test-run id for manual/TCM runs \u2014 so a caller can deep-link to a build without already knowing its uuid.", + "returns": [ + "uuid" + ], + "responses": { + "200": { + "$response": "ResolvedBuildUuidResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No match returns HTTP 200 {uuid:null} \u2014 NOT a 404. Check the uuid value, not the status code.", + "For non-AUTOMATION creation types only tcmTestRunId is consulted; buildNormalisedName / projectNormalisedName / buildSerialId are ignored on that path.", + "Serial-id collisions are possible \u2014 the first matching build wins (a pager marker is logged)." + ] + }, + { + "name": "list_sidebar_build_runs", + "method": "POST", + "path": "/api/v2/builds", + "mode": "read", + "entity": "build", + "body": [ + { + "name": "projects", + "type": "array", + "required": false, + "description": "Project ids to scope to. When null and UDAC is on, the accessible project list is resolved for the caller's sub-group." + }, + { + "name": "query", + "type": "string", + "required": false, + "description": "Free-text search over build names/identifiers." + }, + { + "name": "afterKey", + "type": "string", + "required": false, + "description": "Forward cursor \u2014 pass the response's pagingParams.afterKey to page down." + }, + { + "name": "beforeKey", + "type": "string", + "required": false, + "description": "Backward cursor \u2014 pass the response's pagingParams.beforeKey to page up." + }, + { + "name": "selectedBuildUuid", + "type": "string", + "required": false, + "description": "Centre the returned window on this build (roughly half a page above it, the rest below) so a deep-linked build appears in context." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "required": false, + "description": "Include manual/TCM runs in the listing (default true)." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "required": false, + "description": "Optional filter on the manual-override flag." + }, + { + "name": "buildNames", + "type": "array", + "required": false, + "description": "Part of the inherited GetBuildFilterRqV2 filter surface: build-name filter. The body also accepts users, teams, frameworks, statuses, sources, tags, branches, dateRange, search, etc." + }, + { + "name": "isArchived", + "type": "boolean", + "required": false, + "description": "Filter to archived vs live builds (inherited filter field)." + } + ], + "intent": "List a page of unique build runs for the build sidebar/list rail, matching a large filter body, with bidirectional cursor paging and optional centring on a deep-linked build.", + "returns": [ + "data", + "pagingParams" + ], + "responses": { + "200": { + "$response": "SidebarBuildRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "OBSERVED LIVE: `buildNames` and `query` are IGNORED by the product (200 with the unfiltered page, even when `projects` is set). Do not use this route to find a build by name; use list_project_builds_by_name (project-scoped, `search`) or search_entities (cross-project).", + "Only the body fields declared here are sent; the inherited filter surface mentioned in the field description (users, teams, statuses, tags, dateRange, search...) is not bindable through this capability.", + "POST but a pure READ \u2014 the body is a filter; nothing is written.", + "Page size is fixed server-side (BUILDS_PAGESIZE); it is NOT caller-controllable, so there is no page-size body field. Page with the afterKey/beforeKey cursors from pagingParams (hasNext/hasPrev tell you when to stop).", + "Honours UDAC project scoping: with projects null and UDAC on, only the caller's accessible projects are returned.", + "Each row's usageQuotaStatus splits retention-accessible builds from 'pro quota' ones \u2014 a build can appear but be gated by quota.", + "Use selectedBuildUuid to keep a deep-linked build visible in the window rather than paging to find it." + ] + }, + { + "name": "generate_build_report", + "method": "POST", + "path": "/ext/v1/builds/buildReport", + "mode": "read", + "entity": "build", + "body": [ + { + "name": "requestingCi", + "type": "string", + "required": true, + "description": "The CI requesting the report (e.g. the Jenkins plugin). Missing \u2192 error." + }, + { + "name": "requestType", + "type": "string", + "required": true, + "values": [ + "FIRST", + "POLL" + ], + "description": "FIRST for the initial request, POLL for follow-ups. An unknown value raises a 400 'Invalid request_type'. Missing \u2192 error." + }, + { + "name": "reportFormat", + "type": "array", + "required": true, + "description": "Requested formats: richHtml, basicHtml and/or plainText (case-sensitive). Other values render nothing -> 400 'Build Report request format is invalid'.", + "values": [ + "richHtml", + "basicHtml", + "plainText" + ] + }, + { + "name": "buildUuid", + "type": "string", + "required": false, + "description": "Optional hint passed with originalBuildName (+buildStartedAt); on its own it does not resolve the build." + }, + { + "name": "originalBuildName", + "type": "string", + "required": true, + "description": "Build's original name. REQUIRED: resolution only runs when it is present; buildUuid alone -> 400 'Original build name not found' (40021)." + }, + { + "name": "buildStartedAt", + "type": "integer", + "required": false, + "description": "Build start time in epoch milliseconds; pairs with originalBuildName for resolution." + }, + { + "name": "userTimeout", + "type": "integer", + "required": false, + "description": "Caller's overall timeout budget, used to bound the polling protocol." + } + ], + "intent": "Ask for a build's summary report in the requested formats \u2014 the CI-plugin endpoint. It is a polling protocol: the first request usually returns IN_PROGRESS with the retry count and interval to use, and later poll requests return the finished report once the build's data has settled.", + "returns": [ + "reportStatus", + "retryCount", + "pollingInterval", + "report", + "errorReason" + ], + "responses": { + "200": { + "$response": "BuildReportPollingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Polling contract, not a single call: on reportStatus IN_PROGRESS you also get retryCount and pollingInterval \u2014 wait that interval and POST again with requestType=POLL until reportStatus is COMPLETED/TEST_AVAILABLE (or NOT_AVAILABLE/DATA_NOT_AVAILABLE).", + "Body is an untyped map validated by hand: requestingCi, requestType and reportFormat are each required and their absence is a distinct error; an unknown requestType is a 400.", + "Resolve the build by originalBuildName (+ buildStartedAt; buildUuid optional). Without originalBuildName the handler skips resolution and returns 400 'Original build name not found'. If the resolved build row is missing you get a 200 with a NULL body: handle that as 'not found', not a report.", + "The report itself (report.richHtml/richCss/basicHtml/plainText) is only populated on the terminal statuses; the envelope is @JsonInclude(NON_NULL) so absent fields are omitted.", + "In the demo-sandbox environment this returns HTTP 404 with body 'NOT FOUND'." + ] + }, + { + "name": "get_build_summary", + "method": "GET", + "path": "/ext/v1/builds/{build_id}", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build uuid. A re-run uuid resolves through its parent, so you get the parent build's aggregate." + } + ], + "intent": "Get one build's public record by uuid \u2014 identity and naming, status and per-status test counts, plus the pro-plan smart tags and unique errors. Narrower than get_build_details, which is the same build seen from inside the product.", + "returns": [ + "build_id", + "id", + "project_id", + "project_name", + "name", + "build_number", + "status", + "status_stats" + ], + "responses": { + "200": { + "$response": "PublicBuildDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Enforces the TEST_RUN_AUTOMATED_VIEW service-account project permission on the build's project.", + "Passing a re-run uuid returns the PARENT build's aggregate, not the re-run's own numbers.", + "A build in 'deleted' state raises BUILD_ID_DELETED, not a plain 404 \u2014 distinguish 'deleted' from 'never existed'.", + "The response SHAPE is feature-flagged: when new build listing is enabled it is the V2 build-details shape, otherwise the legacy one \u2014 do not hard-code one envelope.", + "Pro-only fields (smart_tags, unique_errors, failure_categories, alerts) are stripped by JsonView for non-pro plans, so their absence reflects the plan, not the build.", + "`status` here is the build's LIFECYCLE ('finished'), not its outcome ('failed') as get_build_details reports it \u2014 the two routes answer different questions with the same field name.", + "project_id, project_name and created_at can come back null on this route even when get_build_details has them; read those from the internal route when they matter.", + "Each route carries what the other lacks: this one has smart_tags and unique_errors, while get_build_details has the quality-gate status and AI RCA counts." + ] + }, + { + "name": "list_build_history_with_defect_counts", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/history", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build uuid the history is anchored on." + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "required": false, + "default": 10, + "minimum": 1, + "maximum": 10, + "description": "How many builds to return (this build plus prior runs of the same name). Silently CLAMPED to 1..10 \u2014 asking for more than 10 is ignored, not an error." + } + ], + "intent": "Get the trend context for a build for an automated failure agent \u2014 this build plus up to N earlier runs of the same build name, newest first, each with number, status, timings and a pass/fail/skip plus defect-type count summary.", + "returns": [ + "builds" + ], + "responses": { + "200": { + "$response": "BuildHistoryWithDefectCountsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Not a general history read \u2014 distinct from the internal build-history capability: this is the agent-facing trend view with per-build defect and status counts.", + "limit is clamped server-side to 1..10; there is no paging beyond 10 runs.", + "Only the anchor build and EARLIER runs of the same name are returned; later runs of the same name are filtered out. The anchor build is flagged current:true.", + "Build lookup is group-scoped (the IDOR gate), so a foreign uuid is not found.", + "Per-build counts (statusStats + defectStats: automation bug / product bug / to-be-investigated / not-determined / environment issue) come from a single Elasticsearch aggregation; per-entity recurrence (flaky/theme names) is intentionally not included." + ] + }, + { + "name": "list_build_ai_failure_cluster_test_runs", + "method": "GET", + "path": "/api/v1/ai/failures/{buildUUID}/flat", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "query": [ + { + "name": "testRunIds", + "type": "array", + "required": false, + "description": "Comma-separated integer test-run ids to fetch directly, skipping the theme/workflow lookup. Max 200 (more => 400). Sending this selects the by-ids behaviour; omit it to scope by cluster or whole build.", + "fields": [ + { + "name": "item", + "type": "integer" + } + ] + }, + { + "name": "buildFailureThemeId", + "type": "integer", + "required": false, + "description": "Integer id of a failure THEME to drill into (from get_build_ai_failures). Give this OR buildFailureWorkflowId to scope to one cluster. Ignored if testRunIds is sent." + }, + { + "name": "buildFailureWorkflowId", + "type": "integer", + "required": false, + "description": "Integer id of a failure WORKFLOW to drill into (from get_build_ai_failures). Ignored if testRunIds is sent." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend's Elasticsearch default; do not rely on it. Use searchAfter to page." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Opaque search-after cursor. Pass the previous response's pagingParams.searchAfter (JSON-encoded) to fetch the next page." + } + ], + "intent": "List a build's AI-failure test runs as a flat, cursor-paginated list with full details \u2014 fetch an explicit set of test-run ids, drill into one failure theme or workflow cluster, or omit both to span the whole build.", + "returns": [ + "testRuns", + "pagingParams" + ], + "guidance": [ + "ONE route, three modes chosen by which query params you send: testRunIds -> fetch exactly those runs (skips the theme/workflow lookup); buildFailureThemeId OR buildFailureWorkflowId -> drill into one cluster (ids from get_build_ai_failures); neither -> the whole build.", + "testRunIds is comma-separated integer ids, capped at 200; more returns 400 naming testRunIds.", + "Group-scoped / IDOR-safe: results filter on buildId, so ids or clusters outside the caller's build simply do not match \u2014 a short or empty testRuns (with hasNext=false) is not an error.", + "limit is ACCEPTED BUT IGNORED (fixed backend page size). Paginate with searchAfter and stop when pagingParams.hasNext is false.", + "Not plan-fenced; a plan-downgraded account can still read prior content." + ], + "responses": { + "200": { + "$response": "FlatAIFailureTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + } + ], + "schemas": { + "ErrorResponse": { + "type": "object", + "description": "Standard TRA error body built by GlobalExceptionHandler.", + "properties": { + "errorTraceId": { + "type": "string", + "description": "UUID correlating the error to server logs." + }, + "message": { + "type": "string", + "description": "Human-readable error message; for relayed TestHub errors this is the upstream message." + }, + "code": { + "type": "string", + "description": "Application/TestHub error code. Present on many 4xx; absent on generic 500s." + }, + "classification": { + "type": "string", + "description": "Blame classification when stamped, e.g. USER_ISSUE. Never present on /ext responses.", + "example": "USER_ISSUE" + }, + "hasAccess": { + "type": "boolean", + "description": "Present (false) on access-denied 403s.", + "example": false + } + } + }, + "BuildGroupAggResult": { + "type": "object", + "description": "One build-group row in the grouped build listing (models/BuildGroupAggResult).", + "properties": { + "buildGroupId": { + "type": "integer", + "description": "Build group id." + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "buildCount": { + "type": "integer" + }, + "closedBuildCount": { + "type": "integer" + }, + "closingBuildCount": { + "type": "integer", + "nullable": true + }, + "isGroupClosing": { + "type": "boolean", + "nullable": true + }, + "manualBuildCount": { + "type": "integer" + }, + "automationBuildCount": { + "type": "integer" + }, + "latestCreatedAt": { + "type": "number", + "description": "Epoch millis of the group's newest build (a double)." + }, + "buildGroup": { + "type": "object", + "nullable": true, + "description": "The TestHub build-group record (id, name, closedBy, closedByName, ...), when the group exists in TestHub." + } + } + }, + "SourceFencingFlags": { + "type": "object", + "description": "Cross-source feature-fencing flags; embedded in many widget/build responses. A true flag means results for that product were skipped for this caller.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + }, + "PageInfo": { + "type": "object", + "description": "Standard pagination info envelope.", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer", + "description": "Previous page number, null on the first page." + }, + "next": { + "type": "integer", + "description": "Next page number, null on the last page." + } + } + }, + "BuildAggregate": { + "type": "object", + "description": "Run counts by status for one build name.", + "properties": { + "count": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "BuildSummary": { + "type": "object", + "description": "Build summary (models/response/BuildSummary). Every field except uuid/id/name/status can be null - nulls are serialised, not omitted. About 30 further keys are also returned (alertCount, buildError, creationType, workflowStatusStats, aiHealingMeta, origins, testCaseCount, parallelsUsed, ...); only the ones agents normally need are declared.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "derivedName": { + "type": "string", + "nullable": true + }, + "originalName": { + "type": "string", + "nullable": true + }, + "isAutoDetectedName": { + "type": "boolean", + "nullable": true + }, + "normalisedName": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "description": "Lifecycle status (e.g. finished); the pass/fail verdict is displayStatus." + }, + "duration": { + "type": "integer", + "nullable": true + }, + "consolidatedDuration": { + "type": "integer", + "nullable": true + }, + "finishedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sessionCount": { + "type": "integer", + "nullable": true + }, + "hostInfo": { + "type": "object", + "nullable": true + }, + "observabilityVersion": { + "type": "object", + "nullable": true + }, + "ciBuildData": { + "type": "object", + "nullable": true + }, + "user": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer", + "nullable": true + }, + "closedBy": { + "type": "string", + "nullable": true + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "metaData": { + "type": "string", + "nullable": true, + "description": "JSON-encoded string." + }, + "percyBuildUrl": { + "type": "string", + "nullable": true + }, + "requestlyRunUrl": { + "type": "string", + "nullable": true, + "description": "Not serialised on every route (absent from the daily-summary by-ids listing)." + }, + "source": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "issueTypes": { + "type": "array", + "nullable": true + }, + "statusStats": { + "type": "object", + "description": "StatusAggregate: pending/passed/failed/skipped/unknown counts, plus an integer `aggregate` total.", + "nullable": true + }, + "aiRcaCompletedCount": { + "type": "integer", + "nullable": true + }, + "selfHealedCount": { + "type": "integer", + "nullable": true + }, + "showHealingReport": { + "type": "boolean", + "nullable": true + }, + "showAuthoringReport": { + "type": "boolean", + "nullable": true + }, + "appAccessibilityEnabled": { + "type": "boolean", + "nullable": true + }, + "issueTypeAggregate": { + "type": "object", + "nullable": true + }, + "historyAggregate": { + "type": "object", + "nullable": true + }, + "buildNumber": { + "type": "integer", + "nullable": true + }, + "sdKVersion": { + "type": "string", + "nullable": true + }, + "displayStatus": { + "type": "string", + "nullable": true, + "description": "Build verdict shown in the UI (passed/failed/...); prefer this over status." + }, + "state": { + "type": "string", + "nullable": true, + "description": "Build lifecycle state, e.g. NEW or CLOSED." + }, + "projectId": { + "type": "integer", + "nullable": true, + "description": "TRA project id." + }, + "thProjectId": { + "type": "integer", + "nullable": true, + "description": "TestHub project id." + }, + "projectName": { + "type": "string", + "nullable": true + }, + "qualityGateStatus": { + "type": "string", + "nullable": true + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "reRun": { + "type": "boolean", + "nullable": true + }, + "tcmTestRunIdentifier": { + "type": "string", + "nullable": true, + "description": "Linked Test Management run identifier (TR-...), when any." + }, + "runInfos": { + "type": "array", + "nullable": true, + "items": { + "type": "object" + } + }, + "versionControlInfo": { + "type": "object", + "description": "{url, name, commitId, branch}; always an object, fields null when no VCS data.", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "commitId": { + "type": "string", + "nullable": true + }, + "branch": { + "type": "string", + "nullable": true + } + } + } + } + }, + "BuildRunsSummary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "BuildAggregateV2": { + "type": "object", + "description": "Top failed build entry, optionally enriched with best-effort AI summaries (omitted when null).", + "properties": { + "name": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "serialId": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "aiRcaSummary": { + "type": "string", + "description": "Best-effort; may be absent if enrichment failed." + }, + "aiRcaStatus": { + "type": "string" + }, + "themeCount": { + "type": "integer" + }, + "workflowCount": { + "type": "integer" + }, + "selfHealingSummary": { + "type": "string", + "description": "Best-effort; omitted when null." + }, + "authoringSummary": { + "type": "string", + "description": "Best-effort; omitted when null." + } + } + }, + "FailedTestSummary": { + "type": "object", + "properties": { + "uniqueErrors": { + "type": "integer" + }, + "flaky": { + "type": "integer" + }, + "totalFailed": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "newlyFailing": { + "type": "integer" + } + } + }, + "IssueTypeSummary": { + "type": "object", + "description": "Failure-category counts (pb=Product Bug, tbi=To be Investigated, ei=Environment Issue, ab=Automation Bug, nd=No Defect).", + "properties": { + "pb": { + "type": "integer" + }, + "tbi": { + "type": "integer" + }, + "ei": { + "type": "integer" + }, + "ab": { + "type": "integer" + }, + "nd": { + "type": "integer" + } + } + }, + "ProjectSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true, + "description": "Can be null in the daily-summary payload; key projects by id." + }, + "normalisedName": { + "type": "string", + "nullable": true + }, + "buildRunsSummary": { + "$schema": "BuildRunsSummary" + }, + "topFailedBuilds": { + "type": "array", + "items": { + "$schema": "BuildAggregateV2" + } + }, + "failedTestSummary": { + "$schema": "FailedTestSummary" + }, + "issueTypeSummary": { + "$schema": "IssueTypeSummary" + } + } + }, + "OverallDailySummary": { + "type": "object", + "description": "Aggregated daily-summary report across projects.", + "properties": { + "filterQueries": { + "type": "object", + "description": "Fixed canned query strings (dateFilter, buildRuns, topFailedBuilds, failedTests, issueTypes) used to deep-link filtered views." + }, + "projects": { + "type": "array", + "items": { + "$schema": "ProjectSummary" + } + } + } + }, + "QualityProfile": { + "type": "object", + "description": "A quality-gate profile: a named rule set plus the build filters it applies to. Serialized with null fields omitted (JSON include NON_NULL). Note the @JsonProperty renames: the wire fields are `isGlobalProfile` (internal isRootProfile) and `applicableBuilds` (internal buildFilters).", + "properties": { + "id": { + "type": "integer", + "description": "Internal DB id; changes on every edit because an edit writes a new version row." + }, + "uuid": { + "type": "string", + "format": "uuid", + "description": "Public profile identifier. NOTE: a new UUID is minted on every edit, so it is not stable across updates." + }, + "name": { + "type": "string" + }, + "ruleStatus": { + "type": "string", + "enum": [ + "pass", + "fail" + ], + "description": "'pass' = profile passes only when all rules hold; 'fail' = profile fails when any rule holds." + }, + "rules": { + "type": "array", + "items": { + "$schema": "QualityProfileRule" + } + }, + "applicableBuilds": { + "$schema": "BuildFilter", + "description": "Wire name for the internal buildFilters." + }, + "isGlobalProfile": { + "type": "boolean", + "description": "Wire name for the internal isRootProfile. True = applies group-wide." + }, + "hooksVisibility": { + "type": "string", + "enum": [ + "all", + "failed", + "beforeFailed", + "none" + ] + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "isLatest": { + "type": "boolean", + "description": "Whether this is the current version of the profile; a retired/deleted version has this false." + } + } + }, + "QualityProfileRule": { + "type": "object", + "description": "One rule inside a quality profile. Serialized with null fields omitted.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Client-generated rule UUID; required on write." + }, + "type": { + "type": "string", + "enum": [ + "TEST_STATUS", + "TEST_STATUS_THRESHOLD", + "STABILITY", + "TEST_EXECUTIONS_THRESHOLD", + "BUILD_DURATION", + "SMART_TAGS", + "SMART_TAGS_THRESHOLD", + "FAILURE_CATEGORY", + "FAILURE_CATEGORY_THRESHOLD", + "ALERTS", + "UNIQUE_ERROR_THRESHOLD", + "NEW_UNIQUE_ERROR", + "UNIQUE_ERROR_STACK_TRACE" + ] + }, + "operator": { + "type": "string", + "enum": [ + "has", + "triggered", + "not_triggered", + "is", + "is_in", + "is_not", + "less_than", + "less_than_equals", + "equals", + "greater_than", + "greater_than_equals" + ] + }, + "lhs": { + "$schema": "RuleLHS" + }, + "rhs": { + "$schema": "RuleRHS" + } + } + }, + "RuleLHS": { + "type": "object", + "description": "Left-hand side of a rule. Null fields omitted.", + "properties": { + "value": { + "type": "string", + "enum": [ + "ALL_TESTS", + "SELECTED_TESTS", + "OVERALL_BUILD", + "ALL_ALERTS", + "SELECTED_ALERTS", + "SHOULD_BE", + "SHOULD_NOT_BE" + ] + }, + "helperConfig": { + "type": "object", + "description": "Scoping for SELECTED_* rules: {regex, testTags[], folders[], alert}.", + "properties": { + "regex": { + "type": "string" + }, + "testTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "alert": { + "type": "object" + } + } + }, + "with": { + "type": "string", + "maxLength": 500, + "description": "Operand (test status, smart tag, failure-category key, ...)." + }, + "displayWith": { + "type": "string", + "description": "Human display name for a failure-category operand; populated only on the single-profile detail reads for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules." + }, + "denominator": { + "type": "string" + } + } + }, + "RuleRHS": { + "type": "object", + "description": "Right-hand side / threshold of a rule. Null fields omitted.", + "properties": { + "type": { + "type": "string", + "enum": [ + "number", + "percentage", + "string" + ] + }, + "value": { + "type": "string", + "maxLength": 500 + } + } + }, + "BuildFilter": { + "type": "object", + "description": "Which builds a profile is evaluated against.", + "properties": { + "allBuilds": { + "type": "boolean", + "description": "Required. True = every build; otherwise scope with buildNames/buildTags." + }, + "buildTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "buildNames": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "QualityProfileLiteDto": { + "type": "object", + "description": "Compact profile row in the settings/list response.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rulesCount": { + "type": "integer" + }, + "applicableBuilds": { + "$schema": "BuildFilter", + "description": "Wire name for buildFilters." + }, + "isGlobalProfile": { + "type": "boolean", + "description": "Wire name for isRootProfile." + }, + "enabled": { + "type": "boolean" + } + } + }, + "QualityGateResponse": { + "type": "object", + "description": "A project's quality-gate settings: the project-level switches plus the profiles mapped to it.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether Quality Gates is on for the project (from a project attribute)." + }, + "shouldOverrideBuildStatus": { + "type": "boolean", + "description": "Whether a failing gate also marks the build failed (from a project attribute)." + }, + "qualityProfiles": { + "type": "array", + "items": { + "$schema": "QualityProfileLiteDto" + }, + "description": "Default profile sorted to the front." + } + } + }, + "QualityProfileResultStats": { + "type": "object", + "description": "Per-status rule counts for a profile on a build.", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + } + }, + "QualityProfileResultLiteDto": { + "type": "object", + "description": "Per-profile result summary for a build.", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Computed profile outcome: 'passed', 'failed' or 'skipped'." + }, + "statusStats": { + "$schema": "QualityProfileResultStats" + } + } + }, + "QualityGateSummary": { + "type": "object", + "description": "Readiness-aware build gate summary. Exactly one of qgResult / message / state is populated per response.", + "properties": { + "qgResult": { + "type": "array", + "items": { + "$schema": "QualityProfileResultLiteDto" + }, + "description": "Per-profile results when ready." + }, + "message": { + "type": "string", + "description": "Present instead of results when the build is pending or the auto-analyser is still running." + }, + "state": { + "type": "string", + "description": "A cached evaluation state string, present instead of results/message when one is cached." + } + } + }, + "QualityProfileRuleResult": { + "type": "object", + "description": "One rule's outcome for a build in the profile detail drawer.", + "properties": { + "rule": { + "$schema": "QualityProfileRule" + }, + "ruleStatus": { + "type": "string", + "description": "Per-build status of this rule (e.g. running/passed/failed)." + }, + "ruleResult": { + "type": "string", + "description": "Rendered result/value for this rule on the build." + } + } + }, + "QualityRuleResult": { + "type": "object", + "description": "One rule's evaluated result inside the CI quality-gate result. Null fields omitted.", + "properties": { + "condition": { + "type": "string", + "description": "Human-rendered rule condition text." + }, + "value": { + "type": "string", + "description": "The rule's per-build status." + }, + "result": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "testTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "alert": { + "type": "string" + } + } + }, + "QualityProfiles": { + "type": "object", + "description": "A profile block inside the CI quality-gate result.", + "properties": { + "id": { + "type": "string", + "description": "Profile UUID." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Rendered as ' if', e.g. 'pass if'." + }, + "result": { + "type": "string", + "description": "'passed', 'failed' or 'skipped'." + }, + "rules": { + "type": "array", + "items": { + "$schema": "QualityRuleResult" + } + } + } + }, + "ExtQualityGateResult": { + "type": "object", + "description": "CI-facing quality gate result for a build. Serialized with null fields omitted (JSON include NON_NULL), so while running only build_uuid/status/quality_gate_result appear.", + "properties": { + "build_uuid": { + "type": "string", + "format": "uuid" + }, + "build_url": { + "type": "string", + "description": "Deep link to the build in the product; absent while the build is still running." + }, + "status": { + "type": "string", + "description": "'running' while unfinished/analysing, otherwise 'passed'/'failed'/'skipped'." + }, + "quality_gate_result": { + "type": "string", + "description": "While running, a human 'try again' message; once done, the same value as status." + }, + "quality_profiles": { + "type": "array", + "items": { + "$schema": "QualityProfiles" + }, + "description": "Per-profile breakdown; absent while running." + } + } + }, + "FilterStruct": { + "type": "object", + "description": "One filter value option for the quality-profile filter builder.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true + }, + "pId": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "displayName": { + "type": "string", + "description": "Present only when set (JSON include NON_NULL)." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + } + } + }, + "QGFilterResponseData": { + "type": "object", + "description": "Filter values for the quality-profile filter builder, with a cursor.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterStruct" + } + }, + "afterKey": { + "type": "string", + "description": "The last key of this page. Still set on a final short page, so stop when fewer than the page size come back.", + "nullable": true + }, + "query": { + "type": "string", + "description": "Echo of the search prefix; null when not sent.", + "nullable": true + } + } + }, + "TrendTimelineDataPoint": { + "type": "object", + "description": "One point of a Testing Trends timeline series. `y` is the metric value (null - or 0 on count cards - when the bucket had no runs). `t` is the total unique tests in the bucket, null on most cards. `pointRange` is [bucketStart, bucketStart+interval] epoch ms, or null on series that do not set it (e.g. performance-chart lineData) - the key is always present.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket start, epoch milliseconds." + }, + "y": { + "type": "number", + "description": "Metric value for the bucket (count, percentage or duration depending on the card) as a JSON number. Null (percent/duration cards) or 0 (count cards such as test executions) for an empty bucket.", + "nullable": true + }, + "t": { + "type": "integer", + "description": "Total unique tests in the bucket; null on most cards (observed null on the test-executions and performance cards).", + "nullable": true + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "[bucketStart, bucketEnd] epoch ms; null (not omitted) on series that do not set it, e.g. performance-chart lineData.", + "nullable": true + } + } + }, + "TrendInsight": { + "type": "object", + "description": "Headline card summary. Fields are computed on serialisation: `count` is the headline number as a STRING (a percentage for flakiness/new-failures/always-failing, a raw total for test-executions); `meta` is a human sentence ('N of M unique tests are ') or null; `metric` is the metric label or null.", + "properties": { + "metric": { + "type": "string", + "description": "Metric label (e.g. 'flaky', 'new failures'); null when not applicable.", + "nullable": true + }, + "meta": { + "type": "string", + "description": "Human summary sentence; null when there is no metric.", + "nullable": true + }, + "count": { + "type": "string", + "description": "Headline value as a string - a percentage (one decimal) for the gated cards, or a raw count for test-executions." + } + } + }, + "PercentTimelineCard": { + "type": "object", + "description": "Standard Testing Trends card: a timeline series plus an optional headline insight. `insights` is null on the test-growth card.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + }, + "insights": { + "$schema": "TrendInsight" + } + } + }, + "FailureCategorySeries": { + "type": "object", + "description": "One defect/failure category with its own timeline.", + "properties": { + "name": { + "type": "string", + "description": "Defect/issue category name." + }, + "color": { + "type": "string", + "description": "Display colour from the group's defect-type mapping." + }, + "data": { + "type": "array", + "description": "Per-bucket counts. Each point's y is null when no tests ran in that bucket (distinct from 0 failures).", + "items": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "Bucket start, epoch ms." + }, + "y": { + "type": "integer", + "description": "Failure count in the bucket; null when the bucket had no runs." } - }, - "SuiteHealthTestListResponse": { - "description": "Stability & Performance test listing (Test Health v2 / Suite Health v3 \u2014 identical envelope).", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "pageNumber": { - "type": "integer", - "description": "Next page index." - }, - "hasNext": { - "type": "boolean" - } - } - }, - "tests": { - "type": "array", - "items": { - "$schema": "SuiteHealthTestListItem" - } - }, - "automateTestsSkipped": { - "type": "boolean", - "description": "True when Automate tests were hidden by source feature-fencing." - }, - "appAutomateTestsSkipped": { - "type": "boolean", - "description": "True when App Automate tests were fenced out." - }, - "lcncTestsSkipped": { - "type": "boolean", - "description": "True when Low-Code/No-Code tests were fenced out." - } - } + } + } + } + } + }, + "CbtDataPoint": { + "type": "object", + "description": "A cross-browser coverage slice. Every point carries name/value/percentage/drilldown/color/os/browser/device; os/browser/device are null on primary points, drilldown is null on secondary points.", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "integer", + "description": "Raw execution count for the slice." + }, + "percentage": { + "type": "string", + "description": "Share as a formatted string (two decimals)." + }, + "drilldown": { + "type": "string", + "description": "Key linking a primary slice to its drill-down entry; null on secondary points.", + "nullable": true + }, + "color": { + "type": "string" + }, + "os": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + }, + "browser": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + }, + "device": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + } + } + }, + "StabilityLeaderboardRow": { + "type": "object", + "description": "One build's row in the stability leaderboard.", + "properties": { + "id": { + "type": "string", + "description": "Row id: \"all\" for the project-wide row, otherwise \"buildName-\"." + }, + "percentage": { + "type": "string", + "description": "Pass-rate / stability percentage as a formatted string (one decimal)." + }, + "buildName": { + "type": "string", + "description": "Build name, or \"All\" for the project-wide row." + }, + "chartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-bucket pass-rate series; entries may be null for empty buckets." + } + } + }, + "PerformanceLeaderboardRow": { + "type": "object", + "description": "One build's row in the performance leaderboard.", + "properties": { + "id": { + "type": "string", + "description": "Row id, \"buildName-\"." + }, + "buildName": { + "type": "string" + }, + "avgDuration": { + "type": "number", + "description": "Average execution duration over the window." + }, + "testCount": { + "type": "integer", + "description": "Average test-run count." + }, + "lineChartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-bucket test COUNTS (null for empty buckets)." + }, + "barChartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-bucket average DURATIONS (null for empty buckets)." + } + } + }, + "TrendPagingParams": { + "type": "object", + "description": "Fixed-page pagination echo. Note the leaderboards nest this under the key `pagingParam` (singular) while recent-runs uses `pagingParams` (plural).", + "properties": { + "hasNext": { + "type": "boolean", + "description": "True when another page exists." + }, + "pageNumber": { + "type": "integer", + "description": "One-based page number just returned (the requested zero-based pageNumber + 1)." + } + } + }, + "BuildFrequencyStats": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Positional index into the list, NOT a build id." + }, + "buildName": { + "type": "string" + }, + "runs": { + "type": "integer", + "description": "Number of runs of this build name in the window." + } + } + }, + "TrendBuildSummary": { + "type": "object", + "description": "Standard build summary as used by the recent-runs picker. Large object; the salient fields are listed. Serialised with default (nulls included) inclusion; vcsInfo is write-only and not returned.", + "properties": { + "uuid": { + "type": "string", + "description": "Build's stable uuid identifier." + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "description": "Timestamp." + }, + "finishedAt": { + "type": "string", + "description": "Timestamp." + }, + "sessionCount": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "statusStats": { + "type": "object", + "description": "Aggregated pass/fail counts for the build." + }, + "source": { + "type": "string" + }, + "qualityGateStatus": { + "type": "string" + }, + "currentQGOverrideStatus": { + "type": "boolean" + } + } + }, + "WebhookProjectMapping": { + "type": "object", + "description": "Projects a rule is linked to, with pagination info. Hydrated from TestHub.", + "properties": { + "info": { + "$schema": "WebhookPaginationInfo" + }, + "projects": { + "type": "array", + "items": { + "$schema": "WebhookProjectInfoLite" + } + } + } + }, + "WebhookPaginationInfo": { + "type": "object", + "description": "Project-picker / project-mapping pagination info (distinct from WebhookListInfo).", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "totalProjectsCount": { + "type": "integer" + }, + "linkedProjectsCount": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "WebhookProjectInfoLite": { + "type": "object", + "description": "A project row in the webhook project picker / mapping list.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "isSelected": { + "type": "boolean", + "description": "True when this project is already linked to the queried webhook rule." + } + } + }, + "RcaRetrigger": { + "type": "object", + "description": "Result of retrigger_rca_analysis. Asynchronous \u2014 carries only the new turn's id and, for trial users, the quota used.", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Id of the new RCA conversation turn; poll this for the refreshed analysis." + }, + "trialQuota": { + "type": "object", + "description": "Present (non-null) only for test-level trial users; omitted otherwise.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + }, + "BuildAccessibilityMeta": { + "type": "object", + "description": "Web/Automate build-level accessibility summary. Note: App-Automate builds with App Accessibility enabled are proxied and return a different shape.", + "properties": { + "totalIssue": { + "type": "integer", + "description": "Sum of the severity counts that were present." + }, + "severityBreakdown": { + "type": "object", + "description": "Per-severity issue counts as the build summary reports them (zero counts included when reported).", + "properties": { + "critical": { + "type": "integer" + }, + "serious": { + "type": "integer" + }, + "moderate": { + "type": "integer" + }, + "minor": { + "type": "integer" + } + } + }, + "autoEnabledBuild": { + "type": "boolean", + "description": "Whether accessibility auto-scan was enabled for the build." + }, + "autoEnabled": { + "type": "boolean", + "description": "Duplicate of autoEnabledBuild." + }, + "reportUrl": { + "type": "string", + "description": "Deep link into the Accessibility dashboard (not an API endpoint)." + } + } + }, + "SimilarTestRun": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "stackTrace": { + "type": "array", + "items": { + "type": "string" + } + }, + "projectNormalisedName": { + "type": "string" + }, + "buildNormalisedName": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + } + } + }, + "SimilarTestRunRs": { + "type": "object", + "properties": { + "similar": { + "type": "array", + "items": { + "$schema": "SimilarTestRun" + } + }, + "current": { + "$schema": "SimilarTestRun" + }, + "message": { + "type": "string" + } + } + }, + "SingleMetricSummary": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "The metric value (e.g. always-failing test count)." + }, + "meta": { + "type": "string", + "description": "Metric metadata, typically the total the count is out of." + }, + "commentCount": { + "type": "integer" + }, + "autoUnmuteCount": { + "type": "integer" + } + } + }, + "BuildAISummary": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Narrative failure summary; null when status is NOT_AVAILABLE." + }, + "topFailureThemes": { + "type": "array", + "description": "Up to 5 failure themes.", + "items": { + "type": "object", + "properties": { + "themeId": { + "type": "string" + }, + "themeName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failurePercentage": { + "type": "string" } - }, - "SuiteHealthStatsResponse": { - "description": "Aggregate stat tiles for one test.", - "schema": { - "type": "object", - "properties": { - "reliability": { - "type": "number", - "description": "Failure percentage (higher = worse)." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "failureOccurrence": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - } - } + } + } + }, + "topAffectedWorkflows": { + "type": "array", + "description": "Up to 5 affected workflows.", + "items": { + "type": "object", + "properties": { + "workflowId": { + "type": "string" + }, + "workflowName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failurePercentage": { + "type": "string" } - }, - "TestHealthTrendResponse": { - "description": "Legacy Test Health per-execution trend. data points are minimal (no smart-tag flags / workflow status).", - "schema": { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "startedAt epochs, parallel to data." - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "color": { - "type": "string" - }, - "y": { - "type": "integer", - "description": "Run duration (ms); 0 when absent." - }, - "id": { - "type": "integer", - "description": "Test-run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id." - }, - "buildName": { - "type": "string" - } - } - } - } + } + } + }, + "status": { + "type": "string", + "description": "Availability status; \"NOT_AVAILABLE\" on soft failure." + } + } + }, + "WorkflowStatusAggregate": { + "type": "object", + "description": "Per-workflow-status counts. Null-valued keys are omitted. Statuses use display-cased JSON keys.", + "properties": { + "Passed": { + "type": "integer" + }, + "Failed": { + "type": "integer" + }, + "In Progress": { + "type": "integer" + }, + "Skipped": { + "type": "integer" + }, + "Retest": { + "type": "integer" + }, + "Blocked": { + "type": "integer" + }, + "Untested": { + "type": "integer" + }, + "Pending": { + "type": "integer" + }, + "Unknown": { + "type": "integer" + }, + "otherCount": { + "type": "object", + "description": "Counts for any non-standard statuses, keyed by status name." + } + } + }, + "BuildAttemptRunStats": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "attemptNumber": { + "type": "integer" + }, + "totalAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "BuildAttemptRunStatsResponse": { + "type": "object", + "properties": { + "attempts": { + "type": "array", + "items": { + "$schema": "BuildAttemptRunStats" + } + }, + "summary": { + "type": "object", + "description": "Aggregate across attempts (same fields as an attempt entry, without a label).", + "properties": { + "attemptNumber": { + "type": "integer" + }, + "totalAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + } + } + }, + "BuildHistoryEntry": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Series name (a test/workflow status)." + }, + "data": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-build value of this status, aligned with the response `keys`." + }, + "color": { + "type": "string" + } + } + }, + "BuildHistory": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildHistoryEntry" + }, + "description": "One series per status." + }, + "keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Build labels along the x-axis (order of the ~10 recent builds)." + }, + "commentCount": { + "type": "integer" + } + } + }, + "BuildIdFromCoordinates": { + "type": "object", + "properties": { + "buildId": { + "type": "string", + "description": "The resolved build UUID." + } + } + }, + "BuildCoordinates": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "description": "URI-encoded, spaces as '+'." + }, + "buildSerialId": { + "type": "integer" + }, + "projectNormalisedName": { + "type": "string", + "description": "URI-encoded, spaces as '+'." + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "projectFinishedAt": { + "type": "string" + }, + "projectCreatedAt": { + "type": "string" + }, + "toProjectId": { + "type": "integer", + "description": "Observability project id." + }, + "thProjectId": { + "type": "integer", + "description": "TestHub project id." + } + } + }, + "BuildReRunEntry": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "e.g. \"First Run\", \"Re-run1\", \"Manual Run\"." + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "id": { + "type": "string", + "description": "The leg's integer build id, as a string." + }, + "buildSdkLogs": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "meta": { + "type": "object", + "nullable": true + } + } + }, + "BuildReRun": { + "type": "object", + "properties": { + "retryData": { + "type": "array", + "items": { + "$schema": "BuildReRunEntry" + }, + "description": "Flat list of re-run legs." + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + } + } + }, + "RerunSummary": { + "type": "object", + "properties": { + "totalTestAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "SplitResult": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "totalTestAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + }, + "metadata": { + "type": "object", + "description": "Build summary metadata for the split." + } + } + }, + "RerunGroup": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "splits": { + "type": "array", + "items": { + "$schema": "SplitResult" + } + }, + "summary": { + "$schema": "RerunSummary" + }, + "metadata": { + "type": "object", + "description": "Build summary metadata for the group." + } + } + }, + "BuildReRunV2": { + "type": "object", + "properties": { + "retryData": { + "type": "array", + "items": { + "$schema": "RerunGroup" + }, + "description": "Re-run groups, each with per-split breakdowns." + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + }, + "summary": { + "$schema": "RerunSummary" + } + } + }, + "FilterField": { + "type": "object", + "description": "A single selectable filter option. `id`/`value` are what you send back as the filter selection; `label` is the display text. For branch/tag options all three are the raw value; for user/team options `id` is the numeric id (stringified) and value/label are the name.", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "count": { + "type": "integer", + "description": "Match count for this option; usually null on these facet lists.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Optional parent-id grouping token; empty string on these lists.", + "nullable": true + } + } + }, + "BuildFilterStatic": { + "type": "object", + "description": "Static catalogue of selectable build-listing filter options. Each list is the full menu of choices, not a presence list.", + "properties": { + "statusList": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "frameworkList": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "types": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "sources": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "creationType": { + "type": "array", + "items": { + "$schema": "FilterField" + } + } + } + }, + "BuildFilterApplied": { + "type": "object", + "description": "The caller's applied build-listing filter, echoed back in resolved form (ids turned into name-bearing objects). Anything not sent comes back null (not an empty list).", + "properties": { + "uniqueBuildNames": { + "type": "array", + "items": { + "$schema": "FilterField" + }, + "nullable": true + }, + "statuses": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "dateRange": { + "type": "object", + "nullable": true, + "description": "Resolved range {lowerBound, upperBound} (epoch ms); null when not sent.", + "properties": { + "lowerBound": { + "type": "integer" + }, + "upperBound": { + "type": "integer" + } + } + }, + "frameworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Raw framework values echoed back.", + "nullable": true + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "nullable": true + }, + "assignees": { + "type": "array", + "nullable": true + }, + "teams": { + "type": "array", + "nullable": true + }, + "buildGroupIds": { + "type": "array", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Echo of the request's buildTags (build-level tags)." + }, + "branches": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "search": { + "type": "string", + "nullable": true, + "description": "Echo of the request's `search` field (not `query`)." + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "state": { + "type": "string" + }, + "types": { + "type": "array", + "nullable": true + }, + "sources": { + "type": "array", + "nullable": true + }, + "excludeLocked": { + "type": "boolean", + "nullable": true + }, + "isCiRun": { + "type": "boolean", + "nullable": true + }, + "selfHealed": { + "type": "boolean", + "nullable": true + }, + "aiAuthored": { + "type": "boolean", + "nullable": true + }, + "appCrash": { + "type": "boolean", + "nullable": true + }, + "selfHosted": { + "type": "boolean", + "nullable": true + }, + "creationType": { + "type": "array", + "nullable": true + }, + "testCasesStatuses": { + "type": "array", + "nullable": true + } + } + }, + "FilterStruct__w2_build_filter1": { + "type": "object", + "description": "A static test-filter option: a display label and the value to send back. `pId` is present only for hierarchical options (e.g. folder trees).", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "pId": { + "type": "string" + } + } + }, + "Dashboard": { + "type": "object", + "description": "A custom dashboard's full definition, as returned under `data` by the dashboard read/create/update/clone endpoints.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "Visibility level (1 or 2)." + }, + "filters": { + "type": "object", + "description": "Saved global filters (DashboardFilters shape).", + "nullable": true + }, + "widgets": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Widget definitions with id/name/type/config/layout.", + "nullable": true + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id." + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer", + "nullable": true + }, + "modifiedByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdByName": { + "type": "string" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM.", + "nullable": true + }, + "type": { + "type": "string", + "description": "Dashboard | View | TABULAR_TEST_RUNS | BUILD_COMPARISON." + }, + "isDefault": { + "type": "boolean", + "nullable": true + }, + "isAutomation": { + "type": "boolean", + "nullable": true + }, + "reports": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Report-widget definitions (present for report-type dashboards).", + "nullable": true + } + } + }, + "MetaItem": { + "type": "object", + "description": "An id resolved to its display name.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "ErrorSearchResult": { + "type": "object", + "description": "A unique-error search hit.", + "properties": { + "errorClusterId": { + "type": "string", + "description": "Stable id of the unique-error cluster." + }, + "error": { + "type": "string", + "description": "The error signature text." + } + } + }, + "FilterDetails": { + "type": "object", + "description": "A custom field offered as a dashboard widget filter.", + "properties": { + "id": { + "type": "integer", + "description": "Custom-field id." + }, + "label": { + "type": "string", + "description": "Custom-field name/key." + }, + "type": { + "type": "string", + "description": "Filter type: Basic or Custom (always Custom on this route)." + }, + "fieldType": { + "type": "string", + "description": "The custom field's data type." + } + } + }, + "DashboardPageInfo": { + "type": "object", + "description": "Pagination cursor for the paginated widget-custom-field-filters response.", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "ReportSummary": { + "type": "object", + "description": "One row of the reports/dashboards listing. Many fields are conditionally present (external-integration and project fields only appear for scheduled/linked reports).", + "properties": { + "id": { + "description": "Dashboard/report id (UUID or numeric depending on row)." + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM." + }, + "type": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "isAutomation": { + "type": "boolean" + }, + "external_report_id": { + "type": "integer" + }, + "report_type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "frequency": { + "type": "string", + "description": "Schedule frequency (scheduled reports only)." + }, + "next_run_at": { + "type": "string", + "format": "date-time" + }, + "identifier": { + "type": "string" + }, + "is_scheduled": { + "type": "boolean" + }, + "owner": { + "type": "object", + "description": "{id, full_name, email, browserstack_user_id}." + }, + "project_id": { + "type": "integer", + "description": "The TestHub project id (thProjectId), not the TRA project id." + }, + "project_name": { + "type": "string" + }, + "projects": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Linked projects as {project_id, project_name}." + } + } + }, + "ReportListPageInfo": { + "type": "object", + "description": "Pagination cursor for the reports listing.", + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer", + "nullable": true + }, + "prev": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer" + }, + "page_size": { + "type": "integer" + } + } + }, + "TakeAwayMetric": { + "type": "object", + "description": "A single Home takeaway metric: current value and the previous-window value.", + "properties": { + "value": { + "type": "number" + }, + "prev": { + "type": "number", + "nullable": true, + "description": "Previous-window value; null (time-savings cards) or 0 when the prior 7 days have no data." + } + } + }, + "TakeAwayMetricV2": { + "type": "object", + "description": "A v2 failure-noise category: absolute count and its percentage of total failures.", + "properties": { + "count": { + "type": "integer" + }, + "reduction": { + "type": "number", + "description": "This category as a percentage of totalFailures (for uniqueErrors it is the complementary reduction)." + } + } + }, + "TakeAwayData": { + "type": "object", + "description": "Home key-takeaway cards, each metric comparing the current 7 days to the prior 7 days.", + "properties": { + "stability": { + "$schema": "TakeAwayMetric" + }, + "uniqueTestExecutions": { + "$schema": "TakeAwayMetric" + }, + "newFailures": { + "$schema": "TakeAwayMetric" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetric" + }, + "flakiness": { + "$schema": "TakeAwayMetric" + }, + "failures": { + "$schema": "TakeAwayMetric" + } + } + }, + "TimeSavingMetric": { + "type": "object", + "description": "Aggregate time-saved breakdown returned by the older Home takeaways variant.", + "properties": { + "timeSavings": { + "$schema": "TakeAwayMetric" + }, + "testToVerify": { + "$schema": "TakeAwayMetric" + }, + "uniqueErrors": { + "$schema": "TakeAwayMetric" + }, + "flakiness": { + "$schema": "TakeAwayMetric" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetric" + }, + "autoTagged": { + "$schema": "TakeAwayMetric" + } + } + }, + "TimeSavingV2Metric": { + "type": "object", + "description": "v2 failure-noise breakdown returned by the newer Home takeaways variant. When totalFailures is 0 the per-category objects are omitted.", + "properties": { + "totalFailures": { + "type": "integer" + }, + "flakyFailing": { + "$schema": "TakeAwayMetricV2" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetricV2" + }, + "autoAnalyzedFailure": { + "$schema": "TakeAwayMetricV2" + }, + "uniqueErrors": { + "$schema": "TakeAwayMetricV2" + } + } + }, + "ProjectExternal": { + "type": "object", + "description": "A project as exposed by the public/external API.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "created_by": { + "type": "integer", + "description": "User id of the project owner." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Derived from the project's finishedAt; can be null.", + "nullable": true + }, + "observability_url": { + "type": "string", + "description": "Ready-made deep link to the project's builds page." + } + } + }, + "ExternalPagination": { + "type": "object", + "description": "Cursor pagination envelope for the external API.", + "properties": { + "has_next": { + "type": "boolean", + "description": "True whenever the current page was non-empty; the final full page is followed by one empty page." + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor to pass as next_page for the following page." + } + } + }, + "RecentView": { + "type": "object", + "description": "One recently-viewed item on the Home page.", + "properties": { + "type": { + "type": "string", + "description": "Item kind (e.g. dashboard, build, view)." + }, + "link": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Field": { + "type": "object", + "description": "A filter option / type-ahead suggestion item as returned across the Suite Health filter routes.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier for the option (often equal to value)." + }, + "value": { + "type": "string", + "description": "The raw value to send back when applying this option as a filter." + }, + "label": { + "type": "string", + "description": "Human-readable display label." + }, + "count": { + "type": "integer", + "description": "Occurrence count; null on suggestion routes that do not compute it.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Parent id for hierarchical options (e.g. folders); null or empty when not applicable.", + "nullable": true + } + } + }, + "TimelineDataPoint": { + "type": "object", + "description": "One time-bucketed chart point.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket timestamp (epoch ms)." + }, + "t": { + "type": "integer", + "description": "Secondary timestamp/marker for the bucket; may be null." + }, + "y": { + "type": "number", + "description": "Value at the bucket (count, average, or percentage depending on the series); null when the series has no value for this bucket." + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "The [start, end] epoch-ms span the bucket covers (present on the failure-rate series)." + } + } + }, + "SPChart": { + "type": "object", + "description": "A Suite Health chart: named data series plus a summary insight.", + "properties": { + "data": { + "type": "object", + "description": "Map of series name -> array of TimelineDataPoint. Series keys depend on the chart (e.g. totalExecutions/totalFailures, averageDuration, averageFailureRates).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "TimelineDataPoint" + } + } + }, + "insights": { + "type": "object", + "description": "Headline summary for the chart.", + "properties": { + "metric": { + "type": "string", + "description": "Metric label, e.g. 'Total Failures'." + }, + "value": { + "type": "string", + "description": "Formatted headline value, e.g. '12/340' or '3.5%' or '02m 10s'." + }, + "meta": { + "type": "string", + "description": "Optional secondary text; null when not set." + } + } + } + } + }, + "SPTestBreakdownRow": { + "type": "object", + "description": "One environment (OS/browser/device) row in a test's stability breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Composite browser-platform key, e.g. 'chrome-windows'." + }, + "reliability": { + "type": "number", + "description": "Failure percentage (failed*100/total), 2 decimals; 0 when there were no executions." + }, + "duration": { + "type": "number", + "description": "Average duration for this environment." + }, + "os": { + "type": "object", + "description": "Formatted platform descriptor." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor, with a nested `device` field." + }, + "browserDevice": { + "type": "object", + "description": "Alternate browser descriptor carrying the full device object." + }, + "totalFailures": { + "type": "object", + "description": "Failure tally for this environment.", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + }, + "AppLogResponse": { + "type": "object", + "description": "Parsed application-log response from /api/v1/appLogs/query.", + "properties": { + "status": { + "type": "string", + "description": "'success', or 'failure' (with empty logs) when Loki reported a non-success status.", + "values": [ + "success", + "failure" + ] + }, + "appLogQuery": { + "type": "object", + "description": "The echoed Loki query parameters the service built from the run.", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Group (tenant) ids the query was scoped to." + }, + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "type": "array", + "items": { + "type": "string" + } + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "logs": { + "type": "array", + "description": "Flat, timestamp-sorted app-log lines (abnormal/error level only). Empty on a clean run or a Loki non-success.", + "items": { + "$schema": "AppLog" + } + } + } + }, + "AppLog": { + "type": "object", + "description": "One parsed application-log line.", + "properties": { + "app": { + "type": "string", + "description": "App name from the Loki stream." + }, + "env": { + "type": "string", + "description": "Environment from the Loki stream." + }, + "level": { + "type": "string", + "description": "Log level; 'unknown' when Loki did not tag a level." + }, + "epochNano": { + "type": "integer", + "description": "Timestamp in epoch nanoseconds; the list is sorted ascending by this." + }, + "line": { + "type": "string", + "description": "Log text after the ' cSep=S ' separator; the raw line when the separator is absent." + } + } + }, + "LogUrls": { + "type": "object", + "description": "Test-run log bundle as URLs plus metadata (from /ext/v1/testRun/{id}/logs and the v2 consolidated-logs route).", + "properties": { + "testStartedAt": { + "type": "string", + "format": "date-time" + }, + "testFinishedAt": { + "type": "string", + "format": "date-time" + }, + "browserstackLogs": { + "$schema": "SessionLogs", + "description": "Per-type BrowserStack session log URLs." + }, + "sdkLogs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "SDK log URLs." + }, + "hookRunLogs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hook-run log URLs." + }, + "failureLogs": { + "type": "array", + "items": { + "$schema": "GenericLog" + }, + "description": "Inline failure log entries." + }, + "stepLogs": { + "type": "array", + "items": { + "$schema": "GenericLog" + }, + "description": "Inline step log entries." + }, + "framework": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + } + }, + "SessionLogs": { + "type": "object", + "description": "Per-type session log artefacts (URLs / nodes). Null-valued types are omitted (JsonInclude NON_NULL).", + "properties": { + "text": { + "type": "object", + "description": "Raw/text log artefact." + }, + "console": { + "type": "object" + }, + "selenium": { + "type": "object" + }, + "appium": { + "type": "object" + }, + "playwright": { + "type": "object" + }, + "performance": { + "type": "object", + "description": "{urls: [], count}." + }, + "network": { + "$schema": "SessionNetwork" + }, + "device": { + "type": "object" + }, + "video": { + "type": "object", + "description": "{url, offsetUrl, status_code, startTime, startTimeStamp, startOffset, finishOffset, launchingAppTime, isIos, isV2}." + }, + "audio": { + "type": "object" + }, + "screenshots": { + "type": "array", + "items": { + "type": "string" + } + }, + "terminal": { + "type": "object", + "description": "{session: {url}, build: {url}}." + }, + "telemetry": { + "type": "object", + "description": "{isSupported, url?}." + }, + "crash": { + "type": "object" + }, + "appProfiling": { + "type": "object" + }, + "instrumentation": { + "type": "object" + }, + "browserprofiling": { + "type": "object" + }, + "issues": { + "type": "object" + }, + "maestro": { + "type": "object" + }, + "commandLogs": { + "type": "object" + }, + "maestroScreenshot": { + "type": "object" + }, + "config": { + "type": "object" + }, + "cypress_screenshots": { + "type": "object", + "description": "Serialised as cypress_screenshots." + } + } + }, + "SessionNetwork": { + "type": "object", + "description": "A session's network (HAR) log artefact pointer.", + "properties": { + "status_code": { + "type": "integer", + "description": "Upstream fetch status code." + }, + "url": { + "type": "string", + "description": "URL of the HAR file." + }, + "message": { + "type": "string", + "description": "Upstream message, if any." + } + } + }, + "SessionNetworkLogUrlResponse": { + "type": "object", + "description": "Envelope returned by /api/v1/sessions/{sessionId}/networkLogs (unparsed form): the HAR URL plus fetch status.", + "properties": { + "networkLogs": { + "type": "string", + "description": "URL of the session's HAR network log file (NOT the parsed entries)." + }, + "status_code": { + "type": "integer", + "description": "Upstream fetch status code." + }, + "message": { + "type": "string", + "description": "Upstream message, if any." + } + } + }, + "GenericLog": { + "type": "object", + "description": "One log entry in a merged/consolidated log stream. Null fields are omitted (JsonInclude NON_NULL).", + "properties": { + "logLevel": { + "type": "string", + "description": "Log level enum (e.g. ERROR, INFO)." + }, + "logType": { + "type": "string", + "description": "Log source/type enum (e.g. FAILURE, APPLICATION_LOGS)." + }, + "timeStamp": { + "type": "string", + "format": "date-time" + }, + "finishedTimeStamp": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string", + "description": "Inline log text (present because content is merged server-side)." + }, + "url": { + "type": "string", + "description": "URL for entries backed by an external artefact rather than inline content." + }, + "duration": { + "type": "number", + "description": "Duration in seconds, formatted to 2 decimals." + }, + "startOffset": { + "type": "integer" + }, + "finishOffset": { + "type": "integer" + }, + "noOfSimilarErrors": { + "type": "integer" + }, + "customHookRunUuid": { + "type": "string" + }, + "customHookType": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "message": { + "type": "string" + }, + "selfHealData": { + "type": "string" + }, + "aiAuthoringData": { + "type": "string" + }, + "logSubType": { + "type": "string" + } + } + }, + "TestDetailResponse": { + "type": "object", + "description": "Per-hook (or per-test) detail bundle: its test code, opaque metadata and any hook code.", + "properties": { + "testCode": { + "$schema": "TestCodeResponse", + "description": "The hook's source/file info; null for SDK-ingested hooks without captured code.", + "nullable": true + }, + "testMetadata": { + "type": "object", + "description": "Test-run metadata: type (BEFORE_ALL/AFTER_ALL...), testName, status, isFlaky, buildId, origin, issueType, observabilityVersion, scopes, isNewFailure, isAlwaysFailing, plus many null keys." + }, + "hooksCode": { + "type": "array", + "items": { + "$schema": "TestCodeResponse" + }, + "description": "Source for each associated hook.", + "nullable": true + } + } + }, + "TestCodeResponse": { + "type": "object", + "description": "A test's source code and location.", + "properties": { + "code": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string", + "description": "VCS URL to the file, built from the run's VCS info." + }, + "language": { + "type": "string" + } + } + }, + "TestRunCode": { + "type": "object", + "description": "Captured source for a test or hook run. code/language are null when nothing was captured for that run; the row is still returned.", + "properties": { + "language": { + "type": "string" + }, + "code": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "testRunId": { + "type": "integer" + }, + "type": { + "type": "string", + "description": "Run type (test vs hook kind)." + } + } + }, + "TestRunDetail": { + "type": "object", + "description": "Full detail of a single test run: its captured code, its metadata, and the code of the hooks that ran around it.", + "properties": { + "testCode": { + "$schema": "TestRunCode", + "nullable": true, + "description": "Captured code; null when nothing was captured (e.g. JUnit-report builds)." + }, + "testMetadata": { + "type": "object", + "description": "Metadata for the run - the broad internal test-meta object (testName, status, buildId, history, retries, testCase, projectId, device/cbtBrowser/cbtPlatform, observabilityVersion, origin, workflowStatus*, errorClusterId, isAutoAnalyzed*, ... many null). Read keys off the actual response." + }, + "hooksCode": { + "type": "array", + "description": "Code of before/after hooks. Populated on get_test_run_details; the key is present but null on the baseline map read.", + "items": { + "$schema": "TestRunCode" + }, + "nullable": true + } + } + }, + "BuildInsights": { + "type": "object", + "description": "Build-level failure/flakiness insights surrounding a test run.", + "properties": { + "buildId": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer" + }, + "summary": { + "type": "object", + "properties": { + "statusMetrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" } + } } - }, - "SuiteHealthTrendResponse": { - "description": "Suite Health per-execution trend with rich points.", - "schema": { + }, + "totalTests": { + "type": "integer" + }, + "totalHooks": { + "type": "integer" + } + } + }, + "failureCategorization": { + "type": "object", + "properties": { + "newFailures": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "flaky": { + "type": "integer" + } + } + }, + "failureContext": { + "type": "object", + "description": "Per-run failure context: file and parent-module failure rates for the run under inspection.", + "properties": { + "file": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failedCount": { + "type": "integer" + }, + "failureRate": { + "type": "number", + "description": "failedCount/testCount * 100" + } + } + }, + "parentModule": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failedCount": { + "type": "integer" + }, + "failureRate": { + "type": "number" + } + } + } + } + }, + "topErrorsAnalysis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty/partial unless COMPLETE." + }, + "uniqueErrorCount": { + "type": "integer" + }, + "uniqueErrors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "occurrences": { + "type": "integer" + }, + "truncatedError": { + "type": "string", + "description": "First ~200 chars of the error for a quick overview." + } + } + } + } + } + }, + "defectStats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueType": { + "type": "string" + }, + "totalFailureTestsCount": { + "type": "integer" + }, + "totalFailureHooksCount": { + "type": "integer" + }, + "categories": { + "type": "array", + "items": { "type": "object", "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "startedAt epochs, parallel to data." - }, - "data": { - "type": "array", - "items": { - "$schema": "SuiteHealthTrendPoint" - } - } + "category": { + "type": "string" + }, + "failureTestCount": { + "type": "integer" + }, + "failureHooksCount": { + "type": "integer" + } } + } } - }, - "SuiteHealthTestInfoResponse": { - "description": "Header metadata for one test's Suite Health detail page.", - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Test display name; empty string when no run matched." - }, - "combinations": { - "type": "array", - "description": "OS/browser/device combinations the test ran on (non-displayable combos filtered out).", - "items": { - "type": "object", - "properties": { - "os": { - "type": "object", - "description": "Platform display object." - }, - "browser": { - "type": "object", - "description": "Browser display object; device nested as an object.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "object" - } - } - } - } - } - } - } + } + } + } + } + }, + "BuildTestRunWithAttachments": { + "type": "object", + "description": "Trimmed test-run row for the 'runs having attachments' discovery listing.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "build_id": { + "type": "integer" + }, + "attachments_count": { + "type": "integer" + }, + "screenshots_count": { + "type": "integer" + } + } + }, + "FlatTestRun": { + "type": "object", + "description": "One row of the flat build test-run listing. Top-level `id` is the test HASH (string); the numeric run id is `details.id`.", + "properties": { + "id": { + "type": "string", + "description": "Test hash, NOT the numeric run id." + }, + "title": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric test run id." + }, + "status": { + "type": "string" + }, + "os": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "browser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "device": { + "type": "string" + }, + "isRealDevice": { + "type": "boolean" + }, + "filePath": { + "type": "string" + }, + "vcFileUrl": { + "type": "string" + }, + "issueType": { + "type": "object" + }, + "aiRcaState": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isMuted": { + "type": "boolean" + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "VcsCheckMapping": { + "type": "object", + "description": "A vcs_ingestion row: a PR check registered against a check configuration for evaluation. Serialised directly from the JPA entity (no wrapper).", + "properties": { + "id": { + "type": "integer", + "description": "Ingestion row id \u2014 the id used by get_checks_mapping." + }, + "prNumber": { + "type": "integer", + "description": "Pull request number." + }, + "commitSha": { + "type": "string" + }, + "callbackUrl": { + "type": "string", + "description": "URL the evaluation pipeline calls back with results." + }, + "checkStates": { + "type": "string", + "description": "Evaluation state; newly created rows are 'Not Started'." + }, + "checkType": { + "type": "string", + "description": "QG_STATUS or BUILD_STATUS, copied from the matched check configuration.", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "retryCount": { + "type": "integer", + "description": "Evaluation retry count; 0 on creation." + }, + "splitRepos": { + "type": "boolean" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id, copied from the matched check configuration." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "commitTimestamp": { + "type": "string", + "format": "date-time", + "description": "Parsed from pr_data.date; may be unset when the date was omitted." + }, + "prMetadataJson": { + "type": "object", + "description": "The pr_data object stored verbatim, with githubCheckId merged in." + }, + "thProjectId": { + "type": "integer", + "description": "Target project id, copied from the matched check configuration." + }, + "buidlParentId": { + "type": "integer", + "description": "Build parent id (field is spelled 'buidlParentId' in the payload); typically null at creation." + }, + "checkConfiguration": { + "$schema": "CheckConfigurationEntity" + } + } + }, + "CheckConfigurationEntity": { + "type": "object", + "description": "A checks_configuration row as embedded inside a vcs_ingestion mapping (the raw JPA entity, camelCase fields).", + "properties": { + "id": { + "type": "integer", + "description": "Check-configuration id." + }, + "checkName": { + "type": "string" + }, + "repoName": { + "type": "string" + }, + "vcsName": { + "type": "string" + }, + "vcsOrgName": { + "type": "string" + }, + "splitRepos": { + "type": "boolean" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "branchName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "checkType": { + "type": "string", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "commitCheck": { + "type": "boolean" + }, + "isEnabled": { + "type": "boolean" + }, + "buildName": { + "type": "string" + }, + "buildTags": { + "type": "string" + }, + "thProjectId": { + "type": "integer" + } + } + }, + "CheckConfigurationItem": { + "type": "object", + "description": "A single check-run configuration as returned by the check-runs endpoints (CheckConfigurationItemResponse).", + "properties": { + "checkId": { + "type": "integer", + "description": "Check-configuration id \u2014 the id used in the update/toggle/delete path." + }, + "checkName": { + "type": "string" + }, + "repoName": { + "type": "string" + }, + "vcsOrgName": { + "type": "string" + }, + "vcsName": { + "type": "string" + }, + "checkType": { + "type": "string", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "splitRepos": { + "type": "boolean" + }, + "projectId": { + "type": "integer", + "description": "Resolved BrowserStack project id for projectName." + }, + "projectName": { + "type": "string" + }, + "branchName": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "buildTags": { + "type": "string" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "commitCheck": { + "type": "boolean" + }, + "isEnabled": { + "type": "boolean" + } + } + }, + "View": { + "type": "object", + "description": "A saved view (filters row) as returned by the view endpoints.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "View id (UUID)." + }, + "type": { + "type": "integer", + "description": "Screen type code: 1 builds, 2 testRuns, 3 testHealth, 4 uniqueErrors, 5 buildInsights." + }, + "query": { + "type": "string", + "description": "The stored serialised filter." + }, + "level": { + "type": "integer", + "description": "1 = private, 2 = public/shared.", + "enum": [ + 1, + 2 + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Optional free-text description; typically null (not settable through create/update)." + }, + "createdBy": { + "type": "integer", + "description": "Creator user id (from the session, not the body)." + }, + "groupId": { + "type": "integer", + "description": "Owning group id (from the session, not the body)." + } + } + }, + "DashboardView": { + "type": "object", + "description": "A dashboard view as returned by the dashboards/views read, create, update and clone endpoints (the object under the response `data` key).", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Dashboard UUID." + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "Visibility level (2 = group/shared by default)." + }, + "filters": { + "type": "object", + "description": "Global dashboard filters (DashboardFilters): dateRange, tags, buildTags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields." + }, + "widgets": { + "type": "array", + "description": "Widget layout (Widgets objects: id, name, type, globalFilters, desc, config, layout).", + "items": { + "type": "object" + } + }, + "totalWidget": { + "type": "integer", + "description": "Count of widgets on the dashboard." + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id." + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "modifiedByName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdByName": { + "type": "string" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM." + }, + "type": { + "type": "string", + "description": "DashboardType." + }, + "isDefault": { + "type": "boolean", + "description": "True for the built-in default dashboard (date range then forced to last 30 days)." + }, + "isAutomation": { + "type": "boolean", + "description": "True for the built-in automation-trend dashboard." + }, + "reports": { + "type": "array", + "description": "Report widgets, when present.", + "items": { + "type": "object" + } + } + } + }, + "WidgetCatalogueEntry": { + "type": "object", + "description": "One entry in the addable-widget catalogue (WidgetDetails).", + "properties": { + "name": { + "type": "string", + "description": "Display name, e.g. 'Test Case Statistics'." + }, + "key": { + "type": "string", + "description": "WidgetTypeEnum value to use as a widget's `type`." + } + } + }, + "CustomFieldFilterDetail": { + "type": "object", + "description": "A custom (user-defined) test-case field usable as a dashboard-widget filter (FilterDetails).", + "properties": { + "id": { + "type": "integer", + "description": "Custom field id." + }, + "label": { + "type": "string", + "description": "Filter key / display label." + }, + "type": { + "type": "string", + "description": "FilterType \u2014 'Custom' for custom-field filters." + }, + "fieldType": { + "type": "string", + "description": "The underlying field data type." + } + } + }, + "ReportRecord": { + "type": "object", + "description": "A saved report (the DashboardsData shape; reports and dashboards share it). Nulls are included. Widgets for a report-typed record are under `reports`; for a dashboard-typed record under `widgets`.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "1 = private, 2 = group." + }, + "filters": { + "type": "object", + "description": "Saved global filters (DashboardFilters shape); null in the listing.", + "nullable": true + }, + "widgets": { + "type": "array", + "description": "Widget list for a dashboard-typed record; null for a report-typed one.", + "nullable": true + }, + "reports": { + "type": "array", + "description": "Widget list for a report-typed record ({id, name, type, config, layout, reportType}); null in the listing.", + "nullable": true + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer", + "nullable": true + }, + "modifiedByName": { + "type": "string", + "nullable": true + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM.", + "nullable": true + }, + "type": { + "type": "string" + }, + "isDefault": { + "type": "boolean", + "nullable": true + }, + "isAutomation": { + "type": "boolean", + "nullable": true + } + } + }, + "TestRunReportRow": { + "type": "object", + "description": "One row in a tabular test-runs report dataset OR one build row in the static-builds listing (the same TestRunReport class, populated differently per endpoint). A large flat row; only representative fields are listed. For datasets these are test-run rows (testName, status, flakiness, customFields, \u2026); for static-builds these are build rows (buildId, buildName, buildNumber, displayStatus, buildStartedAt, workflowStatusStats, \u2026).", + "properties": { + "uuid": { + "type": "string" + }, + "testName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "buildId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "displayStatus": { + "type": "string", + "description": "Build display status (static-builds rows)." + }, + "projectId": { + "type": "integer" + }, + "customFields": { + "type": "object" + }, + "isFlaky": { + "type": "boolean" + }, + "flakiness": { + "type": "number" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + } + } + }, + "PaginationSummary": { + "type": "object", + "description": "Paging summary for report datasets/builds. totalDatasets is -1 on these routes (not a real total); page using hasNext.", + "properties": { + "totalDatasets": { + "type": "integer" + }, + "paginatedResults": { + "type": "integer" + }, + "hasNext": { + "type": "boolean" + } + } + }, + "StatTile": { + "type": "object", + "description": "One aggregate stat tile: a metric key and its value.", + "properties": { + "meta": { + "type": "string", + "description": "Metric key, e.g. totalUniqueTests, flakyTestsCount, totalPassedExecutions." + }, + "value": { + "type": "integer" + } + } + }, + "ReportCustomFieldItem": { + "type": "object", + "description": "A custom-field / column definition usable in a report.", + "properties": { + "fieldId": { + "type": "integer" + }, + "fieldName": { + "type": "string" + }, + "fieldType": { + "type": "string" + }, + "entityType": { + "type": "string", + "description": "TestCase or TestExecution \u2014 the only thing distinguishing two same-named fields." + } + } + }, + "ReportPageInfo": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "ReportTestStats": { + "type": "object", + "description": "Headline stats for one test in the tabular report. An empty object `{}` is returned when the test has no matching runs.", + "properties": { + "reliability": { + "type": "number", + "description": "Failure ratio as a percentage: failed*100/total." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "failureOccurrence": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + }, + "ReportTrendDataPoint": { + "type": "object", + "description": "One point in a test's trend series. Null fields are omitted.", + "properties": { + "color": { + "type": "string", + "description": "Colour derived from the workflow status." + }, + "workflowStatus": { + "type": "string" + }, + "y": { + "type": "integer", + "description": "Run duration in MILLISECONDS." + }, + "id": { + "type": "integer", + "description": "Test run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id." + }, + "buildName": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "flakyReason": { + "type": "integer" + } + } + }, + "ReportTestTrendResponse": { + "type": "object", + "description": "Per-run trend series for one test.", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Build started-at epochs, index-aligned to `data`." + }, + "data": { + "type": "array", + "items": { + "$schema": "ReportTrendDataPoint" + } + } + } + }, + "TabularReportRow": { + "type": "object", + "description": "One row of the tabular test-runs report as built by the report handler (only the fields this path populates).", + "properties": { + "testId": { + "type": "integer" + }, + "globalTestHash": { + "type": "string" + }, + "testCaseId": { + "type": "integer" + }, + "folderId": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "avgDuration": { + "type": "number", + "description": "Average duration in ms." + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAutoAnalyzerRunning": { + "type": "boolean" + }, + "buildId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "originalBuildName": { + "type": "string" + }, + "buildTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "buildNumber": { + "type": "integer" + }, + "projectId": { + "type": "integer" + }, + "buildStartedAt": { + "type": "string", + "format": "date-time" + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "normalisedBuildName": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Normalised workflow status, or 'unknown'." + }, + "workflowStatus": { + "type": "string" + }, + "smartTagSettings": { + "type": "object", + "description": "Present only for builds newer than the applied schema date." + } + } + }, + "TabularTestRunsReportResponse": { + "type": "object", + "description": "Paginated tabular test-runs report page. Empty result returns null data and null summary.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TabularReportRow" + } + }, + "summary": { + "type": "object", + "properties": { + "totalDatasets": { + "type": "integer", + "description": "-1 on these routes (not a real total)." + }, + "paginatedResults": { + "type": "integer", + "description": "Count of rows on this page." + }, + "hasNext": { + "type": "boolean", + "description": "True when the page came back full." + } + } + }, + "sourceFencingFlags": { + "type": "object", + "description": "Present only when non-null.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "ReportStatTilesResponse": { + "type": "object", + "description": "Summary tiles for the tabular report.", + "properties": { + "data": { + "type": "array", + "description": "Tiles. Empty/absent when all datasets conflict or no stats were returned.", + "items": { + "type": "object", + "properties": { + "meta": { + "type": "string", + "description": "Tile name (e.g. totalUniqueTests). Note the key is `meta`, not `name`." + }, + "value": { + "type": "integer" } - }, - "SuiteHealthPlatformBreakdownResponse": { - "description": "Per OS/browser/device breakdown for one test \u2014 a BARE ARRAY, sorted by reliability descending.", - "schema": { - "type": "array", - "items": { - "$schema": "SuiteHealthPlatformBreakdownItem" - } + } + } + }, + "sourceFencingFlags": { + "type": "object", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "WidgetFilterValue": { + "type": "object", + "description": "A single selectable filter value.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true + }, + "pId": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + } + } + }, + "WidgetFilterValueList": { + "type": "object", + "description": "Flat list of values for one widget filter key.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + } + }, + "WidgetEnhancedFilterResponse": { + "type": "object", + "description": "Widget filter values bucketed with paging cursors.", + "properties": { + "data": { + "type": "object", + "description": "Map keyed by bucket (e.g. suggested / others); each value is a list of filter-value structs.", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + }, + "fullList": { + "type": "boolean", + "nullable": true + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + }, + "nullable": true + }, + "suggestedAfterKey": { + "type": "string", + "description": "Cursor to continue the suggested bucket.", + "nullable": true + }, + "othersAfterKey": { + "type": "string", + "description": "Cursor to continue the others bucket.", + "nullable": true + } + } + }, + "WidgetDrillDownData": { + "type": "object", + "description": "Row-level records behind a widget data point.", + "properties": { + "data": { + "type": "array", + "description": "Drill-down rows. Each carries project identity (projectId, thProjectId, projectName, normalisedName) plus widget-specific fields.", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "normalisedName": { + "type": "string" } - }, - "SuiteHealthTestRunsResponse": { - "description": "One page of a test's executions across builds, cursor-paginated by start.", - "schema": { + } + } + }, + "insights": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "meta": { + "type": "string" + } + } + }, + "isConflicting": { + "type": "boolean" + }, + "isEmptyNumerator": { + "type": "boolean" + }, + "isEmptyDenominator": { + "type": "boolean" + } + } + }, + "ParallelQueueSummaryDetails": { + "type": "object", + "description": "Per-product concurrency figures.", + "properties": { + "product": { + "type": "string" + }, + "totalParallelsUsed": { + "type": "integer" + }, + "maximumParallelsAllowed": { + "type": "integer" + }, + "totalQueuedSessions": { + "type": "integer" + }, + "maximumQueueAllowed": { + "type": "integer" + }, + "userLevelQueuedSessions": { + "type": "integer" + }, + "totalParallelsBreakdown": { + "type": "object", + "description": "Per-pool breakdown." + }, + "maximumParallelsBreakdown": { + "type": "object" + }, + "totalQueuedBreakdown": { + "type": "object" + }, + "maximumQueueBreakdown": { + "type": "object" + } + } + }, + "ParallelQueueUsageSummary": { + "type": "object", + "description": "Group-level parallel-queue summary.", + "properties": { + "summary": { + "type": "array", + "description": "Per-product SummaryDetails; present only when a product was supplied.", + "items": { + "$schema": "ParallelQueueSummaryDetails" + } + }, + "testExecutionsUsage": { + "type": "object", + "description": "Plan test-execution usage block." + } + } + }, + "ParallelQueueTeamUsage": { + "type": "object", + "description": "Per-team and per-member parallel-session usage.", + "properties": { + "summary": { + "$schema": "ParallelQueueSummaryDetails" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "teamName": { + "type": "string" + }, + "totalCount": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "maximumTeamParallelsAllowed": { + "type": "integer" + }, + "members": { + "type": "array", + "items": { "type": "object", "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "start": { - "type": "integer", - "description": "Cursor for the next (older) page \u2014 pass back as the start query param." - }, - "offset": { - "type": "integer", - "description": "Fixed placeholder (20) kept for FE contract; not honoured by the backend." - } - } - }, - "builds": { - "type": "array", - "items": { - "$schema": "SuiteHealthTestRun" - } - } + "userId": { + "type": "integer" + }, + "userName": { + "type": "string" + }, + "parallelsUsed": { + "type": "integer" + }, + "sessionQueued": { + "type": "integer" + }, + "lastBuildRanAt": { + "type": "integer" + }, + "showRunningBuildsButton": { + "type": "boolean" + }, + "parallelsBreakdown": { + "type": "object" + }, + "sessionQueuedBreakdown": { + "type": "object" + } } + } } - }, - "TestRunHistoryDetailsResponse": { - "description": "Execution-history map keyed by the requested id. Top-level keys are the test run ids (or hook run ids when isHook=true); an id with no history is omitted.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestRunHistoryEntry" - } + } + } + } + } + }, + "RunningBuildsResponse": { + "type": "object", + "description": "A user's currently-running builds.", + "properties": { + "data": { + "type": "array", + "description": "Running builds in the standard build-summary shape (uuid, id, name, status, duration, startedAt, projectId, tags, statusStats, ...).", + "items": { + "$schema": "BuildSummary" + } + } + } + }, + "DeviceTierUsageInfo": { + "type": "object", + "description": "Aggregated mobile device-tier usage and device list for a product. Both fields are raw JSON trees from the backend.", + "properties": { + "tierInfo": { + "type": "array", + "description": "One row per mobile tier: {id, name, maxUsed, maxAvailable}.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Tier id, e.g. Tier1." + }, + "name": { + "type": "string" + }, + "maxUsed": { + "type": "integer" + }, + "maxAvailable": { + "type": "integer" } - }, - "TestRunRetryLogsResponse": { - "description": "The retry attempt's screenshot/log structure. Throws (not 200) when no screenshot data exists for the attempt.", - "schema": { - "$schema": "TestRunRetryLogStruct" + } + } + }, + "devices": { + "type": "object", + "description": "Map of tier id (Tier1..Tier4) to the array of devices in that tier (each carrying maxParallels and parallelsUsed).", + "additionalProperties": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "TestExecutionUsageCount": { + "type": "object", + "description": "Execution counts for a window. NOTE: when both userId and groupId are missing the endpoint instead returns the plain string 'Missing userId or groupId'.", + "properties": { + "totalTestCount": { + "type": "integer", + "description": "All tests reported in the window." + }, + "browserstackTestCount": { + "type": "integer", + "description": "Subset that ran on BrowserStack infra (carry a sessionId)." + } + } + }, + "GroupPlanUsageDetails": { + "type": "object", + "description": "Plan info/details plus non-BrowserStack usage figures. All counts are compacted display strings (e.g. '1.2K'). Usage fields are null when the group has no execution limit; the null-branch uses `nonBrowserstackTestExecutions` in place of `nonBrowserstackUsedTestExecutions`.", + "properties": { + "planInfo": { + "type": "object" + }, + "planDetails": { + "type": "object" + }, + "nonBrowserstackTotalTestExecutions": { + "type": "string" + }, + "nonBrowserstackUsedTestExecutions": { + "type": "string", + "description": "Populated branch only." + }, + "nonBrowserstackTestExecutions": { + "type": "string", + "description": "Null-branch key only (when no execution limit)." + }, + "nonBrowserstackPercentageOfTestExecutionUsage": { + "type": "string" + }, + "billingResetDate": { + "type": "integer", + "description": "Epoch millis; null when no limit." + }, + "browserstackUsedTestExecutions": { + "type": "string" + }, + "totalTestExecutions": { + "type": "string" + }, + "nonBrowserstackPercentageOfGroupTestExecutionUsage": { + "type": "string" + }, + "nonBrowserstackGroupTotalTestExecutions": { + "type": "string" + } + } + }, + "GlobalSearchEntity": { + "type": "object", + "description": "One search result. Concrete shape is per-type (Build / BuildTestCaseExecutionResult / Project), discriminated by `entity`.", + "properties": { + "id": { + "type": "integer" + }, + "entity": { + "type": "string", + "description": "Type discriminator: Build / BuildTestCaseExecutionResult / Project." + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userName": { + "type": "string", + "description": "Resolved from userId server-side." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GlobalSearchResult": { + "type": "object", + "description": "Search results. pagingParams and totalCount are omitted in overlay mode.", + "properties": { + "response": { + "type": "array", + "items": { + "$schema": "GlobalSearchEntity" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string" + } + } + }, + "totalCount": { + "type": "integer" + } + } + }, + "CreateHealingPrResponse": { + "type": "object", + "description": "Self-healing PR job state. At create time only healingPrId + status are set (status=IN_PROGRESS); the remaining fields are populated by the background worker and read back via the status endpoint.", + "properties": { + "healingPrId": { + "type": "string", + "description": "The job id (UUID)." + }, + "status": { + "type": "string", + "description": "One of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED." + }, + "prUrl": { + "type": "string", + "description": "URL of the opened PR; null until a terminal PR-producing state." + }, + "prNumber": { + "type": "integer", + "description": "PR number; null until opened." + }, + "headSha": { + "type": "string", + "description": "Head commit SHA of the PR branch; null until committed." + }, + "perFileReport": { + "type": "object", + "description": "Per-file applied/skipped detail (arbitrary JSON); present on terminal outcomes." + }, + "errorMessage": { + "type": "string", + "description": "Failure detail; present on FAILED/PARTIAL outcomes." + } + } + }, + "HealingVerdictResponse": { + "type": "object", + "description": "A recorded verdict on one AI-healed locator.", + "properties": { + "elementHash": { + "type": "string", + "description": "Hash keying the healed element (used to join with the healing report)." + }, + "healedLocator": { + "type": "object", + "description": "The healed locator that was judged (arbitrary JSON node, e.g. {type, value})." + }, + "verdict": { + "type": "string", + "description": "'accurate' or 'inaccurate'." + }, + "feedback": { + "type": "object", + "description": "Optional free-form feedback (arbitrary JSON); null when none." + }, + "reviewedByUserId": { + "type": "integer" + }, + "reviewedAt": { + "type": "string" + }, + "isCarryover": { + "type": "boolean", + "description": "True when this verdict was first recorded against an earlier build." + }, + "carryoverBuildSerialId": { + "type": "integer", + "description": "The earlier build's serial id when isCarryover is true." + } + } + }, + "SelfHealSummaryStats": { + "type": "object", + "description": "Build-level self-healing headline numbers.", + "properties": { + "testSummary": { + "type": "object", + "properties": { + "totalTests": { + "type": "integer" + }, + "healedTests": { + "type": "integer" + }, + "percentHealedTests": { + "type": "number" + } + } + }, + "locatorSummary": { + "type": "object", + "properties": { + "totalLocators": { + "type": "integer" + }, + "healedLocators": { + "type": "integer" + }, + "percentHealedLocators": { + "type": "number" + } + } + }, + "buildSummary": { + "type": "object", + "properties": { + "totalBuild": { + "type": "integer" + }, + "healedBuilds": { + "type": "integer" + }, + "percentHealedBuilds": { + "type": "number" + } + }, + "nullable": true, + "description": "Build-level roll-up; null on a single-build read without healing data." + } + } + }, + "BuildComparisonAggregation": { + "type": "object", + "description": "Headline counts for a base-vs-target build comparison. On a fenced (cross-source) pair all counts are 0 and sourceFencingFlags is set.", + "properties": { + "newlyFailed": { + "type": "integer" + }, + "failedWithDifferentReason": { + "type": "integer" + }, + "startedPassing": { + "type": "integer" + }, + "additionalTests": { + "type": "integer" + }, + "droppedTests": { + "type": "integer" + }, + "noChange": { + "type": "integer" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags", + "description": "Present only on a fenced (cross-source) pair; OMITTED (not null/false) on a normal pair." + } + } + }, + "BuildComparisonAvailable": { + "type": "object", + "description": "Comparison availability pre-check. On a fenced pair only sourceFencingFlags is set (status/message null).", + "properties": { + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonStringEnvelope": { + "type": "object", + "description": "base/target build UUIDs resolved to their parent builds.", + "properties": { + "base": { + "type": "string", + "description": "Resolved parent build UUID for the base side." + }, + "target": { + "type": "string", + "description": "Resolved parent build UUID for the target side." + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonMetaEnvelope": { + "type": "object", + "description": "The two build summary headers, base and target. On a fenced pair base/target are null and sourceFencingFlags is set. Status counts are AMR-corrected.", + "properties": { + "base": { + "$schema": "BuildSummary" + }, + "target": { + "$schema": "BuildSummary" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags", + "description": "Present only on a fenced pair; omitted otherwise." + } + } + }, + "BuildComparisonList": { + "type": "object", + "description": "One comparison bucket's tests, grouped for display. Fenced pairs return an empty groups list with sourceFencingFlags set.", + "properties": { + "bucketName": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "$schema": "BuildComparisonListGroup" + } + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonListGroup": { + "type": "object", + "description": "A file/environment grouping of compared tests.", + "properties": { + "file": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "vcFilepath": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "$schema": "BuildComparisonListGroupScope" + } + } + } + }, + "BuildComparisonListGroupScope": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "elements": { + "type": "array", + "description": "base/target pairs of test elements.", + "items": { + "type": "object", + "properties": { + "base": { + "$schema": "BuildComparisonListGroupScopeElement" + }, + "target": { + "$schema": "BuildComparisonListGroupScopeElement" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" } - }, - "TestRunSdkLogsListResponse": { - "description": "One fixed-size (15) page of test runs with SDK logs/attachments. totalAttachments is present only on the first (cursor-absent) page.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunSdkLogEntry" - } - }, - "cursorInfo": { - "type": "object", - "properties": { - "nextCursor": { - "type": "integer", - "description": "Pass back as `cursor` for the next page." - }, - "hasMore": { - "type": "boolean" - } - } - }, - "totalAttachments": { - "type": "integer", - "description": "Per-build attachment total; only on the first page." - } - } + } + } + } + } + }, + "BuildComparisonListGroupScopeElement": { + "type": "object", + "description": "One compared test's detail on one side.", + "properties": { + "id": { + "type": "integer" + }, + "testHash": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "isManuallyOverridden": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "issueType": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isPerformanceAnamoly": { + "type": "boolean" + }, + "fileName": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "vcFilepath": { + "type": "string" + } + } + }, + "CommentRes": { + "type": "object", + "description": "A comment as returned by the comment list reads. Note it carries `commentNode` but NOT the `content` HTML.", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "nullable": true, + "description": "Comment page type; null in list reads even when filtered by type." + }, + "threadId": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "commentNode": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "projectId": { + "type": "integer" + }, + "buildUuid": { + "type": "string" + }, + "buildSerialId": { + "type": "integer" + }, + "runUuid": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "testHash": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "mentions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "BuildRunComment": { + "type": "object", + "description": "A comment-thread summary for the build-level side panel.", + "properties": { + "threadId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "browser": { + "type": "object", + "description": "Browser descriptor (name/version)." + }, + "os": { + "type": "object", + "description": "OS descriptor (name/version)." + }, + "device": { + "type": "string" + }, + "type": { + "type": "string", + "description": "The thread's comment page type." + }, + "testRunUuid": { + "type": "string", + "format": "uuid" + }, + "commentCount": { + "type": "integer" + }, + "testHash": { + "type": "string" + } + } + }, + "SPFilterField": { + "type": "object", + "description": "A single filter option / labelled value used across the Unique Errors filter surface.", + "properties": { + "id": { + "type": "string", + "description": "Option key (for OS/browser the comma-encoded value is turned into an underscore id)." + }, + "value": { + "type": "string", + "description": "Backing value; usually equal to id for tags/branches." + }, + "label": { + "type": "string", + "description": "Display text." + }, + "count": { + "type": "integer", + "description": "Occurrence count for the option; usually populated, may be null.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Parent grouping key (JSON key is `pId`); null or empty for non-hierarchical options.", + "nullable": true + } + } + }, + "UniqueErrorClusterRow": { + "type": "object", + "description": "One error cluster row in the unique-errors list (serialised ErrorResult).", + "properties": { + "id": { + "type": "string", + "description": "The errorClusterId as a numeric string \u2014 the cluster id to pass as errorId / clusterId to the detail views." + }, + "impactedTests": { + "type": "integer", + "description": "Distinct tests affected by this error (cardinality of stabilityHash)." + }, + "errorCount": { + "type": "integer", + "description": "Total occurrences of the error in the window." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps; one bucket per calendar day over the window)." + }, + "error": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample error text split on newlines into lines." + } + } + }, + "UniqueErrorImpactedTest": { + "type": "object", + "description": "One impacted test in an error-cluster breakdown (serialised ErrorTests).", + "properties": { + "id": { + "type": "string", + "description": "Stability hash (test signature), not a numeric id." + }, + "title": { + "type": "string", + "description": "Test name (empty string when absent)." + }, + "filePath": { + "type": "string", + "description": "Source file path; may be null." + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scope/describe hierarchy; may be null." + }, + "browsers": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Browser objects (each with a formatted browser label and a `device`)." + }, + "platforms": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Formatted OS/platform objects." + }, + "device": { + "type": "string", + "description": "Device label; may be null." + }, + "errorCount": { + "type": "integer", + "description": "Occurrences of the error for this test." + }, + "testRunId": { + "type": "integer", + "description": "A representative failing run id; may be null." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps)." + } + } + }, + "UniqueErrorBuild": { + "type": "object", + "description": "One build in which a unique error occurred (serialised ErrorBuild). Several derived fields are exposed only via getters.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "originalName": { + "type": "string" + }, + "normalisedName": { + "type": "string", + "description": "URL-encoded form of name (getter-derived)." + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "startedAt": { + "type": "string", + "description": "Timestamp." + }, + "finishedAt": { + "type": "string", + "description": "Timestamp." + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "browser": { + "type": "object", + "description": "Formatted browser object." + }, + "os": { + "type": "object", + "description": "Formatted platform object." + }, + "device": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "ciBuildData": { + "type": "object", + "description": "CI info; null when the build has none." + }, + "isFailedWithSameError": { + "type": "boolean", + "description": "True when this build's run failed with THIS exact cluster." + }, + "thProjectId": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "testCreationType": { + "type": "string" + }, + "state": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string" + }, + "tcmTestRunId": { + "type": "integer" + }, + "pmToolDetails": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Linked PM-tool (Jira etc.) entries; empty array when none." + }, + "flakyReason": { + "type": "integer" + }, + "smartTagSettings": { + "type": "object", + "description": "Smart-tag settings applied to the build; may be null." + }, + "workflowStatus": { + "type": "string" + }, + "versionControlInfo": { + "type": "object", + "description": "Getter-derived {url, commitId}.", + "properties": { + "url": { + "type": "string" + }, + "commitId": { + "type": "string" + } + } + }, + "issueType": { + "type": "object", + "description": "Getter-derived {isAutoAnalyzed, value}; null unless the run status is failed.", + "properties": { + "isAutoAnalyzed": { + "type": "boolean" + }, + "value": { + "type": "string" + } + } + }, + "historyAggregate": { + "type": "object", + "description": "Getter-derived flags.", + "properties": { + "isAlwaysFailing": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + } + } + } + } + }, + "SPTimelineDataPoint": { + "type": "object", + "description": "One point in a Suite Health chart series.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket epoch." + }, + "y": { + "type": "number", + "description": "Value; may be null." + }, + "t": { + "type": "integer", + "description": "Secondary timestamp; may be null." + }, + "percentage": { + "type": "number", + "description": "Percentage value; may be null." + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SPChart__w3_sp_error": { + "type": "object", + "description": "A Suite Health chart: one or more named series plus a headline insight.", + "properties": { + "data": { + "type": "object", + "description": "Single-entry map from series name to a list of points. The series name differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "SPTimelineDataPoint" + } + } + }, + "insights": { + "type": "object", + "properties": { + "metric": { + "type": "string", + "description": "Human label, e.g. 'Total Unique Errors'." + }, + "value": { + "type": "string", + "description": "Headline total, formatted as a STRING." + }, + "meta": { + "type": "string" + } + } + } + } + }, + "TestRunsRcaData": { + "type": "object", + "description": "The stored AI RCA for a test run. Peek-only fields (pendingMessageId, alreadyTracked, isLive) and NON_NULL fields (failureTypeDisplayName, conversation, trialQuota) appear only when set. rcaData/thinkingData are model output populated once state is Completed. A peek on a run with no RCA returns 200 with every field null.", + "properties": { + "id": { + "type": "integer", + "description": "The stored RCA row id.", + "nullable": true + }, + "testRunId": { + "type": "integer", + "nullable": true + }, + "rcaData": { + "type": "object", + "description": "Generated analysis {root_cause, description, failure_type, failure_sub_type, possible_fix, related_prs, alternatives_considered, unique_id}; {} while Pending.", + "nullable": true + }, + "thinkingData": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "data": {} + } + }, + "description": "Reasoning/thinking trace: an ARRAY of {type, data}." + }, + "state": { + "type": "string", + "description": "Generation state.", + "enum": [ + "Pending", + "FETCHING_LOGS", + "GENERATING_RCA", + "GENERATED_RCA", + "THINKING", + "RETRYING", + "LLM_SERVICE_ERROR", + "LOG_FETCH_ERROR", + "AI_SYSTEM_ERROR", + "UNKNOWN_ERROR", + "TIMEOUT", + "Completed" + ], + "nullable": true + }, + "bulkRcaId": { + "type": "string", + "description": "UUID linking runs analysed together via a bulk RCA request; null for a singly-generated RCA.", + "nullable": true + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "isAgentic": { + "type": "boolean", + "description": "Whether this RCA used the agentic flow. Forced false for LTS-product callers.", + "nullable": true + }, + "isMcpDriven": { + "type": "boolean", + "description": "True when the RCA is owned by the MCP-powered chat agent (the web composer is then disabled).", + "nullable": true + }, + "failureTypeDisplayName": { + "type": "string", + "description": "Present only when set." + }, + "pendingMessageId": { + "type": "string", + "description": "Peek-only: messageId of the in-flight PENDING conversation row, if any.", + "nullable": true + }, + "alreadyTracked": { + "type": "boolean", + "description": "Peek-only: whether a build-chat row already tracks pendingMessageId. Null for web/MCP-started generations.", + "nullable": true + }, + "isLive": { + "type": "boolean", + "description": "Peek-only: true when a generation is genuinely running right now \u2014 the field to gate a re-trigger on.", + "nullable": true + }, + "conversation": { + "type": "object", + "description": "Present only for a chat-driven RCA turn; its `state` uses the UPPER-CASE conversation enum (COMPLETED) while the top-level state uses the RCA enum (Completed)." + }, + "trialQuota": { + "type": "object", + "description": "Present only for trial users; carries used-quota info." + } + } + }, + "BuildGroup": { + "type": "object", + "description": "A run group (build group). Serialized with non-null fields only; `state` is computed (ACTIVE until closed, then CLOSED).", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "project_id": { + "type": "integer", + "description": "TestHub project id the group belongs to." + }, + "group_id": { + "type": "integer" + }, + "created_by": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "isClosed": { + "type": "boolean" + }, + "closedAt": { + "type": "string", + "format": "date-time" + }, + "closedBy": { + "type": "integer" + }, + "closedByName": { + "type": "string" + }, + "state": { + "type": "string", + "description": "Computed from isClosed.", + "enum": [ + "ACTIVE", + "CLOSED" + ] + } + } + }, + "SplitBuildLegs": { + "type": "object", + "description": "Split-build legs payload built by BuildService.getSplitBuildDetails (a ProjectDto subset). Only the fields this render actually populates are described.", + "properties": { + "id": { + "type": "integer", + "description": "Observability project id the split build belongs to." + }, + "name": { + "type": "string", + "description": "Project name." + }, + "builds": { + "type": "array", + "description": "The child legs, each a full build-summary object (same shape as BuildDetails).", + "items": { + "$schema": "BuildDetails" + } + }, + "pagingParams": { + "$schema": "PagingParams" + } + } + }, + "PagingParams": { + "type": "object", + "description": "Cursor pagination envelope. `searchAfter` is a build uuid (or list) to pass back as the next cursor; `hasNext` says whether another page exists.", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "description": "Opaque next-root cursor map (composite-agg paging); may be null." + }, + "searchAfter": { + "description": "Either a list of cursor tokens (build uuids) or a single integer, depending on the endpoint." + } + } + }, + "BuildDetails": { + "type": "object", + "description": "Full single-build detail object (BuildSummary DTO). Large flat object (~80 keys); only the non-obvious / gating fields are declared. Nulls ARE serialised (derivedName, originalName, ciBuildData, versionControlInfo, ... often null) - the NON_NULL notes on the nested aggregates do not apply to this object. `vcsInfo` is write-only and never serialised.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Live/computed status; may reflect an in-flight aggregation mid-run." + }, + "displayStatus": { + "type": "string", + "description": "Settled pipeline verdict; prefer this over `status` for the final outcome." + }, + "duration": { + "type": "integer", + "description": "Consolidated duration when present, else raw duration (the getter resolves this)." + }, + "statusStats": { + "$schema": "StatusAggregate" + }, + "issueTypeAggregate": { + "$schema": "IssueTypeAggregate", + "nullable": true, + "description": "Null when no failures have been categorised yet." + }, + "historyAggregate": { + "$schema": "HistoryAggregate", + "description": "Smart-tag roll-up; all zeros (not null) when nothing is tagged." + }, + "showHealingReport": { + "type": "boolean", + "description": "UI gate: whether a self-healing report exists to show (S3 key-existence check)." + }, + "showAuthoringReport": { + "type": "boolean", + "description": "UI gate: whether an authoring-agent report exists (S3 check); forced true on demo-sandbox envs." + }, + "appAccessibilityEnabled": { + "type": "boolean", + "description": "Per-group feature flag; fails CLOSED (false) if the flag lookup errors." + }, + "qualityGateStatus": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "tcmTestPlans": { + "type": "array", + "description": "Cross-product test-management plan links, if any.", + "items": { + "type": "object" + } + }, + "workflowStatusStats": { + "type": "object", + "description": "Workflow-status roll-up." + }, + "runInfos": { + "type": "array", + "items": { + "type": "object" + } + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "isLocked": { + "type": "boolean", + "nullable": true + }, + "buildNumber": { + "type": "integer", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true, + "description": "TestHub project id." + }, + "tcmTestRunIdentifier": { + "type": "string", + "nullable": true, + "description": "Linked Test Management run (TR-...), when any." + }, + "issueTypes": { + "type": "array", + "nullable": true, + "items": { + "type": "object" + } + }, + "aiRcaCompletedCount": { + "type": "integer", + "nullable": true + }, + "selfHealedCount": { + "type": "integer", + "nullable": true + }, + "testCaseCount": { + "type": "integer", + "nullable": true + }, + "state": { + "type": "string", + "nullable": true, + "description": "Lifecycle state, e.g. NEW or CLOSED." + } + } + }, + "BuildNotificationSummary": { + "type": "object", + "description": "Compact build-summary email/notification card (BuildSummaryEmail DTO). `startedAt` is @JsonIgnore; the payload exposes formatted date/time/duration helpers instead. `accessibility` is present only for APP_ALLY_AUT builds.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "projectNormalisedName": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "displayStatus": { + "type": "string", + "description": "Computed display status derived from status + statusStats." + }, + "duration": { + "type": "integer" + }, + "formattedDuration": { + "type": "string", + "description": "Human-readable duration helper (e.g. '2m 5s')." + }, + "startTime": { + "type": "string" + }, + "startDate": { + "type": "string" + }, + "qualityGateStatus": { + "type": "string" + }, + "totalCount": { + "type": "integer" + }, + "mutedCount": { + "type": "integer" + }, + "statusStats": { + "$schema": "StatusAggregate" + }, + "issueTypeAggregate": { + "$schema": "IssueTypeAggregate" + }, + "historyAggregate": { + "$schema": "HistoryAggregate" + }, + "alerts": { + "$schema": "BuildAlerts__w4_build2" + }, + "buildNumber": { + "type": "integer" + }, + "colorMapping": { + "type": "object", + "description": "Map of status/category -> hex color for rendering the card." + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "accessibility": { + "type": "object", + "description": "APP_ALLY_AUT builds only; omitted (NON_NULL) otherwise. Populated fail-open.", + "properties": { + "totalIssues": { + "type": "integer" + }, + "totalScans": { + "type": "integer" + }, + "a11yScore": { + "type": "number" + }, + "severityBreakdown": { + "type": "object", + "properties": { + "critical": { + "type": "integer" + }, + "serious": { + "type": "integer" + }, + "moderate": { + "type": "integer" + }, + "minor": { + "type": "integer" + } + } + } + } + } + } + }, + "BuildStabilityTile": { + "type": "object", + "description": "Stability tile for a build (BuildStability DTO). `count` on the DTO is @JsonIgnore; the surfaced number lives in `overview.count`.", + "properties": { + "data": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-run stability display series." + }, + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Bucket keys aligned to `data`." + }, + "median": { + "type": "number" + }, + "commentCount": { + "type": "integer" + }, + "overview": { + "type": "object", + "description": "Headline verdict for the tile.", + "properties": { + "count": { + "type": "string", + "description": "Current stability as a formatted percentage string (e.g. '92.3')." + }, + "inPercentage": { + "type": "boolean" + }, + "meta": { + "type": "string", + "description": "e.g. 'based on last 30 runs'." + }, + "insights": { + "type": "object", + "description": "Positive/negative/default verdict vs recent builds; only populated with >= 10 comparison runs.", + "properties": { + "type": { + "type": "string", + "values": [ + "positive", + "negative", + "default" + ] + }, + "text": { + "type": "string" + } } - }, - "AssignTestRunFailureCategoriesResponse": { - "description": "Flat array of the resulting issue types after bulk categorisation.", - "schema": { + } + } + } + } + }, + "BuildSummaryStats": { + "type": "object", + "description": "Headline test-count strip for a build (BuildSummaryStats DTO).", + "properties": { + "data": { + "type": "array", + "description": "Colored metric tiles (total/passed/failed/skipped/pending roll-ups).", + "items": { + "$schema": "ColoredMetric" + } + }, + "aiRcaStats": { + "$schema": "AiRcaStats" + }, + "isArchived": { + "type": "boolean" + }, + "commentCount": { + "type": "integer" + }, + "hooksVisibility": { + "type": "string", + "description": "Whether hook rows are shown/hidden for this project." + }, + "manuallyOverriddenCount": { + "type": "integer" + }, + "sessionStats": { + "$schema": "SessionStats" + } + } + }, + "BuildTimelineStats": { + "type": "object", + "description": "Build execution timeline (BuildTimelineAggregate DTO).", + "properties": { + "data": { + "type": "array", + "description": "Three chart series with ids `passed`, `failed` and `pending` (pending = tests still running at that instant). Each point is [x, count] where x is MILLISECONDS since the build's first test started (relative, not epoch time).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Series key: passed, failed or pending." + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Chart type, default 'column'." + }, + "data": { + "type": "array", + "description": "Array of [msSinceFirstTestStart, count] pairs.", + "items": { "type": "array", "items": { - "$schema": "TestRunIssueType" + "type": "integer" } + } } - }, - "BstackErrorSessionsResponse": { - "description": "Raw array of BrowserStack-error session objects straight from the session repository (opaque element shape). Empty for non-SDK builds and in demo-sandbox.", - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Opaque session object." - } + } + } + } + } + }, + "BuildFailureCategoryStats": { + "type": "object", + "description": "Failure-category (defect) breakdown (DefectStats DTO). On a PENDING build, `data` is empty and `message` explains why (still HTTP 200).", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "DefectStatsData" + } + }, + "aiRcaStats": { + "$schema": "AiRcaStats" + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string", + "description": "Set to a 'available after your build finishes' notice while pending; otherwise typically absent." + } + } + }, + "DefectStatsData": { + "type": "object", + "description": "One failure-category bucket.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "value": { + "type": "integer", + "description": "Count of failures in this category." + }, + "color": { + "type": "string" + }, + "categories": { + "type": "array", + "description": "Optional nested sub-buckets.", + "items": { + "$schema": "ColoredMetric" + } + } + } + }, + "ColoredMetric": { + "type": "object", + "description": "A single colored metric tile / bucket.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "value": { + "type": "integer" + }, + "color": { + "type": "string" + }, + "filterCount": { + "type": "integer" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" + } + } + }, + "BuildFailuresByModule": { + "type": "object", + "description": "Failures grouped by test module / file path (FailureByModuleStats DTO).", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "A module/folder node.", + "properties": { + "id": { + "type": "string", + "description": "Full module/file path." + }, + "name": { + "type": "string" + }, + "value": { + "type": "integer", + "description": "Total tests in the module." + }, + "colorValue": { + "type": "integer" + }, + "failedTests": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "parent": { + "type": "string", + "description": "Parent node id for tree reconstruction." + }, + "customPathSplit": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Path split by the inferred separator." + }, + "separator": { + "type": "string" + }, + "joinSeparator": { + "type": "string" } - }, - "FlatTestRunListResponse": { - "description": "Flat, cursor-paged test-run listing (V2 flat surface).", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "type": "object", - "description": "Flattened test detail (per-run row: id, name, status, platform, flags, etc.)." - } - }, - "pagingParams": { - "type": "object", - "description": "Cursor state (hasNext, nextRoot, searchAfter) to feed back for the next page." - }, - "isArchived": { - "type": "boolean" - }, - "smartTagSettings": { - "type": "object" - }, - "status": { - "type": "object", - "description": "Status aggregate over the result." - }, - "workflowStatus": { - "type": "object", - "description": "Workflow-status aggregate over the result." - }, - "folder_path": { - "type": "string" - } - } + } + } + }, + "commentCount": { + "type": "integer" + }, + "isConflicting": { + "type": "boolean" + }, + "sourceFencingFlags": { + "type": "object", + "description": "Present (NON_NULL) only when source-fencing excludes the build's product; names which products' tests were skipped.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "BuildFilterValues": { + "type": "object", + "description": "Older compact build-filter shape (FilterValues DTO): a single `filters` map keyed by facet name.", + "properties": { + "filters": { + "type": "object", + "description": "Map keyed by facet (browser, os, issueType, tags, status, folder, history, flaky). Each value is a list of options. `history` and `flaky` are static option lists always present.", + "properties": { + "browser": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "os": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "issueType": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "tags": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "status": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "folder": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "history": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "flaky": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + } + } + } + } + }, + "BuildTestFolders": { + "type": "object", + "description": "Build test-listing folder tree. Two shapes depending on folder_path: root-folders (RootFoldersResponse) or folder-detail (FolderDetailsResponse). Both are @JsonInclude(NON_NULL). Fields from both are shown; only one branch's fields are populated per call.", + "properties": { + "project_id": { + "type": "integer" + }, + "folders": { + "type": "array", + "description": "ROOT branch only: the root folders.", + "items": { + "$schema": "SubFolderDetails" + } + }, + "name": { + "type": "string", + "description": "DETAIL branch only: the folder's name (last path segment)." + }, + "folder_path": { + "type": "string", + "description": "DETAIL branch only: full folder path." + }, + "cases_count": { + "type": "integer", + "description": "DETAIL branch: unique cases directly in this folder." + }, + "total_cases_count": { + "type": "integer", + "description": "DETAIL branch: unique cases including nested subfolders." + }, + "sub_folders_count": { + "type": "integer", + "description": "DETAIL branch: immediate subfolder count." + }, + "sub_folders": { + "type": "array", + "description": "DETAIL branch only: immediate subfolders.", + "items": { + "$schema": "SubFolderDetails" + } + }, + "pagingParams": { + "$schema": "PagingParams" + }, + "after_key": { + "type": "string", + "description": "ROOT branch composite-agg cursor field; observed returned as null \u2014 page via pagingParams instead." + } + } + }, + "SubFolderDetails": { + "type": "object", + "description": "One folder node in the build's test-listing tree (NON_NULL).", + "properties": { + "name": { + "type": "string" + }, + "folder_path": { + "type": "string" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + } + } + }, + "BuildNonSdkTestCounts": { + "type": "string", + "description": "IMPORTANT: the body is a JSON *string* (Gson JsonObject.toString()), not a JSON object. After parsing the string it is `{untitled_test_count:int, unmarked_test_count:int, missing_failure_reason_count:int}`, counts spanning the parent build and its split child builds." + }, + "StatusAggregate": { + "type": "object", + "description": "Test-status roll-up (NON_NULL). Keys are lower-case status names.", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer", + "description": "Running/pending count (DTO field runningCount)." + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "aggregate": { + "type": "integer", + "nullable": true, + "description": "Aggregate (total) count across statuses." + } + } + }, + "IssueTypeAggregate": { + "type": "object", + "description": "Issue-type roll-up (NON_NULL). Keys are the human-readable issue-type labels, not camelCase.", + "properties": { + "To be Investigated": { + "type": "integer" + }, + "Automation Bug": { + "type": "integer" + }, + "Product Bug": { + "type": "integer" + }, + "No Defect": { + "type": "integer" + }, + "Environment Issue": { + "type": "integer" + } + } + }, + "HistoryAggregate": { + "type": "object", + "description": "Smart-tag history roll-up (NON_NULL).", + "properties": { + "isFlaky": { + "type": "integer" + }, + "isAlwaysFailing": { + "type": "integer" + }, + "isNewFailure": { + "type": "integer" + }, + "isPerformanceAnomaly": { + "type": "integer" + } + } + }, + "SessionStats": { + "type": "object", + "description": "Session-level counters (SessionStats DTO).", + "properties": { + "totalSessions": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "error": { + "type": "integer" + }, + "unmarked": { + "type": "integer" + }, + "idletimeout": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "done": { + "type": "integer" + }, + "timeout": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "AiRcaStats": { + "type": "object", + "description": "AI RCA availability counter.", + "properties": { + "completedCount": { + "type": "integer", + "description": "How many failures already have AI RCA completed." + } + } + }, + "FilterStruct__w4_build2": { + "type": "object", + "description": "A single filter dropdown option. Folder labels embed the count, e.g. 'auth.test.js(3)'.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Serialised from the DTO's `pid` field via @JsonProperty(\"pId\").", + "nullable": true + }, + "id": { + "type": "string", + "description": "Option id (same as value)." + } + } + }, + "SingleMetricSummary__w4_build2": { + "type": "object", + "description": "Shared single-metric tile (SingleMetricSummary DTO) used by the flaky / muted / new-failure tiles.", + "properties": { + "count": { + "type": "integer", + "description": "The metric count (flaky / muted / new-failure depending on endpoint)." + }, + "meta": { + "type": "string", + "description": "Context string, typically the 'out of N total' text." + }, + "commentCount": { + "type": "integer" + }, + "autoUnmuteCount": { + "type": "integer", + "description": "Most meaningful for the muted tile; how many are scheduled to auto-unmute." + } + } + }, + "BuildAlerts__w4_build2": { + "type": "object", + "description": "Build alerts panel (BuildAlerts DTO).", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Individual alert entries." + }, + "alertsConfigured": { + "type": "boolean" + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "ExternalBuildDetails": { + "type": "object", + "description": "Single external build-details object returned by get_latest_build (BuildDetails / BuildDetailsV2 by the new-build-listing flag). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are plan-gated by the external JsonView wrapper.", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "id": { + "type": "integer", + "description": "Integer build id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "status_stats": { + "type": "object", + "description": "Per-status counts (V2: workflow-status counts), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 shape only." + }, + "failure_categories": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "smart_tags": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "unique_errors": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "alerts": { + "type": "array", + "description": "Pro-only (JsonView)." + }, + "re_run": { + "type": "boolean" + }, + "build_error": { + "type": "object" + }, + "is_archived": { + "type": "boolean" + }, + "observability_version": { + "type": "object" + }, + "vcs_info": { + "type": "object" + }, + "ci_info": { + "type": "object" + }, + "host_info": { + "type": "object" + }, + "run_information": { + "type": "array" + }, + "observability_url": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "app_details": { + "type": "object" + } + } + }, + "ExternalProjectBuildListing": { + "type": "object", + "description": "Public-API build listing (BuildListingResponse / BuildListingResponseV2, selected by the new-build-listing group flag). Keys are snake_case except tcmTestRunIdentifier; the two versions differ only in what status_stats holds and V2's is_manually_overridden.", + "properties": { + "id": { + "type": "integer", + "description": "Project id." + }, + "name": { + "type": "string", + "description": "Project name." + }, + "builds": { + "type": "array", + "description": "The fetched page, reversed before returning (Lists.reverse), so oldest-first within a page.", + "items": { + "type": "object", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid (40 chars); use it for per-build calls. There is no `uuid` or integer `id` key (id is @JsonIgnore)." + }, + "name": { + "type": "string" + }, + "original_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "build_number": { + "type": "integer" + }, + "is_archived": { + "type": "boolean" + }, + "observability_url": { + "type": "string", + "description": "Deep link to the build in the TRA UI." + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "status_stats": { + "type": "object", + "description": "Per-status counts. Same key on both shapes: v1 StatusAggregate, V2 WorkflowStatusAggregate." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 items only." } - }, - "HierarchyTestRunListResponse": { - "description": "Hierarchical (suite/file/test) test-run tree with cursor paging over top-level roots.", - "schema": { - "type": "object", - "properties": { - "hierarchy": { - "type": "array", - "items": { - "type": "object", - "description": "A suite/file/test node (nested children under the node)." - } - }, - "pagingParams": { - "type": "object", - "description": "Cursor state (hasNext, nextRoot, searchAfter); paging is by top-level roots (5 per page, 1 for builds over 2000 runs)." - }, - "status": { - "type": "object", - "description": "Status aggregate." - }, - "workflowStatus": { - "type": "object", - "description": "Workflow-status aggregate." - }, - "isArchived": { - "type": "boolean" - }, - "smartTagSettings": { - "type": "object" - }, - "source": { - "type": "string" - }, - "folder_path": { - "type": "string" - } + } + } + }, + "pagination": { + "type": "object", + "description": "snake_case keys (models/external/Pagination).", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." + } + } + } + } + }, + "ProjectBuildListing": { + "type": "object", + "description": "Internal build-listing payload (ProjectDto) returned by list_project_builds. builds[] are full BuildSummary objects.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "builds": { + "type": "array", + "description": "Rich BuildSummary rows (uuid, id, name, status, statusStats, duration, session/run counts, tags, qualityGateStatus, isArchived, displayStatus, parallelsUsed, tcmTestRunIdentifier, ...).", + "items": { + "type": "object" + } + }, + "pagingParams": { + "type": "object", + "description": "Cursor paging state (hasNext + searchAfter) for the next page." + }, + "activeCount": { + "type": "integer" + }, + "closedCount": { + "type": "integer" + }, + "manualBuildCount": { + "type": "integer" + }, + "automationBuildCount": { + "type": "integer" + }, + "isArchiving": { + "type": "boolean" + }, + "isClosing": { + "type": "boolean" + } + } + }, + "RunningBuildsOnDevice": { + "type": "object", + "description": "Builds currently queued/running on a device+OS combination (PQRunningBuildsResponse). Empty data list when nothing matches.", + "properties": { + "data": { + "type": "array", + "description": "BuildSummary rows for pending builds on the combination; each carries parallelsUsed (unique parallel sessions consumed).", + "items": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "parallelsUsed": { + "type": "integer", + "description": "Number of unique parallel sessions this build is consuming on the device+OS combination." + } + } + } + } + } + }, + "BuildInsights__w4_build4": { + "type": "object", + "description": "Agent-oriented build failure summary returned by get_build_insights (BuildInsightsResponse).", + "properties": { + "buildId": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer" + }, + "summary": { + "type": "object", + "properties": { + "statusMetrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" } + } } - }, - "TestRunCountListResponse": { - "description": "Per-unique-test rerun counts for a build.", - "schema": { + }, + "totalTests": { + "type": "integer" + }, + "totalHooks": { + "type": "integer" + } + } + }, + "failureCategorization": { + "type": "object", + "properties": { + "newFailures": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "flaky": { + "type": "integer" + } + } + }, + "failureContext": { + "type": "object", + "description": "ALWAYS null on this build-keyed route (no test run supplied). Present only on the test-run variant: { file:{path,testCount,failedCount,failureRate}, parentModule:{...} }.", + "nullable": true + }, + "topErrorsAnalysis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty until COMPLETE." + }, + "uniqueErrorCount": { + "type": "integer" + }, + "uniqueErrors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "occurrences": { + "type": "integer" + }, + "truncatedError": { + "type": "string", + "description": "First ~200 chars of the error only." + } + } + } + } + } + }, + "defectStats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueType": { + "type": "string" + }, + "totalFailureTestsCount": { + "type": "integer" + }, + "totalFailureHooksCount": { + "type": "integer" + }, + "categories": { + "type": "array", + "items": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunCountEntry" - } - } + "category": { + "type": "string" + }, + "failureTestCount": { + "type": "integer" + }, + "failureHooksCount": { + "type": "integer" + } } + } } - }, - "BuildTopErrorStatsResponse": { - "description": "Top-unique-errors insight panel; may be not-ready (ready:false + message) while analysis/clustering runs.", - "schema": { - "$schema": "BuildTopErrorStatsPanel" - } - }, - "BuildQualityGateRecalcAckResponse": { - "description": "Async acknowledgement; poll the quality-gate summary for the outcome.", - "schema": { - "$schema": "BuildQualityGateRecalcAck" + } + } + } + } + }, + "ProjectWithAggregates": { + "type": "object", + "description": "A project enriched with Elasticsearch-derived build activity aggregates, as returned by the aggregate project-list routes.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "buildCount": { + "type": "integer", + "description": "Distinct build count for the project in the caller's product context." + }, + "projectStability": { + "type": "number", + "description": "Aggregate stability score for the project." + }, + "stabilityChartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Series of stability values for a mini chart." + }, + "lastRanAt": { + "type": "string", + "format": "date-time", + "description": "When the project's most recent build finished." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastRanBy": { + "type": "integer", + "description": "User id who triggered the most recent build." + }, + "thProjectId": { + "type": "integer" + } + } + }, + "SuiteHealthTestListItem": { + "type": "object", + "description": "One test row in the Stability & Performance / Suite/Test Health listing (com.browserstack.observability.models.response.SuiteHealth.TestStabilityAndPerformance).", + "properties": { + "id": { + "type": "string", + "description": "The test's stabilityHash \u2014 the identifier to carry into the detail endpoints (serialized from testHash via @JsonProperty(\"id\"))." + }, + "title": { + "type": "string", + "description": "Test name." + }, + "filePath": { + "type": "string" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "isPlatformError": { + "type": "boolean", + "description": "True when the sampled run's error was a platform (infra) error." + }, + "reliability": { + "type": "number", + "description": "Failure percentage (failed*100/total). HIGHER = less stable." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "totalFailures": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "browserList": { + "type": "array", + "description": "Browser/device combinations the test ran on.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "string" } - }, - "ResolvedBuildUuidResponse": { - "description": "Resolved build uuid, or {uuid:null} with HTTP 200 when nothing matched.", - "schema": { - "$schema": "ResolvedBuildUuid" + } + } + }, + "platformList": { + "type": "array", + "description": "OS/platform combinations the test ran on.", + "items": { + "type": "object" + } + }, + "browsers": { + "type": "array", + "description": "Always EMPTY on this listing path (populated only when the internal browserString is set, which this path never sets). Read browserList instead.", + "items": { + "type": "object" + } + }, + "platforms": { + "type": "array", + "description": "Always EMPTY on this listing path. Read platformList instead.", + "items": { + "type": "object" + } + } + } + }, + "SuiteHealthPlatformBreakdownItem": { + "type": "object", + "description": "One OS/browser/device row of a test's platform breakdown (TestStabilityAndPerformanceBreakdown).", + "properties": { + "id": { + "type": "string", + "description": "Composite \"-\" key (lower-cased)." + }, + "reliability": { + "type": "number", + "description": "Failure percentage for this combination (higher = worse)." + }, + "duration": { + "type": "number", + "description": "Average run duration for this combination." + }, + "totalFailures": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "os": { + "type": "object", + "description": "Platform display object {name, version}." + }, + "browser": { + "type": "object", + "description": "Browser display object with device as a plain string.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "string" + } + } + }, + "browserDevice": { + "type": "object", + "description": "Browser display object with device as a nested object.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "object" + } + } + } + } + }, + "SuiteHealthTrendPoint": { + "type": "object", + "description": "One execution point in the Suite Health trend series (TrendDataPoint, @JsonInclude NON_NULL \u2014 null fields are omitted).", + "properties": { + "color": { + "type": "string", + "description": "Status colour for the run." + }, + "workflowStatus": { + "type": "string", + "description": "Workflow status (workflowStatusV2), or \"Unknown\"." + }, + "y": { + "type": "integer", + "description": "Run duration (ms); 0 when absent." + }, + "id": { + "type": "integer", + "description": "Test-run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id (display build number)." + }, + "buildName": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "flakyReason": { + "type": "integer", + "description": "Flaky-status code; 0 when absent." + }, + "creationType": { + "type": "string", + "description": "Upper-cased creation type (defaults to AUTOMATION)." + } + } + }, + "SuiteHealthTestRun": { + "type": "object", + "description": "One test execution in the Suite Health run-history table (TestStabilityAndPerformanceDetails). Smart-tag flags are under historyAggregate, not top-level.", + "properties": { + "id": { + "type": "integer", + "description": "Test-run id." + }, + "name": { + "type": "string", + "description": "Composed display name (build name + re-run/manual suffix + #serialId)." + }, + "originalName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "workflowStatusEditedBy": { + "type": "string" + }, + "state": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer", + "description": "Run duration (ms)." + }, + "buildNumber": { + "type": "integer" + }, + "normalisedName": { + "type": "string", + "description": "URL-encoded build name." + }, + "flakyReason": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "testCreationType": { + "type": "string" + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "isPlatformError": { + "type": "boolean" + }, + "thProjectId": { + "type": "integer" + }, + "tcmTestRunId": { + "type": "integer" + }, + "tcmTestRunIdentifier": { + "type": "string" + }, + "historyAggregate": { + "type": "object", + "description": "Smart-tag flags for the run.", + "properties": { + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + } + } + }, + "browser": { + "type": "object", + "description": "Browser display object." + }, + "os": { + "type": "object", + "description": "Platform display object." + }, + "device": { + "type": "string" + }, + "ciBuildData": { + "type": "object", + "description": "CI context (best-effort; empty when CI info absent).", + "properties": { + "buildUrl": { + "type": "string" + }, + "buildNumber": { + "type": "string" + }, + "jobName": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "versionControlInfo": { + "type": "object", + "description": "VCS context (best-effort).", + "properties": { + "url": { + "type": "string" + }, + "commitId": { + "type": "string" + } + } + }, + "pmToolDetails": { + "type": "array", + "description": "Linked PM-tool defects.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" } - }, - "SidebarBuildRunsResponse": { - "description": "A page of unique build runs for the sidebar rail with cursor paging params.", - "schema": { - "$schema": "SidebarBuildRuns" + } + } + }, + "smartTagSettings": { + "type": "object", + "description": "The smart-tag settings resolved for this run from its build's setting version." + } + } + }, + "ObsSessionVideo": { + "type": "object", + "description": "Session video descriptor with playback offsets. Sourced from SessionVideo in observability-api. Offsets/flags can be null when the video is not yet processed; status_code 0 is normal.", + "properties": { + "status_code": { + "type": "integer" + }, + "url": { + "type": "string", + "nullable": true + }, + "offsetUrl": { + "type": "string", + "nullable": true + }, + "startTime": { + "type": "number", + "nullable": true + }, + "startTimeStamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "startOffset": { + "type": "integer", + "description": "ms offset of video start relative to the test/session, for aligning playback with the log timeline.", + "nullable": true + }, + "finishOffset": { + "type": "integer", + "nullable": true + }, + "launchingAppTime": { + "type": "integer", + "description": "VM/browser boot lead-in in seconds.", + "nullable": true + }, + "isIos": { + "type": "boolean", + "nullable": true + }, + "isV2": { + "type": "boolean", + "nullable": true + } + } + }, + "ObsHistoryTab": { + "type": "object", + "description": "One past execution of the same test across builds. Sourced from HistoryTab in observability-api; startedAt is used for ordering and is not serialized.", + "properties": { + "testRunId": { + "type": "integer", + "description": "Integer id of the historical run (serialized from the internal Id field)." + }, + "status": { + "type": "string" + }, + "serialId": { + "type": "integer" + }, + "isHittingRetentionQuota": { + "type": "boolean" + }, + "testRunUuid": { + "type": "string" + }, + "usageQuotaStatus": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "buildUuid": { + "type": "string" + }, + "thProjectId": { + "type": "integer" + } + } + }, + "TestRunHistoryEntry": { + "type": "object", + "description": "One test run's (or hook run's) execution-history entry from get_test_runs_history_details. Only the keys present for that run appear.", + "properties": { + "buildId": { + "type": "integer" + }, + "serialId": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "issueType": { + "$schema": "TestRunIssueType", + "nullable": true, + "description": "Null on passed runs." + }, + "duration": { + "type": "integer", + "description": "Run duration." + }, + "isFlaky": { + "type": "boolean", + "nullable": true + }, + "isAlwaysFailing": { + "type": "boolean", + "nullable": true + }, + "isNewlyFailed": { + "type": "boolean", + "nullable": true + }, + "isPerformanceAnomaly": { + "type": "boolean", + "nullable": true + }, + "flakyReason": { + "type": "integer", + "nullable": true + }, + "flakySetting": { + "type": "object", + "description": "Flaky configuration for the run (opaque).", + "nullable": true + }, + "creationType": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Test tags." + }, + "logs": { + "$schema": "TestRunRetryLogStruct" + }, + "workflowStatus": { + "type": "string", + "nullable": true + }, + "workflowStatusEditedBy": { + "type": "string", + "nullable": true + }, + "workflowStatusEditedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "TestRunRetryLogStruct": { + "type": "object", + "description": "Serialized log/attachment structure for a run or retry attempt. Each key is present only when that evidence exists.", + "properties": { + "TEST_FAILURE": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Failure text: the message followed by the stack, as an array of strings." + }, + "TEST_LOG": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Screenshot / attachment URLs; fetch each directly from storage (not proxied through this API)." + }, + "BROWSERSTACK_ERROR": { + "type": "object", + "description": "BrowserStack-side error log, when the run failed on infra." + } + } + }, + "TestRunSdkLogEntry": { + "type": "object", + "description": "One test run in a build that has SDK-captured logs/attachments.", + "properties": { + "logId": { + "type": "integer" + }, + "url": { + "type": "string", + "description": "Direct attachment/log URL, fetched from storage." + }, + "testRunId": { + "type": "integer" + }, + "testRunName": { + "type": "string" + }, + "attachmentsCount": { + "type": "integer" + }, + "screenshotsCount": { + "type": "integer" + } + } + }, + "TestRunCountEntry": { + "type": "object", + "description": "Per-unique-test rerun count within a build.", + "properties": { + "testHash": { + "type": "string", + "description": "Stable hash identifying the unique test across runs." + }, + "count": { + "type": "integer", + "description": "Number of times the test ran across the build and its re-runs." + }, + "isDifferentPlatformsUsed": { + "type": "boolean", + "description": "True when those runs spanned more than one platform." + } + } + }, + "TestRunIssueType": { + "type": "object", + "description": "A failure category (issue type or sub-type) as returned on test-run endpoints.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "locator": { + "type": "string", + "description": "Stable locator code for the issue type (e.g. a category code)." + }, + "abbreviation": { + "type": "string" + }, + "color": { + "type": "string" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "displayName": { + "type": "string", + "description": "Present only when set (JsonInclude NON_NULL)." + }, + "issueGroup": { + "type": "object", + "description": "Parent issue group (opaque; carries the group/enum this type sits under)." + } + } + }, + "BuildTopErrorStatsPanel": { + "type": "object", + "description": "InsightStatsPanel for a build's top unique errors.", + "properties": { + "overview": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "insights": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + }, + "description": "Defaults to the 'Analysis yet to run' overview when analysis has not completed." + }, + "data": { + "type": "array", + "description": "Error clusters sorted by count desc; each: id, title (the error), count, impactedTests, testRuns[], aiRcaTestRunIds[]. Empty while clustering is incomplete." + }, + "ready": { + "type": "boolean", + "description": "False while the auto-analyser is still running." + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string", + "description": "Set (e.g. 'Unique Errors are being detected') when not ready; the only meaningful field in the not-ready state.", + "nullable": true + } + } + }, + "ResolvedBuildUuid": { + "type": "object", + "description": "Build uuid resolved from identifiers.", + "properties": { + "uuid": { + "type": "string", + "description": "The resolved build uuid, or null (with HTTP 200) when nothing matched." + } + } + }, + "SidebarBuildRuns": { + "type": "object", + "description": "A page of unique build runs for the sidebar rail with bidirectional cursor paging.", + "properties": { + "data": { + "type": "array", + "description": "SidebarBuildSummary rows: uuid, id, name, originalName, normalisedName, status, displayStatus, startedAt, finishedAt, buildNumber, tags[], isArchived, usageQuotaStatus (retention vs pro-quota), qualityGateStatus, creationType, statusStats, source, selfHealedCount, etc." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasPrev": { + "type": "boolean" + }, + "hasNext": { + "type": "boolean" + }, + "beforeKey": { + "type": "string" + }, + "afterKey": { + "type": "string" + } + } + } + } + }, + "BuildQualityGateRecalcAck": { + "type": "object", + "description": "Acknowledgement that quality-gate recalculation was queued (QualityGateSummary with only message set).", + "properties": { + "message": { + "type": "string", + "description": "'Quality Gate result will be available after recalculation finishes'." + } + } + }, + "BuildReportPolling": { + "type": "object", + "description": "BuildReport polling envelope (@JsonInclude NON_NULL). May be a NULL body when the build cannot be resolved.", + "properties": { + "reportStatus": { + "type": "string", + "description": "IN_PROGRESS | TEST_AVAILABLE | COMPLETED | NOT_AVAILABLE | DATA_NOT_AVAILABLE." + }, + "retryCount": { + "type": "integer", + "description": "How many more times to poll (present with IN_PROGRESS)." + }, + "pollingInterval": { + "type": "integer", + "description": "Interval to wait before the next POLL request." + }, + "report": { + "type": "object", + "properties": { + "richHtml": { + "type": "string" + }, + "richCss": { + "type": "string" + }, + "basicHtml": { + "type": "string" + }, + "plainText": { + "type": "string" + } + }, + "description": "Populated only on terminal statuses." + }, + "errorReason": { + "type": "string", + "description": "BUILD_NOT_FOUND | MULTIPLE_BUILD_FOUND | DATA_NOT_AVAILABLE | SOMETHING_WENT_WRONG." + } + } + }, + "BuildHistoryWithDefectCounts": { + "type": "object", + "description": "Agent-facing build history: anchor build + prior same-name runs, each with status and defect count summaries.", + "properties": { + "builds": { + "type": "array", + "description": "Newest first; each entry: buildUuid, buildNumber (serialId), originalName, status, startedAt, finishedAt, durationMs, current (true for the anchor build), statusStats{passed,failed,skipped,total}, defectStats{automationBug,productBug,toBeInvestigated,notDetermined,environmentIssue}, flakyCount, alwaysFailingCount, newFailureCount." + } + } + }, + "PublicBuildDetails": { + "type": "object", + "description": "Public build record (models/external BuildDetails, or BuildDetailsV2 when new build listing is enabled). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are JsonView-gated, so the exact key set varies by flag and plan.", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "id": { + "type": "integer", + "description": "Integer build id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "status_stats": { + "type": "object", + "description": "Per-status counts (V2: workflow-status counts), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 shape only." + }, + "failure_categories": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "smart_tags": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "unique_errors": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "alerts": { + "type": "array", + "description": "Pro-only (JsonView)." + }, + "re_run": { + "type": "boolean" + }, + "build_error": { + "type": "object" + }, + "is_archived": { + "type": "boolean" + }, + "observability_version": { + "type": "object" + }, + "vcs_info": { + "type": "object" + }, + "ci_info": { + "type": "object" + }, + "host_info": { + "type": "object" + }, + "run_information": { + "type": "array" + }, + "observability_url": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "app_details": { + "type": "object" + } + } + } + }, + "responses": { + "Unauthorized": { + "description": "401 - no valid credential was accepted by the authenticator chain.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Forbidden": { + "description": "403 - authenticated but lacks the required permission/plan/role, or access-denied.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BadRequest": { + "description": "400 - invalid or missing parameters; also the catch-all for many client errors.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "NotFound": { + "description": "404 - the addressed resource does not exist or is not visible to this group.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "InternalServerError": { + "description": "500 - unhandled server error; carries only errorTraceId + message.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Conflict": { + "description": "409 - the request conflicts with current state, e.g. an operation already in progress.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BuildSummaryListResponse": { + "description": "Top-level array of build summaries.", + "schema": { + "type": "array", + "items": { + "$schema": "BuildSummary" + } + } + }, + "BuildAggregateMapResponse": { + "description": "Top-level object keyed by build name; each value is a BuildAggregate.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "BuildAggregate" + } + } + }, + "OverallDailySummaryResponse": { + "description": "Aggregated daily-summary report across projects.", + "schema": { + "$schema": "OverallDailySummary" + } + }, + "PublicLinkSettingResponse": { + "description": "Public-link on/off setting for a dashboard or project. Built inline by the handler; defaults to false when no token row exists yet.", + "schema": { + "type": "object", + "properties": { + "publicLink": { + "type": "boolean", + "description": "Whether public link sharing is enabled. false is the default for a dashboard/project that has never been shared." + } + } + } + }, + "QualityGateSettings": { + "description": "A project's quality-gate settings and mapped profiles.", + "schema": { + "$schema": "QualityGateResponse" + } + }, + "QualityProfileDetail": { + "description": "One quality profile in full (rules, filters, status). Null fields omitted.", + "schema": { + "$schema": "QualityProfile" + } + }, + "BuildQualityGateSummary": { + "description": "Readiness-aware build gate summary; exactly one of qgResult/message/state is present.", + "schema": { + "$schema": "QualityGateSummary" + } + }, + "BuildQualityProfileResults": { + "description": "Bare JSON array of per-profile result summaries for a build (no wrapper).", + "schema": { + "type": "array", + "items": { + "$schema": "QualityProfileResultLiteDto" + } + } + }, + "BuildQualityProfileResultDetail": { + "description": "One profile's per-rule results for a build.", + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$schema": "QualityProfileRuleResult" + } + }, + "ruleStatus": { + "type": "string", + "enum": [ + "pass", + "fail" + ] + } + } + } + }, + "CiQualityGateResult": { + "description": "CI-facing pass/fail quality gate result for a build. Null fields omitted while running.", + "schema": { + "$schema": "ExtQualityGateResult" + } + }, + "QualityProfileFilterValues": { + "description": "Filter values for the quality-profile filter builder, with an afterKey cursor.", + "schema": { + "$schema": "QGFilterResponseData" + } + }, + "PercentTimelineCardResponse": { + "description": "A Testing Trends card: timeline series plus optional headline insight.", + "schema": { + "$schema": "PercentTimelineCard" + } + }, + "FailureCategoriesResponse": { + "description": "Failure counts broken down by defect category over time.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FailureCategorySeries" + } + } + } + } + }, + "CbtResponse": { + "description": "Cross-browser coverage: primary platform groups plus their drill-down breakdowns.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "CbtDataPoint" + } + }, + "drillDownData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Primary group key this drill-down belongs to." + }, + "data": { + "type": "array", + "items": { + "$schema": "CbtDataPoint" + } + } } - }, - "BuildReportPollingResponse": { - "description": "Build-report polling envelope; IN_PROGRESS carries retryCount + pollingInterval. May be a NULL body when the build cannot be resolved. Demo-sandbox returns 404 'NOT FOUND'.", - "schema": { - "$schema": "BuildReportPolling" + } + } + } + } + }, + "BuildFrequencyResponse": { + "description": "Run counts per build name for the window. sourceFencingFlags exists on the model but is not populated on this path.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildFrequencyStats" + } + } + } + } + }, + "BuildNamesResponse": { + "description": "Distinct build names within the plan retention window, wrapped under data.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "StabilityLeaderboardResponse": { + "description": "Paged stability leaderboard. Pagination is nested under `pagingParam` (singular).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "StabilityLeaderboardRow" + } + }, + "pagingParam": { + "$schema": "TrendPagingParams" + } + } + } + }, + "PerformanceLeaderboardResponse": { + "description": "Paged performance leaderboard. Pagination is nested under `pagingParam` (singular).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "PerformanceLeaderboardRow" + } + }, + "pagingParam": { + "$schema": "TrendPagingParams" + } + } + } + }, + "StabilityChartResponse": { + "description": "Stability-over-time series with the window median.", + "schema": { + "type": "object", + "properties": { + "median": { + "type": "number", + "description": "Overall pass-rate percentage for the window." + }, + "data": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + } + } + } + }, + "PerformanceChartResponse": { + "description": "Performance-over-time: barData is average duration per bucket, lineData is test count per bucket (lineData pointRange is null).", + "schema": { + "type": "object", + "properties": { + "barData": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + }, + "lineData": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + } + } + } + }, + "RecentRunsResponse": { + "description": "Recent builds picker. Pagination is nested under `pagingParams` (plural).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TrendBuildSummary" + } + }, + "pagingParams": { + "$schema": "TrendPagingParams" + }, + "currentQGOverrideStatus": { + "type": "boolean", + "description": "Project's quality-gate override flag." + } + } + } + }, + "WebhookProjectMappingResponse": { + "description": "Project picker / mapping list for the webhook editor.", + "schema": { + "type": "object", + "properties": { + "projectMapping": { + "$schema": "WebhookProjectMapping" + } + } + } + }, + "RcaRetriggerResponse": { + "description": "New RCA conversation turn id (and trial quota for trial users).", + "schema": { + "$schema": "RcaRetrigger" + } + }, + "BuildAccessibilityMetaResponse": { + "description": "Build accessibility summary header (web/Automate shape).", + "schema": { + "$schema": "BuildAccessibilityMeta" + } + }, + "SimilarTestRunsResponse": { + "description": "Reference run and the runs with a similar failure.", + "schema": { + "$schema": "SimilarTestRunRs" + } + }, + "SingleMetricSummaryResponse": { + "description": "A single build-insight tile metric.", + "schema": { + "$schema": "SingleMetricSummary" + } + }, + "BuildAISummaryResponse": { + "description": "AI-generated build failure summary; soft-fails with status NOT_AVAILABLE.", + "schema": { + "$schema": "BuildAISummary" + } + }, + "BuildAttemptRunStatsResponse": { + "description": "Per-attempt result breakdown plus an aggregate summary.", + "schema": { + "$schema": "BuildAttemptRunStatsResponse" + } + }, + "BuildHistoryResponse": { + "description": "Trend over the ~10 most recent builds of the same name.", + "schema": { + "$schema": "BuildHistory" + } + }, + "BuildIdFromCoordinatesResponse": { + "description": "The resolved build UUID for the given coordinates.", + "schema": { + "$schema": "BuildIdFromCoordinates" + } + }, + "BuildCoordinatesResponse": { + "description": "URL-safe coordinates and project ids for a build UUID.", + "schema": { + "$schema": "BuildCoordinates" + } + }, + "BuildReRunResponse": { + "description": "Flat (legacy) re-run summary for a build.", + "schema": { + "$schema": "BuildReRun" + } + }, + "BuildReRunV2Response": { + "description": "Split-build-aware grouped re-run summary with an aggregate.", + "schema": { + "$schema": "BuildReRunV2" + } + }, + "FieldSearchResponse": { + "description": "Type-ahead facet options for a per-project build filter. `data` is a set of {id, value, label} option objects.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterField" + } + } + } + } + }, + "SidebarSearchResponse": { + "description": "Sidebar facet options. `data` is a list of BARE VALUES \u2014 plain strings for branches, build names and tags. Capped at 100 entries by the underlying ES aggregation.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "BuildListFiltersResponse": { + "description": "Per-project build-listing filter bar: applied selection resolved plus the static option catalogue.", + "schema": { + "type": "object", + "properties": { + "applied": { + "$schema": "BuildFilterApplied" + }, + "staticFilters": { + "$schema": "BuildFilterStatic" + } + } + } + }, + "BuildSidebarFiltersResponse": { + "description": "Cross-project build-listing sidebar filters: applied selection resolved (with the projects it was scoped to) plus the static option catalogue. frameworkList here is the hardcoded master list, not a per-project presence list.", + "schema": { + "type": "object", + "properties": { + "applied": { + "allOf": [ + { + "$schema": "BuildFilterApplied" + } + ], + "properties": { + "projects": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "The project ids this filter view was scoped to." + } + } + }, + "staticFilters": { + "$schema": "BuildFilterStatic" + } + } + } + }, + "BuildTestFiltersResponse": { + "description": "Filter panel for a build's TEST listing. `staticFilters` is a map keyed by filter name to a list of {label, value} options. `proFilters` names plan-locked filter keys (non-empty only for freemium).", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "The caller's applied test filters, echoed back resolved (folders, testTags, status, priority, caseType, automationStatus, owner, lastExecutedBy, deviceList, osList, browserList, failureCategories, dateRange, and the boolean history/flaky flags)." + }, + "staticFilters": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "FilterStruct__w2_build_filter1" + } + }, + "description": "Catalogue of selectable test-filter values, keyed by filter name (browserList, osList, status, testTags, folders, runs, deviceList, failureCategories, history, flaky, ...)." + }, + "hooksVisibility": { + "type": "string", + "description": "One of all / failed / beforeFailed / none." + }, + "proFilters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter keys locked behind a paid plan; empty for a paid workspace, else names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted." + } + } + } + }, + "RunGroupFilterOptionsResponse": { + "description": "Run-group (build-group) sidebar facet. `data` is a list of slim build-group rows; `info` is page metadata but paging is fixed at page 1, size 100 and cannot be advanced through this endpoint.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "Slim build-group row. Casing is mixed: `updated_at` is snake_case.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isClosed": { + "type": "boolean" + }, + "state": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string" + } + } + } + }, + "info": { + "type": "object", + "description": "Page metadata (fixed page 1 / size 100).", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true } + } + } + } + } + }, + "DashboardResponse": { + "description": "A single dashboard, wrapped in `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "Dashboard" + } + } + } + }, + "DashboardsListResponse": { + "description": "List of dashboards, wrapped in `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "Dashboard" + } + } + } + } + }, + "DashboardMetaResponse": { + "description": "Ids resolved to display names.", + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$schema": "MetaItem" + } + }, + "users": { + "type": "array", + "items": { + "$schema": "MetaItem" + } + } + } + } + }, + "UniqueErrorSearchResponse": { + "description": "Top-level JSON array of unique-error search hits (max 10). There is no wrapper object.", + "schema": { + "type": "array", + "items": { + "$schema": "ErrorSearchResult" + } + } + }, + "WidgetCustomFieldFiltersResponse": { + "description": "Custom-field filters. Shape depends on the request: with NO page/per_page/search it is a bare JSON ARRAY of FilterDetails; with any of them it is the paginated object {data, info}.", + "schema": { + "oneOf": [ + { + "type": "array", + "items": { + "$schema": "FilterDetails" + }, + "description": "Unpaginated: returned when page/per_page/search are all omitted." }, - "PublicBuildDetailsResponse": { - "description": "Full public build record. Shape is feature-flagged (legacy vs V2) and pro-only fields are JsonView-gated by plan.", - "schema": { - "$schema": "PublicBuildDetails" + { + "type": "object", + "description": "Paginated: returned when any of page/per_page/search is present.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterDetails" + } + }, + "info": { + "$schema": "DashboardPageInfo" } - }, - "BuildHistoryWithDefectCountsResponse": { - "description": "Anchor build + up to 10 prior same-name runs, each with status and defect count summaries.", - "schema": { - "$schema": "BuildHistoryWithDefectCounts" + } + } + ] + } + }, + "ReportListResponse": { + "description": "Reports/dashboards listing. On an invalid `source` this same 200 carries a top-level `error` string and empty `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "ReportSummary" + } + }, + "my_report_count": { + "type": "integer" + }, + "total_report_count": { + "type": "integer" + }, + "info": { + "$schema": "ReportListPageInfo" + }, + "error": { + "type": "string", + "description": "Present only on the invalid-source 200 refusal." + } + } + } + }, + "ProjectAutomationCoverageResponse": { + "description": "All-time automation-coverage figures for one project. Null fields are omitted (NON_NULL).", + "schema": { + "type": "object", + "properties": { + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "total_test_case_count": { + "type": "integer" + }, + "automated_test_case_count": { + "type": "integer" + }, + "automation_coverage_percent": { + "type": "number" + }, + "calculated_at": { + "type": "string", + "format": "date-time", + "description": "When this figure was computed." + } + } + } + }, + "ProjectListingExternalResponse": { + "description": "One page (up to 10) of the caller's projects, plus the cursor for the next page.", + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$schema": "ProjectExternal" + } + }, + "pagination": { + "$schema": "ExternalPagination" + } + } + } + }, + "HomeUniqueBuildsResponse": { + "description": "Latest runs grouped by unique build name, with dual offset/cursor pagination params.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildSummary" + }, + "description": "One BuildSummary per unique build name (its most recent run)." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "pageNumber": { + "type": "integer" + }, + "afterKey": { + "type": "string", + "description": "search_after cursor to carry into the next request." } + } } - }, - "paging": { - "GET /api/v1/projects/{projectName}/settings/quality-gates/filters": { - "page": "afterKey", - "max": 1000 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/stability": { - "page": "pageNumber", - "max": 8 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/performance": { - "page": "pageNumber", - "max": 8 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns": { - "page": "pageNumber", - "max": 5 - }, - "GET /api/v1/settings/webhooks/projects": { - "page": "page", - "max": 30 - }, - "GET /api/v1/dashboards/filters/{widgetType}": { - "page": "page" - }, - "GET /api/v1/reports/list": { - "size": "size", - "max": 100 - }, - "GET /ext/v1/projects": { - "max": 10 - }, - "GET /api/v1/home/uniqueBuilds": { - "page": "pageNumber", - "max": 10 - }, - "GET /api/v1/projects/{normalisedName}/build-groups-listing": { - "page": "searchAfter" - }, - "GET /ext/v1/builds/{build_id}/testRuns": {}, - "GET /ext/v1/builds/{build_id}/testRuns/withAttachments": { - "max": 100 - }, - "GET /api/v1/builds/{buildUuid}/testRuns/flat": { - "page": "searchAfter", - "max": 10000 - }, - "GET /api/v1/ai/failures/{buildUUID}/flat": { - "page": "searchAfter", - "size": "limit" - }, - "GET /ext/v1/ai/failures/{buildUUID}/flat": { - "page": "searchAfter", - "size": "limit" - }, - "POST /api/v1/dashboards/views/widgets/filters": { - "page": "page" - }, - "POST /api/v1/reports/{id}/datasets": { - "page": "page" - }, - "GET /api/v1/reports/custom-fields": { - "page": "page", - "max": 300 - }, - "GET /api/v1/reports/{projectNormalisedName}/builds": { - "page": "page" - }, - "GET /api/v1/reports/view/{globalTestHash}/details/testsLists": { - "page": "page" - }, - "POST /api/v1/reports/view/{id}/search": { - "page": "page" - }, - "POST /api/v1/dashboards/widgets/filters": { - "page": "page" - }, - "POST /api/v1/search": { - "max": 30 - }, - "GET /api/v1/projects/{projectName}/snp/v2/errors": { - "page": "pageNumber", - "max": 15 - }, - "GET /api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds": { - "page": "searchAfter", - "max": 15 - }, - "GET /api/v1/projects/{projectId}/build-groups": { - "page": "page", - "size": "pageSize" - }, - "GET /api/v1/builds/v2/{buildId}/a11y-testRuns": { - "page": "page", - "size": "limit" - }, - "GET /api/v1/testRuns/{btcerId}/a11y-testMeta": { - "page": "page" - }, - "GET /api/v1/builds/{uuid}/getSplitBuildDetails": { - "page": "searchAfter" - }, - "GET /api/v1/builds/v2/{buildId}/folder": { - "size": "size" - }, - "GET /ext/v1/projects/{project_id}/builds": { - "max": 10 - }, - "GET /api/v1/projects/{normalisedName}/builds/v2": { - "page": "searchAfter" - }, - "GET /api/v1/projects/{projectName}/settings/webhooks/filters": { - "page": "afterKey", - "max": 1000 - }, - "GET /api/v1/settings/webhooks/filters": { - "page": "afterKey", - "max": 1000 - }, - "POST /api/v2/projects/lite": { - "max": 10 - }, - "GET /api/v2/projects/lite/v2": { - "page": "searchAfter", - "max": 100 - }, - "GET /api/v2/projects/v2": { - "page": "searchAfter", - "max": 100 - }, - "GET /api/v1/projects/{projectName}/snp/v2/tests": { - "page": "pageNumber", - "max": 10 + } + } + }, + "HomeRecentViewsResponse": { + "description": "The user's up-to-5 recently-viewed items.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "RecentView" + } + } + } + } + }, + "HomeTakeawaysResponse": { + "description": "Older Home takeaways variant: aggregate time-saved metric. The v2 timeSavingV2Metric key is present but null on this path.", + "schema": { + "type": "object", + "properties": { + "timeSavingsData": { + "$schema": "TimeSavingMetric" + }, + "takeAwayData": { + "$schema": "TakeAwayData" + }, + "timeSavingV2Metric": { + "description": "Always null on this older variant (the key is present, the value is not).", + "nullable": true + } + } + } + }, + "HomeTakeawaysNoiseBreakdownResponse": { + "description": "Newer Home takeaways variant: failure-noise breakdown. timeSavingsData is explicitly null on this path.", + "schema": { + "type": "object", + "properties": { + "timeSavingV2Metric": { + "$schema": "TimeSavingV2Metric" + }, + "timeSavingsData": { + "type": "null", + "description": "Always null through this endpoint." + }, + "takeAwayData": { + "$schema": "TakeAwayData" + } + } + } + }, + "ProjectBuildGroupListingResponse": { + "description": "A project's builds consolidated by build group, plus per-group aggregates and cursor pagination.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "isHittingRetentionQuota": { + "type": "boolean" + }, + "builds": { + "type": "array", + "items": { + "$schema": "BuildSummary" + }, + "nullable": true, + "description": "Never populated on this route (always null); the per-group rows are in buildGroupAggResult." + }, + "buildGroupAggResult": { + "type": "array", + "items": { + "$schema": "BuildGroupAggResult" + }, + "description": "Per-build-group rolled-up aggregates." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "description": "Cursor (array or integer) to carry into the next request." + } + }, + "nullable": true, + "description": "Never populated on this route (always null); all groups come back in one response." }, - "GET /api/v1/projects/{projectName}/snp/v3/tests": { - "page": "pageNumber", - "max": 10 + "activeCount": { + "type": "integer" }, - "GET /api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds": { - "max": 20 + "closedCount": { + "type": "integer" }, - "GET /api/v1/testRuns/{testRunId}/testCases": { - "page": "searchAfter", - "max": 100 + "isArchiving": { + "type": "boolean" }, - "GET /api/v1/builds/{buildId}/testRunLogs": { - "max": 15 + "isClosing": { + "type": "boolean" }, - "GET /api/v1/builds/v2/{buildId}/flatTestRuns": { - "page": "searchAfter" + "manualBuildCount": { + "type": "integer" }, - "GET /api/v1/builds/v2/{buildId}/testRuns": { - "page": "searchAfter" + "automationBuildCount": { + "type": "integer" }, - "GET /api/v1/builds/v2/{buildId}/testRuns/hierarchy": { - "page": "searchAfter" + "invalidQgBuildCount": { + "type": "integer" }, - "POST /api/v2/builds": {} - }, - "entities": { - "notification": { - "entity": "notification", - "title": "Notification (and notifier)", - "description": "A project's alerting config: recipients (email/Slack notifiers) and their daily-summary, build-insight and alert subscriptions.", - "aliases": [ - "notification", - "notifications", - "notifier", - "notification settings", - "notification recipient", - "recipient", - "subscription", - "email notification", - "slack notification", - "daily summary", - "daily summary email", - "build insight notification", - "alert subscription", - "notification preferences" - ], - "id_convention": "A notifier id is a plain integer, discovered from the project's recipient list (there is no prefixed identifier). The per-alert and per-build-name mapping ids used when toggling a single subscription are also plain integers, found by reading one notifier's configuration first.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "scoped to one project" - }, - { - "entity": "alert", - "via": "a notifier subscribes to a project's alerts by alert id" - }, - { - "entity": "build", - "via": "build-insight subscriptions are keyed by build name" + "currentQGOverrideStatus": { + "type": "boolean", + "nullable": true + } + } + } + }, + "SPTestFilterOptions": { + "description": "Suite Health test-list filter sidebar: the applied filters resolved into display fields, the static option catalogue, and the project default hooks visibility.", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "The currently applied filters echoed back: list filters resolved into Field objects (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList/osList/browserList, branches, pmToolStatus), scalar/tri-state flags (search, isFlaky, isNewFailure, isAlwaysFailing, hasPmToolDefects, isManuallyOverridden, isCiRun, isLatest), dateRange as {lowerBound, upperBound}, isMuted and hooksVisibility. Filters you did not send come back null or [] (inconsistently per key)." + }, + "staticFilters": { + "type": "object", + "description": "The choosable option catalogue: osList, browserList, deviceList, status, folders, failureCategories, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan - each a list of Field objects. The run-level lists (typeOfRun, assignedTo, state, testPlan) come from an upstream service and are null or [] when it has nothing or times out, rather than erroring." + }, + "hooksVisibility": { + "type": "string", + "description": "The project's default hooks visibility (all / failed / beforeFailed / none)." + } + } + } + }, + "FieldSearchResults": { + "description": "A set of filter suggestions/options. Shared by the branch, build-name, build-tag, host-name and test-tag type-ahead routes.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "Suggestion items (deduplicated set).", + "items": { + "$schema": "Field" + } + } + } + } + }, + "SPTestMetricsChart": { + "description": "Suite Health page charts. A top-level map keyed by chart name (no data wrapper).", + "schema": { + "type": "object", + "properties": { + "failureChart": { + "$schema": "SPChart" + }, + "durationChart": { + "$schema": "SPChart" + }, + "failureRateChart": { + "$schema": "SPChart" + } + } + } + }, + "SPTestBaseInfo": { + "description": "Identifying header info for one test in the Test Health detail view. Flat object, no data wrapper.", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Test display name; empty string when the hash matches nothing in the window." + }, + "combinations": { + "type": "array", + "description": "OS/browser/device environment combinations the test ran on.", + "items": { + "type": "object", + "properties": { + "os": { + "type": "object", + "description": "Formatted platform descriptor." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor, with a nested `device` field." + } + } + } + } + } + } + }, + "SPTestEnvironmentBreakdown": { + "description": "Per-environment stability breakdown for one test. A BARE JSON array (no object wrapper).", + "schema": { + "type": "array", + "items": { + "$schema": "SPTestBreakdownRow" + } + } + }, + "TestRunDetailResponse": { + "description": "Full detail of a single test run.", + "schema": { + "$schema": "TestRunDetail" + } + }, + "TestRunDetailMapResponse": { + "description": "A MAP keyed by test run id; each value is that run's detail ({ testCode, testMetadata }). Empty object when nothing matched \u2014 not an error.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestRunDetail" + } + } + }, + "BuildInsightsResponse": { + "description": "Build-level insights surrounding a test run.", + "schema": { + "$schema": "BuildInsights" + } + }, + "BuildTestRunListingResponse": { + "description": "A build's test runs as a nested hierarchy, plus pagination. Keys are snake_case (TestListingResponse / V2 by account flag); V2 adds is_manually_overridden and test_summary holds workflow-status counts.", + "schema": { + "type": "object", + "properties": { + "hierarchy": { + "type": "array", + "description": "Nested nodes (suites/folders/tests), each with details and children. On freemium/source-fenced builds analysis keys are stripped from node details.", + "items": { + "type": "object" + } + }, + "project_id": { + "type": "integer" + }, + "name": { + "type": "string", + "description": "Project name." + }, + "group_id": { + "type": "integer" + }, + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "build_name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "test_summary": { + "type": "object", + "description": "Per-status counts: StatusAggregate (older envelope) or WorkflowStatusAggregate (newer), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "Newer envelope only." + }, + "is_archived": { + "type": "boolean" + }, + "app_details": { + "type": "object" + }, + "pagination": { + "type": "object", + "description": "snake_case keys (models/external/Pagination).", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." + } + } + } + } + } + }, + "BuildTestRunsWithAttachmentsResponse": { + "description": "Trimmed test-run rows that have attachments/screenshots, with a numeric cursor.", + "schema": { + "type": "object", + "properties": { + "test_runs": { + "type": "array", + "items": { + "$schema": "BuildTestRunWithAttachments" + } + }, + "next_cursor": { + "type": "integer", + "description": "Numeric cursor; null on the last page. Pass back as `cursor`." + } + } + } + }, + "BugReportDraftResponse": { + "description": "Pre-filled bug-report draft composed from a failed run. Read-only; not a stored bug.", + "schema": { + "type": "object", + "properties": { + "buildName": { + "type": "string" + }, + "buildId": { + "type": "integer", + "description": "The build NUMBER (e.g. 1), not the numeric build_id." + }, + "ciInfo": { + "type": "object", + "nullable": true + }, + "testDetails": { + "type": "string", + "description": "Composed test hierarchy/description text for the ticket body." + }, + "testUrl": { + "type": "string", + "nullable": true + }, + "videoUrl": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "platform": { + "type": "string", + "nullable": true + }, + "browser": { + "type": "string", + "nullable": true + }, + "hostInfo": { + "type": "object", + "nullable": true + }, + "logStruct": { + "type": "object", + "description": "Image/log attachment references keyed by log type (e.g. TEST_FAILURE: array of strings); empty when the run has no screenshots." + }, + "retries": { + "type": "array", + "items": { + "type": "object" + } + }, + "issueType": { + "type": "string", + "nullable": true + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "duration": { + "type": "integer" + }, + "aiRca": { + "type": "object", + "description": "AI RCA snippet ({data: ...}); null when no RCA is available.", + "nullable": true + } + } + } + }, + "LinkBugToTestRunResponse": { + "description": "Result of linking a filed bug to a run. 200 carries the created integration id; a 204 (no body) means the bug key was already tracked.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Created integration id; null if none was recorded." + } + } + } + }, + "FlatTestRunListingResponse": { + "description": "Flat list of a build's test runs with search_after pagination.", + "schema": { + "type": "object", + "properties": { + "testRuns": { + "type": "array", + "items": { + "$schema": "FlatTestRun" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object" + }, + "searchAfter": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cursor to pass back as searchAfter for the next page." + } + } + } + } + } + }, + "BuildAIFailuresResponse": { + "description": "Build-level AI failure analysis: failure themes and workflows, sorted by failure share. Serialized from AIFailuresResponse; trialQuota is omitted (NON_NULL) unless the account is on the build-level trial.", + "schema": { + "type": "object", + "properties": { + "buildId": { + "type": "integer" + }, + "buildThemes": { + "type": "array", + "description": "Failure themes, sorted by testPercentage descending.", + "items": { + "type": "object", + "properties": { + "themeId": { + "type": "string" + }, + "buildFailureThemeId": { + "type": "integer", + "description": "Integer id used to drill into this theme's test runs." + }, + "themeData": { + "type": "object", + "description": "Opaque theme detail map." + }, + "affectedWorkflows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "workflowData": { + "type": "object" + } + } + } + }, + "affectedWorkflowCount": { + "type": "integer" + }, + "testRunCount": { + "type": "integer" + }, + "testPercentage": { + "type": "string", + "description": "Share of failures as a string (may include a % sign)." + }, + "isNewTheme": { + "type": "boolean" + } + } + } + }, + "buildWorkflows": { + "type": "array", + "description": "Failure workflows, sorted by testPercentage descending.", + "items": { + "type": "object", + "properties": { + "workflowIdentifier": { + "type": "string" + }, + "buildFailureWorkflowId": { + "type": "integer", + "description": "Integer id used to drill into this workflow's test runs." + }, + "workflowData": { + "type": "object", + "description": "Opaque workflow detail map." + }, + "testRunCount": { + "type": "integer" + }, + "testPercentage": { + "type": "string" + }, + "themesIdentified": { + "type": "array", + "items": { + "type": "object", + "properties": { + "buildRunThemeId": { + "type": "integer" + }, + "themeData": { + "type": "object" + }, + "isNewTheme": { + "type": "boolean" + } + } } - ], - "capabilities": [ - "add_notification_recipients", - "delete_notification_subscriptions", - "delete_slack_notifiers", - "get_build_name_status_aggregation", - "get_daily_summary_report", - "get_my_email_notification_subscription", - "get_notifier_notification_config", - "get_project_email_notification_preferences", - "list_build_summaries_by_ids", - "list_home_notification_feed", - "list_latest_build_summaries", - "list_project_alerts_for_subscription", - "list_project_notification_recipients", - "resolve_notification_recipient_emails", - "send_slack_test_notification", - "set_user_email_notification_preferences", - "toggle_notification_subscriptions", - "toggle_notifier_channel_status", - "update_notifier_subscriptions", - "update_slack_notifier_channels" - ], - "key_facts": [ - "Every project has two independent notifier channels, email and Slack, and almost every read and write acts on ONE channel that you must name; the same project can have a completely different set of email recipients and Slack recipients.", - "Reading the recipient list or a single recipient's configuration needs only project read access; every change to recipients or subscriptions needs the project's notification-settings permission, and a change to a recipient owned by another group is refused.", - "Editing one recipient's subscriptions is a FULL REPLACE, not a patch: subscriptions you omit are removed, and there is no version history, so a prior configuration cannot be restored once overwritten.", - "Bulk enabling or disabling of subscriptions is applied one entry at a time with no overall transaction, so a single bad entry aborts partway and leaves the earlier entries already changed.", - "A Test Management caller addresses a project by its Test Management project id, which is translated to the underlying observability project id; an id with no mapping reads as project-not-found rather than empty.", - "In a recipient-list summary, a subscription count of -1 is a sentinel meaning the recipient is subscribed to all builds or all alerts, not a real count.", - "The daily-summary email content builders select builds themselves (the latest few in a time window) unless you hand them explicit build ids; the fully enriched daily report also merges several aggregations plus best-effort AI summaries that are silently dropped when they fail, so those fields can be absent.", - "Two of the notification settings operations address the project by its normalised NAME instead of its numeric id.", - "Validating typed-in recipient email addresses is not a side-effect-free lookup: for each address that resolves to an in-group user it also provisions that user's email-integration records if they do not exist yet.", - "The home notification feed is capped to roughly the first fifty entries however deep you page, and when the feature is switched off for the group it returns an empty payload rather than an error.", - "Sending a test Slack notification posts a fixed sample message to the channel and stores nothing; only the daily-summary, build-insight and alert sample types are supported and it is suppressed in the demo-sandbox environment." - ] + }, + "themeIdentifiedCount": { + "type": "integer" + } + } + } }, - "public_link": { - "entity": "public_link", - "title": "Public link & status badge", - "description": "Anonymous share credentials: public-link tokens for dashboards and project report pages, plus signed keys for build-status badges.", - "aliases": [ - "public link", - "share link", - "shareable link", - "anyone with the link", - "public token", - "share token", - "public sharing", - "status badge", - "build badge", - "badge", - "readme badge", - "shields badge", - "badge key" - ], - "id_convention": "A public-link token is an opaque server-generated string, discovered only by calling the get-token operation for a dashboard or project (there is no list). A status-badge key is a signed JWT minted on demand. Dashboards are addressed by UUID; projects by their TestHub project id.", - "parents": [ - "project", - "dashboard" - ], - "relations": [ - { - "entity": "dashboard", - "via": "one token per dashboard (page type CUSTOM_DASHBOARD)" - }, - { - "entity": "project", - "via": "one token per (project, page type)" - } - ], - "capabilities": [ - "get_dashboard_public_link_setting", - "get_dashboard_public_link_token", - "get_project_public_link_setting", - "get_project_public_link_token", - "get_status_badge_key", - "update_dashboard_public_link_setting", - "update_project_public_link_setting", - "validate_public_link_token" - ], - "key_facts": [ - "Two kinds of share credential live here: a public-link token that unlocks a dashboard or a project report page for anyone with the URL, and a status-badge key (a signed JWT) that exposes one build's high-level status for embedding.", - "Asking for a token is itself what starts sharing: the fetch creates the token on first request and leaves it enabled. There is no separate create step, so a plain read has a side effect the first time.", - "The public-link setting defaults to OFF. A dashboard or project whose setting has never been touched reads as not shared rather than as an error.", - "Turning sharing off immediately breaks every share URL already handed out and clears the token's cached validation, so revocation takes effect at once. Turning it back on reactivates the SAME token, so it is reversible but disruptive; there is no separate undo.", - "A project token is scoped to a single report page type (build insights, test runs, test health, unique errors or testing trends). Custom dashboards are a distinct page type reached only through the dashboard token operation.", - "A status-badge key is a bearer credential: once minted it cannot be revoked or listed, it exposes that build's status to anyone holding it, and it is refused for free-plan users.", - "Projects are named here by their TestHub project id, which is not the same as the observability project id." - ] + "buildThemeWorkflow": { + "type": "object", + "description": "Analysis-run status envelope.", + "properties": { + "status": { + "type": "string", + "description": "e.g. SUCCESS, PENDING, RETRIED_AND_FAILED. Non-terminal means results still being produced." + }, + "summary": { + "type": "string" + } + } }, - "quality_gate": { - "entity": "quality_gate", - "title": "Quality gate (and quality profile)", - "description": "A pass/fail policy on a build, made of quality profiles: named rule sets (thresholds on status, flakiness, failure categories, etc.) scop...", - "aliases": [ - "quality gate", - "quality gates", - "QG", - "gate", - "quality profile", - "quality profiles", - "qg profile", - "quality gate profile", - "gate profile", - "quality gate rule set", - "build quality gate" - ], - "id_convention": "A quality profile is addressed by its UUID (path `profile_id`/`profileUuid`); a project by its normalised name; a build by its UUID. There are no integer ids on these routes. The one built-in default profile has a fixed UUID and behaves specially.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "profiles are mapped to a project; project attributes hold the feature enabled/override switches" - }, - { - "entity": "build", - "via": "the gate is evaluated per build; results are read from the parent build for a split build's child" - } - ], - "capabilities": [ - "create_quality_profile", - "create_quality_profile_via_public_api", - "delete_quality_profile", - "delete_quality_profile_via_public_api", - "get_build_quality_gate_result", - "get_build_quality_gate_summary", - "get_build_quality_profile_result", - "get_project_quality_gate_settings", - "get_project_quality_gate_settings_via_public_api", - "get_quality_profile", - "get_quality_profile_via_public_api", - "list_build_quality_profile_results", - "list_quality_profile_filter_values", - "toggle_project_quality_gate", - "toggle_project_quality_gate_via_public_api", - "toggle_quality_profile", - "toggle_quality_profile_via_public_api", - "update_quality_profile", - "update_quality_profile_via_public_api" - ], - "key_facts": [ - "Editing a quality profile does NOT change it in place: it writes a new version and mints a NEW UUID, which the response returns. The UUID you edited stops resolving afterwards, so always carry the returned UUID forward. (The source inventory's claim that the UUID is stable is wrong.)", - "Deleting a quality profile is not reversible through the API. The profile is retired and its project mapping removed; there is no restore call, so treat it as permanent.", - "The one built-in default profile can be neither edited nor deleted (attempts are rejected). Enabling or disabling a default profile applies only to the one project, whereas enabling or disabling a custom profile applies across every project it is attached to.", - "There are two separate switches: one turns Quality Gates on or off for a whole project (and sets whether a failed gate also marks the build failed), the other enables or disables a single profile. They are different operations even though they share a name internally.", - "On the create and edit body the two fields go on the wire as `isGlobalProfile` and `applicableBuilds`; the internal names `isRootProfile` and `buildFilters` are not accepted and, if sent, are dropped and then fail the required-field check.", - "A profile must carry between 1 and 20 rules, and every rule must include a client-generated UUID `id`; a rule with an out-of-range test status, threshold, smart tag or alert level is rejected.", - "Quality gate evaluation runs asynchronously after a build finishes, so a build's gate result reads back as 'running'/pending until it completes and a CI check must poll rather than assume a single call is final.", - "A profile's ruleStatus ('pass' vs 'fail') decides how the per-profile outcome is computed: 'pass' means every rule must hold, 'fail' means none may hold.", - "Quality Gates is a plan-gated feature: when the plan lacks it, profile reads and writes are refused as feature-inaccessible, which is a plan gap and not a missing profile. The project-level on/off switch and the build-page result reads skip that plan check.", - "For a split build's child, gate results are read from the parent build, so a child on its own can look like it has no results.", - "Failure-category rules are enriched with the project's human display names only on the single-profile detail reads, not on the list or settings reads.", - "A profile can only be deleted from the group that owns it; deleting one that belongs to another group is refused." - ] + "stats": { + "type": "object", + "properties": { + "totalThemes": { + "type": "integer" + }, + "newThemes": { + "type": "integer" + }, + "existingThemes": { + "type": "integer" + }, + "totalWorkflows": { + "type": "integer" + }, + "totalTestRuns": { + "type": "integer" + } + } }, - "testing_trends": { - "entity": "testing_trends", - "title": "Testing Trends", - "description": "Aggregated quality-trend cards for a project - flakiness, stability, performance, failures and coverage over a time window.", - "aliases": [ - "testing trends", - "trends", - "test trends", - "quality trends", - "trend widgets", - "trend cards", - "insights dashboard", - "flakiness", - "stability", - "cbt coverage", - "test growth", - "build frequency" - ], - "id_convention": "Not addressed by its own id. Scoped by the project's normalised name in the path and narrowed by build name. Chart and leaderboard rows are addressed by build name as the identifier, or by the literal \"all\" for the project-wide series.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "buildName filter / row identifier" - } - ], - "capabilities": [ - "get_always_failing_tests_trend", - "get_build_frequency", - "get_build_performance_chart", - "get_build_stability_chart", - "get_cross_browser_coverage", - "get_failure_categories_trend", - "get_flakiness_trend", - "get_new_failures_trend", - "get_project_performance_chart", - "get_project_stability_chart", - "get_test_executions_trend", - "get_test_growth_trend", - "list_build_names", - "list_performance_leaderboard", - "list_recent_runs", - "list_stability_leaderboard" - ], - "key_facts": [ - "Every card is scoped to one project and a time window given as two epoch-millisecond bounds. When a bound is omitted it defaults to the plan's data-retention window, so a call with no window still returns the retention window's data.", - "The window is silently clamped to the plan's data retention: asking for data older than the plan retains returns retention-bounded results with no error and nothing in the body to say the window was narrowed.", - "Time-bucket granularity (hour / day / week) is chosen automatically from the width of the window, so the series interval changes as the window widens; the caller cannot set it.", - "The flakiness, new-failures and always-failing cards are plan-feature-gated: on a plan without that widget the call is refused with a 403, it does not return empty data.", - "Build frequency is the one card that requires both window bounds and does NOT clamp them to retention; its counts come from the relational build store rather than the analytics index, so they can differ slightly from the other cards.", - "Opening the recent-runs picker records the project as subscribed for the calling user - a persisted user preference, so that read has a lasting side effect.", - "Any card can be narrowed to a single build by its build name; the leaderboards and charts address a specific build by that name, or use the literal \"all\" for the project-wide series.", - "Leaderboards return a fixed number of builds per page and expose only a zero-based page number, not a page size; a hasNext flag in the response tells you whether to fetch the next page.", - "The always-failing card also records the trends view in the calling user's recently-viewed list for non-superusers, a minor convenience side effect that does not change any test data.", - "Test-growth counts distinct test cases (suite size) over the window, not test executions; test-executions counts how many tests ran. They answer different questions despite looking similar." - ] + "trialQuota": { + "type": "object", + "description": "Present only for build-level trial accounts.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + } + }, + "BuildAIFailureTriggerResponse": { + "description": "Get-or-trigger response for build-level AI failure analysis. The AIFailuresResponse fields are unwrapped at the TOP level (JsonUnwrapped) alongside triggered; when analysis was freshly triggered they are absent/null and only triggered (and trialQuota) are present.", + "schema": { + "type": "object", + "properties": { + "triggered": { + "type": "boolean", + "description": "true when analysis was enqueued (results not in this response); false when existing terminal analysis is returned inline." + }, + "buildId": { + "type": "integer", + "description": "Present only when existing analysis is returned; null/absent on a fresh trigger." + }, + "buildThemes": { + "type": "array", + "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildThemes.", + "items": { + "type": "object" + } + }, + "buildWorkflows": { + "type": "array", + "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildWorkflows.", + "items": { + "type": "object" + } + }, + "buildThemeWorkflow": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Set to PENDING when a non-terminal analysis is retriggered." + }, + "summary": { + "type": "string" + } + } }, - "webhook": { - "entity": "webhook", - "title": "Webhook rule", - "description": "A stored rule that fires an outbound HTTP request to a target URL when a matching build/test event occurs.", - "aliases": [ - "webhook", - "webhooks", - "webhook rule", - "webhook rules", - "hook", - "callback", - "notification", - "notifications", - "webhook notification", - "event webhook", - "outbound notification" - ], - "id_convention": "Addressed by the rule's UUID; there is no prefixed display id. Discover the UUID from a list call \u2014 it is not the integer database id.", - "parents": [ - "project", - "group" - ], - "relations": [ - { - "entity": "project", - "via": "project mapping (a group rule links up to 30 projects; a project rule is scoped to one)" - } - ], - "capabilities": [ - "create_group_webhook", - "create_project_webhook", - "delete_group_webhook", - "delete_project_webhook", - "get_group_webhook", - "get_project_webhook", - "list_group_webhook_projects", - "list_group_webhooks", - "list_project_webhooks", - "test_group_webhook", - "test_project_webhook", - "toggle_group_webhook", - "toggle_project_webhook", - "update_group_webhook", - "update_project_webhook" - ], - "key_facts": [ - "A webhook rule is addressed by its UUID, which you read from a list call; there is no prefixed display id and it is not the integer database id.", - "There are two parallel surfaces: project-scoped rules and group-scoped rules that can span many projects. A rule created on one surface is managed on that surface, and the two lists do not overlap.", - "Listing or reading webhook rules needs no special permission, but creating, editing, toggling, deleting or testing one requires the webhook settings permission \u2014 so an account can see rules it is not allowed to change.", - "Editing a rule replaces its definition in place and keeps no history: an overwritten URL, headers, auth or trigger conditions cannot be recovered. There is no undo short of re-entering the previous values.", - "Deleting a rule is permanent and also removes its project mappings; disable the rule instead if the intent is only to pause it.", - "Every create, edit, toggle, delete and test is refused in the demo-sandbox environment.", - "Testing a rule sends a real outbound HTTP request to the target URL (a server-side call whose side effects land on the target system), and it always reports success even when the target failed \u2014 read the isError flag in the body, never the HTTP status, to know if it worked.", - "A list of rules returns only each rule's summary (name, target URL, event type, enabled flag); the headers, request body and stored auth are returned only when you fetch a single rule by id.", - "A group rule links at most 30 projects. The 'all projects' scope is available only to Test Management callers; other callers are refused if they try to enter it.", - "Newly created project-scoped rules are always enabled regardless of the enabled flag sent; use the toggle operation to disable one afterwards.", - "A rule's target auth can be none, basic (username/password) or bearer token, and the request method is limited to POST or PUT." - ] + "stats": { + "type": "object", + "description": "Same shape as BuildAIFailuresResponse.stats." }, - "ai_insight": { - "entity": "ai_insight", - "title": "AI insight (RCA & code-fix)", - "description": "AI-generated root-cause analysis for a failed test run or build, plus the code-fix guidance and session tokens that drive it.", - "aliases": [ - "rca", - "root cause analysis", - "ai rca", - "ai insight", - "ai analysis", - "test failure analysis", - "tfa", - "ai code fix", - "code fix preference", - "failure analysis", - "ai token" - ], - "id_convention": "No single id. RCA is addressed by the test-run integer id or the build uuid; code-fix preferences by a plain integer id discovered through the get-preference call; RCA conversation turns by a UUID message id.", - "parents": [ - "test_run", - "build", - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "per-test-run RCA and improve-analysis" - }, - { - "entity": "build", - "via": "build-level RCA and RCA chat" - }, - { - "entity": "project", - "via": "code-fix preferences per build name" + "trialQuota": { + "type": "object", + "description": "Present only for build-level trial accounts.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + } + }, + "FlatAIFailureTestRunsResponse": { + "description": "Flat, cursor-paginated list of the test runs behind an AI failure cluster (or an explicit id set). Serialized from FlatTestResponse. Detail fields are null when the run has no such data (os/browser/issueType on SDK-less runs, the analyser flags before analysis).", + "schema": { + "type": "object", + "properties": { + "testRuns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" } - ], - "capabilities": [ - "create_code_fix_preference", - "delete_code_fix_preference", - "get_ai_orchestration_capabilities", - "get_ai_session_token", - "get_code_fix_preference", - "get_rca_context_attachment_download_url", - "get_release_readiness_connect_state", - "get_release_readiness_prs", - "get_release_readiness_summary", - "get_release_readiness_workflows", - "presign_build_chat_upload", - "presign_code_fix_preference_upload", - "presign_rca_context_upload", - "retrigger_rca_analysis", - "retrigger_rca_analysis_via_public_api", - "submit_ai_workflow_callback", - "trigger_release_readiness", - "trigger_release_readiness_via_public_api", - "update_code_fix_preference" - ], - "key_facts": [ - "AI features are plan-gated: on a group without the AI test-failure-analysis plan the code-fix and RCA-context calls are refused with 403 PLAN_FEATURE_NOT_ALLOWED, and the session-token call returns empty token and feature list rather than erroring.", - "Some AI actions also require the calling user to have AI enabled individually, separate from the group plan; the per-user check applies to saving and presigning code-fix guidance but not to deleting it.", - "Improving (retriggering) an RCA is asynchronous: the call returns a message id and the refreshed analysis is delivered later, so poll the RCA conversation for the result rather than expecting it in the response.", - "Attaching a file is always a two-step flow: first mint an upload credential, upload the file straight to S3 with it, then quote the returned key when saving a preference or retriggering. The credential step persists nothing on its own.", - "Uploaded files land in an unconfirmed area and are kept only when a later save or turn submission references their key; an upload that is never referenced is discarded, and there are caps on how many uploads can be pending.", - "Only agentic RCAs accept extra context uploads; a non-agentic analysis rejects them.", - "RCA context uploads are refused for test runs older than an allowed age.", - "Code-fix guidance is keyed by build name within a project, one preference per build name; creating a second for the same build name is refused as already existing, so change it with an update instead.", - "Deleting code-fix guidance also removes its uploaded files and cannot be undone; the RCA analyses themselves keep no version history." - ] - }, - "build": { - "entity": "build", - "title": "Build", - "description": "A single CI/automation run reported to Test Observability, grouping the test runs executed together with their aggregate results.", - "aliases": [ - "build", - "ci build", - "ci run", - "automation build", - "test build", - "build run", - "launch", - "ci run id", - "build run id" - ], - "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier. A build ALSO carries a CI run id (`buildRunId`, a string set by the CI/upload client), which is NOT the serial number (#N): users often say \"run \" meaning buildRunId. Resolve a bare run number with search_entities (entity=\"Build\", days<=90): each hit carries both `buildRunId` and `serialId` plus the project; get_build_id_from_build_coordinates only understands the serial.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "a build contains the test runs executed in it" - }, - { - "entity": "project", - "via": "each build belongs to one project" + }, + "details": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "os": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "nullable": true + }, + "browser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "nullable": true + }, + "device": { + "type": "string", + "nullable": true + }, + "isRealDevice": { + "type": "boolean", + "nullable": true + }, + "filePath": { + "type": "string", + "nullable": true + }, + "vcFileUrl": { + "type": "string", + "nullable": true + }, + "issueType": { + "type": "object", + "nullable": true + }, + "aiRcaState": { + "type": "string", + "nullable": true + }, + "isFlaky": { + "type": "boolean", + "nullable": true + }, + "isPerformanceAnomaly": { + "type": "boolean", + "nullable": true + }, + "isNewFailure": { + "type": "boolean", + "nullable": true + }, + "isAlwaysFailing": { + "type": "boolean", + "nullable": true + }, + "isMuted": { + "type": "boolean", + "nullable": true + }, + "isAutoAnalyzed": { + "type": "boolean", + "nullable": true + }, + "isAutoAnalyzerRunning": { + "type": "boolean", + "nullable": true + }, + "isUniqueErrorsRunning": { + "type": "boolean", + "nullable": true + }, + "isFailingLast5Builds": { + "type": "boolean", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } } - ], - "capabilities": [ - "archive_project_builds", - "bulk_close_builds", - "close_build", - "delete_build_via_public_api", - "delete_project_builds", - "generate_build_report_via_public_api", - "get_build_accessibility_meta", - "get_build_ai_failure_summary", - "get_build_alerts", - "get_build_always_failing_stats", - "get_build_attempt_run_stats", - "get_build_authoring_report_url", - "get_build_coordinates_from_uuid", - "get_build_details", - "get_build_details_via_public_api", - "get_build_failure_category_stats", - "get_build_failures_by_module", - "get_build_filter_values", - "get_build_flaky_stats", - "get_build_history", - "get_build_history_via_public_api", - "get_build_id_from_build_coordinates", - "get_build_insights", - "get_build_muted_stats", - "get_build_new_failure_stats", - "get_build_nl_automation_agent_report_url", - "get_build_non_sdk_test_counts", - "get_build_notification_summary", - "get_build_optimization_data", - "get_build_rerun_stats", - "get_build_self_healing_report_url", - "get_build_self_healing_report_url_via_public_api", - "get_build_smart_test_selection_report", - "get_build_stability_tile", - "get_build_summary_stats", - "get_build_timeline_stats", - "get_build_top_error_stats", - "get_latest_build", - "get_split_build_rerun_stats", - "list_build_test_folders", - "list_project_builds", - "list_project_builds_via_public_api", - "list_running_builds_on_device", - "list_sidebar_build_runs", - "list_similar_test_runs", - "list_split_build_legs", - "mark_build_rerun_expected", - "recalculate_build_quality_gate", - "resolve_build_uuid_from_identifiers", - "stop_build", - "submit_sdk_instrumentation", - "trigger_build_rerun", - "update_build_metadata", - "update_build_metadata_via_public_api" - ], - "key_facts": [ - "Most build endpoints are keyed by the build UUID, a string. A few instead take the internal numeric build id (the AI failure summary is one), and passing a UUID where a numeric id is expected simply fails to find the build.", - "A build locked by plan fencing is refused on most insight reads with a plan-feature-not-allowed error even though the build exists; treat that as an entitlement gap, not a missing build.", - "An unknown build UUID comes back as a build-not-found error on most reads.", - "Cross-group access is blocked: a build whose owning group does not match the caller reads as not-found on most routes (and as a forbidden error on the coordinate lookups).", - "A build can be reached by its human coordinates from the dashboard URL (project name + build name + build serial number), which resolve to the UUID; the reverse lookup rebuilds those coordinates from a UUID.", - "Closing a build cannot be undone through the API \u2014 there is no reopen operation \u2014 so treat a close as final.", - "Many insight tiles and history reads accept the same standard build filter set (search text, status, folders, tags, browser/OS, priority, owner, flaky/new-failure/always-failing toggles, and so on); the filter narrows which of the build's tests the metric is computed over.", - "On demo-sandbox environments some report reads (the authoring report, the optimisation widget) return canned data instead of touching the real backend.", - "Re-run statistics come in two shapes: a flat legacy summary and a split-build-aware grouped summary; the grouped one is the one to use for split or parent/child builds." - ] + } + } + } }, - "build_filter": { - "entity": "build_filter", - "title": "Build filter", - "description": "Filter-option sources for the build listing: the static option catalogue plus per-facet type-ahead value lists.", - "aliases": [ - "build filter", - "build filters", - "build list filter", - "build listing filter", - "filter options", - "filter values", - "filter bar", - "sidebar filter", - "facet", - "filter dropdown", - "branch filter", - "build name filter", - "run group filter" - ], - "id_convention": "Not an addressable record \u2014 there is no filter id. A filter view is fetched by scope: one project by its normalised name (a URL-safe slug, discovered from a project read/list), or a set of integer project ids sent in a POST filter body.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "option values are aggregated from the builds the filter scope resolves to" - }, - { - "entity": "project", - "via": "scoped by project normalised name or project id set" - } - ], - "capabilities": [ - "get_build_list_filters", - "get_build_sidebar_filters", - "get_build_test_filters", - "get_home_personalization_filters", - "list_build_branch_filter_options", - "list_build_tag_filter_options", - "list_build_team_filter_options", - "list_build_user_filter_options", - "list_group_webhook_filter_options", - "list_home_filter_options", - "list_project_webhook_filter_options", - "list_sidebar_branch_filter_options", - "list_sidebar_build_name_filter_options", - "list_sidebar_run_group_filter_options", - "list_sidebar_tag_filter_options", - "list_sidebar_team_filter_options", - "list_sidebar_test_case_status_filter_options", - "list_sidebar_user_filter_options", - "save_home_personalization_filters" - ], - "key_facts": [ - "Filters are not stored objects you address by id. You fetch either a filter catalogue or a single facet's option list, scoped to one project (by its normalised-name slug) or to a set of project ids passed in a filter body.", - "Two families exist. A per-project set is scoped by the project's normalised name and searched with a simple prefix. A cross-project 'sidebar' set takes a full build filter body and returns only the option values that actually occur under the currently applied selection across the chosen projects.", - "The per-project facet lists return option objects that carry separate id, value and label (id is what you send back as the filter selection, label is for display). The cross-project sidebar facet lists return bare values instead (plain strings for branches, build names and tags).", - "Facet option values come from an Elasticsearch terms aggregation capped at 100 entries, so a facet list is a type-ahead suggestion set, not a guaranteed-complete inventory of every value in the project.", - "A filter view echoes the caller's currently applied selection back in a resolved 'applied' block (user and team ids turned into names, date range, tags, branches, statuses) alongside a static catalogue of all selectable option lists.", - "On a freemium workspace some filters are locked behind a paid plan; the build test-filter payload names those locked keys in a separate list rather than dropping them, so a caller can grey them out instead of losing them.", - "For a split build, the test-filter payload is resolved across the parent build and all of its child legs together, not just the single leg addressed.", - "The framework filter carries raw values: branded SDK names and pass-through non-SDK values such as 'appium' or 'selenium-4'. Matching is case-insensitive, so send the value exactly as the static catalogue offers it." - ] + "pagingParams": { + "type": "object", + "description": "Cursor paging state. Feed searchAfter back to fetch the next page; stop when hasNext is false.", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "description": "Opaque map, may be null.", + "nullable": true + }, + "searchAfter": { + "description": "Opaque cursor: an array (or integer) to pass back as the searchAfter query param." + } + } + } + } + } + }, + "QueuedSessionsResponse": { + "description": "Sessions currently sitting in the automation queue for a user and product.", + "schema": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "buildUuid": { + "type": "string" + }, + "sessionName": { + "type": "string" + }, + "buildName": { + "type": "string", + "description": "Null when the build is outside the caller's group." + }, + "projectName": { + "type": "string", + "description": "Null when the build is outside the caller's group." + }, + "queueReason": { + "type": "string", + "description": "Why the session is queued, e.g. no_terminal_available." + }, + "capabilities": { + "type": "object", + "description": "Resolved caps; shape depends on caps_format (selenium / appium / default)." + }, + "duration": { + "type": "string", + "description": "How long the session has waited, computed at read time." + } + } + } + } + } + } + }, + "SessionAppProfilingResponse": { + "description": "Opaque upstream app-profiling JSON (CPU/memory/etc.). An empty object means the report is still generating or was never captured.", + "schema": { + "type": "object", + "description": "Opaque app-profiling payload from the App Automate product API; {} when not yet available." + } + }, + "SessionCapabilitiesResponse": { + "description": "Opaque session capabilities JSON. Normally the upstream sessionParams object; on upstream failure a fallback {outputCapabilities:{capabilities:...}} built from stored session caps.", + "schema": { + "type": "object", + "description": "Opaque capabilities object; key shape varies between the upstream sessionParams and the stored-caps fallback." + } + }, + "SessionDetailsResponse": { + "description": "Session header/metadata for the Test Details page (serialized from TestLogsInfo). customMetadata and buildId are always null through this path.", + "schema": { + "type": "object", + "properties": { + "videoLogs": { + "$schema": "ObsSessionVideo", + "nullable": true, + "description": "Session video handle (url, offsetUrl, status_code, startTime/startTimeStamp, startOffset/finishOffset, launchingAppTime, isIos, isV2); null if the media fetch failed." + }, + "audioLogs": { + "type": "object", + "description": "Opaque audio log node; null if unavailable.", + "nullable": true + }, + "browserstackSessionUrl": { + "type": "string", + "description": "Deep link to the session on the Automate/App-Automate dashboard." + }, + "percyBuildUrl": { + "type": "string", + "description": "Linked Percy build URL, extracted from build metadata; may be null.", + "nullable": true + }, + "isSdk": { + "type": "boolean", + "description": "Whether the build was produced by the observability SDK." + }, + "device": { + "type": "string", + "description": "Device name; an EMPTY string for desktop sessions (\"NA\" on some other routes)." + }, + "framework": { + "type": "string", + "description": "Short framework name, e.g. \"mocha\" (not the build's full framework/version string)." + }, + "customMetadata": { + "type": "object", + "description": "Always null through this path.", + "nullable": true + }, + "testStartedAt": { + "type": "string", + "description": "ISO-8601 session start time; drives the network-logs 30-day-expired empty state. May be null.", + "nullable": true + }, + "buildId": { + "type": "string", + "description": "Always null through this path despite the field name.", + "nullable": true + } + } + } + }, + "SessionTestCodeResponse": { + "description": "Test source code captured for an Automate session, one entry per test executed.", + "schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "build_uuid": { + "type": "string" + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "test_run_id": { + "type": "integer" + }, + "code": { + "type": "string", + "description": "Captured source; may be truncated server-side without any flag in the response." + }, + "language": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + } + } + }, + "BuildLinkNotReady": { + "description": "Retryable 425: the session is not yet linked to its build. Retry shortly rather than treating as fatal.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "CheckConfigurationResponse": { + "description": "A repository's check-run configurations. On the internal empty-fallback path the repoName/ownerName fields may be populated in swapped order.", + "schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "checks": { + "type": "array", + "items": { + "$schema": "CheckConfigurationItem" + } + } + } + } + }, + "CheckConfigurationItemResponseWrap": { + "description": "A single check-run configuration (returned by update and toggle on success).", + "schema": { + "$schema": "CheckConfigurationItem" + } + }, + "VcsCheckMappingResponse": { + "description": "A single registered PR-check mapping (vcs_ingestion row with its joined check configuration).", + "schema": { + "$schema": "VcsCheckMapping" + } + }, + "ViewResponse": { + "description": "A single saved view.", + "schema": { + "$schema": "View" + } + }, + "ViewListResponse": { + "description": "A bare JSON array of saved views (no envelope, not paginated).", + "schema": { + "type": "array", + "items": { + "$schema": "View" + } + } + }, + "DashboardViewResponse": { + "description": "Single dashboard view wrapped under `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "DashboardView" + } + } + } + }, + "WidgetFilterValuesResponse": { + "description": "Paginated widget filter-value picker results (WidgetEnhancedFilterResp).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Map keyed by bucket - `suggested` and `others` - each a list of value structs (not keyed by filter name).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + }, + "fullList": { + "type": "boolean", + "description": "True when the whole value set fit in this page.", + "nullable": true + }, + "info": { + "type": "object", + "description": "Page metadata (PageInfo).", + "nullable": true + }, + "suggestedAfterKey": { + "type": "string", + "nullable": true + }, + "othersAfterKey": { + "type": "string", + "nullable": true + } + } + } + }, + "IgnoredFilterValuesResponse": { + "description": "Report of which saved widget filter values are still valid vs ignored (TestHub IgnoredFilterValuesResponse).", + "schema": { + "type": "object", + "properties": { + "filterMatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filterType": { + "type": "string" + }, + "matchedValuesCount": { + "type": "integer" + }, + "ignoredValuesCount": { + "type": "integer" + }, + "isMismatched": { + "type": "boolean" + } + } + } + }, + "ignoredFiltersCount": { + "type": "integer", + "description": "Count of filters completely ignored." + }, + "isMismatched": { + "type": "boolean" + } + } + } + }, + "WidgetDataResponse": { + "description": "Computed data for one dashboard-view widget. The concrete shape is untyped and depends entirely on the widgetType requested \u2014 inspect per widget type.", + "schema": { + "type": "object", + "description": "Widget-type-specific payload (charts, stat cards, trend series, drilldown data, etc.)." + } + }, + "ReportRecordResponse": { + "description": "A single report record wrapped under `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "ReportRecord" + } + } + } + }, + "ReportRecordListResponse": { + "description": "A flat list of report records under `data` (no pagination).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "ReportRecord" + } + } + } + } + }, + "ReportDatasetResponse": { + "description": "Report dataset rows (or static build rows) with a paging summary. Can be null when the report type is not TABULAR_TEST_RUNS.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunReportRow" + } + }, + "summary": { + "$schema": "PaginationSummary" + }, + "sourceFencingFlags": { + "type": "object" + } + } + } + }, + "ReportStatTilesResponse": { + "description": "Aggregate stat tiles for a report widget. `data` is null when the report type is not TABULAR_TEST_RUNS.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "StatTile" + } + }, + "sourceFencingFlags": { + "type": "object" + } + } + } + }, + "ReportCustomFieldsResponse": { + "description": "Custom-field / column definitions plus page info.", + "schema": { + "type": "object", + "properties": { + "customFields": { + "type": "array", + "items": { + "$schema": "ReportCustomFieldItem" + } + }, + "info": { + "$schema": "ReportPageInfo" + } + } + } + }, + "CreateHealingPrAccepted": { + "description": "Healing-PR job accepted (async). Only healingPrId + status (IN_PROGRESS) are set.", + "schema": { + "$schema": "CreateHealingPrResponse" + } + }, + "HealingPrStatusResponse": { + "description": "Current state of a healing-PR job; PR fields populated once terminal.", + "schema": { + "$schema": "CreateHealingPrResponse" + } + }, + "SubmitHealingVerdictResponse": { + "description": "The verdict as recorded.", + "schema": { + "$schema": "HealingVerdictResponse" + } + }, + "HealingVerdictListResponse": { + "description": "Recorded verdicts for the build. Empty array (200) when the HITL healing feature is off for the group.", + "schema": { + "type": "array", + "items": { + "$schema": "HealingVerdictResponse" + } + } + }, + "SelfHealSummaryResponse": { + "description": "Build self-heal headline numbers.", + "schema": { + "$schema": "SelfHealSummaryStats" + } + }, + "BuildComparisonAggregationResponse": { + "description": "Build-comparison headline counts.", + "schema": { + "$schema": "BuildComparisonAggregation" + } + }, + "BuildComparisonCheckResponse": { + "description": "Comparison availability pre-check.", + "schema": { + "$schema": "BuildComparisonAvailable" + } + }, + "BuildComparisonActualResponse": { + "description": "base/target resolved to parent build UUIDs.", + "schema": { + "$schema": "BuildComparisonStringEnvelope" + } + }, + "BuildComparisonListResponse": { + "description": "One bucket's grouped tests.", + "schema": { + "$schema": "BuildComparisonList" + } + }, + "BuildComparisonMetaResponse": { + "description": "base/target build summary headers.", + "schema": { + "$schema": "BuildComparisonMetaEnvelope" + } + }, + "CommentListResponse": { + "description": "A bare array of comments (commentNode, no content HTML).", + "schema": { + "type": "array", + "items": { + "$schema": "CommentRes" + } + } + }, + "CommentThreadsSidePanelResponse": { + "description": "A bare array of thread summaries for the build side panel.", + "schema": { + "type": "array", + "items": { + "$schema": "BuildRunComment" + } + } + }, + "UniqueErrorListResponse": { + "description": "Suite Health unique-errors list. An empty `errors` with a 200 is the honest state for a demo tenant or a buildless project.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "pageNumber": { + "type": "integer", + "description": "The NEXT page index to request (requested pageNumber + 1)." + }, + "hasNext": { + "type": "boolean", + "description": "Whether the page just returned had rows; false marks the end." + } + } }, - "dashboard": { - "entity": "dashboard", - "title": "Custom dashboard", - "description": "A saved, shareable analytics page of widgets and global filters over test-observability and test-management data.", - "aliases": [ - "dashboard", - "dashboards", - "custom dashboard", - "custom dashboards", - "analytics dashboard", - "TO dashboard", - "TCM dashboard", - "report", - "reports", - "dashboard listing", - "reports listing" - ], - "id_convention": "UUID (e.g. 07c9f7be-0cd7-4669-8d3d-a1adff0b2aec); discover ids from the dashboard listing \u2014 a dashboard name is never accepted in place of the id.", - "parents": [], - "relations": [ - { - "entity": "widget", - "via": "widgets" - } - ], - "capabilities": [ - "clone_dashboard", - "clone_dashboard_via_public_api", - "create_dashboard", - "create_dashboard_via_public_api", - "delete_dashboard", - "delete_dashboard_via_public_api", - "get_dashboard", - "get_dashboard_via_public_api", - "list_dashboards", - "list_dashboards_via_public_api", - "list_reports", - "list_widget_custom_field_filters", - "resolve_dashboard_filter_names", - "search_unique_errors", - "update_dashboard", - "update_dashboard_via_public_api" - ], - "key_facts": [ - "A dashboard is addressed by its UUID id; get the id from the dashboard listing first \u2014 a dashboard name cannot be used as the identifier.", - "The dashboard listing and the internal dashboard reads require the Custom Dashboard feature entitlement; without it the listing is refused as feature-inaccessible rather than returning an empty list.", - "Test Management (TCM) dashboards are hidden from the default listing, which is scoped to Test Observability (source TO); ask for source TCM to see them (or the group must have GCD enabled).", - "Dashboards keep no version history: an overwritten name, visibility level, widget layout or filter set cannot be restored, and a deleted dashboard is gone permanently along with its widget layout.", - "A dashboard's visibility level is 1 or 2 and decides whether only the owner or the whole group can see it.", - "Scheduled reports are stored as dashboards but are excluded from the ordinary dashboard listing; they have their own reports listing, which spans both TO and TCM.", - "On the public-API write surface a treemap widget's visualization override is silently dropped (not rejected) and widget configs are validated for render-safety up front; the internal write surface does neither.", - "In demo-sandbox environments the seeded default dashboard cannot be updated or deleted \u2014 those calls are rejected with a 400 whose body shape differs from a normal success." - ] + "errors": { + "type": "array", + "items": { + "$schema": "UniqueErrorClusterRow" + } }, - "project": { - "entity": "project", - "title": "Project", - "description": "A Test Observability container mirroring a TestHub project; groups the builds, runs and dashboards for one app or repo.", - "aliases": [ - "project", - "projects", - "TO project", - "observability project", - "testhub project", - "th project", - "app project" - ], - "id_convention": "Plain integer project id on most paths; many read/listing paths instead take the project's normalisedName (a URL-safe slug of the project name). A project also carries a separate TestHub project id (thProjectId) used for cross-product linkage. Ids and names are discovered through a project listing.", - "parents": [], - "relations": [ - { - "entity": "test_plan", - "via": "project groups its test plans" - }, - { - "entity": "build", - "via": "project contains its builds" - } - ], - "capabilities": [ - "assign_issue_types_to_test_runs", - "claim_extended_free_trial", - "create_project", - "delete_project", - "get_default_project", - "get_home_takeaways", - "get_home_takeaways_with_noise_breakdown", - "get_project_automation_coverage_via_public_api", - "get_project_by_id_or_name", - "get_project_non_sdk_build_count", - "get_project_non_sdk_summary", - "get_user_access_details", - "list_home_recent_views", - "list_home_unique_builds", - "list_project_build_names", - "list_project_builds_by_group", - "list_projects_for_sidebar", - "list_projects_full", - "list_projects_lite", - "list_projects_lite_paginated", - "list_projects_via_public_api", - "list_projects_with_build_aggregates", - "list_projects_with_build_aggregates_paginated", - "meter_feature_usage", - "rename_project", - "whitelist_group_for_observability" - ], - "key_facts": [ - "A project is addressed two different ways depending on the operation: by its integer id, or by its normalised name (a URL-safe slug of the display name). Carry whichever the capability you picked declares - they are not interchangeable.", - "Deleting a project is irreversible and cascades in TestHub: every build, run and result under it is removed with it. There is no restore.", - "A project keeps a separate TestHub project id alongside its own id. Some lookups and the delete path can resolve a TestHub/TCM id to the mirrored observability project, so passing the wrong id family can silently act on a different project.", - "In the demo-sandbox environment, creating and deleting projects is refused, and the app-bootstrap identity fields (user id and group id) come back null.", - "Projects are only ever created as a service-to-service mirror of a TestHub project, keyed on the TestHub project id and idempotent on it - there is no customer-facing 'new project' create that a user session can call.", - "The public project listing is scoped to the caller's group and does not report a total count; it is walked page by page until an empty page is returned.", - "Automation-coverage figures for a project are computed over all time as an aggregation, not stored on the project, so a read recomputes them and carries the timestamp it was calculated at." - ] + "automateTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded Automate tests." }, - "sp_test": { - "entity": "sp_test", - "title": "Suite Health test (Stability & Performance)", - "description": "A unique automated test tracked across runs in Suite Health, keyed by a stability hash, carrying stability, failure and duration metrics.", - "aliases": [ - "test", - "suite health test", - "test health", - "stability test", - "sp test", - "snp test", - "flaky test", - "test stability", - "stability and performance", - "unique test" - ], - "id_convention": "Identified by an opaque stabilityHash string, not a numeric id. The hash is discovered from the Suite Health test listing; the same hash addresses every Test Health detail read.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "aggregated over the test's executions across builds" - } - ], - "capabilities": [ - "get_suite_health_stats", - "get_suite_health_test_info", - "get_suite_health_test_platform_breakdown", - "get_suite_health_trend", - "get_test_base_info", - "get_test_environment_breakdown", - "get_test_filter_options", - "get_test_health_stats", - "get_test_health_trend", - "get_test_metrics_chart", - "list_suite_health_test_runs", - "list_suite_health_tests", - "list_test_health_tests", - "search_test_filter_branches", - "search_test_filter_build_names", - "search_test_filter_build_tags", - "search_test_filter_host_names", - "search_test_filter_test_tags" - ], - "key_facts": [ - "A test is identified by an opaque stabilityHash string, not a numeric id. You must discover the hash from the Suite Health test listing first; the same hash is what every Test Health detail read takes.", - "Every Suite Health read is scoped to a date window, and a two-element millisecond [from, to] range is mandatory: results only ever cover that window, and omitting the range is rejected.", - "The filter type-ahead suggestions are capped server-side and are suggestions, not the complete set: git branches cap at ~100, while build tags, test tags and host names cap at ~10.", - "Metric counts for the very same query can differ depending on an account feature flag: with the new build listing enabled and the query not restricted to automation-latest, failure counts are sourced from workflow status rather than the raw recorded status, so the same window can report different failure numbers.", - "Muted-test visibility is an explicit, required input on the Test Health detail reads (show all, only muted, or only unmuted) with no default value.", - "Suite Health metrics and the environment breakdown exclude archived builds.", - "The Suite Health filter sidebar degrades gracefully: run-level dropdown options are fetched from an upstream service under a timeout, and on timeout those specific dropdowns come back empty instead of failing the whole request." - ] + "appAutomateTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded App Automate tests." }, - "test_detail": { - "entity": "test_detail", - "title": "Test detail (logs, hooks, capabilities)", - "description": "The logs, hooks, capabilities and error detail attached to one test run, shown on the Test Details page.", - "aliases": [ - "test details", - "test detail", - "logs tab", - "session logs", - "network logs", - "har", - "app logs", - "application logs", - "hooks", - "hook logs", - "capabilities tab", - "behaviour tab", - "console logs", - "consolidated logs", - "test run logs" - ], - "id_convention": "Not its own id. Most tabs are addressed by the parent test run's INTEGER id (testRunId); session-scoped log tabs by the session's HASHED string id; the app-log stream by the test run's UUID. Discover these from a test-run / session read.", - "parents": [ - "test_run" - ], - "relations": [ - { - "entity": "test_run", - "via": "detail of a test run" + "lcncTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded low-code / no-code tests." + } + } + } + }, + "UniqueErrorSummaryResponse": { + "description": "Header/summary block for one unique error.", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Sample failing test name." + }, + "error": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample error text split on newlines." + }, + "combinations": { + "type": "array", + "description": "Platform combos the error was seen on; combos NA on both os and browser are dropped.", + "items": { + "type": "object", + "properties": { + "os": { + "$schema": "SPFilterField" + }, + "browser": { + "type": "object", + "description": "Formatted browser object that also carries a `device`." + } + } + } + } + } + } + }, + "UniqueErrorOccurrenceCountResponse": { + "description": "Total occurrences of one unique error plus a per-bucket count series.", + "schema": { + "type": "object", + "properties": { + "errorCount": { + "type": "integer", + "description": "Total matching-run count for the window." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence doc counts with NO timestamps \u2014 reconstruct the x-axis from the window." + } + } + } + }, + "UniqueErrorBuildTrendResponse": { + "description": "Occurrence trend of one unique error grouped into status series (hard-capped at 5000 runs).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Status series name." + }, + "color": { + "type": "string", + "description": "Series colour (defaults to #999999)." + }, + "data": { + "type": "array", + "items": { + "type": "object" }, - { - "entity": "session", - "via": "session hashed id" - } - ], - "capabilities": [ - "get_session_log_urls", - "get_session_network_log_url", - "get_test_run_app_logs", - "get_test_run_app_logs_raw", - "get_test_run_auto_unmute_config", - "get_test_run_behaviour", - "get_test_run_capabilities", - "get_test_run_hooks", - "get_test_run_log_bundle", - "get_test_run_log_urls", - "get_test_run_media_logs", - "get_test_run_merged_logs", - "get_test_run_meta", - "get_test_run_network_log_url", - "get_test_run_network_logs", - "get_test_run_overview", - "get_test_run_test_code", - "list_test_run_detail_tabs", - "list_test_run_execution_history", - "list_test_run_linked_test_cases" - ], - "key_facts": [ - "The detail is derived, not stored as its own record: it has no id of its own and no history to restore. Everything here is read-only and nothing mutates the run.", - "The same detail is addressed three different ways depending on the tab: most tabs by the test run's integer id, the session-scoped log tabs by the session's hashed string id, and the application-log stream by the test run's UUID. Carry whichever id the capability you picked declares.", - "Log tabs come in two flavours. One downloads and merges the log text server-side and returns it inline; the other returns only URLs for the caller to fetch. The inline-merge form is slow for long sessions, so prefer the URL form there.", - "Application logs are filtered to abnormal (error-level) lines only, capped at 5000 lines, and bounded to the run's own start/finish window (padded five seconds each side). A clean run legitimately returns an empty list.", - "Retention-locked or plan-locked builds refuse every detail read with a forbidden / plan-feature-not-allowed error rather than returning empty; treat that as a plan gap, not a missing run.", - "Hook attribution is heuristic: hooks are matched by same build, filename, OS, browser and device, latest run only. It is not a stored parent/child link, so hooks from a differently-configured session never appear.", - "On the demo-sandbox environment several of these reads return canned or empty payloads (an empty log envelope, a demo HAR, or logs chosen by test name) instead of real data." - ] + "description": "Enriched run/build info entries for that series." + } + } + } }, - "test_run": { - "entity": "test_run", - "title": "Test run", - "description": "One execution of a single test within a build \u2014 its status, platform, code, retries and failure analysis.", - "aliases": [ - "test", - "test result", - "run", - "test execution", - "test case execution", - "session test", - "btcer", - "tr", - "spec run", - "automated test" - ], - "id_convention": "Most routes take the INTEGER test run id (Long). A few public routes are scoped by the parent BUILD's UUID instead (a string). Discover run ids from a build's test-run listing rather than guessing them; the TR-NNN prefixed identifier some surfaces show is for display, not for these numeric paths.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildId" - }, - { - "entity": "test_plan", - "via": "groups" - } - ], - "capabilities": [ - "assign_test_run_failure_categories", - "cancel_test_run_auto_unmute", - "get_test_run_baseline", - "get_test_run_bug_report_draft", - "get_test_run_build_insights", - "get_test_run_details", - "get_test_run_retry_logs", - "get_test_runs_history_details", - "link_bug_to_test_run", - "list_build_bstack_error_sessions", - "list_build_test_run_counts", - "list_build_test_run_sdk_logs", - "list_build_test_runs", - "list_build_test_runs_flat", - "list_build_test_runs_flat_filtered", - "list_build_test_runs_hierarchy", - "list_build_test_runs_hierarchy_without_public_link", - "list_build_test_runs_with_attachments", - "list_test_run_attachment_urls", - "list_test_runs_sharing_error", - "unlink_bug_from_test_run", - "update_test_run_mute_status" - ], - "key_facts": [ - "A test run is found by its own numeric id and the caller's group; when a route's path also carries a build id, some routes use it and some ignore it entirely, so a run still resolves even if the build segment is wrong.", - "The baseline, similar-error and hooks reads return a MAP keyed by test run id, not a single object, and the map is EMPTY (not an error) when nothing qualifies \u2014 an empty body means 'none found', not a failure.", - "The baseline look-back is a fixed 30 days before the run's start time; a last-known-good run older than that is invisible, so a returned empty map does not prove the test never passed.", - "Downloading a run's attachments or screenshots is gated by an account feature; when it is off the attachment read is refused even for a run that clearly has attachments.", - "Attachment and screenshot downloads come back as presigned URLs that expire roughly two hours after they are issued \u2014 fetch them promptly and re-request rather than caching the URL.", - "A test can be muted with an automatic unmute condition (after N passes, or after a date); making that mute permanent is reversible by muting again with a condition, so it is a change, not a deletion.", - "The bug-report read composes a fresh, pre-filled ticket draft from the failure; it does NOT fetch a bug that was already filed.", - "Linking a filed bug to a run is idempotent on the issue key: linking the same key twice is a silent no-op, and the link can be removed again afterwards.", - "Retries of a test are separate executions, each with its own status; a run carries its retry history rather than overwriting the previous attempt.", - "Public listings are plan- and source-aware: a freemium or source-fenced build yields a stripped or empty result rather than the analysis fields, and smart-tag filters on such a build are refused outright.", - "The status a run reports is lowercased on the public /ext detail routes but may come back uppercase from other surfaces \u2014 compare case-insensitively.", - "In the flat build listing the top-level id of each row is the test's hash (a string), while the numeric run id lives under that row's details.id." - ] + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Parallel list of run start timestamps (epoch)." + } + } + } + }, + "UniqueErrorBuildsResponse": { + "description": "Cursor-paginated list of builds in which a unique error occurred.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean", + "description": "Set to false only on an empty page." + }, + "searchAfter": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cursor to send back as the next request's searchAfter." + }, + "nextRoot": { + "type": "object", + "description": "Usually absent for this endpoint." + } + } }, - "ai_failure": { - "entity": "ai_failure", - "title": "AI failure analysis (build-level)", - "description": "A build-level AI cluster of related test failures: a failure theme or a workflow grouping the runs that failed for the same reason.", - "aliases": [ - "ai failure", - "ai failure analysis", - "failure theme", - "failure cluster", - "failure workflow", - "build failure theme", - "build failure workflow", - "build rca", - "build level rca", - "ai clustered failures", - "rca theme" - ], - "id_convention": "The build is addressed by its build UUID (an opaque string) in the path. Individual clusters are identified by an integer buildFailureThemeId or buildFailureWorkflowId, which are discovered from the build AI-failures read, not guessable.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildUUID" - }, - { - "entity": "test_run", - "via": "flat test-run drill-down under a theme/workflow" + "builds": { + "type": "array", + "items": { + "$schema": "UniqueErrorBuild" + } + } + } + } + }, + "UniqueErrorPlatformBreakdownResponse": { + "description": "Flat list of platform buckets for one unique error.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "browser": { + "type": "object", + "description": "Formatted browser object that also carries a `device`." + }, + "os": { + "type": "object", + "description": "Formatted platform object." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps)." + }, + "totalFailure": { + "type": "integer", + "description": "Occurrence doc count for this combination." + }, + "errorCount": { + "type": "integer", + "description": "Distinct impacted-test count for this combination." + } + } + } + } + }, + "UniqueErrorImpactedTestsResponse": { + "description": "Bare array of impacted tests behind one error cluster (no envelope).", + "schema": { + "type": "array", + "items": { + "$schema": "UniqueErrorImpactedTest" + } + } + }, + "UniqueErrorTimelineChartsResponse": { + "description": "Three Suite Health charts keyed uniqueErrorChart / uniqueTestChart / impactedTestChart.", + "schema": { + "type": "object", + "properties": { + "uniqueErrorChart": { + "$schema": "SPChart__w3_sp_error" + }, + "uniqueTestChart": { + "$schema": "SPChart__w3_sp_error" + }, + "impactedTestChart": { + "$schema": "SPChart__w3_sp_error" + } + } + } + }, + "UniqueErrorFiltersResponse": { + "description": "The Unique Errors filter sidebar: applied selections, static option sets, and default hooks visibility.", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "Caller's current selections resolved into display objects. Scalar keys (search, isFlaky, isNewFailure, isAlwaysFailing, isManuallyOverridden, isMuted, hooksVisibility, dateRange) plus option-set keys (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList, osList, browserList, pmToolStatus, branches), each a list of {id, value, label, count, pId} option objects." + }, + "staticFilters": { + "type": "object", + "description": "Selectable option lists for the page: osList, browserList, status, folders, failureCategories, deviceList, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan \u2014 each a list of {id, value, label, count, pId} option objects. Any list can be empty when its upstream (ES / TestHub) times out." + }, + "hooksVisibility": { + "type": "string", + "description": "The project's default hooks-visibility setting." + } + } + } + }, + "FieldSearchResponse__w3_sp_error": { + "description": "Typeahead result: a bounded set of matching filter options.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "SPFilterField" + } + } + } + } + }, + "TestRunsRcaDataResponse": { + "description": "A test run's AI RCA (state + analysis). Top-level object; there is NO `data` wrapper.", + "schema": { + "$schema": "TestRunsRcaData" + } + }, + "BulkTestRcaResponse": { + "description": "Acknowledgement that a bulk test-RCA batch was queued. bulkRcaUuid is the batch handle; the analyses arrive later.", + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "buildUuid": { + "type": "string" + }, + "bulkRcaUuid": { + "type": "string", + "description": "UUID identifying the queued bulk batch." + }, + "trialQuota": { + "type": "object", + "description": "Present only for trial users." + } + } + } + }, + "BuildGroupListResponse": { + "description": "Paged run-group listing. Groups at top-level `data`; paging under `info`. No `success` wrapper.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildGroup" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "BuildGroupOperationResponse": { + "description": "Result of a create/update/delete/close on a run group. Serialized non-null only, so the exact key set depends on the operation: create/update carry {success, data, message}; delete carries {success, message}; close carries {success, message, build_group_id, closed_builds}.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$schema": "BuildGroup" + }, + "message": { + "type": "string" + }, + "build_group_id": { + "type": "integer", + "description": "Echoed on close." + }, + "closed_builds": { + "type": "integer", + "description": "Number of builds flipped to closed (close only)." + } + } + } + }, + "AccessibilityTestRunsResponse": { + "description": "Build-level a11y test listing assembled by TRA. NOTE: for App-Accessibility groups whose build source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", + "schema": { + "type": "object", + "properties": { + "testCases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Test case id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "file": { + "type": "string" + }, + "thBtcerId": { + "type": "string", + "description": "The btcer UUID for this test." + }, + "osData": { + "type": "object", + "description": "OS name/version/icon." + }, + "browserData": { + "type": "object", + "description": "Browser name/version/icon." + }, + "summary": { + "type": "object", + "properties": { + "pageCount": { + "type": "integer" + }, + "componentCount": { + "type": "integer" + }, + "score": { + "type": "number" + }, + "issueCount": { + "type": "integer" + }, + "severityBreakdown": { + "type": "object", + "description": "Counts keyed by critical/serious/moderate/minor." + } } - ], - "capabilities": [ - "get_build_ai_failures", - "get_build_ai_failures_via_public_api", - "list_build_ai_failure_cluster_test_runs_via_public_api", - "list_build_ai_failure_test_runs_flat", - "trigger_build_ai_failure_analysis", - "trigger_build_ai_failure_analysis_via_public_api" - ], - "key_facts": [ - "AI failure clustering is computed asynchronously. Requesting analysis on a build that has none returns immediately with a triggered flag and NO themes or workflows yet; the results arrive later, so re-read to collect them.", - "The read side returns only what has already been computed and never starts analysis. Starting analysis is a separate trigger operation.", - "Themes and workflows always come back sorted by their share of failures, highest first.", - "Build-level AI analysis is gated by AI opt-in consent, a per-account AI-enabled flag, and the build-level RCA feature; a trial account also has a usage quota that returns a plan-not-allowed error once exhausted, and each fresh trigger consumes one unit of it.", - "A build whose source is excluded by source-fencing is refused analysis.", - "The trial-quota figure is present only for accounts on the build-level AI trial; a paid account never sees it, so its absence does not mean zero usage.", - "Drilling from a cluster to its test runs is cursor-paginated, and the per-page size is a fixed backend default that the caller cannot change." - ] + } + } + } }, - "session": { - "entity": "session", - "title": "Session", - "description": "A single automation run on BrowserStack (Automate or App Automate) recorded against a build, with its config, timings, logs and status.", - "aliases": [ - "session", - "automate session", - "app automate session", - "test session", - "run session", - "browser session", - "device session", - "live session", - "queued session" - ], - "id_convention": "Most session reads take the session's hashed id, an opaque alphanumeric string (not a numeric id). The stop control takes that hashed id in the body. One list read is the exception: it takes the NUMERIC build id even though its slot looks like a session id.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildId" - }, - { - "entity": "test_run", - "via": "a session executes the build's test runs" + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Next page, re-based to 0-index for the FE (upstream next page - 1)." } - ], - "capabilities": [ - "get_session_app_profiling", - "get_session_capabilities", - "get_session_details", - "get_session_test_code_via_public_api", - "list_build_sessions", - "list_queued_sessions", - "stop_session" - ], - "key_facts": [ - "A session is identified by its hashed id (a string), not a numeric id, except the per-build session list, which takes the build's numeric id even though the value slots where the other routes expect a session id.", - "App profiling exists only for App Automate sessions; asking for it on an Automate session is rejected, and an empty object means the report is still generating or was never captured, not an error.", - "Session detail can come back with a retryable not-ready status while the session is still being linked to its build; treat that as retry-shortly, not a failure.", - "A session that belongs to a plan-locked build is refused with a plan-not-allowed error even though the session exists.", - "A public-link viewer does not see still-running sessions in the per-build session list.", - "Stopping a session ends the live run and cannot be undone, but removes nothing: the session record and its logs remain.", - "The public test-code read reports a session that exists but captured no code identically to a missing session, so a not-found there does not prove the id is wrong.", - "On the demo sandbox environment, capabilities, session details and app profiling are served from a canned dataset rather than real data." - ] - }, - "check_config": { - "entity": "check_config", - "title": "Check configuration (SCM check-run)", - "description": "A repo's SCM check-run rule: which BrowserStack build/project a commit or PR check reports on, as quality-gate or build status.", - "aliases": [ - "check", - "checks", - "check run", - "check-run", - "check config", - "check configuration", - "pr check", - "pr checks", - "vcs check", - "github check", - "quality gate check", - "commit check", - "scm check", - "checks mapping", - "check mapping", - "pr checks mapping" - ], - "id_convention": "Check-configuration id is a plain integer (discover it from the repo's check-runs listing). A checks-mapping registration has its OWN integer ingestion id, which is neither the check-configuration id nor the GitHub check id.", - "capabilities": [ - "create_check_configurations", - "create_checks_mapping", - "delete_check_configuration", - "get_checks_mapping", - "list_check_configurations", - "toggle_check_configuration", - "update_check_configuration" - ], - "key_facts": [ - "The whole feature is gated by the CHECKS_EVALUATION entitlement: create, read, list, update, delete and toggle all refuse when the workspace lacks it, and that refusal is flattened into a generic bad-request error rather than a typed one.", - "Check configurations are scoped to the caller's group AND user together: you only see and can change checks you created, and a teammate's checks are invisible and cannot be edited, toggled or deleted.", - "A repository may hold at most 10 check configurations; a configuration is unique by the combination of its name, repository, group, build name and target project.", - "checkType is one of exactly two values: quality-gate status or build status.", - "The target project named on a check must already exist in the workspace, or the write is refused as project-not-found.", - "Deleting a check configuration removes the row for good; there is no undo and no bin.", - "A checks-mapping registration is a separate record from the check configuration it points at: it captures one pull request's commit, callback URL and evaluation state (starting at 'Not Started'), and is addressed by its own ingestion id.", - "A checks-mapping registration only succeeds for check ids that already match an existing check configuration in the caller's group for that repository and organisation; ids that match nothing are rejected." - ] + } + } + } + } + } + } + }, + "AccessibilityTestMetaResponse": { + "description": "Per-test a11y detail. NOTE: for App-Accessibility groups whose run source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", + "schema": { + "type": "object", + "properties": { + "totalIssues": { + "type": "integer" + }, + "severityBreakdown": { + "type": "object", + "description": "Counts keyed by critical/serious/moderate/minor." + }, + "reportURL": { + "type": "string", + "description": "URL of the a11y report to fetch elsewhere." + }, + "score": { + "type": "number" + }, + "testCaseUuid": { + "type": "string" + } + } + } + }, + "SplitBuildLegsResponse": { + "description": "Paginated child legs of a split/parallel build (cursor by build uuid).", + "schema": { + "$schema": "SplitBuildLegs" + } + }, + "BuildNotificationSummaryResponse": { + "description": "Compact build notification/email summary. HTTP 200 with a NULL body when the build id is unknown.", + "schema": { + "$schema": "BuildNotificationSummary" + } + }, + "BuildStabilityTileResponse": { + "description": "Build stability tile.", + "schema": { + "$schema": "BuildStabilityTile" + } + }, + "BuildSummaryStatsResponse": { + "description": "Build headline test-count strip.", + "schema": { + "$schema": "BuildSummaryStats" + } + }, + "BuildTimelineStatsResponse": { + "description": "Build execution timeline series.", + "schema": { + "$schema": "BuildTimelineStats" + } + }, + "BuildDetailsResponse": { + "description": "Full single-build detail with UI gates.", + "schema": { + "$schema": "BuildDetails" + } + }, + "BuildFailureCategoryStatsResponse": { + "description": "Failure-category breakdown. Empty data + explanatory message (still 200) while the build is pending.", + "schema": { + "$schema": "BuildFailureCategoryStats" + } + }, + "BuildFailuresByModuleResponse": { + "description": "Failures grouped by module/file path.", + "schema": { + "$schema": "BuildFailuresByModule" + } + }, + "BuildFilterValuesResponse": { + "description": "Older compact build filter-values map.", + "schema": { + "$schema": "BuildFilterValues" + } + }, + "BuildTestFoldersResponse": { + "description": "Build test-listing folder tree; root-folders or folder-detail shape depending on folder_path.", + "schema": { + "$schema": "BuildTestFolders" + } + }, + "BuildNonSdkTestCountsResponse": { + "description": "Data-hygiene counters. The body is a JSON STRING that must be parsed to read the three count keys.", + "schema": { + "$schema": "BuildNonSdkTestCounts" + } + }, + "SingleMetricSummaryResponse__w4_build2": { + "description": "Single-metric tile (flaky / muted / new-failure).", + "schema": { + "$schema": "SingleMetricSummary__w4_build2" + } + }, + "SidebarUserFilterResponse": { + "description": "Users offered by the build-list sidebar Users facet. `data` is an unordered, de-duplicated set capped at 100.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "User id." + }, + "name": { + "type": "string", + "description": "User's full name (the wire field is `name`, populated from the user's full name)." + } + } + } + } + } + } + }, + "SidebarTeamFilterResponse": { + "description": "Teams offered by the build-list sidebar Teams facet, resolved via the BrowserStack teams service. `data` is unordered and capped at 100 owning sub-groups.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Team id (BrowserStack team / sub-group id)." + }, + "name": { + "type": "string", + "description": "Team name, from central/rails team data." + } + } + } + } + } + } + }, + "SidebarTestCaseStatusesResponse": { + "description": "Raw pass-through of Test Management's custom-fields API for entity `TestResult` (the configured manual test-result status options) for the selected projects. Returned verbatim, NOT wrapped in the {data:[...]} envelope the other sidebar facets use.", + "schema": { + "type": "object", + "description": "Opaque TestHub custom-fields JSON; keys and nesting are defined by Test Management, not by this service, and are returned as received." + } + }, + "HomeUserFilterOptionsResponse": { + "description": "Selectable values for one home/overview personal-filter dropdown, keyed by the requested `key`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true, + "description": "Null on this route." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true, + "description": "Occurrence count; null for buildStatus/sources." + }, + "pId": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "WebhookFilterOptionsResponse": { + "description": "Webhook rule-builder filter options for the requested `type`, with a composite-aggregation paging cursor.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display label; for `folder` it is 'name(count)'." + }, + "value": { + "type": "string", + "description": "The value to store in the rule; for `folder` it is the full path." + }, + "pId": { + "type": "string", + "description": "For `folder`, the parent folder path; for other types it echoes the value." + }, + "count": { + "type": "integer", + "description": "Matching document count for the value.", + "nullable": true + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + }, + "meta": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + } + } + } + }, + "afterKey": { + "type": "string", + "description": "The last key of this page. It is still set on the final page, so stop when a page returns fewer rows than the page size (10; 1000 for folder) or an empty `data`.", + "nullable": true + } + } + } + }, + "SuiteHealthTestListResponse": { + "description": "Stability & Performance test listing (Test Health v2 / Suite Health v3 \u2014 identical envelope).", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "pageNumber": { + "type": "integer", + "description": "Next page index." + }, + "hasNext": { + "type": "boolean" + } + } }, - "view": { - "entity": "view", - "title": "Saved view", - "description": "A named saved filter preset for a listing screen, private to its creator or shared with the whole group.", - "aliases": [ - "view", - "views", - "saved view", - "saved views", - "custom view", - "custom saved view", - "filter preset", - "view preset", - "saved filter", - "named filter", - "filter view" - ], - "id_convention": "A view id is a UUID (discover it from a views listing). The path viewType is a SCREEN name, not an id: one of builds, testRuns, testHealth, uniqueErrors or buildInsights.", - "capabilities": [ - "create_view", - "create_view_via_public_api", - "delete_view", - "list_views", - "list_views_via_public_api", - "update_view", - "update_view_via_public_api" - ], - "key_facts": [ - "A view belongs to exactly one screen type (builds, test runs, test health, unique errors or build insights); the same name can be reused across different screen types.", - "Views are either private (level 1, visible only to the creator) or shared/public (level 2, visible to the whole group).", - "A view name must be unique within its screen type: within the group for shared views, within the creator for private views. A clash is refused with a name-exists error rather than overwriting.", - "Deleting a view is a hard, unrecoverable removal, and the view disappears from every group member's view switcher immediately.", - "The creator and group of a view are always taken from the caller's session, never from the request body; a createdBy or groupId sent in the body is ignored.", - "On a shared public link only public views are returned; private views are never exposed through a shared link.", - "Editing a view is a partial update (omitted fields keep their stored values), but views keep no revision history, so an overwritten name, filter query or visibility cannot be restored.", - "The external (public-API) surface enforces stricter input than the internal one: name must be non-blank and at most 100 characters, query non-blank and at most 4000, and it has no delete operation at all." - ] + "tests": { + "type": "array", + "items": { + "$schema": "SuiteHealthTestListItem" + } }, - "dashboard_view": { - "entity": "dashboard_view", - "title": "Dashboard view", - "description": "A saved dashboard configuration (name, global filters, widget layout) for the Test Observability or Test Management dashboard page.", - "aliases": [ - "dashboard", - "dashboard view", - "view", - "dashboard page", - "custom dashboard", - "dashboard tab", - "testing trends", - "testing-trends", - "automation trend", - "automation-trend", - "default dashboard", - "builds dashboard", - "test runs dashboard", - "test health dashboard", - "unique errors dashboard", - "build insights dashboard" - ], - "id_convention": "UUID. Custom views' UUIDs are discovered from the list capability. On READS only, three built-in views are also addressable by alias: `default` resolves to the TCM view for source=TCM and to the TO 'testing trends' view for source=TO, and `automation-trend` resolves to the automation-trend view. Writes (update/delete/clone-source) require the raw UUID; the aliases are rejected there.", - "parents": [ - "project" - ], - "relations": [], - "capabilities": [ - "check_dashboard_widget_ignored_filters", - "clone_dashboard_view", - "create_dashboard_view", - "delete_dashboard_view", - "get_dashboard_view", - "get_dashboard_view_widget_data", - "list_dashboard_view_custom_field_filters", - "list_dashboard_view_widget_types", - "list_dashboard_views", - "list_dashboard_widget_filter_values", - "update_dashboard_view" - ], - "key_facts": [ - "There are three built-in dashboards: the default TCM dashboard, the default TO dashboard (shown as 'testing trends'), and 'automation-trend'. None of the three can be renamed, edited, cloned into, or deleted; an attempt to update or delete one is refused, and the built-ins never appear as normal editable views.", - "A dashboard is addressed by its UUID everywhere. Only when reading a single dashboard may you also use the aliases `default` and `automation-trend` in place of the UUID; every write needs the raw UUID.", - "Opening a built-in default dashboard forces its date range to the last 30 days, ignoring whatever range was stored, and injects the current filter values into its widgets.", - "Reading a single dashboard records it in your 'recently viewed' dashboards list (the same list the home page shows); there is no read that avoids leaving that trace. The trace is skipped for super-users unless they are in group 2.", - "Dashboards keep no version history: an overwritten name, filter set, or widget layout cannot be restored to a previous revision, and a delete is permanent.", - "Dashboard names must be unique within the project; creating or cloning with a name already in use is rejected.", - "Every operation on this entity must name a project (by id, name, or identifier). The named project both scopes the dashboard and is the resource the create/update/delete/clone permission check runs against, even for calls that otherwise ignore the project.", - "Visibility is governed by the numeric `level` field on the dashboard; the default level is 2 (group / shared).", - "Custom (private/group) dashboards are only visible to their owner and their group; a UUID that belongs to someone else's private dashboard reads as not-found rather than forbidden.", - "A dashboard name is limited to 1-100 characters." - ] + "automateTestsSkipped": { + "type": "boolean", + "description": "True when Automate tests were hidden by source feature-fencing." }, - "report": { - "entity": "report", - "title": "Report (saved report)", - "description": "A saved, shareable analytics view over test runs with persisted global filters and widgets; private or group-visible.", - "aliases": [ - "report", - "saved report", - "reports", - "test report", - "test execution report", - "analytics report", - "tm report", - "test management report", - "custom report" - ], - "id_convention": "UUID (the report id). Reports mirrored from Test Management are ALSO addressable by their TM-side numeric external report id through the sync surface. Discover a report's UUID from the report listing.", - "parents": [], - "relations": [ - { - "entity": "project", - "via": "selected projects in the report's global filters" - }, - { - "entity": "test_run", - "via": "the tabular test-runs report widget" - } - ], - "capabilities": [ - "bulk_sync_tm_reports", - "clone_report", - "create_report", - "delete_report", - "delete_tm_report", - "get_report", - "get_report_dataset_rows", - "get_report_print_download_token", - "get_report_stat_tiles", - "list_report_custom_fields", - "list_report_static_builds", - "list_saved_reports", - "sync_tm_report", - "update_report", - "update_tm_report" - ], - "key_facts": [ - "Reports and dashboards are the same stored object under the hood and share one storage, name space and permission family; a report is just the report-typed view of it, so its visibility, filters and widgets behave exactly like a dashboard's.", - "A report is addressed two ways for the same record: end-user reports by a UUID, and reports mirrored from Test Management additionally by their TM-side numeric external id on the sync surface.", - "Visibility level is 1 = private (only the owner sees it) or 2 = group (everyone in the group sees it). A report you own at any level, or any group-level (level 2) report in your group, is yours to delete.", - "Report names must be unique within the same type and visibility level; a clashing name is rejected on create, on clone, and on an edit that changes the name, type or level.", - "Reports keep no version history \u2014 a rename, filter change or widget replacement overwrites the previous definition with no way to restore it.", - "A report stores its columns and filters by numeric custom-field id, so rendering or editing a saved report means resolving those ids back to display names first; the same field name can exist at both the test-case and test-execution level, distinguished only by entity type.", - "Only the tabular test-runs report type has live data behind it; asking a report's tiles or dataset for any other report type returns a 200 with a null body rather than an error.", - "Reports mirrored from Test Management are stamped with a TCM source and are meant to be written only by the Test Management service (identity taken from the request body, no permission check); the end-user builder creates reports under the TO source.", - "In the demo-sandbox environment there is a built-in default report that cannot be edited or deleted.", - "Creating or listing reports requires the custom-dashboard plan feature, and minting a report's PDF-print token requires the download-report plan feature; without the feature the call is refused even for a user who holds report permissions." - ] + "appAutomateTestsSkipped": { + "type": "boolean", + "description": "True when App Automate tests were fenced out." }, - "report_view": { - "entity": "report_view", - "title": "Report view", - "description": "A configured analytics report (currently the tabular test-runs report) run on demand to return its table, summary tiles and per-test detail.", - "aliases": [ - "report", - "reports view", - "tabular report", - "test-runs report", - "report table", - "analytics report", - "tabular test runs report" - ], - "id_convention": "No stored id: the report is defined by the definition sent in the request body. Per-test detail is keyed by a test's globalTestHash (a string hash identifying one test case across runs), taken from a report-table row.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "report rows / per-test detail" + "lcncTestsSkipped": { + "type": "boolean", + "description": "True when Low-Code/No-Code tests were fenced out." + } + } + } + }, + "SuiteHealthStatsResponse": { + "description": "Aggregate stat tiles for one test.", + "schema": { + "type": "object", + "properties": { + "reliability": { + "type": "number", + "description": "Failure percentage (higher = worse)." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "failureOccurrence": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + } + }, + "TestHealthTrendResponse": { + "description": "Legacy Test Health per-execution trend. data points are minimal (no smart-tag flags / workflow status).", + "schema": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "startedAt epochs, parallel to data." + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "y": { + "type": "integer", + "description": "Run duration (ms); 0 when absent." + }, + "id": { + "type": "integer", + "description": "Test-run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id." + }, + "buildName": { + "type": "string" + } + } + } + } + } + } + }, + "SuiteHealthTrendResponse": { + "description": "Suite Health per-execution trend with rich points.", + "schema": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "startedAt epochs, parallel to data." + }, + "data": { + "type": "array", + "items": { + "$schema": "SuiteHealthTrendPoint" + } + } + } + } + }, + "SuiteHealthTestInfoResponse": { + "description": "Header metadata for one test's Suite Health detail page.", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Test display name; empty string when no run matched." + }, + "combinations": { + "type": "array", + "description": "OS/browser/device combinations the test ran on (non-displayable combos filtered out).", + "items": { + "type": "object", + "properties": { + "os": { + "type": "object", + "description": "Platform display object." + }, + "browser": { + "type": "object", + "description": "Browser display object; device nested as an object.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "object" + } } - ], - "capabilities": [ - "get_report_test_stats", - "get_report_test_trend", - "get_tabular_test_runs_report_tiles", - "list_report_test_runs", - "run_tabular_test_runs_report" - ], - "key_facts": [ - "Only the tabular test-runs report is implemented. Asking for any other report type returns an empty or null body with a 200, not an error, so check the body rather than the status.", - "The report is defined entirely by the definition you send (datasets, segments, global filters); the id in the address is not read at all, so any value satisfies it.", - "Running the report has no side effects even though the request is write-shaped: the body is the query, not saved state.", - "The report table and its summary tiles are two separate calls that take the same definition; the tiles call has no paging.", - "Per-test detail (stats, run list, trend) is keyed by a test's global hash, which you read from a row of the report table.", - "A test with no matching runs in the window returns an empty object for its stats rather than zeroed counts.", - "Trend point durations are in milliseconds, and each point carries the build it came from.", - "When every dataset in the definition mutually conflicts, the table comes back empty and the tiles come back with all zero/false fencing flags rather than an error." - ] + } + } + } + } + } + } + }, + "SuiteHealthPlatformBreakdownResponse": { + "description": "Per OS/browser/device breakdown for one test \u2014 a BARE ARRAY, sorted by reliability descending.", + "schema": { + "type": "array", + "items": { + "$schema": "SuiteHealthPlatformBreakdownItem" + } + } + }, + "SuiteHealthTestRunsResponse": { + "description": "One page of a test's executions across builds, cursor-paginated by start.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "start": { + "type": "integer", + "description": "Cursor for the next (older) page \u2014 pass back as the start query param." + }, + "offset": { + "type": "integer", + "description": "Fixed placeholder (20) kept for FE contract; not honoured by the backend." + } + } }, - "widget": { - "entity": "widget", - "title": "Dashboard widget", - "description": "A single configured tile on a dashboard (chart, stat card or distribution) whose data is computed on demand from filters and a time window.", - "aliases": [ - "widget", - "dashboard widget", - "tile", - "chart", - "dashboard tile", - "widget filter", - "dashboard chart" - ], - "id_convention": "Addressed on these routes by a widget-type enum plus the definition in the body, not by a stored widget id. The dashboard id in the address is not read on the data and drill-down calls.", - "parents": [ - "dashboard" - ], - "relations": [ - { - "entity": "dashboard", - "via": "dashboardId (unused on data routes)" - } - ], - "capabilities": [ - "get_org_dashboard_widget_data", - "get_widget_drilldown_records", - "list_widget_filter_values", - "search_widget_filter_values" - ], - "key_facts": [ - "Widget data is computed from the definition and filters you send, not read from stored widget state; the dashboard id in the address is ignored on the org-level data and drill-down calls.", - "Filter-value lookups come in two forms: a flat list of values for one filter key, and an enhanced form that splits values into suggested and others with search and paging.", - "For ClickHouse-powered widget types, the project ids you send are automatically translated to TestHub project ids before the query runs.", - "Group 2 asking for the active-builds widget is forced to a seven-day window regardless of the range requested.", - "These reads have no side effects even though the requests are write-shaped.", - "The widget-data response shape depends on the widget type requested, so read the type you asked for before assuming a chart vs card vs distribution envelope." - ] + "builds": { + "type": "array", + "items": { + "$schema": "SuiteHealthTestRun" + } + } + } + } + }, + "TestRunHistoryDetailsResponse": { + "description": "Execution-history map keyed by the requested id. Top-level keys are the test run ids (or hook run ids when isHook=true); an id with no history is omitted.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestRunHistoryEntry" + } + } + }, + "TestRunRetryLogsResponse": { + "description": "The retry attempt's screenshot/log structure. Throws (not 200) when no screenshot data exists for the attempt.", + "schema": { + "$schema": "TestRunRetryLogStruct" + } + }, + "TestRunSdkLogsListResponse": { + "description": "One fixed-size (15) page of test runs with SDK logs/attachments. totalAttachments is present only on the first (cursor-absent) page.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunSdkLogEntry" + } + }, + "cursorInfo": { + "type": "object", + "properties": { + "nextCursor": { + "type": "integer", + "description": "Pass back as `cursor` for the next page." + }, + "hasMore": { + "type": "boolean" + } + } }, - "parallel_queue": { - "entity": "parallel_queue", - "title": "Parallel queue usage", - "description": "A view of a group's parallel-session concurrency for a product: parallels in use vs allowed and queued sessions vs the queue limit.", - "aliases": [ - "parallel queue", - "parallels", - "queue", - "concurrency", - "parallel sessions", - "queue usage", - "session queue" - ], - "id_convention": "Scoped to the authenticated caller's group; the running-builds view additionally takes a numeric user id in the address.", - "parents": [], - "relations": [], - "capabilities": [ - "get_parallel_queue_usage_summary", - "list_parallel_queue_team_usage", - "list_user_running_builds" - ], - "key_facts": [ - "Usage is always for the signed-in caller's group and the product named in the request (automate or app-automate). A product value other than automate is treated as app-automate on the running-builds view.", - "The team breakdown and running-builds views pass through to the billing backend using the caller's own BrowserStack credentials, so an unrecognised product fails downstream there.", - "The summary view only works for session/JWT callers because it reads the user from the security context; omit the product and you get back only the test-execution usage block, with no live queue numbers.", - "The running-builds view takes a user id in the address and does not check that it belongs to the caller's own group.", - "The summary view strips the internal user-details block out before returning." - ] + "totalAttachments": { + "type": "integer", + "description": "Per-build attachment total; only on the first page." + } + } + } + }, + "AssignTestRunFailureCategoriesResponse": { + "description": "Flat array of the resulting issue types after bulk categorisation.", + "schema": { + "type": "array", + "items": { + "$schema": "TestRunIssueType" + } + } + }, + "BstackErrorSessionsResponse": { + "description": "Raw array of BrowserStack-error session objects straight from the session repository (opaque element shape). Empty for non-SDK builds and in demo-sandbox.", + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Opaque session object." + } + } + }, + "FlatTestRunListResponse": { + "description": "Flat, cursor-paged test-run listing (V2 flat surface).", + "schema": { + "type": "object", + "properties": { + "testRuns": { + "type": "array", + "items": { + "type": "object", + "description": "Flattened test detail (per-run row: id, name, status, platform, flags, etc.)." + } }, - "usage": { - "entity": "usage", - "title": "Usage & entitlement", - "description": "Test-execution consumption and plan/tier entitlement for a group or user: counts run, limits, and how much of the plan is used.", - "aliases": [ - "usage", - "consumption", - "test executions", - "billing usage", - "plan usage", - "tier usage", - "entitlement", - "device tier" - ], - "id_convention": "Group/user scoping comes from query params or the authenticated session; the device-tier and plan-details views use the caller's own group with no id argument.", - "parents": [], - "relations": [], - "capabilities": [ - "get_device_tier_usage", - "get_group_plan_usage_details", - "get_test_execution_usage_count" - ], - "key_facts": [ - "The raw execution-count view takes the user id or group id straight from the request and never checks they belong to the caller, so any authenticated caller can read another user's or group's counts.", - "That same count view returns a 200 with the plain text body 'Missing userId or groupId' when both are omitted, instead of a 400.", - "The plan/usage-details view returns every count as a compacted display string (e.g. '1.2K'), not a number, and nulls out every usage field when the group has no execution limit configured.", - "The device-tier view lists only mobile tiers; non-mobile tiers are filtered out, and a missing product is a 400.", - "Execution counts are split into all tests reported and the subset that ran on BrowserStack infrastructure (those carrying a session id)." - ] + "pagingParams": { + "type": "object", + "description": "Cursor state (hasNext, nextRoot, searchAfter) to feed back for the next page." }, - "search": { - "entity": "search", - "title": "Global search", - "description": "A global free-text search across product entities (builds, tests, projects) for the search bar and quick overlay.", - "aliases": [ - "search", - "global search", - "find", - "search bar", - "quick search", - "entity search" - ], - "id_convention": "No stored entity; each result carries its own numeric id and a type discriminator.", - "parents": [], - "relations": [], - "capabilities": [ - "search_entities" - ], - "key_facts": [ - "Search covers a fixed set of entity types and rejects any other value; the accepted values are case-sensitive (Build, BuildTestCaseExecutionResult, Project, All).", - "The query must be at least three characters and not blank, or the request is rejected.", - "Results are always sorted by most-recently-updated; the sort order cannot be changed by the caller.", - "The quick-overlay mode returns a smaller page and omits the paging and total-count fields that the full mode includes.", - "Each result is enriched with the matching record's user name, resolved from its user id." - ] + "isArchived": { + "type": "boolean" }, - "self_healing": { - "entity": "self_healing", - "title": "Self-healing (AI-healed locators)", - "description": "AI-suggested replacement selectors for tests whose locators broke, plus the review verdicts and PRs that apply them.", - "aliases": [ - "self heal", - "self-heal", - "ai healing", - "aiHealing", - "healed locator", - "healed selector", - "locator healing", - "auto heal", - "healing pr", - "heal pr", - "healing verdict" - ], - "id_convention": "A build is addressed by its build UUID (^[a-zA-Z0-9-]{1,64}$). A healing-PR job is a random UUID (8-4-4-4-12 hex) returned by create_self_healing_pr; it is NOT discoverable through any list call and lives only in Redis with a short TTL, so capture it from the create response.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "healed locators are computed per build; summary and verdicts are build-scoped" - }, - { - "entity": "test_run", - "via": "each healed selector carries the sessionId of the run it broke in" - } - ], - "capabilities": [ - "create_self_healing_pr", - "get_build_self_heal_summary", - "get_self_healing_pr_status", - "list_self_healing_verdicts", - "submit_self_healing_verdict" - ], - "key_facts": [ - "Opening a healing PR is asynchronous: the create call returns immediately with a job id and an IN_PROGRESS state, and the actual GitHub PR is opened later by a background worker. Poll the status separately; the PR URL only appears once the job reaches a terminal state.", - "A healing-PR job's terminal state can be SUCCEEDED, PARTIAL (some files changed, some skipped), FAILED, or NO_CHANGES_NEEDED. PARTIAL and NO_CHANGES_NEEDED are successful outcomes, not errors.", - "Healing-PR job state is kept only briefly and only for the group that started it; once it expires the id reads as not-found rather than as a finished state, so a job whose status was never polled in time is unrecoverable.", - "Opening a healing PR is GitHub-only and needs the build to carry version-control info (origin remote, branch) and the group to have a GitHub source-code integration connected; a GitHub Enterprise or non-standard host must pass an explicit owner/repo.", - "The human-in-the-loop verdict flow (recording whether a healed locator was accurate) is an optional feature per group. When it is off, reading verdicts returns an empty list while submitting a verdict is refused outright.", - "Only reviewers with a full product role can record a verdict; view-only members cannot.", - "A recorded verdict can carry over from an earlier build: a build shows verdicts first captured against a previous build, flagged as carryover, so the same healed element is not re-reviewed every build.", - "Self-heal summary numbers require the build to be within the account's plan quota; a build locked by quota is refused rather than returning zeroes.", - "The healing report itself (the list of individual healed selectors, with their original and healed values) is delivered as a downloadable report artefact, separate from these APIs; the summary here is only the headline counts." - ] + "smartTagSettings": { + "type": "object" }, - "build_comparison": { - "entity": "build_comparison", - "title": "Build comparison", - "description": "A side-by-side diff of two builds (a base and a target) showing which tests newly failed, started passing, were added or dropped.", - "aliases": [ - "compare builds", - "build diff", - "build vs build", - "compare two builds", - "buildComparison", - "comparison", - "base vs target" - ], - "id_convention": "Not a stored entity with its own id: a comparison is identified by the pair of build UUIDs passed as the `base` and `target` query params. Comparison is always done at the top-level parent build, so a re-run/child UUID should be normalised first via resolve_build_comparison_parent_uuids.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "base and target are both build UUIDs" - } - ], - "capabilities": [ - "check_build_comparison_available", - "get_build_comparison_aggregation", - "get_build_comparison_meta", - "list_build_comparison_bucket_tests", - "resolve_build_comparison_parent_uuids" - ], - "key_facts": [ - "Every comparison endpoint takes exactly two required query params, `base` and `target`, each a build UUID; an empty or missing one comes back as build-not-found.", - "Comparison is defined only between two top-level parent builds. Child builds, archived builds and plan-locked builds are rejected as unsupported, so normalise a pasted re-run UUID to its parent before comparing.", - "The two builds must come from compatible sources. An incompatible pair is not an error: the response comes back with source-fencing flags set and its actual comparison payload empty, so check for those flags before trusting empty counts.", - "Comparing is a paid feature: the aggregation, the per-bucket listing and the availability pre-check are all refused when the plan does not include build comparison. Resolving parent UUIDs and fetching the two build headers are NOT plan-gated and work regardless.", - "The side-by-side header counts are recomputed with the same de-duplication the comparison buckets use, so the header agrees with the buckets; this makes the header fetch heavier than a plain build summary and it silently falls back to raw counts if that recompute fails.", - "The whole flow is heavy: it resolves both builds' child builds and runs search + aggregation across them, with a large per-build ceiling on the test rows it will pull, so treat these as expensive reads.", - "A typical page flow is: normalise both UUIDs to parents, check the pair is comparable, fetch the two headers and the headline counts, then drill into one bucket's tests." - ] + "status": { + "type": "object", + "description": "Status aggregate over the result." }, - "comment": { - "entity": "comment", - "title": "Comment", - "description": "A rich-text note left on a build, test run or test, threaded and able to @mention group users, used for review discussion.", - "aliases": [ - "comments", - "note", - "discussion", - "thread", - "reply", - "feedback", - "annotation" - ], - "id_convention": "A comment id is a random UUID string generated on create and returned in the create response. A comment belongs to a thread identified by threadId (also a UUID; a new top-level comment starts its own thread). There is no list-by-id call: ids are discovered through the build/test comment reads.", - "parents": [ - "project", - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildUuid / buildSerialId" - }, - { - "entity": "test_run", - "via": "runUuid / testHash" - }, - { - "entity": "project", - "via": "scoped by project normalised name in the path" - } - ], - "capabilities": [ - "create_comment", - "delete_comment", - "list_build_comment_threads", - "list_comments", - "list_test_run_comments_via_public_api", - "update_comment" - ], - "key_facts": [ - "Comments are a paid feature: creating, editing and deleting are refused when the plan does not include comments.", - "A comment can only be edited or deleted by the person who wrote it; another user's comment reads as not-found on those operations even inside the same group.", - "Comments have no version history. Editing a comment overwrites its body in place with no way to recover the previous text.", - "Reads are bounded by the plan's data-retention window, so older comments silently fall out of results rather than erroring.", - "@mentioning a group user notifies them shortly after the comment is posted; the notification is a side effect that fires even though the create call has already returned.", - "Comment reads are keyed to a build's numeric serial id and look back over a fixed window of preceding builds (so recurring discussion on the same test carries across re-runs), not over all history.", - "The public /ext read is view-only: there is no external endpoint to post a comment, only the group-authenticated internal ones can create." - ] + "workflowStatus": { + "type": "object", + "description": "Workflow-status aggregate over the result." }, - "sp_error": { - "entity": "sp_error", - "title": "Unique error (Suite Health)", - "description": "A cluster of test failures sharing one error signature in a project, the unit of the Suite Health Unique Errors view.", - "aliases": [ - "unique error", - "unique errors", - "error cluster", - "error signature", - "suite health error", - "sp error", - "failure cluster", - "errorClusterId", - "clustered failure" - ], - "id_convention": "Numeric cluster id (errorClusterId), a plain integer discovered from the unique-errors list (each row's `id`, returned as a numeric string). Every detail view takes it in the path as errorId / clusterId.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "impacted tests hitting the error (breakdown / platforms)" - }, - { - "entity": "build", - "via": "builds in which the error occurred (details/builds)" - } - ], - "capabilities": [ - "get_unique_error_build_trend", - "get_unique_error_filters", - "get_unique_error_impacted_tests", - "get_unique_error_occurrence_count", - "get_unique_error_platform_breakdown", - "get_unique_error_summary", - "get_unique_error_timeline_charts", - "list_unique_error_builds", - "list_unique_errors", - "search_unique_error_filter_branches", - "search_unique_error_filter_build_names", - "search_unique_error_filter_build_tags", - "search_unique_error_filter_host_names", - "search_unique_error_filter_test_tags" - ], - "key_facts": [ - "A cluster id is a plain integer. Get it from the unique-errors list (each row's `id`, a numeric string); every per-error detail view takes that same value in its path.", - "Every Unique Errors call needs a date window of EXACTLY two epoch-millisecond bounds; omitting it or sending one bound is rejected. A single lowerBound/upperBound pair is the only fallback.", - "These views only ever consider FAILED test runs. Archived builds and usage-locked builds are always excluded, and manual (Test Management) runs are excluded from the list and per-error queries whatever you send.", - "On a demo tenant, or a real project that has no builds yet, the list answers with an empty set and a success status, not a not-found. Only a genuinely absent project is a 404.", - "Free / non-pro accounts are capped: the list returns only the single highest-occurrence error and is force-sorted by occurrence count descending, and per-error filtering is skipped.", - "Source feature-fencing can silently drop Automate, App-Automate or low-code tests from results; the list response carries automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped booleans that say when this happened.", - "The window is clamped to the account's data-retention range before querying, so a range wider than retention silently returns only the retained slice.", - "Every Unique Errors endpoint is read-only and accepts central-auth, obs token, service key, rails-basic, public-link and speedboat tokens; none of them accepts username + access-key auth.", - "Occurrence bar/chart data comes back as bare per-bucket counts with no timestamps on some views, so the time axis has to be reconstructed from the requested window; buckets are one calendar day." - ] + "folder_path": { + "type": "string" + } + } + } + }, + "HierarchyTestRunListResponse": { + "description": "Hierarchical (suite/file/test) test-run tree with cursor paging over top-level roots.", + "schema": { + "type": "object", + "properties": { + "hierarchy": { + "type": "array", + "items": { + "type": "object", + "description": "A suite/file/test node (nested children under the node)." + } }, - "test_rca": { - "entity": "test_rca", - "title": "Test RCA (AI root-cause analysis)", - "description": "The AI root-cause analysis stored for a single failed test run, with its generation state and thinking data.", - "aliases": [ - "rca", - "test rca", - "ai rca", - "root cause analysis", - "root-cause analysis", - "failure analysis", - "ai failure analysis", - "why did this test fail" - ], - "id_convention": "No id of its own in paths: an RCA is addressed by the integer test-run id it belongs to (one RCA per test run). The stored row does carry an integer id and a bulkRcaId (UUID) linking runs analysed together in one bulk request.", - "parents": [ - "test_run" - ], - "relations": [ - { - "entity": "test_run", - "via": "one RCA per test run, keyed by test run id" - } - ], - "capabilities": [ - "get_test_rca", - "get_test_rca_data", - "get_test_rca_via_public_api", - "save_test_rca", - "trigger_bulk_test_rca", - "trigger_test_rca_via_public_api" - ], - "key_facts": [ - "Generating an RCA is asynchronous: a request only queues the work, and the finished analysis arrives later. The immediate reply carries a state field, not the completed analysis; the terminal states are Completed (success) and the error states (LLM_SERVICE_ERROR, LOG_FETCH_ERROR, AI_SYSTEM_ERROR, UNKNOWN_ERROR, TIMEOUT).", - "Fetching an RCA is get-or-create by default: asking for one that does not exist yet creates the record and queues a generation, so the plain fetch is NOT a safe read. There is a separate pure-read that never has side effects, and a peek option on the public route.", - "Both fetch and trigger need the caller's own BrowserStack username and Selenium access key; these are resolved server-side from the caller's user record, and a caller whose record lacks them is refused before the AI pipeline runs.", - "The feature is gated three ways: the group must have accepted the AI opt-in consent, the group's plan/AI token must carry the RCA feature, and the build's source must not be fenced off \u2014 any of these missing turns every RCA call into a refusal rather than a result.", - "There is no version history for an RCA: a fresh generation overwrites the stored analysis for that test run, and the previous one is gone.", - "The completed analysis body is opaque model output whose internal shape can change; treat it as display content rather than parsing fixed fields out of it.", - "The analysis is written back by the AI service itself through an ingestion callback; a user-facing caller normally only reads it or triggers it, and the reading side reflects whatever the service last wrote." - ] + "pagingParams": { + "type": "object", + "description": "Cursor state (hasNext, nextRoot, searchAfter); paging is by top-level roots (5 per page, 1 for builds over 2000 runs)." }, - "build_group": { - "entity": "build_group", - "title": "Build group (run group)", - "description": "A named grouping of builds within a project, used to collect the builds of one release or test cycle.", - "aliases": [ - "run group", - "run-group", - "build group", - "test run group", - "trg", - "release group", - "group of builds", - "build set" - ], - "id_convention": "Plain integer build-group id, discovered from the group listing. Note: the project id in these paths is the TestHub project id, not the observability project id.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "builds are linked into a group (a build belongs to at most one group)" - } - ], - "capabilities": [ - "apply_build_bulk_action", - "close_build_group", - "create_build_group", - "delete_build_group", - "list_build_groups", - "update_build_group" - ], - "key_facts": [ - "Groups live in TestHub; this service is a pass-through that mirrors what TestHub returns, so writes here take effect in TestHub and are not undoable from this API.", - "A group has a name (required, up to 255 chars) and an optional description (up to 1000 chars); the name 'Ungrouped' is reserved and rejected.", - "A group's state is derived: it reads ACTIVE until it is closed, then CLOSED. Closing freezes the group's builds and test runs (they become non-editable / locked) but deletes no data.", - "Deleting a group removes the group and its build associations but never the underlying builds; the builds are detached asynchronously afterwards, so they may still show the group for a short window.", - "Bulk actions across many builds and the group delete both flip to asynchronous background work above a size threshold, returning an acknowledgement rather than a finished result \u2014 re-read to confirm the outcome.", - "Only one bulk action can run per project at a time: while one is in flight a second is refused, and the caller should retry rather than treat it as bad input.", - "The permission to add builds to a run group governs creating, editing, deleting and closing a group; the bulk-action operations are gated per action instead (linking needs that same run-group permission, while assigning a test plan or an assignee needs the automated-run edit permission)." - ] + "status": { + "type": "object", + "description": "Status aggregate." }, - "accessibility": { - "entity": "accessibility", - "title": "Accessibility (a11y) data", - "description": "Accessibility scan results for a build's and a test run's tests, plus the group's a11y plan and free-trial entitlement.", - "aliases": [ - "a11y", - "accessibility testing", - "a11y testing", - "accessibility scan", - "accessibility results", - "ally", - "a11y report" - ], - "id_convention": "No id of its own: build-level a11y is addressed by the build UUID; test-run-level a11y is addressed by the TestHub build-test-case-execution-result (btcer) id, a plain integer. Plan/trial reads take no id and use the session's group/user.", - "parents": [ - "build", - "test_run" - ], - "relations": [ - { - "entity": "build", - "via": "a11y test-run listing scoped by build UUID" - }, - { - "entity": "test_run", - "via": "per-test a11y detail by btcer id" - } - ], - "key_facts": [ - "For groups on the App-Accessibility feature, an accessibility read whose build/test source is the App-Accessibility automation source is proxied to a separate Accessibility service and comes back in that service's shape, not the shape assembled here \u2014 the same route can therefore return two different bodies depending on the group flag and the source.", - "Several a11y reads are thin pass-throughs to a separate Accessibility service; on an upstream parse failure they return a plain text error body with a 500 status instead of the usual structured error envelope.", - "Severity of accessibility issues is always broken down into the four buckets critical, serious, moderate and minor.", - "Starting the accessibility free trial is a state change on the Accessibility product's entitlement even though it reads like a details fetch; checking trial status is a plain read.", - "The build must have finished (or timed out) and actually carry accessibility data, otherwise the a11y test-run listing returns a typed 4xx rather than an empty list.", - "Tests with no accessibility summary are silently dropped from the build's a11y listing, so its rows can be fewer than the build's total tests." - ], - "capabilities": [ - "get_a11y_plan_details", - "get_accessibility_free_trial_status", - "get_test_run_accessibility_meta", - "list_build_accessibility_test_runs", - "start_accessibility_free_trial" - ] + "workflowStatus": { + "type": "object", + "description": "Workflow-status aggregate." + }, + "isArchived": { + "type": "boolean" + }, + "smartTagSettings": { + "type": "object" + }, + "source": { + "type": "string" + }, + "folder_path": { + "type": "string" } - }, - "version": "1.7" - } + } + } + }, + "TestRunCountListResponse": { + "description": "Per-unique-test rerun counts for a build.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunCountEntry" + } + } + } + } + }, + "BuildTopErrorStatsResponse": { + "description": "Top-unique-errors insight panel; may be not-ready (ready:false + message) while analysis/clustering runs.", + "schema": { + "$schema": "BuildTopErrorStatsPanel" + } + }, + "BuildQualityGateRecalcAckResponse": { + "description": "Async acknowledgement; poll the quality-gate summary for the outcome.", + "schema": { + "$schema": "BuildQualityGateRecalcAck" + } + }, + "ResolvedBuildUuidResponse": { + "description": "Resolved build uuid, or {uuid:null} with HTTP 200 when nothing matched.", + "schema": { + "$schema": "ResolvedBuildUuid" + } + }, + "SidebarBuildRunsResponse": { + "description": "A page of unique build runs for the sidebar rail with cursor paging params.", + "schema": { + "$schema": "SidebarBuildRuns" + } + }, + "BuildReportPollingResponse": { + "description": "Build-report polling envelope; IN_PROGRESS carries retryCount + pollingInterval. May be a NULL body when the build cannot be resolved. Demo-sandbox returns 404 'NOT FOUND'.", + "schema": { + "$schema": "BuildReportPolling" + } + }, + "PublicBuildDetailsResponse": { + "description": "Full public build record. Shape is feature-flagged (legacy vs V2) and pro-only fields are JsonView-gated by plan.", + "schema": { + "$schema": "PublicBuildDetails" + } + }, + "BuildHistoryWithDefectCountsResponse": { + "description": "Anchor build + up to 10 prior same-name runs, each with status and defect count summaries.", + "schema": { + "$schema": "BuildHistoryWithDefectCounts" + } + } + }, + "paging": { + "GET /api/v1/projects/{projectName}/settings/quality-gates/filters": { + "page": "afterKey", + "max": 1000 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/stability": { + "page": "pageNumber", + "max": 8 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/performance": { + "page": "pageNumber", + "max": 8 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns": { + "page": "pageNumber", + "max": 5 + }, + "GET /api/v1/settings/webhooks/projects": { + "page": "page", + "max": 30 + }, + "GET /api/v1/dashboards/filters/{widgetType}": { + "page": "page" + }, + "GET /api/v1/reports/list": { + "size": "size", + "max": 100 + }, + "GET /ext/v1/projects": { + "max": 10 + }, + "GET /api/v1/home/uniqueBuilds": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{normalisedName}/build-groups-listing": { + "page": "searchAfter" + }, + "GET /ext/v1/builds/{build_id}/testRuns": {}, + "GET /ext/v1/builds/{build_id}/testRuns/withAttachments": { + "max": 100 + }, + "GET /api/v1/builds/{buildUuid}/testRuns/flat": { + "page": "searchAfter", + "max": 10000 + }, + "GET /api/v1/ai/failures/{buildUUID}/flat": { + "page": "searchAfter", + "size": "limit" + }, + "GET /ext/v1/ai/failures/{buildUUID}/flat": { + "page": "searchAfter", + "size": "limit" + }, + "POST /api/v1/dashboards/views/widgets/filters": { + "page": "page" + }, + "POST /api/v1/reports/{id}/datasets": { + "page": "page" + }, + "GET /api/v1/reports/custom-fields": { + "page": "page", + "max": 300 + }, + "GET /api/v1/reports/{projectNormalisedName}/builds": { + "page": "page" + }, + "GET /api/v1/reports/view/{globalTestHash}/details/testsLists": { + "page": "page" + }, + "POST /api/v1/reports/view/{id}/search": { + "page": "page" + }, + "POST /api/v1/dashboards/widgets/filters": { + "page": "page" + }, + "POST /api/v1/search": { + "max": 30 + }, + "GET /api/v1/projects/{projectName}/snp/v2/errors": { + "page": "pageNumber", + "max": 15 + }, + "GET /api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds": { + "page": "searchAfter", + "max": 15 + }, + "GET /api/v1/projects/{projectId}/build-groups": { + "page": "page", + "size": "pageSize" + }, + "GET /api/v1/builds/v2/{buildId}/a11y-testRuns": { + "page": "page", + "size": "limit" + }, + "GET /api/v1/testRuns/{btcerId}/a11y-testMeta": { + "page": "page" + }, + "GET /api/v1/builds/{uuid}/getSplitBuildDetails": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/folder": { + "size": "size" + }, + "GET /ext/v1/projects/{project_id}/builds": { + "max": 10 + }, + "GET /api/v1/projects/{normalisedName}/builds/v2": { + "page": "searchAfter" + }, + "GET /api/v1/projects/{projectName}/settings/webhooks/filters": { + "page": "afterKey", + "max": 1000 + }, + "GET /api/v1/settings/webhooks/filters": { + "page": "afterKey", + "max": 1000 + }, + "POST /api/v2/projects/lite": { + "max": 10 + }, + "GET /api/v2/projects/lite/v2": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v2/projects/v2": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v1/projects/{projectName}/snp/v2/tests": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{projectName}/snp/v3/tests": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds": { + "max": 20 + }, + "GET /api/v1/testRuns/{testRunId}/testCases": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v1/builds/{buildId}/testRunLogs": { + "max": 15 + }, + "GET /api/v1/builds/v2/{buildId}/flatTestRuns": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/testRuns": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/testRuns/hierarchy": { + "page": "searchAfter" + }, + "POST /api/v2/builds": {} + }, + "entities": { + "notification": { + "entity": "notification", + "title": "Notification (and notifier)", + "description": "A project's alerting config: recipients (email/Slack notifiers) and their daily-summary, build-insight and alert subscriptions.", + "aliases": [ + "notification", + "notifications", + "notifier", + "notification settings", + "notification recipient", + "recipient", + "subscription", + "email notification", + "slack notification", + "daily summary", + "daily summary email", + "build insight notification", + "alert subscription", + "notification preferences" + ], + "id_convention": "A notifier id is a plain integer, discovered from the project's recipient list (there is no prefixed identifier). The per-alert and per-build-name mapping ids used when toggling a single subscription are also plain integers, found by reading one notifier's configuration first.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "scoped to one project" + }, + { + "entity": "alert", + "via": "a notifier subscribes to a project's alerts by alert id" + }, + { + "entity": "build", + "via": "build-insight subscriptions are keyed by build name" + } + ], + "capabilities": [ + "add_notification_recipients", + "delete_notification_subscriptions", + "delete_slack_notifiers", + "get_build_name_status_aggregation", + "get_daily_summary_report", + "get_my_email_notification_subscription", + "get_notifier_notification_config", + "get_project_email_notification_preferences", + "list_build_summaries_by_ids", + "list_home_notification_feed", + "list_latest_build_summaries", + "list_project_alerts_for_subscription", + "list_project_notification_recipients", + "resolve_notification_recipient_emails", + "send_slack_test_notification", + "set_user_email_notification_preferences", + "toggle_notification_subscriptions", + "toggle_notifier_channel_status", + "update_notifier_subscriptions", + "update_slack_notifier_channels" + ], + "key_facts": [ + "Every project has two independent notifier channels, email and Slack, and almost every read and write acts on ONE channel that you must name; the same project can have a completely different set of email recipients and Slack recipients.", + "Reading the recipient list or a single recipient's configuration needs only project read access; every change to recipients or subscriptions needs the project's notification-settings permission, and a change to a recipient owned by another group is refused.", + "Editing one recipient's subscriptions is a FULL REPLACE, not a patch: subscriptions you omit are removed, and there is no version history, so a prior configuration cannot be restored once overwritten.", + "Bulk enabling or disabling of subscriptions is applied one entry at a time with no overall transaction, so a single bad entry aborts partway and leaves the earlier entries already changed.", + "A Test Management caller addresses a project by its Test Management project id, which is translated to the underlying observability project id; an id with no mapping reads as project-not-found rather than empty.", + "In a recipient-list summary, a subscription count of -1 is a sentinel meaning the recipient is subscribed to all builds or all alerts, not a real count.", + "The daily-summary email content builders select builds themselves (the latest few in a time window) unless you hand them explicit build ids; the fully enriched daily report also merges several aggregations plus best-effort AI summaries that are silently dropped when they fail, so those fields can be absent.", + "Two of the notification settings operations address the project by its normalised NAME instead of its numeric id.", + "Validating typed-in recipient email addresses is not a side-effect-free lookup: for each address that resolves to an in-group user it also provisions that user's email-integration records if they do not exist yet.", + "The home notification feed is capped to roughly the first fifty entries however deep you page, and when the feature is switched off for the group it returns an empty payload rather than an error.", + "Sending a test Slack notification posts a fixed sample message to the channel and stores nothing; only the daily-summary, build-insight and alert sample types are supported and it is suppressed in the demo-sandbox environment." + ] + }, + "public_link": { + "entity": "public_link", + "title": "Public link & status badge", + "description": "Anonymous share credentials: public-link tokens for dashboards and project report pages, plus signed keys for build-status badges.", + "aliases": [ + "public link", + "share link", + "shareable link", + "anyone with the link", + "public token", + "share token", + "public sharing", + "status badge", + "build badge", + "badge", + "readme badge", + "shields badge", + "badge key" + ], + "id_convention": "A public-link token is an opaque server-generated string, discovered only by calling the get-token operation for a dashboard or project (there is no list). A status-badge key is a signed JWT minted on demand. Dashboards are addressed by UUID; projects by their TestHub project id.", + "parents": [ + "project", + "dashboard" + ], + "relations": [ + { + "entity": "dashboard", + "via": "one token per dashboard (page type CUSTOM_DASHBOARD)" + }, + { + "entity": "project", + "via": "one token per (project, page type)" + } + ], + "capabilities": [ + "get_dashboard_public_link_setting", + "get_dashboard_public_link_token", + "get_project_public_link_setting", + "get_project_public_link_token", + "get_status_badge_key", + "update_dashboard_public_link_setting", + "update_project_public_link_setting", + "validate_public_link_token" + ], + "key_facts": [ + "Two kinds of share credential live here: a public-link token that unlocks a dashboard or a project report page for anyone with the URL, and a status-badge key (a signed JWT) that exposes one build's high-level status for embedding.", + "Asking for a token is itself what starts sharing: the fetch creates the token on first request and leaves it enabled. There is no separate create step, so a plain read has a side effect the first time.", + "The public-link setting defaults to OFF. A dashboard or project whose setting has never been touched reads as not shared rather than as an error.", + "Turning sharing off immediately breaks every share URL already handed out and clears the token's cached validation, so revocation takes effect at once. Turning it back on reactivates the SAME token, so it is reversible but disruptive; there is no separate undo.", + "A project token is scoped to a single report page type (build insights, test runs, test health, unique errors or testing trends). Custom dashboards are a distinct page type reached only through the dashboard token operation.", + "A status-badge key is a bearer credential: once minted it cannot be revoked or listed, it exposes that build's status to anyone holding it, and it is refused for free-plan users.", + "Projects are named here by their TestHub project id, which is not the same as the observability project id." + ] + }, + "quality_gate": { + "entity": "quality_gate", + "title": "Quality gate (and quality profile)", + "description": "A pass/fail policy on a build, made of quality profiles: named rule sets (thresholds on status, flakiness, failure categories, etc.) scop...", + "aliases": [ + "quality gate", + "quality gates", + "QG", + "gate", + "quality profile", + "quality profiles", + "qg profile", + "quality gate profile", + "gate profile", + "quality gate rule set", + "build quality gate" + ], + "id_convention": "A quality profile is addressed by its UUID (path `profile_id`/`profileUuid`); a project by its normalised name; a build by its UUID. There are no integer ids on these routes. The one built-in default profile has a fixed UUID and behaves specially.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "profiles are mapped to a project; project attributes hold the feature enabled/override switches" + }, + { + "entity": "build", + "via": "the gate is evaluated per build; results are read from the parent build for a split build's child" + } + ], + "capabilities": [ + "create_quality_profile", + "create_quality_profile_via_public_api", + "delete_quality_profile", + "delete_quality_profile_via_public_api", + "get_build_quality_gate_result", + "get_build_quality_gate_summary", + "get_build_quality_profile_result", + "get_project_quality_gate_settings", + "get_project_quality_gate_settings_via_public_api", + "get_quality_profile", + "get_quality_profile_via_public_api", + "list_build_quality_profile_results", + "list_quality_profile_filter_values", + "toggle_project_quality_gate", + "toggle_project_quality_gate_via_public_api", + "toggle_quality_profile", + "toggle_quality_profile_via_public_api", + "update_quality_profile", + "update_quality_profile_via_public_api" + ], + "key_facts": [ + "Editing a quality profile does NOT change it in place: it writes a new version and mints a NEW UUID, which the response returns. The UUID you edited stops resolving afterwards, so always carry the returned UUID forward. (The source inventory's claim that the UUID is stable is wrong.)", + "Deleting a quality profile is not reversible through the API. The profile is retired and its project mapping removed; there is no restore call, so treat it as permanent.", + "The one built-in default profile can be neither edited nor deleted (attempts are rejected). Enabling or disabling a default profile applies only to the one project, whereas enabling or disabling a custom profile applies across every project it is attached to.", + "There are two separate switches: one turns Quality Gates on or off for a whole project (and sets whether a failed gate also marks the build failed), the other enables or disables a single profile. They are different operations even though they share a name internally.", + "On the create and edit body the two fields go on the wire as `isGlobalProfile` and `applicableBuilds`; the internal names `isRootProfile` and `buildFilters` are not accepted and, if sent, are dropped and then fail the required-field check.", + "A profile must carry between 1 and 20 rules, and every rule must include a client-generated UUID `id`; a rule with an out-of-range test status, threshold, smart tag or alert level is rejected.", + "Quality gate evaluation runs asynchronously after a build finishes, so a build's gate result reads back as 'running'/pending until it completes and a CI check must poll rather than assume a single call is final.", + "A profile's ruleStatus ('pass' vs 'fail') decides how the per-profile outcome is computed: 'pass' means every rule must hold, 'fail' means none may hold.", + "Quality Gates is a plan-gated feature: when the plan lacks it, profile reads and writes are refused as feature-inaccessible, which is a plan gap and not a missing profile. The project-level on/off switch and the build-page result reads skip that plan check.", + "For a split build's child, gate results are read from the parent build, so a child on its own can look like it has no results.", + "Failure-category rules are enriched with the project's human display names only on the single-profile detail reads, not on the list or settings reads.", + "A profile can only be deleted from the group that owns it; deleting one that belongs to another group is refused." + ] + }, + "testing_trends": { + "entity": "testing_trends", + "title": "Testing Trends", + "description": "Aggregated quality-trend cards for a project - flakiness, stability, performance, failures and coverage over a time window.", + "aliases": [ + "testing trends", + "trends", + "test trends", + "quality trends", + "trend widgets", + "trend cards", + "insights dashboard", + "flakiness", + "stability", + "cbt coverage", + "test growth", + "build frequency" + ], + "id_convention": "Not addressed by its own id. Scoped by the project's normalised name in the path and narrowed by build name. Chart and leaderboard rows are addressed by build name as the identifier, or by the literal \"all\" for the project-wide series.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "buildName filter / row identifier" + } + ], + "capabilities": [ + "get_always_failing_tests_trend", + "get_build_frequency", + "get_build_performance_chart", + "get_build_stability_chart", + "get_cross_browser_coverage", + "get_failure_categories_trend", + "get_flakiness_trend", + "get_new_failures_trend", + "get_project_performance_chart", + "get_project_stability_chart", + "get_test_executions_trend", + "get_test_growth_trend", + "list_build_names", + "list_performance_leaderboard", + "list_recent_runs", + "list_stability_leaderboard" + ], + "key_facts": [ + "Every card is scoped to one project and a time window given as two epoch-millisecond bounds. When a bound is omitted it defaults to the plan's data-retention window, so a call with no window still returns the retention window's data.", + "The window is silently clamped to the plan's data retention: asking for data older than the plan retains returns retention-bounded results with no error and nothing in the body to say the window was narrowed.", + "Time-bucket granularity (hour / day / week) is chosen automatically from the width of the window, so the series interval changes as the window widens; the caller cannot set it.", + "The flakiness, new-failures and always-failing cards are plan-feature-gated: on a plan without that widget the call is refused with a 403, it does not return empty data.", + "Build frequency is the one card that requires both window bounds and does NOT clamp them to retention; its counts come from the relational build store rather than the analytics index, so they can differ slightly from the other cards.", + "Opening the recent-runs picker records the project as subscribed for the calling user - a persisted user preference, so that read has a lasting side effect.", + "Any card can be narrowed to a single build by its build name; the leaderboards and charts address a specific build by that name, or use the literal \"all\" for the project-wide series.", + "Leaderboards return a fixed number of builds per page and expose only a zero-based page number, not a page size; a hasNext flag in the response tells you whether to fetch the next page.", + "The always-failing card also records the trends view in the calling user's recently-viewed list for non-superusers, a minor convenience side effect that does not change any test data.", + "Test-growth counts distinct test cases (suite size) over the window, not test executions; test-executions counts how many tests ran. They answer different questions despite looking similar." + ] + }, + "webhook": { + "entity": "webhook", + "title": "Webhook rule", + "description": "A stored rule that fires an outbound HTTP request to a target URL when a matching build/test event occurs.", + "aliases": [ + "webhook", + "webhooks", + "webhook rule", + "webhook rules", + "hook", + "callback", + "notification", + "notifications", + "webhook notification", + "event webhook", + "outbound notification" + ], + "id_convention": "Addressed by the rule's UUID; there is no prefixed display id. Discover the UUID from a list call \u2014 it is not the integer database id.", + "parents": [ + "project", + "group" + ], + "relations": [ + { + "entity": "project", + "via": "project mapping (a group rule links up to 30 projects; a project rule is scoped to one)" + } + ], + "capabilities": [ + "create_group_webhook", + "create_project_webhook", + "delete_group_webhook", + "delete_project_webhook", + "get_group_webhook", + "get_project_webhook", + "list_group_webhook_projects", + "list_group_webhooks", + "list_project_webhooks", + "test_group_webhook", + "test_project_webhook", + "toggle_group_webhook", + "toggle_project_webhook", + "update_group_webhook", + "update_project_webhook" + ], + "key_facts": [ + "A webhook rule is addressed by its UUID, which you read from a list call; there is no prefixed display id and it is not the integer database id.", + "There are two parallel surfaces: project-scoped rules and group-scoped rules that can span many projects. A rule created on one surface is managed on that surface, and the two lists do not overlap.", + "Listing or reading webhook rules needs no special permission, but creating, editing, toggling, deleting or testing one requires the webhook settings permission \u2014 so an account can see rules it is not allowed to change.", + "Editing a rule replaces its definition in place and keeps no history: an overwritten URL, headers, auth or trigger conditions cannot be recovered. There is no undo short of re-entering the previous values.", + "Deleting a rule is permanent and also removes its project mappings; disable the rule instead if the intent is only to pause it.", + "Every create, edit, toggle, delete and test is refused in the demo-sandbox environment.", + "Testing a rule sends a real outbound HTTP request to the target URL (a server-side call whose side effects land on the target system), and it always reports success even when the target failed \u2014 read the isError flag in the body, never the HTTP status, to know if it worked.", + "A list of rules returns only each rule's summary (name, target URL, event type, enabled flag); the headers, request body and stored auth are returned only when you fetch a single rule by id.", + "A group rule links at most 30 projects. The 'all projects' scope is available only to Test Management callers; other callers are refused if they try to enter it.", + "Newly created project-scoped rules are always enabled regardless of the enabled flag sent; use the toggle operation to disable one afterwards.", + "A rule's target auth can be none, basic (username/password) or bearer token, and the request method is limited to POST or PUT." + ] + }, + "ai_insight": { + "entity": "ai_insight", + "title": "AI insight (RCA & code-fix)", + "description": "AI-generated root-cause analysis for a failed test run or build, plus the code-fix guidance and session tokens that drive it.", + "aliases": [ + "rca", + "root cause analysis", + "ai rca", + "ai insight", + "ai analysis", + "test failure analysis", + "tfa", + "ai code fix", + "code fix preference", + "failure analysis", + "ai token" + ], + "id_convention": "No single id. RCA is addressed by the test-run integer id or the build uuid; code-fix preferences by a plain integer id discovered through the get-preference call; RCA conversation turns by a UUID message id.", + "parents": [ + "test_run", + "build", + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "per-test-run RCA and improve-analysis" + }, + { + "entity": "build", + "via": "build-level RCA and RCA chat" + }, + { + "entity": "project", + "via": "code-fix preferences per build name" + } + ], + "capabilities": [ + "create_code_fix_preference", + "delete_code_fix_preference", + "get_ai_orchestration_capabilities", + "get_ai_session_token", + "get_code_fix_preference", + "get_rca_context_attachment_download_url", + "get_release_readiness_connect_state", + "get_release_readiness_prs", + "get_release_readiness_summary", + "get_release_readiness_workflows", + "presign_build_chat_upload", + "presign_code_fix_preference_upload", + "presign_rca_context_upload", + "retrigger_rca_analysis", + "retrigger_rca_analysis_via_public_api", + "submit_ai_workflow_callback", + "trigger_release_readiness", + "trigger_release_readiness_via_public_api", + "update_code_fix_preference" + ], + "key_facts": [ + "AI features are plan-gated: on a group without the AI test-failure-analysis plan the code-fix and RCA-context calls are refused with 403 PLAN_FEATURE_NOT_ALLOWED, and the session-token call returns empty token and feature list rather than erroring.", + "Some AI actions also require the calling user to have AI enabled individually, separate from the group plan; the per-user check applies to saving and presigning code-fix guidance but not to deleting it.", + "Improving (retriggering) an RCA is asynchronous: the call returns a message id and the refreshed analysis is delivered later, so poll the RCA conversation for the result rather than expecting it in the response.", + "Attaching a file is always a two-step flow: first mint an upload credential, upload the file straight to S3 with it, then quote the returned key when saving a preference or retriggering. The credential step persists nothing on its own.", + "Uploaded files land in an unconfirmed area and are kept only when a later save or turn submission references their key; an upload that is never referenced is discarded, and there are caps on how many uploads can be pending.", + "Only agentic RCAs accept extra context uploads; a non-agentic analysis rejects them.", + "RCA context uploads are refused for test runs older than an allowed age.", + "Code-fix guidance is keyed by build name within a project, one preference per build name; creating a second for the same build name is refused as already existing, so change it with an update instead.", + "Deleting code-fix guidance also removes its uploaded files and cannot be undone; the RCA analyses themselves keep no version history." + ] + }, + "build": { + "entity": "build", + "title": "Build", + "description": "A single CI/automation run reported to Test Observability, grouping the test runs executed together with their aggregate results.", + "aliases": [ + "build", + "ci build", + "ci run", + "automation build", + "test build", + "build run", + "launch", + "ci run id", + "build run id" + ], + "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier. A build ALSO carries a CI run id (`buildRunId`, a string set by the CI/upload client), which is NOT the serial number (#N): users often say \"run \" meaning buildRunId. Resolve a bare run number with search_entities (entity=\"Build\", days<=90): each hit carries both `buildRunId` and `serialId` plus the project; get_build_id_from_build_coordinates only understands the serial.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "a build contains the test runs executed in it" + }, + { + "entity": "project", + "via": "each build belongs to one project" + } + ], + "capabilities": [ + "archive_project_builds", + "bulk_close_builds", + "close_build", + "delete_build_via_public_api", + "delete_project_builds", + "generate_build_report_via_public_api", + "get_build_accessibility_meta", + "get_build_ai_failure_summary", + "get_build_alerts", + "get_build_always_failing_stats", + "get_build_attempt_run_stats", + "get_build_authoring_report_url", + "get_build_coordinates_from_uuid", + "get_build_details", + "get_build_details_via_public_api", + "get_build_failure_category_stats", + "get_build_failures_by_module", + "get_build_filter_values", + "get_build_flaky_stats", + "get_build_history", + "get_build_history_via_public_api", + "get_build_id_from_build_coordinates", + "get_build_insights", + "get_build_muted_stats", + "get_build_new_failure_stats", + "get_build_nl_automation_agent_report_url", + "get_build_non_sdk_test_counts", + "get_build_notification_summary", + "get_build_optimization_data", + "get_build_rerun_stats", + "get_build_self_healing_report_url", + "get_build_self_healing_report_url_via_public_api", + "get_build_smart_test_selection_report", + "get_build_stability_tile", + "get_build_summary_stats", + "get_build_timeline_stats", + "get_build_top_error_stats", + "get_latest_build", + "get_split_build_rerun_stats", + "list_build_test_folders", + "list_project_builds", + "list_project_builds_via_public_api", + "list_running_builds_on_device", + "list_sidebar_build_runs", + "list_similar_test_runs", + "list_split_build_legs", + "mark_build_rerun_expected", + "recalculate_build_quality_gate", + "resolve_build_uuid_from_identifiers", + "stop_build", + "submit_sdk_instrumentation", + "trigger_build_rerun", + "update_build_metadata", + "update_build_metadata_via_public_api" + ], + "key_facts": [ + "Most build endpoints are keyed by the build UUID, a string. A few instead take the internal numeric build id (the AI failure summary is one), and passing a UUID where a numeric id is expected simply fails to find the build.", + "A build locked by plan fencing is refused on most insight reads with a plan-feature-not-allowed error even though the build exists; treat that as an entitlement gap, not a missing build.", + "An unknown build UUID comes back as a build-not-found error on most reads.", + "Cross-group access is blocked: a build whose owning group does not match the caller reads as not-found on most routes (and as a forbidden error on the coordinate lookups).", + "A build can be reached by its human coordinates from the dashboard URL (project name + build name + build serial number), which resolve to the UUID; the reverse lookup rebuilds those coordinates from a UUID.", + "Closing a build cannot be undone through the API \u2014 there is no reopen operation \u2014 so treat a close as final.", + "Many insight tiles and history reads accept the same standard build filter set (search text, status, folders, tags, browser/OS, priority, owner, flaky/new-failure/always-failing toggles, and so on); the filter narrows which of the build's tests the metric is computed over.", + "On demo-sandbox environments some report reads (the authoring report, the optimisation widget) return canned data instead of touching the real backend.", + "Re-run statistics come in two shapes: a flat legacy summary and a split-build-aware grouped summary; the grouped one is the one to use for split or parent/child builds." + ] + }, + "build_filter": { + "entity": "build_filter", + "title": "Build filter", + "description": "Filter-option sources for the build listing: the static option catalogue plus per-facet type-ahead value lists.", + "aliases": [ + "build filter", + "build filters", + "build list filter", + "build listing filter", + "filter options", + "filter values", + "filter bar", + "sidebar filter", + "facet", + "filter dropdown", + "branch filter", + "build name filter", + "run group filter" + ], + "id_convention": "Not an addressable record \u2014 there is no filter id. A filter view is fetched by scope: one project by its normalised name (a URL-safe slug, discovered from a project read/list), or a set of integer project ids sent in a POST filter body.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "option values are aggregated from the builds the filter scope resolves to" + }, + { + "entity": "project", + "via": "scoped by project normalised name or project id set" + } + ], + "capabilities": [ + "get_build_list_filters", + "get_build_sidebar_filters", + "get_build_test_filters", + "get_home_personalization_filters", + "list_build_branch_filter_options", + "list_build_tag_filter_options", + "list_build_team_filter_options", + "list_build_user_filter_options", + "list_group_webhook_filter_options", + "list_home_filter_options", + "list_project_webhook_filter_options", + "list_sidebar_branch_filter_options", + "list_sidebar_build_name_filter_options", + "list_sidebar_run_group_filter_options", + "list_sidebar_tag_filter_options", + "list_sidebar_team_filter_options", + "list_sidebar_test_case_status_filter_options", + "list_sidebar_user_filter_options", + "save_home_personalization_filters" + ], + "key_facts": [ + "Filters are not stored objects you address by id. You fetch either a filter catalogue or a single facet's option list, scoped to one project (by its normalised-name slug) or to a set of project ids passed in a filter body.", + "Two families exist. A per-project set is scoped by the project's normalised name and searched with a simple prefix. A cross-project 'sidebar' set takes a full build filter body and returns only the option values that actually occur under the currently applied selection across the chosen projects.", + "The per-project facet lists return option objects that carry separate id, value and label (id is what you send back as the filter selection, label is for display). The cross-project sidebar facet lists return bare values instead (plain strings for branches, build names and tags).", + "Facet option values come from an Elasticsearch terms aggregation capped at 100 entries, so a facet list is a type-ahead suggestion set, not a guaranteed-complete inventory of every value in the project.", + "A filter view echoes the caller's currently applied selection back in a resolved 'applied' block (user and team ids turned into names, date range, tags, branches, statuses) alongside a static catalogue of all selectable option lists.", + "On a freemium workspace some filters are locked behind a paid plan; the build test-filter payload names those locked keys in a separate list rather than dropping them, so a caller can grey them out instead of losing them.", + "For a split build, the test-filter payload is resolved across the parent build and all of its child legs together, not just the single leg addressed.", + "The framework filter carries raw values: branded SDK names and pass-through non-SDK values such as 'appium' or 'selenium-4'. Matching is case-insensitive, so send the value exactly as the static catalogue offers it." + ] + }, + "dashboard": { + "entity": "dashboard", + "title": "Custom dashboard", + "description": "A saved, shareable analytics page of widgets and global filters over test-observability and test-management data.", + "aliases": [ + "dashboard", + "dashboards", + "custom dashboard", + "custom dashboards", + "analytics dashboard", + "TO dashboard", + "TCM dashboard", + "report", + "reports", + "dashboard listing", + "reports listing" + ], + "id_convention": "UUID (e.g. 07c9f7be-0cd7-4669-8d3d-a1adff0b2aec); discover ids from the dashboard listing \u2014 a dashboard name is never accepted in place of the id.", + "parents": [], + "relations": [ + { + "entity": "widget", + "via": "widgets" + } + ], + "capabilities": [ + "clone_dashboard", + "clone_dashboard_via_public_api", + "create_dashboard", + "create_dashboard_via_public_api", + "delete_dashboard", + "delete_dashboard_via_public_api", + "get_dashboard", + "get_dashboard_via_public_api", + "list_dashboards", + "list_dashboards_via_public_api", + "list_reports", + "list_widget_custom_field_filters", + "resolve_dashboard_filter_names", + "search_unique_errors", + "update_dashboard", + "update_dashboard_via_public_api" + ], + "key_facts": [ + "A dashboard is addressed by its UUID id; get the id from the dashboard listing first \u2014 a dashboard name cannot be used as the identifier.", + "The dashboard listing and the internal dashboard reads require the Custom Dashboard feature entitlement; without it the listing is refused as feature-inaccessible rather than returning an empty list.", + "Test Management (TCM) dashboards are hidden from the default listing, which is scoped to Test Observability (source TO); ask for source TCM to see them (or the group must have GCD enabled).", + "Dashboards keep no version history: an overwritten name, visibility level, widget layout or filter set cannot be restored, and a deleted dashboard is gone permanently along with its widget layout.", + "A dashboard's visibility level is 1 or 2 and decides whether only the owner or the whole group can see it.", + "Scheduled reports are stored as dashboards but are excluded from the ordinary dashboard listing; they have their own reports listing, which spans both TO and TCM.", + "On the public-API write surface a treemap widget's visualization override is silently dropped (not rejected) and widget configs are validated for render-safety up front; the internal write surface does neither.", + "In demo-sandbox environments the seeded default dashboard cannot be updated or deleted \u2014 those calls are rejected with a 400 whose body shape differs from a normal success." + ] + }, + "project": { + "entity": "project", + "title": "Project", + "description": "A Test Observability container mirroring a TestHub project; groups the builds, runs and dashboards for one app or repo.", + "aliases": [ + "project", + "projects", + "TO project", + "observability project", + "testhub project", + "th project", + "app project" + ], + "id_convention": "Plain integer project id on most paths; many read/listing paths instead take the project's normalisedName (a URL-safe slug of the project name). A project also carries a separate TestHub project id (thProjectId) used for cross-product linkage. Ids and names are discovered through a project listing.", + "parents": [], + "relations": [ + { + "entity": "test_plan", + "via": "project groups its test plans" + }, + { + "entity": "build", + "via": "project contains its builds" + } + ], + "capabilities": [ + "assign_issue_types_to_test_runs", + "claim_extended_free_trial", + "create_project", + "delete_project", + "get_default_project", + "get_home_takeaways", + "get_home_takeaways_with_noise_breakdown", + "get_project_automation_coverage_via_public_api", + "get_project_by_id_or_name", + "get_project_non_sdk_build_count", + "get_project_non_sdk_summary", + "get_user_access_details", + "list_home_recent_views", + "list_home_unique_builds", + "list_project_build_names", + "list_project_builds_by_group", + "list_projects_for_sidebar", + "list_projects_full", + "list_projects_lite", + "list_projects_lite_paginated", + "list_projects_via_public_api", + "list_projects_with_build_aggregates", + "list_projects_with_build_aggregates_paginated", + "meter_feature_usage", + "rename_project", + "whitelist_group_for_observability" + ], + "key_facts": [ + "A project is addressed two different ways depending on the operation: by its integer id, or by its normalised name (a URL-safe slug of the display name). Carry whichever the capability you picked declares - they are not interchangeable.", + "Deleting a project is irreversible and cascades in TestHub: every build, run and result under it is removed with it. There is no restore.", + "A project keeps a separate TestHub project id alongside its own id. Some lookups and the delete path can resolve a TestHub/TCM id to the mirrored observability project, so passing the wrong id family can silently act on a different project.", + "In the demo-sandbox environment, creating and deleting projects is refused, and the app-bootstrap identity fields (user id and group id) come back null.", + "Projects are only ever created as a service-to-service mirror of a TestHub project, keyed on the TestHub project id and idempotent on it - there is no customer-facing 'new project' create that a user session can call.", + "The public project listing is scoped to the caller's group and does not report a total count; it is walked page by page until an empty page is returned.", + "Automation-coverage figures for a project are computed over all time as an aggregation, not stored on the project, so a read recomputes them and carries the timestamp it was calculated at." + ] + }, + "sp_test": { + "entity": "sp_test", + "title": "Suite Health test (Stability & Performance)", + "description": "A unique automated test tracked across runs in Suite Health, keyed by a stability hash, carrying stability, failure and duration metrics.", + "aliases": [ + "test", + "suite health test", + "test health", + "stability test", + "sp test", + "snp test", + "flaky test", + "test stability", + "stability and performance", + "unique test" + ], + "id_convention": "Identified by an opaque stabilityHash string, not a numeric id. The hash is discovered from the Suite Health test listing; the same hash addresses every Test Health detail read.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "aggregated over the test's executions across builds" + } + ], + "capabilities": [ + "get_suite_health_stats", + "get_suite_health_test_info", + "get_suite_health_test_platform_breakdown", + "get_suite_health_trend", + "get_test_base_info", + "get_test_environment_breakdown", + "get_test_filter_options", + "get_test_health_stats", + "get_test_health_trend", + "get_test_metrics_chart", + "list_suite_health_test_runs", + "list_suite_health_tests", + "list_test_health_tests", + "search_test_filter_branches", + "search_test_filter_build_names", + "search_test_filter_build_tags", + "search_test_filter_host_names", + "search_test_filter_test_tags" + ], + "key_facts": [ + "A test is identified by an opaque stabilityHash string, not a numeric id. You must discover the hash from the Suite Health test listing first; the same hash is what every Test Health detail read takes.", + "Every Suite Health read is scoped to a date window, and a two-element millisecond [from, to] range is mandatory: results only ever cover that window, and omitting the range is rejected.", + "The filter type-ahead suggestions are capped server-side and are suggestions, not the complete set: git branches cap at ~100, while build tags, test tags and host names cap at ~10.", + "Metric counts for the very same query can differ depending on an account feature flag: with the new build listing enabled and the query not restricted to automation-latest, failure counts are sourced from workflow status rather than the raw recorded status, so the same window can report different failure numbers.", + "Muted-test visibility is an explicit, required input on the Test Health detail reads (show all, only muted, or only unmuted) with no default value.", + "Suite Health metrics and the environment breakdown exclude archived builds.", + "The Suite Health filter sidebar degrades gracefully: run-level dropdown options are fetched from an upstream service under a timeout, and on timeout those specific dropdowns come back empty instead of failing the whole request." + ] + }, + "test_detail": { + "entity": "test_detail", + "title": "Test detail (logs, hooks, capabilities)", + "description": "The logs, hooks, capabilities and error detail attached to one test run, shown on the Test Details page.", + "aliases": [ + "test details", + "test detail", + "logs tab", + "session logs", + "network logs", + "har", + "app logs", + "application logs", + "hooks", + "hook logs", + "capabilities tab", + "behaviour tab", + "console logs", + "consolidated logs", + "test run logs" + ], + "id_convention": "Not its own id. Most tabs are addressed by the parent test run's INTEGER id (testRunId); session-scoped log tabs by the session's HASHED string id; the app-log stream by the test run's UUID. Discover these from a test-run / session read.", + "parents": [ + "test_run" + ], + "relations": [ + { + "entity": "test_run", + "via": "detail of a test run" + }, + { + "entity": "session", + "via": "session hashed id" + } + ], + "capabilities": [ + "get_session_log_urls", + "get_session_network_log_url", + "get_test_run_app_logs", + "get_test_run_app_logs_raw", + "get_test_run_auto_unmute_config", + "get_test_run_behaviour", + "get_test_run_capabilities", + "get_test_run_hooks", + "get_test_run_log_bundle", + "get_test_run_log_urls", + "get_test_run_media_logs", + "get_test_run_merged_logs", + "get_test_run_meta", + "get_test_run_network_log_url", + "get_test_run_network_logs", + "get_test_run_overview", + "get_test_run_test_code", + "list_test_run_detail_tabs", + "list_test_run_execution_history", + "list_test_run_linked_test_cases" + ], + "key_facts": [ + "The detail is derived, not stored as its own record: it has no id of its own and no history to restore. Everything here is read-only and nothing mutates the run.", + "The same detail is addressed three different ways depending on the tab: most tabs by the test run's integer id, the session-scoped log tabs by the session's hashed string id, and the application-log stream by the test run's UUID. Carry whichever id the capability you picked declares.", + "Log tabs come in two flavours. One downloads and merges the log text server-side and returns it inline; the other returns only URLs for the caller to fetch. The inline-merge form is slow for long sessions, so prefer the URL form there.", + "Application logs are filtered to abnormal (error-level) lines only, capped at 5000 lines, and bounded to the run's own start/finish window (padded five seconds each side). A clean run legitimately returns an empty list.", + "Retention-locked or plan-locked builds refuse every detail read with a forbidden / plan-feature-not-allowed error rather than returning empty; treat that as a plan gap, not a missing run.", + "Hook attribution is heuristic: hooks are matched by same build, filename, OS, browser and device, latest run only. It is not a stored parent/child link, so hooks from a differently-configured session never appear.", + "On the demo-sandbox environment several of these reads return canned or empty payloads (an empty log envelope, a demo HAR, or logs chosen by test name) instead of real data." + ] + }, + "test_run": { + "entity": "test_run", + "title": "Test run", + "description": "One execution of a single test within a build \u2014 its status, platform, code, retries and failure analysis.", + "aliases": [ + "test", + "test result", + "run", + "test execution", + "test case execution", + "session test", + "btcer", + "tr", + "spec run", + "automated test" + ], + "id_convention": "Most routes take the INTEGER test run id (Long). A few public routes are scoped by the parent BUILD's UUID instead (a string). Discover run ids from a build's test-run listing rather than guessing them; the TR-NNN prefixed identifier some surfaces show is for display, not for these numeric paths.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildId" + }, + { + "entity": "test_plan", + "via": "groups" + } + ], + "capabilities": [ + "assign_test_run_failure_categories", + "cancel_test_run_auto_unmute", + "get_test_run_baseline", + "get_test_run_bug_report_draft", + "get_test_run_build_insights", + "get_test_run_details", + "get_test_run_retry_logs", + "get_test_runs_history_details", + "link_bug_to_test_run", + "list_build_bstack_error_sessions", + "list_build_test_run_counts", + "list_build_test_run_sdk_logs", + "list_build_test_runs", + "list_build_test_runs_flat", + "list_build_test_runs_flat_filtered", + "list_build_test_runs_hierarchy", + "list_build_test_runs_hierarchy_without_public_link", + "list_build_test_runs_with_attachments", + "list_test_run_attachment_urls", + "list_test_runs_sharing_error", + "unlink_bug_from_test_run", + "update_test_run_mute_status" + ], + "key_facts": [ + "A test run is found by its own numeric id and the caller's group; when a route's path also carries a build id, some routes use it and some ignore it entirely, so a run still resolves even if the build segment is wrong.", + "The baseline, similar-error and hooks reads return a MAP keyed by test run id, not a single object, and the map is EMPTY (not an error) when nothing qualifies \u2014 an empty body means 'none found', not a failure.", + "The baseline look-back is a fixed 30 days before the run's start time; a last-known-good run older than that is invisible, so a returned empty map does not prove the test never passed.", + "Downloading a run's attachments or screenshots is gated by an account feature; when it is off the attachment read is refused even for a run that clearly has attachments.", + "Attachment and screenshot downloads come back as presigned URLs that expire roughly two hours after they are issued \u2014 fetch them promptly and re-request rather than caching the URL.", + "A test can be muted with an automatic unmute condition (after N passes, or after a date); making that mute permanent is reversible by muting again with a condition, so it is a change, not a deletion.", + "The bug-report read composes a fresh, pre-filled ticket draft from the failure; it does NOT fetch a bug that was already filed.", + "Linking a filed bug to a run is idempotent on the issue key: linking the same key twice is a silent no-op, and the link can be removed again afterwards.", + "Retries of a test are separate executions, each with its own status; a run carries its retry history rather than overwriting the previous attempt.", + "Public listings are plan- and source-aware: a freemium or source-fenced build yields a stripped or empty result rather than the analysis fields, and smart-tag filters on such a build are refused outright.", + "The status a run reports is lowercased on the public /ext detail routes but may come back uppercase from other surfaces \u2014 compare case-insensitively.", + "In the flat build listing the top-level id of each row is the test's hash (a string), while the numeric run id lives under that row's details.id." + ] + }, + "ai_failure": { + "entity": "ai_failure", + "title": "AI failure analysis (build-level)", + "description": "A build-level AI cluster of related test failures: a failure theme or a workflow grouping the runs that failed for the same reason.", + "aliases": [ + "ai failure", + "ai failure analysis", + "failure theme", + "failure cluster", + "failure workflow", + "build failure theme", + "build failure workflow", + "build rca", + "build level rca", + "ai clustered failures", + "rca theme" + ], + "id_convention": "The build is addressed by its build UUID (an opaque string) in the path. Individual clusters are identified by an integer buildFailureThemeId or buildFailureWorkflowId, which are discovered from the build AI-failures read, not guessable.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildUUID" + }, + { + "entity": "test_run", + "via": "flat test-run drill-down under a theme/workflow" + } + ], + "capabilities": [ + "get_build_ai_failures", + "get_build_ai_failures_via_public_api", + "list_build_ai_failure_cluster_test_runs_via_public_api", + "list_build_ai_failure_test_runs_flat", + "trigger_build_ai_failure_analysis", + "trigger_build_ai_failure_analysis_via_public_api" + ], + "key_facts": [ + "AI failure clustering is computed asynchronously. Requesting analysis on a build that has none returns immediately with a triggered flag and NO themes or workflows yet; the results arrive later, so re-read to collect them.", + "The read side returns only what has already been computed and never starts analysis. Starting analysis is a separate trigger operation.", + "Themes and workflows always come back sorted by their share of failures, highest first.", + "Build-level AI analysis is gated by AI opt-in consent, a per-account AI-enabled flag, and the build-level RCA feature; a trial account also has a usage quota that returns a plan-not-allowed error once exhausted, and each fresh trigger consumes one unit of it.", + "A build whose source is excluded by source-fencing is refused analysis.", + "The trial-quota figure is present only for accounts on the build-level AI trial; a paid account never sees it, so its absence does not mean zero usage.", + "Drilling from a cluster to its test runs is cursor-paginated, and the per-page size is a fixed backend default that the caller cannot change." + ] + }, + "session": { + "entity": "session", + "title": "Session", + "description": "A single automation run on BrowserStack (Automate or App Automate) recorded against a build, with its config, timings, logs and status.", + "aliases": [ + "session", + "automate session", + "app automate session", + "test session", + "run session", + "browser session", + "device session", + "live session", + "queued session" + ], + "id_convention": "Most session reads take the session's hashed id, an opaque alphanumeric string (not a numeric id). The stop control takes that hashed id in the body. One list read is the exception: it takes the NUMERIC build id even though its slot looks like a session id.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildId" + }, + { + "entity": "test_run", + "via": "a session executes the build's test runs" + } + ], + "capabilities": [ + "get_session_app_profiling", + "get_session_capabilities", + "get_session_details", + "get_session_test_code_via_public_api", + "list_build_sessions", + "list_queued_sessions", + "stop_session" + ], + "key_facts": [ + "A session is identified by its hashed id (a string), not a numeric id, except the per-build session list, which takes the build's numeric id even though the value slots where the other routes expect a session id.", + "App profiling exists only for App Automate sessions; asking for it on an Automate session is rejected, and an empty object means the report is still generating or was never captured, not an error.", + "Session detail can come back with a retryable not-ready status while the session is still being linked to its build; treat that as retry-shortly, not a failure.", + "A session that belongs to a plan-locked build is refused with a plan-not-allowed error even though the session exists.", + "A public-link viewer does not see still-running sessions in the per-build session list.", + "Stopping a session ends the live run and cannot be undone, but removes nothing: the session record and its logs remain.", + "The public test-code read reports a session that exists but captured no code identically to a missing session, so a not-found there does not prove the id is wrong.", + "On the demo sandbox environment, capabilities, session details and app profiling are served from a canned dataset rather than real data." + ] + }, + "check_config": { + "entity": "check_config", + "title": "Check configuration (SCM check-run)", + "description": "A repo's SCM check-run rule: which BrowserStack build/project a commit or PR check reports on, as quality-gate or build status.", + "aliases": [ + "check", + "checks", + "check run", + "check-run", + "check config", + "check configuration", + "pr check", + "pr checks", + "vcs check", + "github check", + "quality gate check", + "commit check", + "scm check", + "checks mapping", + "check mapping", + "pr checks mapping" + ], + "id_convention": "Check-configuration id is a plain integer (discover it from the repo's check-runs listing). A checks-mapping registration has its OWN integer ingestion id, which is neither the check-configuration id nor the GitHub check id.", + "capabilities": [ + "create_check_configurations", + "create_checks_mapping", + "delete_check_configuration", + "get_checks_mapping", + "list_check_configurations", + "toggle_check_configuration", + "update_check_configuration" + ], + "key_facts": [ + "The whole feature is gated by the CHECKS_EVALUATION entitlement: create, read, list, update, delete and toggle all refuse when the workspace lacks it, and that refusal is flattened into a generic bad-request error rather than a typed one.", + "Check configurations are scoped to the caller's group AND user together: you only see and can change checks you created, and a teammate's checks are invisible and cannot be edited, toggled or deleted.", + "A repository may hold at most 10 check configurations; a configuration is unique by the combination of its name, repository, group, build name and target project.", + "checkType is one of exactly two values: quality-gate status or build status.", + "The target project named on a check must already exist in the workspace, or the write is refused as project-not-found.", + "Deleting a check configuration removes the row for good; there is no undo and no bin.", + "A checks-mapping registration is a separate record from the check configuration it points at: it captures one pull request's commit, callback URL and evaluation state (starting at 'Not Started'), and is addressed by its own ingestion id.", + "A checks-mapping registration only succeeds for check ids that already match an existing check configuration in the caller's group for that repository and organisation; ids that match nothing are rejected." + ] + }, + "view": { + "entity": "view", + "title": "Saved view", + "description": "A named saved filter preset for a listing screen, private to its creator or shared with the whole group.", + "aliases": [ + "view", + "views", + "saved view", + "saved views", + "custom view", + "custom saved view", + "filter preset", + "view preset", + "saved filter", + "named filter", + "filter view" + ], + "id_convention": "A view id is a UUID (discover it from a views listing). The path viewType is a SCREEN name, not an id: one of builds, testRuns, testHealth, uniqueErrors or buildInsights.", + "capabilities": [ + "create_view", + "create_view_via_public_api", + "delete_view", + "list_views", + "list_views_via_public_api", + "update_view", + "update_view_via_public_api" + ], + "key_facts": [ + "A view belongs to exactly one screen type (builds, test runs, test health, unique errors or build insights); the same name can be reused across different screen types.", + "Views are either private (level 1, visible only to the creator) or shared/public (level 2, visible to the whole group).", + "A view name must be unique within its screen type: within the group for shared views, within the creator for private views. A clash is refused with a name-exists error rather than overwriting.", + "Deleting a view is a hard, unrecoverable removal, and the view disappears from every group member's view switcher immediately.", + "The creator and group of a view are always taken from the caller's session, never from the request body; a createdBy or groupId sent in the body is ignored.", + "On a shared public link only public views are returned; private views are never exposed through a shared link.", + "Editing a view is a partial update (omitted fields keep their stored values), but views keep no revision history, so an overwritten name, filter query or visibility cannot be restored.", + "The external (public-API) surface enforces stricter input than the internal one: name must be non-blank and at most 100 characters, query non-blank and at most 4000, and it has no delete operation at all." + ] + }, + "dashboard_view": { + "entity": "dashboard_view", + "title": "Dashboard view", + "description": "A saved dashboard configuration (name, global filters, widget layout) for the Test Observability or Test Management dashboard page.", + "aliases": [ + "dashboard", + "dashboard view", + "view", + "dashboard page", + "custom dashboard", + "dashboard tab", + "testing trends", + "testing-trends", + "automation trend", + "automation-trend", + "default dashboard", + "builds dashboard", + "test runs dashboard", + "test health dashboard", + "unique errors dashboard", + "build insights dashboard" + ], + "id_convention": "UUID. Custom views' UUIDs are discovered from the list capability. On READS only, three built-in views are also addressable by alias: `default` resolves to the TCM view for source=TCM and to the TO 'testing trends' view for source=TO, and `automation-trend` resolves to the automation-trend view. Writes (update/delete/clone-source) require the raw UUID; the aliases are rejected there.", + "parents": [ + "project" + ], + "relations": [], + "capabilities": [ + "check_dashboard_widget_ignored_filters", + "clone_dashboard_view", + "create_dashboard_view", + "delete_dashboard_view", + "get_dashboard_view", + "get_dashboard_view_widget_data", + "list_dashboard_view_custom_field_filters", + "list_dashboard_view_widget_types", + "list_dashboard_views", + "list_dashboard_widget_filter_values", + "update_dashboard_view" + ], + "key_facts": [ + "There are three built-in dashboards: the default TCM dashboard, the default TO dashboard (shown as 'testing trends'), and 'automation-trend'. None of the three can be renamed, edited, cloned into, or deleted; an attempt to update or delete one is refused, and the built-ins never appear as normal editable views.", + "A dashboard is addressed by its UUID everywhere. Only when reading a single dashboard may you also use the aliases `default` and `automation-trend` in place of the UUID; every write needs the raw UUID.", + "Opening a built-in default dashboard forces its date range to the last 30 days, ignoring whatever range was stored, and injects the current filter values into its widgets.", + "Reading a single dashboard records it in your 'recently viewed' dashboards list (the same list the home page shows); there is no read that avoids leaving that trace. The trace is skipped for super-users unless they are in group 2.", + "Dashboards keep no version history: an overwritten name, filter set, or widget layout cannot be restored to a previous revision, and a delete is permanent.", + "Dashboard names must be unique within the project; creating or cloning with a name already in use is rejected.", + "Every operation on this entity must name a project (by id, name, or identifier). The named project both scopes the dashboard and is the resource the create/update/delete/clone permission check runs against, even for calls that otherwise ignore the project.", + "Visibility is governed by the numeric `level` field on the dashboard; the default level is 2 (group / shared).", + "Custom (private/group) dashboards are only visible to their owner and their group; a UUID that belongs to someone else's private dashboard reads as not-found rather than forbidden.", + "A dashboard name is limited to 1-100 characters." + ] + }, + "report": { + "entity": "report", + "title": "Report (saved report)", + "description": "A saved, shareable analytics view over test runs with persisted global filters and widgets; private or group-visible.", + "aliases": [ + "report", + "saved report", + "reports", + "test report", + "test execution report", + "analytics report", + "tm report", + "test management report", + "custom report" + ], + "id_convention": "UUID (the report id). Reports mirrored from Test Management are ALSO addressable by their TM-side numeric external report id through the sync surface. Discover a report's UUID from the report listing.", + "parents": [], + "relations": [ + { + "entity": "project", + "via": "selected projects in the report's global filters" + }, + { + "entity": "test_run", + "via": "the tabular test-runs report widget" + } + ], + "capabilities": [ + "bulk_sync_tm_reports", + "clone_report", + "create_report", + "delete_report", + "delete_tm_report", + "get_report", + "get_report_dataset_rows", + "get_report_print_download_token", + "get_report_stat_tiles", + "list_report_custom_fields", + "list_report_static_builds", + "list_saved_reports", + "sync_tm_report", + "update_report", + "update_tm_report" + ], + "key_facts": [ + "Reports and dashboards are the same stored object under the hood and share one storage, name space and permission family; a report is just the report-typed view of it, so its visibility, filters and widgets behave exactly like a dashboard's.", + "A report is addressed two ways for the same record: end-user reports by a UUID, and reports mirrored from Test Management additionally by their TM-side numeric external id on the sync surface.", + "Visibility level is 1 = private (only the owner sees it) or 2 = group (everyone in the group sees it). A report you own at any level, or any group-level (level 2) report in your group, is yours to delete.", + "Report names must be unique within the same type and visibility level; a clashing name is rejected on create, on clone, and on an edit that changes the name, type or level.", + "Reports keep no version history \u2014 a rename, filter change or widget replacement overwrites the previous definition with no way to restore it.", + "A report stores its columns and filters by numeric custom-field id, so rendering or editing a saved report means resolving those ids back to display names first; the same field name can exist at both the test-case and test-execution level, distinguished only by entity type.", + "Only the tabular test-runs report type has live data behind it; asking a report's tiles or dataset for any other report type returns a 200 with a null body rather than an error.", + "Reports mirrored from Test Management are stamped with a TCM source and are meant to be written only by the Test Management service (identity taken from the request body, no permission check); the end-user builder creates reports under the TO source.", + "In the demo-sandbox environment there is a built-in default report that cannot be edited or deleted.", + "Creating or listing reports requires the custom-dashboard plan feature, and minting a report's PDF-print token requires the download-report plan feature; without the feature the call is refused even for a user who holds report permissions." + ] + }, + "report_view": { + "entity": "report_view", + "title": "Report view", + "description": "A configured analytics report (currently the tabular test-runs report) run on demand to return its table, summary tiles and per-test detail.", + "aliases": [ + "report", + "reports view", + "tabular report", + "test-runs report", + "report table", + "analytics report", + "tabular test runs report" + ], + "id_convention": "No stored id: the report is defined by the definition sent in the request body. Per-test detail is keyed by a test's globalTestHash (a string hash identifying one test case across runs), taken from a report-table row.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "report rows / per-test detail" + } + ], + "capabilities": [ + "get_report_test_stats", + "get_report_test_trend", + "get_tabular_test_runs_report_tiles", + "list_report_test_runs", + "run_tabular_test_runs_report" + ], + "key_facts": [ + "Only the tabular test-runs report is implemented. Asking for any other report type returns an empty or null body with a 200, not an error, so check the body rather than the status.", + "The report is defined entirely by the definition you send (datasets, segments, global filters); the id in the address is not read at all, so any value satisfies it.", + "Running the report has no side effects even though the request is write-shaped: the body is the query, not saved state.", + "The report table and its summary tiles are two separate calls that take the same definition; the tiles call has no paging.", + "Per-test detail (stats, run list, trend) is keyed by a test's global hash, which you read from a row of the report table.", + "A test with no matching runs in the window returns an empty object for its stats rather than zeroed counts.", + "Trend point durations are in milliseconds, and each point carries the build it came from.", + "When every dataset in the definition mutually conflicts, the table comes back empty and the tiles come back with all zero/false fencing flags rather than an error." + ] + }, + "widget": { + "entity": "widget", + "title": "Dashboard widget", + "description": "A single configured tile on a dashboard (chart, stat card or distribution) whose data is computed on demand from filters and a time window.", + "aliases": [ + "widget", + "dashboard widget", + "tile", + "chart", + "dashboard tile", + "widget filter", + "dashboard chart" + ], + "id_convention": "Addressed on these routes by a widget-type enum plus the definition in the body, not by a stored widget id. The dashboard id in the address is not read on the data and drill-down calls.", + "parents": [ + "dashboard" + ], + "relations": [ + { + "entity": "dashboard", + "via": "dashboardId (unused on data routes)" + } + ], + "capabilities": [ + "get_org_dashboard_widget_data", + "get_widget_drilldown_records", + "list_widget_filter_values", + "search_widget_filter_values" + ], + "key_facts": [ + "Widget data is computed from the definition and filters you send, not read from stored widget state; the dashboard id in the address is ignored on the org-level data and drill-down calls.", + "Filter-value lookups come in two forms: a flat list of values for one filter key, and an enhanced form that splits values into suggested and others with search and paging.", + "For ClickHouse-powered widget types, the project ids you send are automatically translated to TestHub project ids before the query runs.", + "Group 2 asking for the active-builds widget is forced to a seven-day window regardless of the range requested.", + "These reads have no side effects even though the requests are write-shaped.", + "The widget-data response shape depends on the widget type requested, so read the type you asked for before assuming a chart vs card vs distribution envelope." + ] + }, + "parallel_queue": { + "entity": "parallel_queue", + "title": "Parallel queue usage", + "description": "A view of a group's parallel-session concurrency for a product: parallels in use vs allowed and queued sessions vs the queue limit.", + "aliases": [ + "parallel queue", + "parallels", + "queue", + "concurrency", + "parallel sessions", + "queue usage", + "session queue" + ], + "id_convention": "Scoped to the authenticated caller's group; the running-builds view additionally takes a numeric user id in the address.", + "parents": [], + "relations": [], + "capabilities": [ + "get_parallel_queue_usage_summary", + "list_parallel_queue_team_usage", + "list_user_running_builds" + ], + "key_facts": [ + "Usage is always for the signed-in caller's group and the product named in the request (automate or app-automate). A product value other than automate is treated as app-automate on the running-builds view.", + "The team breakdown and running-builds views pass through to the billing backend using the caller's own BrowserStack credentials, so an unrecognised product fails downstream there.", + "The summary view only works for session/JWT callers because it reads the user from the security context; omit the product and you get back only the test-execution usage block, with no live queue numbers.", + "The running-builds view takes a user id in the address and does not check that it belongs to the caller's own group.", + "The summary view strips the internal user-details block out before returning." + ] + }, + "usage": { + "entity": "usage", + "title": "Usage & entitlement", + "description": "Test-execution consumption and plan/tier entitlement for a group or user: counts run, limits, and how much of the plan is used.", + "aliases": [ + "usage", + "consumption", + "test executions", + "billing usage", + "plan usage", + "tier usage", + "entitlement", + "device tier" + ], + "id_convention": "Group/user scoping comes from query params or the authenticated session; the device-tier and plan-details views use the caller's own group with no id argument.", + "parents": [], + "relations": [], + "capabilities": [ + "get_device_tier_usage", + "get_group_plan_usage_details", + "get_test_execution_usage_count" + ], + "key_facts": [ + "The raw execution-count view takes the user id or group id straight from the request and never checks they belong to the caller, so any authenticated caller can read another user's or group's counts.", + "That same count view returns a 200 with the plain text body 'Missing userId or groupId' when both are omitted, instead of a 400.", + "The plan/usage-details view returns every count as a compacted display string (e.g. '1.2K'), not a number, and nulls out every usage field when the group has no execution limit configured.", + "The device-tier view lists only mobile tiers; non-mobile tiers are filtered out, and a missing product is a 400.", + "Execution counts are split into all tests reported and the subset that ran on BrowserStack infrastructure (those carrying a session id)." + ] + }, + "search": { + "entity": "search", + "title": "Global search", + "description": "A global free-text search across product entities (builds, tests, projects) for the search bar and quick overlay.", + "aliases": [ + "search", + "global search", + "find", + "search bar", + "quick search", + "entity search" + ], + "id_convention": "No stored entity; each result carries its own numeric id and a type discriminator.", + "parents": [], + "relations": [], + "capabilities": [ + "search_entities" + ], + "key_facts": [ + "Search covers a fixed set of entity types and rejects any other value; the accepted values are case-sensitive (Build, BuildTestCaseExecutionResult, Project, All).", + "The query must be at least three characters and not blank, or the request is rejected.", + "Results are always sorted by most-recently-updated; the sort order cannot be changed by the caller.", + "The quick-overlay mode returns a smaller page and omits the paging and total-count fields that the full mode includes.", + "Each result is enriched with the matching record's user name, resolved from its user id." + ] + }, + "self_healing": { + "entity": "self_healing", + "title": "Self-healing (AI-healed locators)", + "description": "AI-suggested replacement selectors for tests whose locators broke, plus the review verdicts and PRs that apply them.", + "aliases": [ + "self heal", + "self-heal", + "ai healing", + "aiHealing", + "healed locator", + "healed selector", + "locator healing", + "auto heal", + "healing pr", + "heal pr", + "healing verdict" + ], + "id_convention": "A build is addressed by its build UUID (^[a-zA-Z0-9-]{1,64}$). A healing-PR job is a random UUID (8-4-4-4-12 hex) returned by create_self_healing_pr; it is NOT discoverable through any list call and lives only in Redis with a short TTL, so capture it from the create response.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "healed locators are computed per build; summary and verdicts are build-scoped" + }, + { + "entity": "test_run", + "via": "each healed selector carries the sessionId of the run it broke in" + } + ], + "capabilities": [ + "create_self_healing_pr", + "get_build_self_heal_summary", + "get_self_healing_pr_status", + "list_self_healing_verdicts", + "submit_self_healing_verdict" + ], + "key_facts": [ + "Opening a healing PR is asynchronous: the create call returns immediately with a job id and an IN_PROGRESS state, and the actual GitHub PR is opened later by a background worker. Poll the status separately; the PR URL only appears once the job reaches a terminal state.", + "A healing-PR job's terminal state can be SUCCEEDED, PARTIAL (some files changed, some skipped), FAILED, or NO_CHANGES_NEEDED. PARTIAL and NO_CHANGES_NEEDED are successful outcomes, not errors.", + "Healing-PR job state is kept only briefly and only for the group that started it; once it expires the id reads as not-found rather than as a finished state, so a job whose status was never polled in time is unrecoverable.", + "Opening a healing PR is GitHub-only and needs the build to carry version-control info (origin remote, branch) and the group to have a GitHub source-code integration connected; a GitHub Enterprise or non-standard host must pass an explicit owner/repo.", + "The human-in-the-loop verdict flow (recording whether a healed locator was accurate) is an optional feature per group. When it is off, reading verdicts returns an empty list while submitting a verdict is refused outright.", + "Only reviewers with a full product role can record a verdict; view-only members cannot.", + "A recorded verdict can carry over from an earlier build: a build shows verdicts first captured against a previous build, flagged as carryover, so the same healed element is not re-reviewed every build.", + "Self-heal summary numbers require the build to be within the account's plan quota; a build locked by quota is refused rather than returning zeroes.", + "The healing report itself (the list of individual healed selectors, with their original and healed values) is delivered as a downloadable report artefact, separate from these APIs; the summary here is only the headline counts." + ] + }, + "build_comparison": { + "entity": "build_comparison", + "title": "Build comparison", + "description": "A side-by-side diff of two builds (a base and a target) showing which tests newly failed, started passing, were added or dropped.", + "aliases": [ + "compare builds", + "build diff", + "build vs build", + "compare two builds", + "buildComparison", + "comparison", + "base vs target" + ], + "id_convention": "Not a stored entity with its own id: a comparison is identified by the pair of build UUIDs passed as the `base` and `target` query params. Comparison is always done at the top-level parent build, so a re-run/child UUID should be normalised first via resolve_build_comparison_parent_uuids.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "base and target are both build UUIDs" + } + ], + "capabilities": [ + "check_build_comparison_available", + "get_build_comparison_aggregation", + "get_build_comparison_meta", + "list_build_comparison_bucket_tests", + "resolve_build_comparison_parent_uuids" + ], + "key_facts": [ + "Every comparison endpoint takes exactly two required query params, `base` and `target`, each a build UUID; an empty or missing one comes back as build-not-found.", + "Comparison is defined only between two top-level parent builds. Child builds, archived builds and plan-locked builds are rejected as unsupported, so normalise a pasted re-run UUID to its parent before comparing.", + "The two builds must come from compatible sources. An incompatible pair is not an error: the response comes back with source-fencing flags set and its actual comparison payload empty, so check for those flags before trusting empty counts.", + "Comparing is a paid feature: the aggregation, the per-bucket listing and the availability pre-check are all refused when the plan does not include build comparison. Resolving parent UUIDs and fetching the two build headers are NOT plan-gated and work regardless.", + "The side-by-side header counts are recomputed with the same de-duplication the comparison buckets use, so the header agrees with the buckets; this makes the header fetch heavier than a plain build summary and it silently falls back to raw counts if that recompute fails.", + "The whole flow is heavy: it resolves both builds' child builds and runs search + aggregation across them, with a large per-build ceiling on the test rows it will pull, so treat these as expensive reads.", + "A typical page flow is: normalise both UUIDs to parents, check the pair is comparable, fetch the two headers and the headline counts, then drill into one bucket's tests." + ] + }, + "comment": { + "entity": "comment", + "title": "Comment", + "description": "A rich-text note left on a build, test run or test, threaded and able to @mention group users, used for review discussion.", + "aliases": [ + "comments", + "note", + "discussion", + "thread", + "reply", + "feedback", + "annotation" + ], + "id_convention": "A comment id is a random UUID string generated on create and returned in the create response. A comment belongs to a thread identified by threadId (also a UUID; a new top-level comment starts its own thread). There is no list-by-id call: ids are discovered through the build/test comment reads.", + "parents": [ + "project", + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildUuid / buildSerialId" + }, + { + "entity": "test_run", + "via": "runUuid / testHash" + }, + { + "entity": "project", + "via": "scoped by project normalised name in the path" + } + ], + "capabilities": [ + "create_comment", + "delete_comment", + "list_build_comment_threads", + "list_comments", + "list_test_run_comments_via_public_api", + "update_comment" + ], + "key_facts": [ + "Comments are a paid feature: creating, editing and deleting are refused when the plan does not include comments.", + "A comment can only be edited or deleted by the person who wrote it; another user's comment reads as not-found on those operations even inside the same group.", + "Comments have no version history. Editing a comment overwrites its body in place with no way to recover the previous text.", + "Reads are bounded by the plan's data-retention window, so older comments silently fall out of results rather than erroring.", + "@mentioning a group user notifies them shortly after the comment is posted; the notification is a side effect that fires even though the create call has already returned.", + "Comment reads are keyed to a build's numeric serial id and look back over a fixed window of preceding builds (so recurring discussion on the same test carries across re-runs), not over all history.", + "The public /ext read is view-only: there is no external endpoint to post a comment, only the group-authenticated internal ones can create." + ] + }, + "sp_error": { + "entity": "sp_error", + "title": "Unique error (Suite Health)", + "description": "A cluster of test failures sharing one error signature in a project, the unit of the Suite Health Unique Errors view.", + "aliases": [ + "unique error", + "unique errors", + "error cluster", + "error signature", + "suite health error", + "sp error", + "failure cluster", + "errorClusterId", + "clustered failure" + ], + "id_convention": "Numeric cluster id (errorClusterId), a plain integer discovered from the unique-errors list (each row's `id`, returned as a numeric string). Every detail view takes it in the path as errorId / clusterId.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "impacted tests hitting the error (breakdown / platforms)" + }, + { + "entity": "build", + "via": "builds in which the error occurred (details/builds)" + } + ], + "capabilities": [ + "get_unique_error_build_trend", + "get_unique_error_filters", + "get_unique_error_impacted_tests", + "get_unique_error_occurrence_count", + "get_unique_error_platform_breakdown", + "get_unique_error_summary", + "get_unique_error_timeline_charts", + "list_unique_error_builds", + "list_unique_errors", + "search_unique_error_filter_branches", + "search_unique_error_filter_build_names", + "search_unique_error_filter_build_tags", + "search_unique_error_filter_host_names", + "search_unique_error_filter_test_tags" + ], + "key_facts": [ + "A cluster id is a plain integer. Get it from the unique-errors list (each row's `id`, a numeric string); every per-error detail view takes that same value in its path.", + "Every Unique Errors call needs a date window of EXACTLY two epoch-millisecond bounds; omitting it or sending one bound is rejected. A single lowerBound/upperBound pair is the only fallback.", + "These views only ever consider FAILED test runs. Archived builds and usage-locked builds are always excluded, and manual (Test Management) runs are excluded from the list and per-error queries whatever you send.", + "On a demo tenant, or a real project that has no builds yet, the list answers with an empty set and a success status, not a not-found. Only a genuinely absent project is a 404.", + "Free / non-pro accounts are capped: the list returns only the single highest-occurrence error and is force-sorted by occurrence count descending, and per-error filtering is skipped.", + "Source feature-fencing can silently drop Automate, App-Automate or low-code tests from results; the list response carries automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped booleans that say when this happened.", + "The window is clamped to the account's data-retention range before querying, so a range wider than retention silently returns only the retained slice.", + "Every Unique Errors endpoint is read-only and accepts central-auth, obs token, service key, rails-basic, public-link and speedboat tokens; none of them accepts username + access-key auth.", + "Occurrence bar/chart data comes back as bare per-bucket counts with no timestamps on some views, so the time axis has to be reconstructed from the requested window; buckets are one calendar day." + ] + }, + "test_rca": { + "entity": "test_rca", + "title": "Test RCA (AI root-cause analysis)", + "description": "The AI root-cause analysis stored for a single failed test run, with its generation state and thinking data.", + "aliases": [ + "rca", + "test rca", + "ai rca", + "root cause analysis", + "root-cause analysis", + "failure analysis", + "ai failure analysis", + "why did this test fail" + ], + "id_convention": "No id of its own in paths: an RCA is addressed by the integer test-run id it belongs to (one RCA per test run). The stored row does carry an integer id and a bulkRcaId (UUID) linking runs analysed together in one bulk request.", + "parents": [ + "test_run" + ], + "relations": [ + { + "entity": "test_run", + "via": "one RCA per test run, keyed by test run id" + } + ], + "capabilities": [ + "get_test_rca", + "get_test_rca_data", + "get_test_rca_via_public_api", + "save_test_rca", + "trigger_bulk_test_rca", + "trigger_test_rca_via_public_api" + ], + "key_facts": [ + "Generating an RCA is asynchronous: a request only queues the work, and the finished analysis arrives later. The immediate reply carries a state field, not the completed analysis; the terminal states are Completed (success) and the error states (LLM_SERVICE_ERROR, LOG_FETCH_ERROR, AI_SYSTEM_ERROR, UNKNOWN_ERROR, TIMEOUT).", + "Fetching an RCA is get-or-create by default: asking for one that does not exist yet creates the record and queues a generation, so the plain fetch is NOT a safe read. There is a separate pure-read that never has side effects, and a peek option on the public route.", + "Both fetch and trigger need the caller's own BrowserStack username and Selenium access key; these are resolved server-side from the caller's user record, and a caller whose record lacks them is refused before the AI pipeline runs.", + "The feature is gated three ways: the group must have accepted the AI opt-in consent, the group's plan/AI token must carry the RCA feature, and the build's source must not be fenced off \u2014 any of these missing turns every RCA call into a refusal rather than a result.", + "There is no version history for an RCA: a fresh generation overwrites the stored analysis for that test run, and the previous one is gone.", + "The completed analysis body is opaque model output whose internal shape can change; treat it as display content rather than parsing fixed fields out of it.", + "The analysis is written back by the AI service itself through an ingestion callback; a user-facing caller normally only reads it or triggers it, and the reading side reflects whatever the service last wrote." + ] + }, + "build_group": { + "entity": "build_group", + "title": "Build group (run group)", + "description": "A named grouping of builds within a project, used to collect the builds of one release or test cycle.", + "aliases": [ + "run group", + "run-group", + "build group", + "test run group", + "trg", + "release group", + "group of builds", + "build set" + ], + "id_convention": "Plain integer build-group id, discovered from the group listing. Note: the project id in these paths is the TestHub project id, not the observability project id.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "builds are linked into a group (a build belongs to at most one group)" + } + ], + "capabilities": [ + "apply_build_bulk_action", + "close_build_group", + "create_build_group", + "delete_build_group", + "list_build_groups", + "update_build_group" + ], + "key_facts": [ + "Groups live in TestHub; this service is a pass-through that mirrors what TestHub returns, so writes here take effect in TestHub and are not undoable from this API.", + "A group has a name (required, up to 255 chars) and an optional description (up to 1000 chars); the name 'Ungrouped' is reserved and rejected.", + "A group's state is derived: it reads ACTIVE until it is closed, then CLOSED. Closing freezes the group's builds and test runs (they become non-editable / locked) but deletes no data.", + "Deleting a group removes the group and its build associations but never the underlying builds; the builds are detached asynchronously afterwards, so they may still show the group for a short window.", + "Bulk actions across many builds and the group delete both flip to asynchronous background work above a size threshold, returning an acknowledgement rather than a finished result \u2014 re-read to confirm the outcome.", + "Only one bulk action can run per project at a time: while one is in flight a second is refused, and the caller should retry rather than treat it as bad input.", + "The permission to add builds to a run group governs creating, editing, deleting and closing a group; the bulk-action operations are gated per action instead (linking needs that same run-group permission, while assigning a test plan or an assignee needs the automated-run edit permission)." + ] + }, + "accessibility": { + "entity": "accessibility", + "title": "Accessibility (a11y) data", + "description": "Accessibility scan results for a build's and a test run's tests, plus the group's a11y plan and free-trial entitlement.", + "aliases": [ + "a11y", + "accessibility testing", + "a11y testing", + "accessibility scan", + "accessibility results", + "ally", + "a11y report" + ], + "id_convention": "No id of its own: build-level a11y is addressed by the build UUID; test-run-level a11y is addressed by the TestHub build-test-case-execution-result (btcer) id, a plain integer. Plan/trial reads take no id and use the session's group/user.", + "parents": [ + "build", + "test_run" + ], + "relations": [ + { + "entity": "build", + "via": "a11y test-run listing scoped by build UUID" + }, + { + "entity": "test_run", + "via": "per-test a11y detail by btcer id" + } + ], + "key_facts": [ + "For groups on the App-Accessibility feature, an accessibility read whose build/test source is the App-Accessibility automation source is proxied to a separate Accessibility service and comes back in that service's shape, not the shape assembled here \u2014 the same route can therefore return two different bodies depending on the group flag and the source.", + "Several a11y reads are thin pass-throughs to a separate Accessibility service; on an upstream parse failure they return a plain text error body with a 500 status instead of the usual structured error envelope.", + "Severity of accessibility issues is always broken down into the four buckets critical, serious, moderate and minor.", + "Starting the accessibility free trial is a state change on the Accessibility product's entitlement even though it reads like a details fetch; checking trial status is a plain read.", + "The build must have finished (or timed out) and actually carry accessibility data, otherwise the a11y test-run listing returns a typed 4xx rather than an empty list.", + "Tests with no accessibility summary are silently dropped from the build's a11y listing, so its rows can be fewer than the build's total tests." + ], + "capabilities": [ + "get_a11y_plan_details", + "get_accessibility_free_trial_status", + "get_test_run_accessibility_meta", + "list_build_accessibility_test_runs", + "start_accessibility_free_trial" + ] + } + }, + "version": "1.7" + } } diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 31258606..2a8bf5e9 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -350,13 +350,16 @@ export function addCapabilityRegistryTools( : { verdict: "settled", product: ambiguity.settled }, ) : undefined; + /** The product's title for the question put to a person; the key stays the handle. */ + const label = (name: string) => + registry.index.products[name]?.title ?? name; const routing = ambiguity.products.length > 1 ? { verdict: "products_clash" as const, note: `These products clash over ${ambiguity.terms.map((t) => `'${t}'`).join(", ")} — ` + - `the same word means something different in ${ambiguity.products.join(" and ")}. ` + + `the same word means something different in ${ambiguity.products.map(label).join(" and ")}. ` + "ASK THE USER which they mean before searching or invoking anything; do not " + "search each product in turn and merge the results. Searching before you " + "ask will be refused: send the user's answer in `user_words` with this " + @@ -364,9 +367,10 @@ export function addCapabilityRegistryTools( routing_token: listingToken, shared_terms: ambiguity.terms, products: ambiguity.products, - question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`, + question: `Which product do you mean — ${ambiguity.products.map(label).join(" or ")}?`, senses: ambiguity.products.map((name) => ({ product: name, + title: label(name), means: ambiguity.terms .map((term) => { const entities = @@ -403,6 +407,7 @@ export function addCapabilityRegistryTools( routing_token: listingToken, question: `Which product do you mean — ${registry .productNames() + .map(label) .join(" or ")}?`, } : query @@ -428,6 +433,9 @@ export function addCapabilityRegistryTools( ...(routing ? { routing } : {}), products: registry.productNames().map((name) => ({ name, + ...(registry.index.products[name].title + ? { title: registry.index.products[name].title } + : {}), summary: registry.index.products[name].summary, // THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a // product summary alone does not do it: "add a tag to xyz test" reads as diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 48bbcd96..7588f2fb 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -435,6 +435,14 @@ export interface AuthScheme { } export interface ProductIndex { + /** + * The product's name as a person would say it — "Mobile Device Control", not the key. + * + * The key stays the handle every call takes; this is what the routing question and + * the product listing show, because "which do you mean — mobiledevicecontrol or tm?" + * is a question asked in our vocabulary rather than the user's. + */ + title?: string; summary: string; /** * Words that route to this product but are not the name of anything in it. diff --git a/tests/tools/capabilityRegistryProductTitle.test.ts b/tests/tools/capabilityRegistryProductTitle.test.ts new file mode 100644 index 00000000..fd45d0b8 --- /dev/null +++ b/tests/tools/capabilityRegistryProductTitle.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// The clash question is put to a person, so it names products the way a person would — +// by `title` — while the key stays the handle every other call takes. + +function listProducts(withTitles: boolean) { + const product = (key: string, title: string, entity: string) => ({ + ...(withTitles ? { title } : {}), + summary: `${title}.`, + base_url: "https://p.example", + capabilities: [ + { + name: `list_${entity}s`, + method: "GET", + path: `/${entity}s`, + mode: "read", + entity, + intent: `List ${entity}s.`, + }, + ], + entities: { + [entity]: { + description: `A ${entity} in ${title}`, + aliases: ["session"], + capabilities: [`list_${entity}s`], + }, + }, + }); + const index = { + schema_version: 1, + build_id: "t", + products: { + mobiledevicecontrol: product( + "mobiledevicecontrol", + "Mobile Device Control", + "app_session", + ), + tm: product("tm", "Test Management", "exploratory_session"), + }, + }; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async () => ({ status: 200, body: {} }) as never, + } as never, + ); + return async (query: string) => + JSON.parse( + (await handlers.listProducts({ query }, {} as never)).content[0].text, + ); +} + +describe("the routing question names products by title", () => { + it("asks in the user's words when titles exist, and keeps the key as the handle", async () => { + const out = await listProducts(true)("end my session"); + expect(out.routing.verdict).toBe("products_clash"); + expect(out.routing.question).toBe( + "Which product do you mean — Mobile Device Control or Test Management?", + ); + expect(out.routing.products).toEqual(["mobiledevicecontrol", "tm"]); + expect(out.routing.senses.map((s: any) => [s.product, s.title])).toEqual([ + ["mobiledevicecontrol", "Mobile Device Control"], + ["tm", "Test Management"], + ]); + expect(out.products.map((p: any) => p.title)).toEqual([ + "Mobile Device Control", + "Test Management", + ]); + }); + + it("falls back to the key for a product that declares no title", async () => { + const out = await listProducts(false)("end my session"); + expect(out.routing.question).toBe( + "Which product do you mean — mobiledevicecontrol or tm?", + ); + expect(out.products[0].title).toBeUndefined(); + }); +}); From 04e267f2d7d3ade14ceeeb6e9a5c380bc5d00a1a Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:37:35 +0530 Subject: [PATCH 45/53] fix(capability): a product clash is a question for the user, not an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchCapability refused an ambiguous request through the same path as a real refusal, so every client rendered the clash in red and agents narrated "the search failed" before asking. The search still does not run, but the outcome now comes back as an ordinary result — status "ask_user", the message, the routing token and the clarify block — and the products in the question and its options are named by title. Telemetry counts it under ask_user_reason rather than refusal_reason. Co-Authored-By: Claude Fable 5.1 --- src/tools/capability-registry/register.ts | 44 +++++++++++-- tests/tools/capabilityRegistryE2E.test.ts | 80 +++++++++++++++-------- 2 files changed, 89 insertions(+), 35 deletions(-) diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 2a8bf5e9..8b108bdd 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -290,6 +290,36 @@ export function addCapabilityRegistryTools( return failed(message, extra); }; + /** + * A question for the user, not a failure. + * + * A product clash stops the search just as a refusal does, but it is the EXPECTED path + * for an ambiguous request, and surfacing it as a tool error made every client render + * it in red and every agent narrate "the search failed". It is returned as an ordinary + * result whose `status` says what to do; the search still does not run. Counted in + * telemetry under `ask_user_reason`, next to the refusals it used to sit with. + */ + const askUser = ( + tool: string, + reason: string, + message: string, + extra: Record, + extras?: MCPEventExtras, + ): CallToolResult => { + try { + trackMCP(tool, server.server.getClientVersion()!, undefined, config, { + ask_user_reason: reason, + ...(extras ?? {}), + }); + } catch { + // Telemetry must not decide whether a tool call succeeds. + } + return ok({ status: "ask_user", reason, message, ...extra }); + }; + + /** The product's title for anything put to a person; the key stays the handle. */ + const label = (name: string) => registry.index.products[name]?.title ?? name; + tools.listProducts = server.tool( "listProducts", "List the BrowserStack products this surface can reach: what each one does, the " + @@ -350,9 +380,6 @@ export function addCapabilityRegistryTools( : { verdict: "settled", product: ambiguity.settled }, ) : undefined; - /** The product's title for the question put to a person; the key stays the handle. */ - const label = (name: string) => - registry.index.products[name]?.title ?? name; const routing = ambiguity.products.length > 1 ? { @@ -792,12 +819,15 @@ export function addCapabilityRegistryTools( .filter(Boolean); return { product: name, + ...(registry.index.products[name]?.title + ? { title: registry.index.products[name]?.title } + : {}), summary: registry.index.products[name]?.summary, ...(senses.length ? { shared_terms: senses } : {}), }; }); const named = anchor.products.length > 0; - return refuse( + return askUser( "searchCapability", "product_ambiguous", (blank @@ -812,12 +842,12 @@ export function addCapabilityRegistryTools( `, so the request points at ${ambiguity.settled}, not ${product}. ` + "Search the product the words name, or — if the user meant " + `${product} — get them to say so and quote them. ` - : `'${subject}' could mean ${ambiguity.products.join(" or ")} — ` + + : `'${subject}' could mean ${ambiguity.products.map(label).join(" or ")} — ` + `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` + `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. `) + (named ? `The words you quoted (${anchor.terms.map((t) => `'${t}'`).join(", ")}) ` + - `point at ${anchor.products.join(" and ")}, not ${product}. ` + `point at ${anchor.products.map(label).join(" and ")}, not ${label(product)}. ` : user_words ? "Nothing in the words you quoted names a product or a term only one " + "product uses. " @@ -830,7 +860,7 @@ export function addCapabilityRegistryTools( { routing_token: token, clarify: { - question: `Which product do you mean — ${choices.join(" or ")}?`, + question: `Which product do you mean — ${choices.map(label).join(" or ")}?`, shared: ambiguity.terms, options, }, diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts index 7d8d2e43..ec551e07 100644 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -25,9 +25,15 @@ const FIXTURE_DIR = fileURLToPath( */ const SHIPPED_DIR = mkdtempSync(join(tmpdir(), "cap-registry-multi-")); for (const [from, name] of [ - [new URL("../../capability/tm.capability-index.json", import.meta.url), "tm.capability-index.json"], [ - new URL("../fixtures/capability-multi/secondproduct.capability-index.json", import.meta.url), + new URL("../../capability/tm.capability-index.json", import.meta.url), + "tm.capability-index.json", + ], + [ + new URL( + "../fixtures/capability-multi/secondproduct.capability-index.json", + import.meta.url, + ), "secondproduct.capability-index.json", ], ] as const) { @@ -148,9 +154,8 @@ describe("capability registry, end to end through the server factory", () => { it("carries each entity's one-line meaning on the routing call", async () => { const server = await buildServer(); const listed = JSON.parse( - ( - await (server.getTools().listProducts as any).handler({}, {} as any) - ).content[0].text, + (await (server.getTools().listProducts as any).handler({}, {} as any)) + .content[0].text, ); const byName = Object.fromEntries( listed.products[0].entities.map((e: any) => [e.entity, e]), @@ -214,18 +219,23 @@ describe("capability registry, end to end through the server factory", () => { const server = await buildServer(); const search = server.getTools().searchCapability as any; const run = async (args: Record) => { - const r = await search.handler( - { product: "tm", ...args }, - {} as any, - ); - return { blocked: r.isError === true, body: JSON.parse(r.content[0].text) }; + const r = await search.handler({ product: "tm", ...args }, {} as any); + const body = JSON.parse(r.content[0].text); + // The gate is a question for the user, not an error: it stops the search without + // flagging the call as failed. + return { + blocked: body.status === "ask_user" && r.isError !== true, + body, + }; }; const refused = await run({ query: "list all projects" }); expect(refused.blocked).toBe(true); // The refusal has to be actionable: which products, and on which word. - expect(refused.body.error).toContain("secondproduct or tm"); - expect(refused.body.error).toContain("'project'"); + // Products are named by title where they have one — tm's is "Test Management"; + // the fixture's second product declares none and reads as its key. + expect(refused.body.message).toContain("secondproduct or Test Management"); + expect(refused.body.message).toContain("'project'"); expect(refused.body.error).toMatch(/to the USER/); // And it must name the failure mode it exists to stop. expect(refused.body.error).toMatch(/search each product in turn and merge/); @@ -248,7 +258,8 @@ describe("capability registry, end to end through the server factory", () => { expect(crossed.body.error).toMatch(/points at secondproduct, not tm/); expect(crossed.body.error).toMatch(/only secondproduct claims \('quota'\)/); expect( - (await run({ query: "check my quota", product: "secondproduct" })).blocked, + (await run({ query: "check my quota", product: "secondproduct" })) + .blocked, ).toBe(false); // ONE WAY OUT, AND IT IS NOT A FLAG. `product_choice: 'user_confirmed'` used to open @@ -256,15 +267,21 @@ describe("capability registry, end to end through the server factory", () => { // caller wrote it on every ambiguous prompt with nothing behind it. What opens it now // is the user's own words, checked against the index rather than taken on trust. expect( - (await run({ - query: "list all projects", - user_words: "the test management one", - })).blocked, + ( + await run({ + query: "list all projects", + user_words: "the test management one", + }) + ).blocked, ).toBe(false); // And a flag by that name buys nothing, because there is no longer such an argument. expect( - (await run({ query: "list all projects", product_choice: "user_confirmed" })) - .blocked, + ( + await run({ + query: "list all projects", + product_choice: "user_confirmed", + }) + ).blocked, ).toBe(true); // `entity` IS NOT A WAY OUT, and used to be. It narrows within a product and says @@ -286,7 +303,11 @@ describe("capability registry, end to end through the server factory", () => { process.env.CAPABILITY_REGISTRY_INDEX_DIR = SHIPPED_DIR; const server = await buildServer(); const r: any = await (server.getTools().searchCapability as any).handler( - { query: "list all projects", product: "tm", product_choice: "not_asked" }, + { + query: "list all projects", + product: "tm", + product_choice: "not_asked", + }, {} as any, ); const { clarify } = JSON.parse(r.content[0].text); @@ -302,7 +323,10 @@ describe("capability registry, end to end through the server factory", () => { // word — the two things a user needs to answer without being shown a schema. const tm = clarify.options.find((o: any) => o.product === "tm"); expect(tm.summary).toMatch(/Test Management/); - expect(tm.shared_terms[0]).toMatchObject({ term: "project", entity: "project" }); + expect(tm.shared_terms[0]).toMatchObject({ + term: "project", + entity: "project", + }); expect(tm.shared_terms[0].means).toMatch(/top-level container/); // The second product ships no entity descriptions, so its sense of `project` has no @@ -321,13 +345,13 @@ describe("capability registry, end to end through the server factory", () => { process.env.CAPABILITY_REGISTRY_INDEX_DIR = SHIPPED_DIR; const server = await buildServer(); const search = server.getTools().searchCapability as any; - const blocked = async (query: string) => - ( - await search.handler( - { query, product: "tm", product_choice: "not_asked" }, - {} as any, - ) - ).isError === true; + const blocked = async (query: string) => { + const r = await search.handler( + { query, product: "tm", product_choice: "not_asked" }, + {} as any, + ); + return JSON.parse(r.content[0].text).status === "ask_user"; + }; // QUERY side: the entry is `project`, the user says "projects". Exact containment // missed this — and it is the query that was actually reported. From bb1a578b896c9437339c51445e88c3f8cc45e31a Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:38:18 +0530 Subject: [PATCH 46/53] test(capability): read the clash message from its new field Co-Authored-By: Claude Fable 5.1 --- tests/tools/capabilityRegistryE2E.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts index ec551e07..6c6a8d73 100644 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -236,9 +236,9 @@ describe("capability registry, end to end through the server factory", () => { // the fixture's second product declares none and reads as its key. expect(refused.body.message).toContain("secondproduct or Test Management"); expect(refused.body.message).toContain("'project'"); - expect(refused.body.error).toMatch(/to the USER/); + expect(refused.body.message).toMatch(/to the USER/); // And it must name the failure mode it exists to stop. - expect(refused.body.error).toMatch(/search each product in turn and merge/); + expect(refused.body.message).toMatch(/search each product in turn and merge/); // A word only one product claims settles the query, so there is nothing to ask — // PROVIDED it settles on the product being searched. From a349c75bde0dc40b96fd70b47dd68d9a91ab6199 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:38:38 +0530 Subject: [PATCH 47/53] test(capability): the mismatch message names the product by title too Co-Authored-By: Claude Fable 5.1 --- tests/tools/capabilityRegistryE2E.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts index 6c6a8d73..a87ff91b 100644 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -255,8 +255,8 @@ describe("capability registry, end to end through the server factory", () => { // settled, never whether it settled on what was being asked for. const crossed = await run({ query: "check my quota" }); expect(crossed.blocked).toBe(true); - expect(crossed.body.error).toMatch(/points at secondproduct, not tm/); - expect(crossed.body.error).toMatch(/only secondproduct claims \('quota'\)/); + expect(crossed.body.message).toMatch(/points at secondproduct, not Test Management/); + expect(crossed.body.message).toMatch(/only secondproduct claims \('quota'\)/); expect( (await run({ query: "check my quota", product: "secondproduct" })) .blocked, From 39a71d63b8b42983ec9ed1557fce77a8a3e69302 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:39:21 +0530 Subject: [PATCH 48/53] fix(capability): the mismatch message names both products by title Co-Authored-By: Claude Fable 5.1 --- src/tools/capability-registry/register.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 8b108bdd..9c7219b9 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -839,9 +839,9 @@ export function addCapabilityRegistryTools( (ambiguity.because?.length ? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})` : "") + - `, so the request points at ${ambiguity.settled}, not ${product}. ` + + `, so the request points at ${label(ambiguity.settled as string)}, not ${label(product)}. ` + "Search the product the words name, or — if the user meant " + - `${product} — get them to say so and quote them. ` + `${label(product)} — get them to say so and quote them. ` : `'${subject}' could mean ${ambiguity.products.map(label).join(" or ")} — ` + `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` + `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. `) + From c3f696518128951b82ad7e4ddaade1336e076838 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 12:41:18 +0530 Subject: [PATCH 49/53] revert(capability): take the clash-as-question change out of this PR Reverts 04e267f, bb1a578, a349c75 and 39a71d6. Returning a product clash as an ordinary "ask_user" result rather than a tool error is a behaviour change on its own, and goes in its own PR (branch fix/clash-as-question) rather than riding on the infra onboarding. Co-Authored-By: Claude Fable 5.1 --- src/tools/capability-registry/register.ts | 48 +++---------- tests/tools/capabilityRegistryE2E.test.ts | 88 +++++++++-------------- 2 files changed, 41 insertions(+), 95 deletions(-) diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 9c7219b9..2a8bf5e9 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -290,36 +290,6 @@ export function addCapabilityRegistryTools( return failed(message, extra); }; - /** - * A question for the user, not a failure. - * - * A product clash stops the search just as a refusal does, but it is the EXPECTED path - * for an ambiguous request, and surfacing it as a tool error made every client render - * it in red and every agent narrate "the search failed". It is returned as an ordinary - * result whose `status` says what to do; the search still does not run. Counted in - * telemetry under `ask_user_reason`, next to the refusals it used to sit with. - */ - const askUser = ( - tool: string, - reason: string, - message: string, - extra: Record, - extras?: MCPEventExtras, - ): CallToolResult => { - try { - trackMCP(tool, server.server.getClientVersion()!, undefined, config, { - ask_user_reason: reason, - ...(extras ?? {}), - }); - } catch { - // Telemetry must not decide whether a tool call succeeds. - } - return ok({ status: "ask_user", reason, message, ...extra }); - }; - - /** The product's title for anything put to a person; the key stays the handle. */ - const label = (name: string) => registry.index.products[name]?.title ?? name; - tools.listProducts = server.tool( "listProducts", "List the BrowserStack products this surface can reach: what each one does, the " + @@ -380,6 +350,9 @@ export function addCapabilityRegistryTools( : { verdict: "settled", product: ambiguity.settled }, ) : undefined; + /** The product's title for the question put to a person; the key stays the handle. */ + const label = (name: string) => + registry.index.products[name]?.title ?? name; const routing = ambiguity.products.length > 1 ? { @@ -819,15 +792,12 @@ export function addCapabilityRegistryTools( .filter(Boolean); return { product: name, - ...(registry.index.products[name]?.title - ? { title: registry.index.products[name]?.title } - : {}), summary: registry.index.products[name]?.summary, ...(senses.length ? { shared_terms: senses } : {}), }; }); const named = anchor.products.length > 0; - return askUser( + return refuse( "searchCapability", "product_ambiguous", (blank @@ -839,15 +809,15 @@ export function addCapabilityRegistryTools( (ambiguity.because?.length ? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})` : "") + - `, so the request points at ${label(ambiguity.settled as string)}, not ${label(product)}. ` + + `, so the request points at ${ambiguity.settled}, not ${product}. ` + "Search the product the words name, or — if the user meant " + - `${label(product)} — get them to say so and quote them. ` - : `'${subject}' could mean ${ambiguity.products.map(label).join(" or ")} — ` + + `${product} — get them to say so and quote them. ` + : `'${subject}' could mean ${ambiguity.products.join(" or ")} — ` + `${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` + `${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. `) + (named ? `The words you quoted (${anchor.terms.map((t) => `'${t}'`).join(", ")}) ` + - `point at ${anchor.products.map(label).join(" and ")}, not ${label(product)}. ` + `point at ${anchor.products.join(" and ")}, not ${product}. ` : user_words ? "Nothing in the words you quoted names a product or a term only one " + "product uses. " @@ -860,7 +830,7 @@ export function addCapabilityRegistryTools( { routing_token: token, clarify: { - question: `Which product do you mean — ${choices.map(label).join(" or ")}?`, + question: `Which product do you mean — ${choices.join(" or ")}?`, shared: ambiguity.terms, options, }, diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts index a87ff91b..7d8d2e43 100644 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -25,15 +25,9 @@ const FIXTURE_DIR = fileURLToPath( */ const SHIPPED_DIR = mkdtempSync(join(tmpdir(), "cap-registry-multi-")); for (const [from, name] of [ + [new URL("../../capability/tm.capability-index.json", import.meta.url), "tm.capability-index.json"], [ - new URL("../../capability/tm.capability-index.json", import.meta.url), - "tm.capability-index.json", - ], - [ - new URL( - "../fixtures/capability-multi/secondproduct.capability-index.json", - import.meta.url, - ), + new URL("../fixtures/capability-multi/secondproduct.capability-index.json", import.meta.url), "secondproduct.capability-index.json", ], ] as const) { @@ -154,8 +148,9 @@ describe("capability registry, end to end through the server factory", () => { it("carries each entity's one-line meaning on the routing call", async () => { const server = await buildServer(); const listed = JSON.parse( - (await (server.getTools().listProducts as any).handler({}, {} as any)) - .content[0].text, + ( + await (server.getTools().listProducts as any).handler({}, {} as any) + ).content[0].text, ); const byName = Object.fromEntries( listed.products[0].entities.map((e: any) => [e.entity, e]), @@ -219,26 +214,21 @@ describe("capability registry, end to end through the server factory", () => { const server = await buildServer(); const search = server.getTools().searchCapability as any; const run = async (args: Record) => { - const r = await search.handler({ product: "tm", ...args }, {} as any); - const body = JSON.parse(r.content[0].text); - // The gate is a question for the user, not an error: it stops the search without - // flagging the call as failed. - return { - blocked: body.status === "ask_user" && r.isError !== true, - body, - }; + const r = await search.handler( + { product: "tm", ...args }, + {} as any, + ); + return { blocked: r.isError === true, body: JSON.parse(r.content[0].text) }; }; const refused = await run({ query: "list all projects" }); expect(refused.blocked).toBe(true); // The refusal has to be actionable: which products, and on which word. - // Products are named by title where they have one — tm's is "Test Management"; - // the fixture's second product declares none and reads as its key. - expect(refused.body.message).toContain("secondproduct or Test Management"); - expect(refused.body.message).toContain("'project'"); - expect(refused.body.message).toMatch(/to the USER/); + expect(refused.body.error).toContain("secondproduct or tm"); + expect(refused.body.error).toContain("'project'"); + expect(refused.body.error).toMatch(/to the USER/); // And it must name the failure mode it exists to stop. - expect(refused.body.message).toMatch(/search each product in turn and merge/); + expect(refused.body.error).toMatch(/search each product in turn and merge/); // A word only one product claims settles the query, so there is nothing to ask — // PROVIDED it settles on the product being searched. @@ -255,11 +245,10 @@ describe("capability registry, end to end through the server factory", () => { // settled, never whether it settled on what was being asked for. const crossed = await run({ query: "check my quota" }); expect(crossed.blocked).toBe(true); - expect(crossed.body.message).toMatch(/points at secondproduct, not Test Management/); - expect(crossed.body.message).toMatch(/only secondproduct claims \('quota'\)/); + expect(crossed.body.error).toMatch(/points at secondproduct, not tm/); + expect(crossed.body.error).toMatch(/only secondproduct claims \('quota'\)/); expect( - (await run({ query: "check my quota", product: "secondproduct" })) - .blocked, + (await run({ query: "check my quota", product: "secondproduct" })).blocked, ).toBe(false); // ONE WAY OUT, AND IT IS NOT A FLAG. `product_choice: 'user_confirmed'` used to open @@ -267,21 +256,15 @@ describe("capability registry, end to end through the server factory", () => { // caller wrote it on every ambiguous prompt with nothing behind it. What opens it now // is the user's own words, checked against the index rather than taken on trust. expect( - ( - await run({ - query: "list all projects", - user_words: "the test management one", - }) - ).blocked, + (await run({ + query: "list all projects", + user_words: "the test management one", + })).blocked, ).toBe(false); // And a flag by that name buys nothing, because there is no longer such an argument. expect( - ( - await run({ - query: "list all projects", - product_choice: "user_confirmed", - }) - ).blocked, + (await run({ query: "list all projects", product_choice: "user_confirmed" })) + .blocked, ).toBe(true); // `entity` IS NOT A WAY OUT, and used to be. It narrows within a product and says @@ -303,11 +286,7 @@ describe("capability registry, end to end through the server factory", () => { process.env.CAPABILITY_REGISTRY_INDEX_DIR = SHIPPED_DIR; const server = await buildServer(); const r: any = await (server.getTools().searchCapability as any).handler( - { - query: "list all projects", - product: "tm", - product_choice: "not_asked", - }, + { query: "list all projects", product: "tm", product_choice: "not_asked" }, {} as any, ); const { clarify } = JSON.parse(r.content[0].text); @@ -323,10 +302,7 @@ describe("capability registry, end to end through the server factory", () => { // word — the two things a user needs to answer without being shown a schema. const tm = clarify.options.find((o: any) => o.product === "tm"); expect(tm.summary).toMatch(/Test Management/); - expect(tm.shared_terms[0]).toMatchObject({ - term: "project", - entity: "project", - }); + expect(tm.shared_terms[0]).toMatchObject({ term: "project", entity: "project" }); expect(tm.shared_terms[0].means).toMatch(/top-level container/); // The second product ships no entity descriptions, so its sense of `project` has no @@ -345,13 +321,13 @@ describe("capability registry, end to end through the server factory", () => { process.env.CAPABILITY_REGISTRY_INDEX_DIR = SHIPPED_DIR; const server = await buildServer(); const search = server.getTools().searchCapability as any; - const blocked = async (query: string) => { - const r = await search.handler( - { query, product: "tm", product_choice: "not_asked" }, - {} as any, - ); - return JSON.parse(r.content[0].text).status === "ask_user"; - }; + const blocked = async (query: string) => + ( + await search.handler( + { query, product: "tm", product_choice: "not_asked" }, + {} as any, + ) + ).isError === true; // QUERY side: the entry is `project`, the user says "projects". Exact containment // missed this — and it is the query that was actually reported. From 81902724f494529a7de9d6bb3ac9a60cd74ecee0 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Fri, 23 Oct 2026 17:10:09 +0530 Subject: [PATCH 50/53] fix(capability): stop naming App Automate and Automate in the infra products' text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every session, upload, plan and dashboard line on the two infra products said "App Automate" or "Automate" — 28 mentions across titles, summaries, intents, guidance, parameter descriptions and key facts — so an agent driving a device narrated the brand on every step. The products are named for what they control, and their text now reads that way: device sessions, device minutes, the session dashboard, the device-session store, the browser-session plan. Where a line was really about the platform's own behaviour (which OS version is picked, when an idle session is ended) it says BrowserStack. App Live stays named where the point is that it is a different store. routing_terms keep "app automate" and "automate" so a user who says the brand still routes here. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 16 ++++---- .../mobiledevicecontrol.capability-index.json | 38 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index 98a31eca..f33aabd0 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -3,7 +3,7 @@ "version": "1.0", "build_id": "8445466_2026-10-19T06:53:46Z", "devicecontrol": { - "title": "Device Control (Automate)", + "title": "Device Control", "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, screenshot the viewport, resize the window, and move between tabs and iframes. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are mobile device control, which drives an installed app rather than a browser. Scrolling is done with scroll_to_element or scroll_page, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.", "routing_terms": [ "automate", @@ -97,11 +97,11 @@ "path": "/automate/plan.json", "mode": "read", "entity": "plan", - "intent": "Show the Automate plan and how many parallel browser sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "intent": "Show the browser-session plan and how many parallel browser sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", "guidance": [ "A start past the limit queues rather than failing, which looks like a slow start; this is how to tell the two apart.", "The counts are account-wide and include sessions other people and CI started.", - "This is the web plan. App Automate has its own, counted separately." + "This is the web plan. Mobile device sessions have their own plan, counted separately." ], "returns": [ "automate_plan", @@ -259,14 +259,14 @@ "ON MOBILE THE BROWSER IS NAMED AFTER THE PLATFORM, not the product: Chrome on an Android phone is \"android\", Safari on an iPhone is \"iphone\" and on an iPad \"ipad\". Samsung Internet is \"samsung\" and Chromium-based mobile browsers are \"chromium\". Asking for \"chrome\" or \"safari\" on a phone is refused.", "Take every value from list_browsers exactly as it spells them; an unlisted combination is refused rather than substituted.", "The session id comes back at value.sessionId \u2014 carry it into every other browser_session capability.", - "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes Automate minutes until it ends: always finish with set_session_status and then delete_session.", + "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes browser minutes until it ends: always finish with set_session_status and then delete_session.", "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured." ], "local": [ - "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the Automate dashboard \u2014 it needs a BrowserStack login in that browser." + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the session dashboard \u2014 it needs a BrowserStack login in that browser." ], "remote": [ "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." @@ -1675,7 +1675,7 @@ "Mark the outcome with set_session_status FIRST; an unmarked session reads as abandoned on the dashboard.", "The session record, video and logs survive and stay readable with get_session.", "A session already ended answers 404, which means it is gone rather than that something failed.", - "Every session consumes Automate minutes until it ends, so end it even when the run failed." + "Every session consumes browser minutes until it ends, so end it even when the run failed." ], "path_params": [ { @@ -2130,7 +2130,7 @@ ], "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element and is valid only until the page navigates or that part of the page re-renders.", "key_facts": [ - "A session holds a browser and consumes Automate minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "A session holds a browser and consumes browser minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", "Element ids go stale the moment the page navigates, reloads or re-renders, and a stale one is refused rather than silently acting on the wrong thing.", "Console and network logs must be asked for when the session starts; they cannot be turned on later.", "Each session is recorded with video and its record outlives it, including the shareable link and the passed or failed status.", @@ -2198,7 +2198,7 @@ }, "plan": { "title": "Plan", - "description": "The account's Automate subscription and how much of its parallel-session allowance is in use.", + "description": "The account's browser-session subscription and how much of its parallel-session allowance is in use.", "aliases": [ "plan", "subscription", diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index f88bc332..dc44450d 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -3,8 +3,8 @@ "version": "1.0", "build_id": "6782f7e_2026-10-16T13:54:22Z", "mobiledevicecontrol": { - "title": "Mobile Device Control (App Automate)", - "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; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", + "title": "Mobile Device Control", + "summary": "Mobile device control: run a real Android or iOS device and drive the app on it step by step. The account side lists the devices this account can use, including private ones, uploads and lists app builds, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link and video \u2014 after the device is released. The session side starts a device with an installed build and drives it: find elements and read their text, position and attributes, tap them, type, swipe, screenshot, press keys, rotate, switch between the native app and a web view, move between the native app and a web view, and launch or close apps. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Browsers are not here \u2014 those are device control, which drives a page rather than an installed app \u2014 and neither is App Live: it has no API an agent can drive, so agent sessions run here, on real devices, and consume the account's device minutes. A build must exist before a session starts; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", "routing_terms": [ "app automate", "app build", @@ -64,8 +64,8 @@ "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", - "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.", - "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the 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.", + "Every session occupies a device and consumes device minutes until it ends: always finish with set_session_status and then delete_session.", + "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the session dashboard and start the interactive session there. That window is two-way: a person's taps and scrolls reach the same device, so ask them to watch only, or find the element you are about to act on again before every action.", "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", @@ -79,7 +79,7 @@ "Someone can only WATCH AND INTERACT if interactive_debugging was set true in this call \u2014 it cannot be turned on once the session is running, it is off by default on Android, and it needs video left on. Ask for it up front when a person is going to follow along, because the alternative is restarting the session." ], "local": [ - "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the App Automate dashboard \u2014 it needs a BrowserStack login in that browser. Set interactive_debugging true in this call when they should be able to tap as well as watch; without it the view is watch-only." + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the session dashboard \u2014 it needs a BrowserStack login in that browser. Set interactive_debugging true in this call when they should be able to tap as well as watch; without it the view is watch-only." ], "remote": [ "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." @@ -102,14 +102,14 @@ "type": "string", "required": true, "json_path": "/capabilities/alwaysMatch/appium:deviceName", - "description": "Device model exactly as App Automate lists it.", + "description": "Device model exactly as list_devices gives it.", "example": "Google Pixel 6" }, { "name": "platform_version", "type": "string", "json_path": "/capabilities/alwaysMatch/appium:platformVersion", - "description": "OS version of that device, as App Automate lists it. Omit to let App Automate pick one for the device.", + "description": "OS version of that device, as list_devices gives it. Omit to let BrowserStack pick one for the device.", "example": "12.0" }, { @@ -117,7 +117,7 @@ "type": "string", "required": true, "json_path": "/capabilities/alwaysMatch/appium:app", - "description": "The bs:// app URL returned by an App Automate upload, or the custom_id given at upload time (the latest app with that id is used).", + "description": "The bs:// app URL returned by upload_app_build, or the custom_id given at upload time (the latest app with that id is used).", "example": "bs://0123456789abcdef0123456789abcdef01234567" }, { @@ -134,7 +134,7 @@ "maximum": 900, "default": 90, "json_path": "/capabilities/alwaysMatch/bstack:options/idleTimeout", - "description": "Seconds the session may sit without a command before App Automate ends it. Raise it when an agent drives the session step by step and may pause to think, or while a human watches; the device is held, and billed, for the whole idle period.", + "description": "Seconds the session may sit without a command before BrowserStack ends it. Raise it when an agent drives the session step by step and may pause to think, or while a human watches; the device is held, and billed, for the whole idle period.", "example": 300 }, { @@ -148,7 +148,7 @@ "name": "project_name", "type": "string", "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", - "description": "Groups sessions on the App Automate dashboard.", + "description": "Groups sessions on the session dashboard.", "example": "Mobile checkout" }, { @@ -203,7 +203,7 @@ "type": "integer", "minimum": 0, "json_path": "/capabilities/alwaysMatch/appium:newCommandTimeout", - "description": "Seconds the Appium server itself waits for the next command before ending the session. Distinct from idle_timeout, which is App Automate's own backstop; the shorter of the two wins.", + "description": "Seconds the Appium server itself waits for the next command before ending the session. Distinct from idle_timeout, which is BrowserStack's own backstop; the shorter of the two wins.", "example": 300 }, { @@ -955,10 +955,10 @@ "path": "/wd/hub/session/{session_id}", "mode": "write", "entity": "app_session", - "intent": "End a live App Automate session and release the device \u2014 use this when the flow is finished, after marking the session passed or failed.", + "intent": "End a live device session and release the device \u2014 use this when the flow is finished, after marking the session passed or failed.", "guidance": [ "There is no way to resume an ended session; any further step needs create_session again, which reinstalls the app.", - "The session's video, logs and pass/fail status stay on the App Automate dashboard after it ends.", + "The session's video, logs and pass/fail status stay on the session dashboard after it ends.", "Mark the result with set_session_status first \u2014 it also works after the session has ended, so this is an ordering preference rather than a last chance." ], "path_params": [ @@ -1056,7 +1056,7 @@ "path": "/app-automate/plan.json", "mode": "read", "entity": "plan", - "intent": "Show the App Automate plan and how many parallel sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "intent": "Show the device-session plan and how many parallel sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", "guidance": [ "A session start blocks and then queues when parallel_sessions_running has reached parallel_sessions_max_allowed, so a start that seems slow is usually this.", "team_parallel_sessions_max_allowed is the ceiling shared across everyone on the team and can be lower than the account's own maximum.", @@ -1090,7 +1090,7 @@ "path": "/app-automate/upload", "mode": "write", "entity": "app_build", - "intent": "Upload an app build to App Automate \u2014 the store device sessions install from, separate from App Live's \u2014 from a public download link, and get back the bs:// URL a session installs. Use this to make a new .apk, .aab or .ipa available to the devices an agent drives.", + "intent": "Upload an app build to the device-session store \u2014 the one sessions here install from, separate from App Live's \u2014 from a public download link, and get back the bs:// URL a session installs. Use this to make a new .apk, .aab or .ipa available to the devices an agent drives.", "guidance": [ "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", "Only the download-link form can be used here. The same endpoint also accepts a multipart upload carrying the file itself, which this surface cannot send \u2014 so for a build that exists only on the caller's own machine, give them that multipart request to run themselves and take the bs:// url it returns. The upload answers with the bs:// url directly: there is no intermediate storage, no presigned url and no second call. There is no way to hand the bytes over from here.", @@ -2880,7 +2880,7 @@ "entity": "app_build", "encoding": "multipart", "local_only": true, - "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.", + "intent": "Upload an app build that is on this machine to the device-session store and get back the bs:// URL a session installs \u2014 use this when the .apk, .aab or .ipa is a local file rather than a download link.", "guidance": { "shared": [ "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", @@ -3632,10 +3632,10 @@ ], "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element, valid only until the screen changes.", "key_facts": [ - "A session holds a real device and consumes App Automate minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "A session holds a real device and consumes device minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", "The app must already be uploaded before a session can start; a build is uploaded by giving a public download link, not by sending a file.", "Actions are decided from the UI tree: read the page source, act, then read it again to confirm the effect.", - "Each session is recorded with video and logs on the App Automate dashboard, including its passed or failed status.", + "Each session is recorded with video and logs on the session dashboard, including its passed or failed status.", "A session's record outlives the session: its status, shareable link and video stay available on the account after the device is released.", "Apps can be launched, closed and removed during a session, and anything removed comes back only when a later session installs it again." ], @@ -3749,7 +3749,7 @@ }, "plan": { "title": "Plan", - "description": "The account's App Automate subscription and how much of its parallel-session allowance is in use.", + "description": "The account's device-session subscription and how much of its parallel-session allowance is in use.", "aliases": [ "plan", "subscription", From 87f4968a17da6e9d25aa2adadc16d3e17b81eb73 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 27 Oct 2026 13:13:45 +0530 Subject: [PATCH 51/53] fix(tools): keep the three grown descriptions inside main's budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main now caps every tool description at 200 characters, or at the length it already had. Three of ours were over: the two Live descriptions written this week (287 and 213), and invokeCapability, which grew past its 852 when the `select` sentence was added. All three say the same things in fewer words — 185, 186 and 849. Co-Authored-By: Claude Fable 5.1 --- src/tools/applive.ts | 2 +- src/tools/capability-registry/register.ts | 23 ++++++++----------- src/tools/live.ts | 2 +- .../liveToolsPointAtCapabilities.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/tools/applive.ts b/src/tools/applive.ts index 8c941efe..c1829b80 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -87,7 +87,7 @@ export default function addAppLiveTools( // this tool ended with "my tools can start it but can't tap anything in it". The // description says so, and names the surface that can — only when that surface is on. const description = - "Open a manual App Live session for the user to operate by hand. It cannot be driven by the agent, and it uploads the app to App Live's own store, which agent-driven device sessions cannot use." + + "Open a manual App Live session for the user to operate by hand; the agent cannot drive it." + (capabilityRegistryEnabled() ? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability." : ""); diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 2a8bf5e9..904779da 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -1099,19 +1099,16 @@ export function addCapabilityRegistryTools( tools.invokeCapability = server.tool( "invokeCapability", - "Call a capability whose contract you have from describeCapability. Pass `name` exactly " + - "as given — that is the handle. Only when a result carries no `name` (some products " + - "do not publish them yet) pass `method` and `path` instead, exactly as returned. " + - "Arguments go in path_params / query / body under the spec's own names. One call " + - "makes exactly one request and returns the product's own response as sent — unless " + - "you pass `select`, which only capabilities whose contract carries `narrowable` " + - "accept, and which reports what it narrowed. When " + - "`completed` is false there is another page, which you fetch by sending the " + - "capability's own page parameter. If the mode is 'write' you MUST ask the user " + - "first, then resend with user_permission='granted' and a change_summary; both are " + - "recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " + - "archiving, closing and merging are ordinary writes and DO run, so read the mode and " + - "intent before confirming with the user.", + "Call a capability whose contract you have from describeCapability. Pass `name` exactly a" + + "s given — that is the handle; only when a result carries no `name` pass `method` and `pa" + + "th` instead. Arguments go in path_params / query / body under the spec's own names. One " + + "call makes one request and returns the product's response as sent; `select` narrows it o" + + "nly where the contract carries `narrowable`, and reports what it narrowed. When `complet" + + "ed` is false there is another page, fetched with the capability's own page parameter. If" + + " the mode is 'write' you MUST ask the user first, then resend with user_permission='gran" + + "ted' and a change_summary; both are recorded. Capabilities whose mode is 'destructive' a" + + "re refused outright — archiving, closing and merging are ordinary writes and DO run, so " + + "read the mode and intent before confirming with the user.", { name: z .string() diff --git a/src/tools/live.ts b/src/tools/live.ts index 6ad480d6..7306a4d8 100644 --- a/src/tools/live.ts +++ b/src/tools/live.ts @@ -128,7 +128,7 @@ export default function addBrowserLiveTools( // Same rule as runAppLiveSession: Live is for a person, and an agent that picks it for a // click-by-locator task ends up reporting that Live cannot click by locator. const description = - "Open a manual Live browser session (desktop or mobile) for the user to operate by hand. It cannot be driven by the agent." + + "Open a manual Live browser session for the user to operate by hand; the agent cannot drive it." + (capabilityRegistryEnabled() ? " To click, type or read the page yourself, use a devicecontrol session via invokeCapability." : ""); diff --git a/tests/tools/liveToolsPointAtCapabilities.test.ts b/tests/tools/liveToolsPointAtCapabilities.test.ts index e78df913..b195f642 100644 --- a/tests/tools/liveToolsPointAtCapabilities.test.ts +++ b/tests/tools/liveToolsPointAtCapabilities.test.ts @@ -38,7 +38,7 @@ describe("the Live tools say what they are for", () => { ); for (const name of ["runAppLiveSession", "runBrowserLiveSession"]) { expect(described[name]).toMatch(/manual/); - expect(described[name]).toMatch(/cannot be driven/); + expect(described[name]).toMatch(/cannot drive it/); } }); @@ -71,6 +71,6 @@ describe("the Live tools say what they are for", () => { expect(described.runAppLiveSession).not.toMatch(/invokeCapability/); expect(described.runBrowserLiveSession).not.toMatch(/invokeCapability/); // the manual warning stays regardless - expect(described.runAppLiveSession).toMatch(/cannot be driven/); + expect(described.runAppLiveSession).toMatch(/cannot drive it/); }); }); From 72dc02481af7975e3ba4f6e8a2787d9687d5fc17 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 27 Oct 2026 17:06:30 +0530 Subject: [PATCH 52/53] =?UTF-8?q?fix(capability):=20address=20the=20review?= =?UTF-8?q?=20of=20#491=20=E2=80=94=20pre-flight=20select,=20honest=20time?= =?UTF-8?q?outs,=20load-time=20fallback=20checks,=20processed=20products,?= =?UTF-8?q?=20no=20files=20on=20hosted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1. A bad `select` was refused only after the request had been sent, so a create_session carrying one really allocated a device and the id was lost; the retry took a second device. `assertSelectable` now runs with the other pre-flight checks, ahead of the consent prompt, and nothing is sent. M2. One 45 s timeout served every capability, including a build upload the guidance itself says takes minutes, and a cut-off read as "could not be reached", which invites the retry that uploads twice or holds two devices. A capability may declare `timeout_seconds` (uploads 600, create_session 180), and a cut-off now says the request was sent and may have taken effect — check before retrying — while a refused connection keeps its old message. M3. The runtime refused a destructive or uncallable fallback but never compared modes, so a read could have fallen back to a write without the consent a write gets. The loader now checks every chain at load: the target must exist, must not be destructive, and may not need more consent than the call that triggered it. M4. The loader kept the raw bundle for a product with nothing disabled, which dropped the per-surface guidance and the reference-only form from search, listProducts and describeEntity for exactly those products. The processed product is always used. M5. A `file` parameter could reach the hosted server's disk whenever an index author forgot `local_only`. `bind` now refuses file parameters outright under REMOTE_MCP, before any path check. Also: the tm, tra and loadtesting indexes are back to main's bytes — the `title` they were reformatted to carry (80k lines) comes from a code-level default until the export emits it; the `select` description is 118 characters; and the three tests that asserted the old behaviours now assert the new ones. Co-Authored-By: Claude Fable 5.1 --- .../devicecontrol.capability-index.json | 1 + capability/loadtesting.capability-index.json | 5867 +- .../mobiledevicecontrol.capability-index.json | 3 + capability/tm.capability-index.json | 99820 ++++++++-------- capability/tra.capability-index.json | 63045 +++++----- src/tools/capability-registry/bind.ts | 9 + src/tools/capability-registry/egress.ts | 32 +- src/tools/capability-registry/index-loader.ts | 59 +- src/tools/capability-registry/narrow.ts | 28 +- src/tools/capability-registry/register.ts | 35 +- src/tools/capability-registry/resolve.ts | 6 +- src/tools/capability-registry/types.ts | 8 + .../tools/capabilityRegistryDisabled.test.ts | 11 +- .../tools/capabilityRegistryFallback.test.ts | 33 +- .../capabilityRegistryProductTitle.test.ts | 8 +- .../capabilityRegistryReviewFixes.test.ts | 309 + 16 files changed, 84855 insertions(+), 84419 deletions(-) create mode 100644 tests/tools/capabilityRegistryReviewFixes.test.ts diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json index f33aabd0..76500fc4 100644 --- a/capability/devicecontrol.capability-index.json +++ b/capability/devicecontrol.capability-index.json @@ -251,6 +251,7 @@ "method": "POST", "path": "/wd/hub/session", "mode": "write", + "timeout_seconds": 180, "entity": "browser_session", "intent": "Start a browser session on a real desktop or mobile browser and get a session id to drive it with \u2014 use this to open a page and interact with it step by step.", "guidance": { diff --git a/capability/loadtesting.capability-index.json b/capability/loadtesting.capability-index.json index 106f1684..13299bb7 100644 --- a/capability/loadtesting.capability-index.json +++ b/capability/loadtesting.capability-index.json @@ -1,3002 +1,3001 @@ { - "schema_version": 1, - "version": "1.0", - "build_id": "43eba70_2026-09-02T14:26:51Z", - "loadtesting": { - "title": "Load Testing", - "summary": "Load and performance testing: run k6, JMeter, Gatling and Locust load tests at scale. List and inspect projects and load tests; create, update, start, stop and monitor runs; read run reports, AI insights and historical trends; compare runs; and check VU-hour quota and cost estimates. Test definitions, runs and their results live here. Account plan and billing do not \u2014 only VU-hour entitlement is exposed, via quota. There is no capability to delete a load test or schedule recurring runs \u2014 for those, point the user to the web dashboard. Each capability returns its full result in one call; reuse a result within a task rather than re-fetching the same data.", - "base_url": "https://load-api.browserstack.com", - "auth": { - "type": "http", - "scheme": "basic" - }, - "capabilities": [ - { - "name": "getLoadTestPlatformStatus", - "method": "GET", - "path": "/api/v1/agent/platform/status", - "mode": "read", - "entity": "platform", - "intent": "Check whether the Load Testing service is operational \u2014 use this if calls are failing or to confirm availability before starting work.", - "guidance": [ - "Always returns HTTP 200; read platformState (operational|degraded), not the status code.", - "Shallow dependency probe (database, redis) \u2014 it does not check a specific test or run." - ], - "returns": [ - "platformState", - "components", - "checkedAt" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestPlatformStatusData" + "schema_version": 1, + "version": "1.0", + "build_id": "43eba70_2026-09-02T14:26:51Z", + "loadtesting": { + "summary": "Load and performance testing: run k6, JMeter, Gatling and Locust load tests at scale. List and inspect projects and load tests; create, update, start, stop and monitor runs; read run reports, AI insights and historical trends; compare runs; and check VU-hour quota and cost estimates. Test definitions, runs and their results live here. Account plan and billing do not — only VU-hour entitlement is exposed, via quota. There is no capability to delete a load test or schedule recurring runs — for those, point the user to the web dashboard. Each capability returns its full result in one call; reuse a result within a task rather than re-fetching the same data.", + "base_url": "https://load-api.browserstack.com", + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "getLoadTestPlatformStatus", + "method": "GET", + "path": "/api/v1/agent/platform/status", + "mode": "read", + "entity": "platform", + "intent": "Check whether the Load Testing service is operational — use this if calls are failing or to confirm availability before starting work.", + "guidance": [ + "Always returns HTTP 200; read platformState (operational|degraded), not the status code.", + "Shallow dependency probe (database, redis) — it does not check a specific test or run." + ], + "returns": [ + "platformState", + "components", + "checkedAt" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestPlatformStatusData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "listLoadTestProjects", - "method": "GET", - "path": "/api/v1/agent/projects", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "search", - "type": "string", - "description": "Filter projects by name (<=200 chars)." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - } - ], - "intent": "List the caller's load-testing projects, most recently active first \u2014 use this to find a projectId or answer 'what projects do I have?'", - "guidance": [ - "projectId from here is required by listLoadTests and createLoadTest.", - "To find a project by name, pass search= so the server matches it in one call \u2014 do NOT page the whole project list to find one. Paging every project is the single largest source of redundant calls on this surface.", - "NEVER call this repeatedly with limit=1: it returns the same first project every time and makes no progress. To find one project use search=; to browse, make ONE call with a larger limit and page with cursor only if the user wants more.", - "search is group-scoped and may match projects in more than one project space; when it returns several, disambiguate by the projectId on each row \u2014 do not assume the first.", - "You already have this turn's results \u2014 reuse them; do not re-issue an identical call you already made.", - "Only enumerate with cursor + limit when the user actually wants the full list; nextCursor is opaque." - ], - "returns": [ - "projects", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" + }, + { + "name": "listLoadTestProjects", + "method": "GET", + "path": "/api/v1/agent/projects", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "search", + "type": "string", + "description": "Filter projects by name (<=200 chars)." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + } + ], + "intent": "List the caller's load-testing projects, most recently active first — use this to find a projectId or answer 'what projects do I have?'", + "guidance": [ + "projectId from here is required by listLoadTests and createLoadTest.", + "To find a project by name, pass search= so the server matches it in one call — do NOT page the whole project list to find one. Paging every project is the single largest source of redundant calls on this surface.", + "NEVER call this repeatedly with limit=1: it returns the same first project every time and makes no progress. To find one project use search=; to browse, make ONE call with a larger limit and page with cursor only if the user wants more.", + "search is group-scoped and may match projects in more than one project space; when it returns several, disambiguate by the projectId on each row — do not assume the first.", + "You already have this turn's results — reuse them; do not re-issue an identical call you already made.", + "Only enumerate with cursor + limit when the user actually wants the full list; nextCursor is opaque." + ], + "returns": [ + "projects", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListLoadTestProjectsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } }, - { - "properties": { - "data": { - "$schema": "ListLoadTestProjectsData" + "paginated": true, + "max_page_size": 100 + }, + { + "name": "listLoadTests", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/loadTests", + "mode": "read", + "entity": "loadTest", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "testType", + "type": "string", + "values": [ + "plu", + "blu", + "hybrid" + ], + "description": "Filter by test type." + }, + { + "name": "framework", + "type": "string", + "values": [ + "k6", + "jmeter", + "gatling", + "locust" + ], + "description": "Filter by framework." + }, + { + "name": "tag", + "type": "string", + "description": "Filter by tag." + }, + { + "name": "search", + "type": "string", + "description": "Filter by test name." + }, + { + "name": "updatedSince", + "type": "string", + "description": "ISO-8601 timestamp; only tests updated after it. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + } + ], + "intent": "List the load tests in a project — use this to find a testId or browse tests, optionally filtered by type, framework, tag or recent activity.", + "guidance": [ + "Resolve projectId via listLoadTestProjects first.", + "To find a test by name, pass search= so the server matches it in one call — do not page the whole project and match names yourself. In a large project this is the difference between one call and many.", + "If several tests match the name, disambiguate by lastRun / updatedSince — do not guess. A descriptive or auto-generated name (e.g. Automation_lts_*) is a real test, not noise; never silently drop it in favour of one that merely sounds more relevant.", + "The numeric testId returned here is what getLoadTest and startLoadTestRun need — not a runId.", + "lastRun summarises the most recent execution when present." + ], + "returns": [ + "tests", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListLoadTestsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "listLoadTests", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/loadTests", - "mode": "read", - "entity": "loadTest", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "testType", - "type": "string", - "values": [ - "plu", - "blu", - "hybrid" - ], - "description": "Filter by test type." - }, - { - "name": "framework", - "type": "string", - "values": [ - "k6", - "jmeter", - "gatling", - "locust" - ], - "description": "Filter by framework." - }, - { - "name": "tag", - "type": "string", - "description": "Filter by tag." - }, - { - "name": "search", - "type": "string", - "description": "Filter by test name." - }, - { - "name": "updatedSince", - "type": "string", - "description": "ISO-8601 timestamp; only tests updated after it. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - } - ], - "intent": "List the load tests in a project \u2014 use this to find a testId or browse tests, optionally filtered by type, framework, tag or recent activity.", - "guidance": [ - "Resolve projectId via listLoadTestProjects first.", - "To find a test by name, pass search= so the server matches it in one call \u2014 do not page the whole project and match names yourself. In a large project this is the difference between one call and many.", - "If several tests match the name, disambiguate by lastRun / updatedSince \u2014 do not guess. A descriptive or auto-generated name (e.g. Automation_lts_*) is a real test, not noise; never silently drop it in favour of one that merely sounds more relevant.", - "The numeric testId returned here is what getLoadTest and startLoadTestRun need \u2014 not a runId.", - "lastRun summarises the most recent execution when present." - ], - "returns": [ - "tests", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" }, - { - "properties": { - "data": { - "$schema": "ListLoadTestsData" + "paginated": true, + "max_page_size": 100 + }, + { + "name": "searchLoadTests", + "method": "GET", + "path": "/api/v1/agent/loadTests/search", + "mode": "read", + "entity": "loadTest", + "query": [ + { + "name": "name", + "type": "string", + "description": "Test name to search for (substring match, case-insensitive); required.", + "example": "checkout" + }, + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + } + ], + "intent": "Find a load test by name across ALL of the caller's projects — use this to resolve a named test to a testId without knowing which project it is in.", + "guidance": [ + "Group-scoped: unlike listLoadTests it needs no projectId. Prefer it to resolve a named test in one call instead of paging every project.", + "Each row carries testId, projectId, name, testType and framework — disambiguate same-named tests in different projects by projectId.", + "Returns one row per test (its latest version); the numeric testId is what getLoadTest and startLoadTestRun need.", + "hasMore:true means more matches exist beyond the returned limit — there is NO cursor and no page 2; narrow the name query or raise limit to surface them (results are the top matches, not a paginated list)." + ], + "returns": [ + "tests", + "hasMore" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "SearchLoadTestsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "searchLoadTests", - "method": "GET", - "path": "/api/v1/agent/loadTests/search", - "mode": "read", - "entity": "loadTest", - "query": [ - { - "name": "name", - "type": "string", - "description": "Test name to search for (substring match, case-insensitive); required.", - "example": "checkout" - }, - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - } - ], - "intent": "Find a load test by name across ALL of the caller's projects \u2014 use this to resolve a named test to a testId without knowing which project it is in.", - "guidance": [ - "Group-scoped: unlike listLoadTests it needs no projectId. Prefer it to resolve a named test in one call instead of paging every project.", - "Each row carries testId, projectId, name, testType and framework \u2014 disambiguate same-named tests in different projects by projectId.", - "Returns one row per test (its latest version); the numeric testId is what getLoadTest and startLoadTestRun need.", - "hasMore:true means more matches exist beyond the returned limit \u2014 there is NO cursor and no page 2; narrow the name query or raise limit to surface them (results are the top matches, not a paginated list)." - ], - "returns": [ - "tests", - "hasMore" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "SearchLoadTestsData" + }, + { + "name": "getLoadTestProjectTrends", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/trends", + "mode": "read", + "entity": "trend", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "windowRuns", + "type": "integer", + "description": "How many recent runs to aggregate (1-100, default 10)." + }, + { + "name": "aggregation", + "type": "string", + "values": [ + "perRun" + ], + "description": "Aggregation granularity. Only perRun is supported today." + }, + { + "name": "verdictFilter", + "type": "string", + "values": [ + "passed", + "failed", + "all" + ], + "description": "Restrict to runs of this verdict." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Show how a project's load-test metrics trend across recent runs — use this for 'is performance getting better or worse across this project?'", + "guidance": [ + "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", + "windowRuns bounds how many recent runs are aggregated.", + "Returns the whole windowed series across metrics in one call — request all needed metrics together; do not call once per metric or re-fetch the same window." + ], + "returns": [ + "metrics" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestProjectTrendsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestProjectTrends", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/trends", - "mode": "read", - "entity": "trend", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "windowRuns", - "type": "integer", - "description": "How many recent runs to aggregate (1-100, default 10)." - }, - { - "name": "aggregation", - "type": "string", - "values": [ - "perRun" - ], - "description": "Aggregation granularity. Only perRun is supported today." - }, - { - "name": "verdictFilter", - "type": "string", - "values": [ - "passed", - "failed", - "all" - ], - "description": "Restrict to runs of this verdict." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Show how a project's load-test metrics trend across recent runs \u2014 use this for 'is performance getting better or worse across this project?'", - "guidance": [ - "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", - "windowRuns bounds how many recent runs are aggregated.", - "Returns the whole windowed series across metrics in one call \u2014 request all needed metrics together; do not call once per metric or re-fetch the same window." - ], - "returns": [ - "metrics" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" + }, + { + "name": "listProjectLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/projects/{projectId}/runs", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "status", + "type": "string", + "values": [ + "active", + "terminal", + "all" + ], + "description": "Filter by lifecycle state." + }, + { + "name": "verdict", + "type": "string", + "values": [ + "passed", + "failed", + "error" + ], + "description": "Filter by run verdict." + }, + { + "name": "startedBy", + "type": "string", + "description": "Filter by the user id that started the run." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-08-01T00:00:00Z" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "hadSlaBreach", + "type": "boolean", + "description": "Filter to runs that did / did not breach SLA." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." + } + ], + "intent": "List the execution history across every load test in a project — newest first, paginated. Use for 'what ran last week in this project?'.", + "guidance": [ + "Returns run metadata only, not metrics — use getLoadTestRunReport for a run's KPIs.", + "Each run carries a tags array — its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from its test. Filter with the tag query param.", + "Page with cursor + limit; nextCursor is opaque.", + "For a single test's history use /loadTests/{testId}/runs; for currently-live runs use /loadTests/runs/active.", + "One call returns the page of run history — reuse it for follow-ups and page with cursor only when more rows are needed; do not re-list the same window." + ], + "returns": [ + "runs", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListProjectLoadTestRunsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } }, - { - "properties": { - "data": { - "$schema": "GetLoadTestProjectTrendsData" + "paginated": true, + "max_page_size": 100 + }, + { + "name": "listActiveLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/active", + "mode": "read", + "entity": "run", + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-200, default 50).", + "minimum": 1, + "maximum": 200 + }, + { + "name": "minElapsedSec", + "type": "integer", + "description": "Only runs elapsed at least this many seconds." + }, + { + "name": "projectId", + "type": "integer", + "description": "Restrict to a project." + }, + { + "name": "userId", + "type": "integer", + "description": "Restrict to runs started by a user." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." + } + ], + "intent": "List load-test runs currently executing — use this for 'what's running right now?' or to find a runId to stop or monitor.", + "guidance": [ + "Returns non-terminal runs across the account.", + "runId here is the UUID needed by getLoadTestRunStatus and stopLoadTestRun.", + "Each run carries a tags array — its own run-level tags if set, otherwise the tags inherited from its test. Filter with the tag query param.", + "vuHoursBurnedSoFar reflects consumption at the moment of the call." + ], + "returns": [ + "runs" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListActiveLoadTestRunsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "listProjectLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/projects/{projectId}/runs", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "status", - "type": "string", - "values": [ - "active", - "terminal", - "all" - ], - "description": "Filter by lifecycle state." - }, - { - "name": "verdict", - "type": "string", - "values": [ - "passed", - "failed", - "error" - ], - "description": "Filter by run verdict." - }, - { - "name": "startedBy", - "type": "string", - "description": "Filter by the user id that started the run." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-08-01T00:00:00Z" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "hadSlaBreach", - "type": "boolean", - "description": "Filter to runs that did / did not breach SLA." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." - } - ], - "intent": "List the execution history across every load test in a project \u2014 newest first, paginated. Use for 'what ran last week in this project?'.", - "guidance": [ - "Returns run metadata only, not metrics \u2014 use getLoadTestRunReport for a run's KPIs.", - "Each run carries a tags array \u2014 its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from its test. Filter with the tag query param.", - "Page with cursor + limit; nextCursor is opaque.", - "For a single test's history use /loadTests/{testId}/runs; for currently-live runs use /loadTests/runs/active.", - "One call returns the page of run history \u2014 reuse it for follow-ups and page with cursor only when more rows are needed; do not re-list the same window." - ], - "returns": [ - "runs", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListProjectLoadTestRunsData" + }, + { + "name": "getLoadTestRunStatus", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/status", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "example": "b1e2c3d4-...", + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + } + ], + "intent": "Poll the live status and progress of a run — use this to wait for a run to finish or check elapsed/remaining time.", + "guidance": [ + "jobId is the run UUID from startLoadTestRun or listLoadTestRuns.", + "Respect pollAfterSeconds between polls instead of tight-looping.", + "slaBreachFlags surfaces threshold breaches as they trip.", + "For 'is it done?' / 'how much time is left?', this status call is sufficient — do not fetch the run report to answer a status question." + ], + "returns": [ + "status", + "elapsedSec", + "remainingSec", + "pollAfterSeconds", + "slaBreachFlags" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestRunStatusData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "listActiveLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/active", - "mode": "read", - "entity": "run", - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-200, default 50).", - "minimum": 1, - "maximum": 200 - }, - { - "name": "minElapsedSec", - "type": "integer", - "description": "Only runs elapsed at least this many seconds." - }, - { - "name": "projectId", - "type": "integer", - "description": "Restrict to a project." - }, - { - "name": "userId", - "type": "integer", - "description": "Restrict to runs started by a user." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from its test when it has none of its own)." - } - ], - "intent": "List load-test runs currently executing \u2014 use this for 'what's running right now?' or to find a runId to stop or monitor.", - "guidance": [ - "Returns non-terminal runs across the account.", - "runId here is the UUID needed by getLoadTestRunStatus and stopLoadTestRun.", - "Each run carries a tags array \u2014 its own run-level tags if set, otherwise the tags inherited from its test. Filter with the tag query param.", - "vuHoursBurnedSoFar reflects consumption at the moment of the call." - ], - "returns": [ - "runs" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListActiveLoadTestRunsData" + }, + { + "name": "getLoadTestInsightSummary", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/insights", + "mode": "read", + "entity": "insight", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "sections", + "type": "string", + "values": [ + "summary", + "rootCause", + "recommendations", + "evidence" + ], + "description": "Comma-separated subset of insight sections (default summary)." + } + ], + "intent": "Get the AI insight (summary, root cause, recommendations) for a completed run — use this for 'why did this run regress or fail?'", + "guidance": [ + "This is the canonical answer to a 'summarize this run' / 'give me a summary' request: return ONLY these AI insight summary points and stop. Do NOT also call getLoadTestRunReport or append KPIs / web vitals / transactions unless the user explicitly asks for them.", + "Only meaningful after the run is terminal; a running job returns an in_progress state, not a report.", + "May return 503 if the insight backend is unavailable — retry later. If no insight exists for the run, fall back to a brief status + headline KPIs from getLoadTestRunReport, not the full report." + ], + "returns": [ + "state", + "status", + "report" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestInsightSummaryData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + }, + "503": { + "$response": "ServiceUnavailable" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestRunStatus", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/status", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "example": "b1e2c3d4-...", - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - } - ], - "intent": "Poll the live status and progress of a run \u2014 use this to wait for a run to finish or check elapsed/remaining time.", - "guidance": [ - "jobId is the run UUID from startLoadTestRun or listLoadTestRuns.", - "Respect pollAfterSeconds between polls instead of tight-looping.", - "slaBreachFlags surfaces threshold breaches as they trip.", - "For 'is it done?' / 'how much time is left?', this status call is sufficient \u2014 do not fetch the run report to answer a status question." - ], - "returns": [ - "status", - "elapsedSec", - "remainingSec", - "pollAfterSeconds", - "slaBreachFlags" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestRunStatusData" + }, + { + "name": "getLoadTestRunReport", + "method": "GET", + "path": "/api/v1/agent/loadTests/runs/{jobId}/report", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId)." + } + ], + "query": [ + { + "name": "detail", + "type": "string", + "values": [ + "aggregate", + "per-txn", + "full" + ], + "description": "Level of detail (default aggregate)." + }, + { + "name": "view", + "type": "string", + "values": [ + "summary", + "detail", + "full" + ], + "description": "Response view." + }, + { + "name": "topN", + "type": "integer", + "description": "Top-N transactions/errors to include (1-100, default 10)." + }, + { + "name": "byteCap", + "type": "integer", + "description": "Hard response size cap in KB (1-256, default 128). Sets meta.truncated when hit." + }, + { + "name": "groupBy", + "type": "string", + "description": "Rollup dimension (url/browser/location/transaction/step)." + }, + { + "name": "slaOnly", + "type": "boolean", + "description": "Return only SLA-related data." + }, + { + "name": "errorCategory", + "type": "string", + "description": "Comma-separated error categories — any combination of: 5xx, 4xx, timeout, connection, assertion (e.g. \"5xx,4xx\")." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound; slices the report to data in this time window. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound for the time-window slice. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Get the full metrics report for a completed run — KPIs, SLA verdicts, per-transaction and error breakdowns. Use this to answer 'how did this run perform?'", + "guidance": [ + "Only valid for a terminal run.", + "Start with detail=aggregate; escalate to per-txn or full only when you need transaction- or network-level detail — full is large.", + "groupBy and errorCategory narrow the payload; byteCap hard-caps the response (max 256 KB).", + "metrics accepts dotted names or @ aliases; see getLoadTestMetricsManifest for what applies to this test type.", + "Answer a scoped question with the narrowest slice instead of the whole report: one metric → metrics=; error breakdown → errorCategory= (or slaOnly=true); a time window → sinceIso/untilIso; slowest transaction → detail=per-txn with groupBy=transaction and topN.", + "One call with the right params returns everything for that question — do not re-fetch the same run with the same params. Fetch the full report (view=full / detail=full) only when the user explicitly asks for the raw or complete report.", + "A plain 'summarize this run' / 'give me a summary' request is answered by getLoadTestInsightSummary, NOT this endpoint. Only call this when the user asks for specific KPIs, SLA verdicts, web vitals, transactions, or the raw/complete report.", + "durationSec is the run's wall-clock length in WHOLE SECONDS. When presenting it to the user, render it as minutes and seconds (e.g. 330 -> '5m 30s', 90 -> '1m 30s', 45 -> '45s'), not as a raw seconds count; keep the raw seconds only for calculations." + ], + "returns": [ + "runId", + "testType", + "status", + "testName", + "durationSec", + "kpis", + "slaVerdicts", + "transactions", + "errorsByCategory", + "meta" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestRunReportData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestInsightSummary", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/insights", - "mode": "read", - "entity": "insight", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "sections", - "type": "string", - "values": [ - "summary", - "rootCause", - "recommendations", - "evidence" - ], - "description": "Comma-separated subset of insight sections (default summary)." - } - ], - "intent": "Get the AI insight (summary, root cause, recommendations) for a completed run \u2014 use this for 'why did this run regress or fail?'", - "guidance": [ - "This is the canonical answer to a 'summarize this run' / 'give me a summary' request: return ONLY these AI insight summary points and stop. Do NOT also call getLoadTestRunReport or append KPIs / web vitals / transactions unless the user explicitly asks for them.", - "Only meaningful after the run is terminal; a running job returns an in_progress state, not a report.", - "May return 503 if the insight backend is unavailable \u2014 retry later. If no insight exists for the run, fall back to a brief status + headline KPIs from getLoadTestRunReport, not the full report." - ], - "returns": [ - "state", - "status", - "report" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestInsightSummaryData" + }, + { + "name": "stopLoadTestRun", + "method": "POST", + "path": "/api/v1/agent/loadTests/runs/{jobId}/stop", + "mode": "write", + "entity": "run", + "path_params": [ + { + "name": "jobId", + "type": "string", + "required": true, + "description": "Run UUID (runId) to stop." + } + ], + "intent": "Stop a running load test — use this to abort an execution that is running or queued.", + "guidance": [ + "Idempotent: stopping an already-terminal run returns alreadyTerminal:true, not an error.", + "Returns status:'stopping'; poll getLoadTestRunStatus to confirm it reaches a terminal state." + ], + "returns": [ + "runId", + "status", + "stoppedAt" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "StopLoadTestRunData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - }, - "503": { - "$response": "ServiceUnavailable" - } - } - }, - { - "name": "getLoadTestRunReport", - "method": "GET", - "path": "/api/v1/agent/loadTests/runs/{jobId}/report", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId)." - } - ], - "query": [ - { - "name": "detail", - "type": "string", - "values": [ - "aggregate", - "per-txn", - "full" - ], - "description": "Level of detail (default aggregate)." - }, - { - "name": "view", - "type": "string", - "values": [ - "summary", - "detail", - "full" - ], - "description": "Response view." - }, - { - "name": "topN", - "type": "integer", - "description": "Top-N transactions/errors to include (1-100, default 10)." - }, - { - "name": "byteCap", - "type": "integer", - "description": "Hard response size cap in KB (1-256, default 128). Sets meta.truncated when hit." - }, - { - "name": "groupBy", - "type": "string", - "description": "Rollup dimension (url/browser/location/transaction/step)." - }, - { - "name": "slaOnly", - "type": "boolean", - "description": "Return only SLA-related data." - }, - { - "name": "errorCategory", - "type": "string", - "description": "Comma-separated error categories \u2014 any combination of: 5xx, 4xx, timeout, connection, assertion (e.g. \"5xx,4xx\")." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound; slices the report to data in this time window. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound for the time-window slice. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Get the full metrics report for a completed run \u2014 KPIs, SLA verdicts, per-transaction and error breakdowns. Use this to answer 'how did this run perform?'", - "guidance": [ - "Only valid for a terminal run.", - "Start with detail=aggregate; escalate to per-txn or full only when you need transaction- or network-level detail \u2014 full is large.", - "groupBy and errorCategory narrow the payload; byteCap hard-caps the response (max 256 KB).", - "metrics accepts dotted names or @ aliases; see getLoadTestMetricsManifest for what applies to this test type.", - "Answer a scoped question with the narrowest slice instead of the whole report: one metric \u2192 metrics=; error breakdown \u2192 errorCategory= (or slaOnly=true); a time window \u2192 sinceIso/untilIso; slowest transaction \u2192 detail=per-txn with groupBy=transaction and topN.", - "One call with the right params returns everything for that question \u2014 do not re-fetch the same run with the same params. Fetch the full report (view=full / detail=full) only when the user explicitly asks for the raw or complete report.", - "A plain 'summarize this run' / 'give me a summary' request is answered by getLoadTestInsightSummary, NOT this endpoint. Only call this when the user asks for specific KPIs, SLA verdicts, web vitals, transactions, or the raw/complete report.", - "durationSec is the run's wall-clock length in WHOLE SECONDS. When presenting it to the user, render it as minutes and seconds (e.g. 330 -> '5m 30s', 90 -> '1m 30s', 45 -> '45s'), not as a raw seconds count; keep the raw seconds only for calculations." - ], - "returns": [ - "runId", - "testType", - "status", - "testName", - "durationSec", - "kpis", - "slaVerdicts", - "transactions", - "errorsByCategory", - "meta" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestRunReportData" + }, + { + "name": "getLoadTestQuota", + "method": "GET", + "path": "/api/v1/agent/loadTests/quota", + "mode": "read", + "entity": "quota", + "intent": "Check the account's remaining VU-hours and concurrency headroom — use this before starting runs to confirm capacity.", + "guidance": [ + "Account-scoped from the caller's credentials; takes no parameters.", + "Pair with estimateLoadTestRunCost to check a specific run fits before starting it." + ], + "returns": [ + "plan", + "vuHours", + "concurrency" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestQuotaData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "stopLoadTestRun", - "method": "POST", - "path": "/api/v1/agent/loadTests/runs/{jobId}/stop", - "mode": "write", - "entity": "run", - "path_params": [ - { - "name": "jobId", - "type": "string", - "required": true, - "description": "Run UUID (runId) to stop." - } - ], - "intent": "Stop a running load test \u2014 use this to abort an execution that is running or queued.", - "guidance": [ - "Idempotent: stopping an already-terminal run returns alreadyTerminal:true, not an error.", - "Returns status:'stopping'; poll getLoadTestRunStatus to confirm it reaches a terminal state." - ], - "returns": [ - "runId", - "status", - "stoppedAt" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "StopLoadTestRunData" + }, + { + "name": "estimateLoadTestRunCost", + "method": "POST", + "path": "/api/v1/agent/loadTests/estimate", + "mode": "read", + "entity": "quota", + "body": [ + { + "name": "testId", + "type": "integer", + "required": true, + "description": "The test to price.", + "example": 1234 + }, + { + "name": "overrides", + "type": "object", + "description": "Hypothetical run configuration to price instead of the saved one.", + "fields": [ + { + "name": "vus", + "type": "integer" + }, + { + "name": "durationSec", + "type": "integer" + } + ] + } + ], + "intent": "Estimate the VU-hour cost of running a test before starting it — use this to answer 'how much will this run cost / will it fit in my quota?'", + "guidance": [ + "Read-only despite being a POST — nothing is started.", + "fitsInQuota / remainingAfterEstimate compare the estimate against current quota.", + "For the same check at start time, call startLoadTestRun with dryRun:true.", + "Always use this for a cost or VU-hour estimate of a hypothetical run — it models ramp-up. Do not compute VU-hours by hand from VUs × duration.", + "An API (plu) run is billed at a full load-generator pod's capacity (e.g. 1000 VUs for k6), not the VUs it actually uses, and carries a minimum billing floor (5 minutes) — so a small or short plu run can estimate far higher than VUs × duration would suggest. That is expected; report the returned estimatedVuHours as-is rather than second-guessing it as an error.", + "The estimate is for the test's OWN type: a browser (blu) run costs about 10x an API (plu) run at the same VUs and duration (browser VUs carry a 10x weight). So the number depends heavily on whether the test is plu, blu or hybrid — always state which type the estimate is for. When the user asks about a hypothetical run without fixing the type, do not silently inherit the type of whatever test you priced against: say the type explicitly, and if it is genuinely open, give both the plu and blu figures (they differ ~10x) or ask which they mean." + ], + "returns": [ + "estimatedVuHours", + "estimationBasis", + "rangeBasis", + "rangeExplanation", + "fitsInQuota", + "remainingAfterEstimate" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "EstimateLoadTestRunCostData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestQuota", - "method": "GET", - "path": "/api/v1/agent/loadTests/quota", - "mode": "read", - "entity": "quota", - "intent": "Check the account's remaining VU-hours and concurrency headroom \u2014 use this before starting runs to confirm capacity.", - "guidance": [ - "Account-scoped from the caller's credentials; takes no parameters.", - "Pair with estimateLoadTestRunCost to check a specific run fits before starting it." - ], - "returns": [ - "plan", - "vuHours", - "concurrency" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestQuotaData" + }, + { + "name": "compareLoadTestRuns", + "method": "POST", + "path": "/api/v1/agent/loadTests/compare", + "mode": "read", + "entity": "report", + "body": [ + { + "name": "baselineRunId", + "type": "string", + "required": true, + "description": "Run UUID to compare against.", + "format": "uuid", + "example": "6f1d2c9a-7b3e-4a10-9c22-0e5f8a1b2c3d" + }, + { + "name": "candidateRunId", + "type": "string", + "required": true, + "description": "Run UUID being evaluated; must differ from baselineRunId.", + "format": "uuid", + "example": "b2e4f6a8-1c3d-4e5f-8a90-2b4c6d8e0f11" + }, + { + "name": "dimensions", + "type": "array", + "description": "Which delta dimensions to compute (any of kpi, transaction, sla).", + "values": [ + "kpi", + "transaction", + "sla" + ] + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. latency.p95, @vitals). Filters kpiDeltas; the first entry with a per-transaction value also picks the transaction metric. Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "groupBy", + "type": "string", + "description": "Axis for transaction deltas (default transaction = request name).", + "values": [ + "transaction", + "label", + "threadGroup", + "scenario" + ] + }, + { + "name": "topN", + "type": "integer", + "description": "Top-N deltas to include (1-100)." + }, + { + "name": "regressedOnly", + "type": "boolean", + "description": "Return only regressed transaction rows." + }, + { + "name": "pctChangeMin", + "type": "number", + "description": "Minimum absolute percent change for a KPI or transaction row to be included." + } + ], + "intent": "Compare two executions (runs) of the SAME load test and surface the deltas — the regression check ('did the latest run regress vs the previous one?'). Not for comparing different tests: runs of different tests are rejected (DIFFERENT_TESTS). For a trend across more than two runs use getLoadTestHistoricalTrends.", + "guidance": [ + "Pick both runs from listLoadTestRuns for ONE testId, filtered to finished runs (status=terminal). The older run is the baseline, the newer the candidate.", + "'Compare the latest run' means the latest finished run (candidate) vs the finished run before it (baseline).", + "Both runs must be finished and must share the same test types — otherwise the call fails with VALIDATION_ERROR. Runs of different tests fail with DIFFERENT_TESTS; do not retry with other tests' runs.", + "Use this to compare two runs — it returns per-KPI, per-transaction and SLA-verdict deltas from the same data as the Compare Runs page. Do not fetch both run reports and diff them yourself.", + "pctChange is signed (candidate vs baseline); direction/regressed already account for whether higher or lower is better — report those, do not re-derive them. direction \"unknown\" (regressed null) means one or both runs have no value for that metric: report it as missing data, never as no change.", + "Transaction deltas are computed on one metric: the first metrics entry that has a per-transaction value (latency.*, errors.rate, throughput.*), else latency.p95. groupBy picks the transaction axis.", + "regressedOnly + pctChangeMin filter to material regressions; regressedOnly applies to transactionDeltas, pctChangeMin to kpiDeltas and transactionDeltas.", + "Check warnings before reporting: they name metrics with no data on a run, runs whose SLA thresholds were not evaluated, SLA definitions that changed between the runs, and requested metrics compare cannot serve (e.g. engine.*). Surface any warning to the user." + ], + "returns": [ + "baseline", + "candidate", + "kpiDeltas", + "transactionDeltas", + "slaVerdictChanges", + "warnings" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "CompareLoadTestRunsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "estimateLoadTestRunCost", - "method": "POST", - "path": "/api/v1/agent/loadTests/estimate", - "mode": "read", - "entity": "quota", - "body": [ - { - "name": "testId", - "type": "integer", - "required": true, - "description": "The test to price.", - "example": 1234 - }, - { - "name": "overrides", - "type": "object", - "description": "Hypothetical run configuration to price instead of the saved one.", - "fields": [ - { - "name": "vus", - "type": "integer" - }, - { - "name": "durationSec", - "type": "integer" - } - ] - } - ], - "intent": "Estimate the VU-hour cost of running a test before starting it \u2014 use this to answer 'how much will this run cost / will it fit in my quota?'", - "guidance": [ - "Read-only despite being a POST \u2014 nothing is started.", - "fitsInQuota / remainingAfterEstimate compare the estimate against current quota.", - "For the same check at start time, call startLoadTestRun with dryRun:true.", - "Always use this for a cost or VU-hour estimate of a hypothetical run \u2014 it models ramp-up. Do not compute VU-hours by hand from VUs \u00d7 duration.", - "An API (plu) run is billed at a full load-generator pod's capacity (e.g. 1000 VUs for k6), not the VUs it actually uses, and carries a minimum billing floor (5 minutes) \u2014 so a small or short plu run can estimate far higher than VUs \u00d7 duration would suggest. That is expected; report the returned estimatedVuHours as-is rather than second-guessing it as an error.", - "The estimate is for the test's OWN type: a browser (blu) run costs about 10x an API (plu) run at the same VUs and duration (browser VUs carry a 10x weight). So the number depends heavily on whether the test is plu, blu or hybrid \u2014 always state which type the estimate is for. When the user asks about a hypothetical run without fixing the type, do not silently inherit the type of whatever test you priced against: say the type explicitly, and if it is genuinely open, give both the plu and blu figures (they differ ~10x) or ask which they mean." - ], - "returns": [ - "estimatedVuHours", - "estimationBasis", - "rangeBasis", - "rangeExplanation", - "fitsInQuota", - "remainingAfterEstimate" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" + }, + { + "name": "createLoadTest", + "method": "POST", + "path": "/api/v1/agent/loadTests", + "mode": "write", + "entity": "loadTest", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "maxLength": 255, + "description": "Test name. Max 255 characters — truncate before sending; a longer name is rejected with a 400.", + "example": "checkout-baseline" + }, + { + "name": "testType", + "type": "string", + "required": true, + "description": "plu = API load (server-side, non-browser); blu = real-browser load; hybrid = BOTH in one test (one PLU leg + one BLU leg, supplied via children). These tokens are INTERNAL wire values — send them here but never show them to the user; the user-facing labels are 'API' (plu), 'Browser' (blu) and 'Hybrid' (hybrid). All three are valid, user-selectable options — hybrid is not a rare/advanced variant, offer it alongside the other two.", + "values": [ + "plu", + "blu", + "hybrid" + ] + }, + { + "name": "framework", + "type": "string", + "required": true, + "description": "The framework MUST match testType (the backend rejects a mismatched pair). Offer ONLY the frameworks for the already-chosen testType — read them from valuesByTestType[testType] below — and NEVER present the full `values` list across types. API (plu): k6, jmeter, gatling, locust. Browser (blu): playwright, selenium, webdriverio, nightwatch, lcncnightwatch. For a hybrid test this top-level framework is the Browser leg; the API leg's framework goes in children[].", + "scopedBy": "testType", + "valuesByTestType": { + "plu": [ + "k6", + "jmeter", + "gatling", + "locust" + ], + "blu": [ + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ], + "hybrid": [ + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ] + }, + "values": [ + "k6", + "jmeter", + "gatling", + "locust", + "playwright", + "selenium", + "webdriverio", + "nightwatch", + "lcncnightwatch" + ] + }, + { + "name": "projectId", + "type": "integer", + "description": "Target project. Provide this OR projectName, not both." + }, + { + "name": "projectName", + "type": "string", + "description": "Target project by name; created if it does not exist. Provide this OR projectId." + }, + { + "name": "config", + "type": "object", + "description": "Test configuration incl. scriptRef {source: s3|s3_upload|existing_zip_id|sample, value|identifier}, loadProfile (constant|ramping|iterations|throughput), vus, vuRamp, durationSec, iterations, targetRps, maxVus, loadGeneratorLocations, slaThresholds, envVarKeys, tags. See the load-profile guidance for which fields each loadProfile needs (iterations + throughput are PLU-only). To supply a script from a local file or URL, use the pendingScriptUpload flow, then source='s3'. To create a test from the platform's built-in sample (no upload), use scriptRef:{source:'sample'} — for Selenium also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber)." + }, + { + "name": "children", + "type": "array", + "description": "Child scenarios for a hybrid test. A hybrid is exactly one PLU (API) leg + one BLU (browser) leg — each leg carries its own framework + scriptRef (a sample or an uploaded script). Send the BLU leg as the top-level testType='blu'/framework/config and put the PLU leg in children[] (one entry, testType='plu' with its framework + config.scriptRef). The backend requires exactly one PLU and one BLU leg and rejects any other composition." + }, + { + "name": "idempotencyKey", + "type": "string", + "description": "Dedupe key so a retried create does not duplicate the test." + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + }, + { + "name": "pendingScriptUpload", + "type": "object", + "description": "Request a presigned upload URL instead of creating; returns uploadUrl + s3Key to PUT the script to, then call create again with config.scriptRef.source='s3_upload'.", + "fields": [ + { + "name": "framework", + "type": "string" + }, + { + "name": "expectedContentType", + "type": "string" + } + ] + } + ], + "intent": "Create a new load test in a project — use this to define a test before running it. JSON body only (no multipart).", + "guidance": [ + "PRECONDITION — do NOT call this endpoint until THREE things are known: (1) testType (plu/blu/hybrid), (2) framework, (3) the script (a built-in sample or the user's own upload). These are MANDATORY and NOT defaultable — never infer or default them. If the user's request does not state one of the three, ASK the user for the missing ones and WAIT for their answer before creating. 'create a test' with no type/framework/script named means all three are missing → ask all three; do not silently pick k6/plu/sample. Only vus, duration, region and load profile are defaultable (see the DEFAULTS line) — the three above are not.", + "'plu' / 'blu' / 'hybrid' are INTERNAL wire values — send them in the request, but NEVER show them to the user. Do not print them in questions, option labels, confirmations, summaries, or prose (no 'Browser (BLU)', no '(plu+blu)'). User-facing labels are: plu → 'API', blu → 'Browser', hybrid → 'Hybrid (both API and Browser)'. Frameworks (k6, jmeter, selenium, playwright, …) are real product names and ARE shown to the user.", + "When you ASK the user to pick testType or framework, present the FULL, exact option set — never drop, merge, or cross-contaminate options. testType: offer all THREE, using the user-facing labels above — 'API', 'Browser', and 'Hybrid'; do not omit Hybrid. framework (ask AFTER testType is chosen, and show only that type's frameworks — the authoritative per-type list is the `framework.valuesByTestType[testType]` array; present exactly those, each as its OWN separate option, and NEVER the full cross-type `values` list): for an API test offer exactly k6, jmeter, gatling AND locust (all four — do not drop locust); for a Browser test offer exactly playwright, selenium, webdriverio AND nightwatch (all four — never list the API frameworks under a Browser test, and never merge selenium and playwright into one option). For a Hybrid test, collect an API leg (its framework + script) AND a Browser leg (its framework + script) and assemble them per the children field — Hybrid needs both legs, so it takes more than the three questions a single-type test does.", + "Provide exactly one of projectId or projectName; projectName creates the project if absent.", + "Script upload is two-phase: send pendingScriptUpload to get a presigned uploadUrl + s3Key, PUT the file, then call create again with the scriptRef source and s3Key returned in the response's nextStep.", + "To spin up a test from a built-in sample with NO file handling (the dashboard's 'use sample script'), pass scriptRef:{source:'sample'} — the platform's bundled sample for the given framework is used. For Selenium, also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber) to pick the binding. testType and framework are still needed; the load profile defaults to constant 1 VU / 30s (see below).", + "Pass idempotencyKey so a retried create does not duplicate the test.", + "Only THREE things must be known before creating, and each is taken from the user's request when they've stated it — ask ONLY for the ones still missing, and ask about nothing else: (1) testType — plu (API load), blu (real-browser load) or hybrid (both); (2) framework — k6 / jmeter / gatling / locust for plu, playwright / selenium / webdriverio / nightwatch for blu; (3) the script — a built-in sample (scriptRef:{source:'sample'}; for Selenium also pass scriptRef.subType) OR the user's own script via the pendingScriptUpload flow. Do not guess a framework/testType, but do not re-ask for one the user already gave.", + "There is no clone capability. To duplicate a test, getLoadTest the source and copy its full config into this create — vuRamp/vus, durationSec, loadGeneratorLocations and slaThresholds included; nothing is inherited from the source, so anything you omit is dropped.", + "DEFAULTS apply ONLY after the three mandatory fields (testType, framework, script) are known — they never substitute for a missing one of those. Everything BEYOND those three — do not ask about it; apply the default unless the user explicitly specifies otherwise: loadProfile='constant', config.vus=1, config.durationSec=30, region us-east-1 (omit loadGeneratorLocations so the backend defaults it), and NO advanced features (do not enable log capture, response capture, artifacts, thresholds, etc.). Send config.vus=1 + config.durationSec=30 with no vuRamp for the default constant profile.", + "Only when the user explicitly asks for a different load shape, set config.loadProfile: 'ramping' (config.vuRamp: array of {vus, durationSec} stages; ramp up/down is inferred from adjacent VU deltas), 'iterations' (PLU only — config.iterations = fixed run count, plus config.vus), or 'throughput' (PLU and only JMeter/Gatling/locust/k6 — config.targetRps + config.maxVus + config.durationSec). A stage's own 'type' is NOT honored — every stage persists as a hold.", + "testType='hybrid' REQUIRES children[] with the PLU leg (top-level is the BLU leg). NEVER call create with testType='hybrid' and no children[] — it is rejected 'hybrid testType requires children[]'. If you don't yet have both legs' framework+script, ASK for them first; do not submit a hybrid without children to 'see what happens'.", + "On a 400, FIX the payload per the error message and only then resend — NEVER resubmit an unchanged payload (add children[] for hybrid, shorten name to <=255, supply testType+framework). Do not call getLoadTest after a FAILED create; there is nothing to fetch. Repeatedly retrying the same rejected body is the main source of wasted create calls.", + "Always send idempotencyKey, and reuse the SAME key across retries of one logical create, so a late success can't create a duplicate test." + ], + "returns": [ + "testId", + "name", + "projectId", + "version", + "dashboardLink" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "CreateLoadTestData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "startLoadTestRun", + "method": "POST", + "path": "/api/v1/agent/loadTests/{testId}/runs", + "mode": "write", + "entity": "run", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "overrides", + "type": "object", + "description": "Per-run overrides.", + "fields": [ + { + "name": "vus", + "type": "integer" + }, + { + "name": "durationSec", + "type": "integer" + }, + { + "name": "loadGeneratorLocations", + "type": "array" + }, + { + "name": "envOverrides", + "type": "object" + } + ] + }, + { + "name": "dryRun", + "type": "boolean", + "description": "Return the VU-hour estimate and quota fit without starting a run." + }, + { + "name": "idempotencyKey", + "type": "string", + "description": "Dedupe key so a retried start does not launch a duplicate run." + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + } + ], + "intent": "Start a run of an existing load test — use this to execute a test, optionally overriding VUs, duration or locations.", + "guidance": [ + "testId is the numeric test id, not a run id.", + "dryRun:true returns the VU-hour estimate and quota fit without starting anything.", + "On success returns runId (UUID) + dashboardLink; poll getLoadTestRunStatus with the runId.", + "A run without dryRun generates real load and consumes VU-hours. Unless the user has already asked to run it now, dryRun:true first, show the estimate/quota fit and the parameters that will be used (VUs, duration, target), and start the real run only after the user confirms — do not treat your own confirmation as the user's." + ], + "returns": [ + "runId", + "status", + "startedAt", + "dashboardLink", + "estimatedVuHoursRange" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "StartLoadTestRunData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "listLoadTestRuns", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/runs", + "mode": "read", + "entity": "run", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "description": "Max items to return (1-100, default 50).", + "minimum": 1, + "maximum": 100 + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." + }, + { + "name": "status", + "type": "string", + "values": [ + "active", + "terminal", + "all" + ], + "description": "Lifecycle filter (default all)." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "verdict", + "type": "string", + "description": "Filter by run verdict." + }, + { + "name": "startedBy", + "type": "integer", + "description": "Filter by the user who started the run." + }, + { + "name": "hadSlaBreach", + "type": "boolean", + "description": "Only runs that breached an SLA." + }, + { + "name": "tag", + "type": "string", + "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from the test when it has none of its own)." + } + ], + "intent": "List the execution history of a test — use this to find past runs of a specific test or to get a runId.", + "guidance": [ + "testId is numeric; runId values returned are UUIDs.", + "Each run carries a tags array — its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from the test. Filter with the tag query param.", + "Filter with status / verdict / date range; page with cursor + limit.", + "To find a run by date, pass sinceIso/untilIso to narrow server-side — do not list the full history and scan it yourself. To compare two dated runs, make one narrowed call per date rather than one broad listing.", + "If a date resolves to more than one run, ask the user which; do not assume the first or the latest.", + "One call returns this test's run history — reuse it and page with cursor when needed; do not re-list the same window." + ], + "returns": [ + "runs", + "hasMore", + "nextCursor" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "ListLoadTestRunsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } }, - { - "properties": { - "data": { - "$schema": "EstimateLoadTestRunCostData" + "paginated": true, + "max_page_size": 100 + }, + { + "name": "getLoadTestHistoricalTrends", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/trends", + "mode": "read", + "entity": "trend", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "windowRuns", + "type": "integer", + "description": "How many recent runs to aggregate (1-100, default 10)." + }, + { + "name": "aggregation", + "type": "string", + "values": [ + "perRun" + ], + "description": "Aggregation granularity. Only perRun is supported today." + }, + { + "name": "verdictFilter", + "type": "string", + "values": [ + "passed", + "failed", + "all" + ], + "description": "Restrict to runs of this verdict." + }, + { + "name": "metrics", + "type": "string", + "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." + }, + { + "name": "sinceIso", + "type": "string", + "description": "ISO-8601 lower bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + }, + { + "name": "untilIso", + "type": "string", + "description": "ISO-8601 upper bound. ISO-8601 — a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", + "example": "2026-09-12" + } + ], + "intent": "Show how one test's metrics trend across its recent runs — use this for 'is this test getting slower over time?'", + "guidance": [ + "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", + "windowRuns bounds how many recent runs are aggregated.", + "Returns the whole windowed series across metrics in one call — request all needed metrics together; do not call once per metric or re-fetch the same window." + ], + "returns": [ + "metrics" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestHistoricalTrendsData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTestMetricsManifest", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}/metricsManifest", + "mode": "read", + "entity": "metrics", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "intent": "Discover which metrics, @ aliases and groupBy options a test supports — call this before requesting a report, trend or comparison so metric names are valid.", + "guidance": [ + "Depends only on the test's type; cache the result client-side.", + "Use the returned names/aliases in the metrics parameter of report, trends and compare." + ], + "returns": [ + "testType", + "metrics", + "aliases" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestMetricsManifestData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "updateLoadTest", + "method": "PUT", + "path": "/api/v1/agent/loadTests/{testId}", + "mode": "write", + "entity": "loadTest", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "description": "New test name." + }, + { + "name": "config", + "type": "object", + "description": "Partial config update (tags, envVarKeys, slaThresholds, scriptRef, integrations)." + }, + { + "name": "ifVersion", + "type": "string", + "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).", + "format": "date-time" + }, + { + "name": "validationToken", + "type": "string", + "description": "Token from a prior validate step, when required." + }, + { + "name": "pendingScriptUpload", + "type": "object", + "description": "Two-phase script replacement, same flow as createLoadTest.", + "fields": [ + { + "name": "framework", + "type": "string" + }, + { + "name": "expectedContentType", + "type": "string" + } + ] + }, + { + "name": "runId", + "type": "string", + "description": "Tag a specific RUN instead of the test definition. When set, config.tags is applied to that run's own tags (the saved test is NOT changed); only config.tags may accompany runId. Omit it to edit the test." + } + ], + "intent": "Update an existing load test's name or configuration — use this to change tags, SLA thresholds, script or settings; also tags a specific run when runId is set.", + "guidance": [ + "Partial update: send only the fields to change.", + "Pass ifVersion for optimistic concurrency; a stale value returns 409 VERSION_CONFLICT.", + "Script replacement uses the same two-phase pendingScriptUpload flow as create.", + "config is a partial update, but each field it carries REPLACES that field wholesale — it does not merge. tags overwrites the entire tag set; it does not append. To add a tag to a test (or the same tag across several tests), getLoadTest each one first and send the union under config.tags.", + "To change the load profile, send config.loadProfile plus that profile's fields (see createLoadTest's load-profile guidance for the four shapes). Switching profiles clears the previous profile's fields — e.g. ramping→throughput empties the stages and drops iterations. iterations and throughput are PLU-only.", + "To tag a specific RUN rather than the test, pass runId (a run UUID from listLoadTestRuns) together with config.tags — the tags apply to that one run and the saved test is untouched. A run inherits the test's tags until you set its own; setting run tags overrides (they also replace wholesale, so send the union to add). Omit runId to tag the test itself.", + "Tags live on the load test, not on its runs — there is no per-run tagging, so do not touch runs when asked to tag a test. To tag every test in a project, list them with listLoadTests and page through with cursor until hasMore is false, then updateLoadTest each one — do not stop after the first page or a subset.", + "Optimistic concurrency is opt-in: pass ifVersion (the version token from a prior get/create/update) so the edit fails with 409 CONFLICT if the test changed meanwhile; OMITTING ifVersion is a last-write-wins update that overwrites any concurrent change." + ], + "returns": [ + "testId", + "version" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "UpdateLoadTestData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "409": { + "$response": "Conflict" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "getLoadTest", + "method": "GET", + "path": "/api/v1/agent/loadTests/{testId}", + "mode": "read", + "entity": "loadTest", + "path_params": [ + { + "name": "testId", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "include", + "type": "string", + "description": "Comma-separated sub-resources to expand — any combination of: config, thresholds, tags, children (e.g. \"config,thresholds\"). Ask for everything you need in ONE call (include=config,thresholds,tags) rather than one request per sub-resource." + }, + { + "name": "fields", + "type": "string", + "description": "Comma-separated response fields to include." + } + ], + "intent": "Get the full configuration of a single load test — use this to inspect a test's settings, script reference, SLA thresholds and children.", + "guidance": [ + "testId is numeric (from listLoadTests).", + "Use include to expand config / thresholds / tags / children.", + "A single call returns the complete configuration — VU/ramp profile, duration, load-generator regions and SLA thresholds (expand them with include=config,thresholds). Do not call again for the same testId within a task; reuse the result." + ], + "returns": [ + "testId", + "name", + "testType", + "framework", + "version", + "projectId", + "ownerUserId", + "config" + ], + "responses": { + "200": { + "description": "Standard success envelope; operation data under `data`.", + "schema": { + "allOf": [ + { + "$schema": "SuccessEnvelope" + }, + { + "properties": { + "data": { + "$schema": "GetLoadTestData" + } + } + } + ] + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" } - } } - ] } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "compareLoadTestRuns", - "method": "POST", - "path": "/api/v1/agent/loadTests/compare", - "mode": "read", - "entity": "report", - "body": [ - { - "name": "baselineRunId", - "type": "string", - "required": true, - "description": "Run UUID to compare against.", - "format": "uuid", - "example": "6f1d2c9a-7b3e-4a10-9c22-0e5f8a1b2c3d" - }, - { - "name": "candidateRunId", - "type": "string", - "required": true, - "description": "Run UUID being evaluated; must differ from baselineRunId.", - "format": "uuid", - "example": "b2e4f6a8-1c3d-4e5f-8a90-2b4c6d8e0f11" - }, - { - "name": "dimensions", - "type": "array", - "description": "Which delta dimensions to compute (any of kpi, transaction, sla).", - "values": [ - "kpi", - "transaction", - "sla" - ] - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. latency.p95, @vitals). Filters kpiDeltas; the first entry with a per-transaction value also picks the transaction metric. Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "groupBy", - "type": "string", - "description": "Axis for transaction deltas (default transaction = request name).", - "values": [ - "transaction", - "label", - "threadGroup", - "scenario" - ] - }, - { - "name": "topN", - "type": "integer", - "description": "Top-N deltas to include (1-100)." - }, - { - "name": "regressedOnly", - "type": "boolean", - "description": "Return only regressed transaction rows." - }, - { - "name": "pctChangeMin", - "type": "number", - "description": "Minimum absolute percent change for a KPI or transaction row to be included." - } - ], - "intent": "Compare two executions (runs) of the SAME load test and surface the deltas \u2014 the regression check ('did the latest run regress vs the previous one?'). Not for comparing different tests: runs of different tests are rejected (DIFFERENT_TESTS). For a trend across more than two runs use getLoadTestHistoricalTrends.", - "guidance": [ - "Pick both runs from listLoadTestRuns for ONE testId, filtered to finished runs (status=terminal). The older run is the baseline, the newer the candidate.", - "'Compare the latest run' means the latest finished run (candidate) vs the finished run before it (baseline).", - "Both runs must be finished and must share the same test types \u2014 otherwise the call fails with VALIDATION_ERROR. Runs of different tests fail with DIFFERENT_TESTS; do not retry with other tests' runs.", - "Use this to compare two runs \u2014 it returns per-KPI, per-transaction and SLA-verdict deltas from the same data as the Compare Runs page. Do not fetch both run reports and diff them yourself.", - "pctChange is signed (candidate vs baseline); direction/regressed already account for whether higher or lower is better \u2014 report those, do not re-derive them. direction \"unknown\" (regressed null) means one or both runs have no value for that metric: report it as missing data, never as no change.", - "Transaction deltas are computed on one metric: the first metrics entry that has a per-transaction value (latency.*, errors.rate, throughput.*), else latency.p95. groupBy picks the transaction axis.", - "regressedOnly + pctChangeMin filter to material regressions; regressedOnly applies to transactionDeltas, pctChangeMin to kpiDeltas and transactionDeltas.", - "Check warnings before reporting: they name metrics with no data on a run, runs whose SLA thresholds were not evaluated, SLA definitions that changed between the runs, and requested metrics compare cannot serve (e.g. engine.*). Surface any warning to the user." - ], - "returns": [ - "baseline", - "candidate", - "kpiDeltas", - "transactionDeltas", - "slaVerdictChanges", - "warnings" ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { + "schemas": { + "SuccessEnvelope": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, "data": { - "$schema": "CompareLoadTestRunsData" + "type": "object", + "description": "Operation payload; its fields are listed in each capability's `returns`." + } + }, + "required": [ + "success", + "data" + ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "errorCode": { + "type": "string", + "example": "NOT_FOUND" + }, + "error": { + "type": "string", + "example": "The requested resource was not found." + } + }, + "required": [ + "success", + "error" + ] + }, + "GetLoadTestPlatformStatusData": { + "type": "object", + "properties": { + "platformState": { + "type": "string" + }, + "components": { + "type": "array" + }, + "checkedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ListLoadTestProjectsData": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "testCount": { + "type": "integer", + "nullable": true + }, + "lastActivityAt": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "ListLoadTestsData": { + "type": "object", + "properties": { + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true + }, + "testType": { + "type": "string", + "description": "plu (API), blu (Browser) or hybrid." + }, + "framework": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "owner": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "lastRun": { + "type": "object", + "description": "Most recent execution, when present.", + "properties": { + "runId": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "verdict": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "SearchLoadTestsData": { + "type": "object", + "properties": { + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "testType": { + "type": "string" + }, + "framework": { + "type": "string", + "nullable": true + } + } + } + }, + "hasMore": { + "type": "boolean" + } + } + }, + "GetLoadTestProjectTrendsData": { + "type": "object", + "properties": { + "metrics": { + "description": "Trend series payload; shape per getLoadTestMetricsManifest." + } + } + }, + "ListProjectLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "durationSec": { + "type": "integer", + "nullable": true, + "description": "Wall-clock seconds; null while running or when timestamps are missing." + }, + "vus": { + "type": "integer", + "nullable": true + }, + "verdict": { + "type": "string", + "nullable": true + }, + "vuHours": { + "type": "number", + "nullable": true, + "description": "Billed VU-hours; null until billed." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." + } + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + } + } + }, + "ListActiveLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "testId": { + "type": "integer", + "nullable": true + }, + "testName": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "startedBy": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "elapsedSec": { + "type": "integer", + "nullable": true + }, + "vus": { + "type": "integer", + "nullable": true + }, + "vuHoursBurnedSoFar": { + "type": "number" + }, + "status": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." + } + } + } + } + } + }, + "GetLoadTestRunStatusData": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "elapsedSec": { + "type": "number" + }, + "remainingSec": { + "type": "number" + }, + "pollAfterSeconds": { + "type": "number" + }, + "slaBreachFlags": { + "type": "array" + } + } + }, + "GetLoadTestInsightSummaryData": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "report": { + "type": "object" + } + } + }, + "GetLoadTestRunReportData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "testName": { + "type": "string" + }, + "durationSec": { + "type": "number", + "description": "Run wall-clock duration in whole seconds. Present to the user as minutes + seconds (e.g. 5m 30s)." + }, + "kpis": { + "type": "object" + }, + "slaVerdicts": { + "type": "array" + }, + "transactions": { + "type": "array" + }, + "errorsByCategory": { + "type": "object" + }, + "meta": { + "type": "object" + } + } + }, + "StopLoadTestRunData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "stoppedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GetLoadTestQuotaData": { + "type": "object", + "properties": { + "plan": { + "type": "object" + }, + "vuHours": { + "type": "object" + }, + "concurrency": { + "type": "object" + } + } + }, + "EstimateLoadTestRunCostData": { + "type": "object", + "properties": { + "estimatedVuHours": { + "type": "number" + }, + "estimationBasis": { + "type": "string" + }, + "rangeBasis": { + "type": "string" + }, + "rangeExplanation": { + "type": "string" + }, + "fitsInQuota": { + "type": "boolean" + }, + "remainingAfterEstimate": { + "type": "number" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "createLoadTest", - "method": "POST", - "path": "/api/v1/agent/loadTests", - "mode": "write", - "entity": "loadTest", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "maxLength": 255, - "description": "Test name. Max 255 characters \u2014 truncate before sending; a longer name is rejected with a 400.", - "example": "checkout-baseline" - }, - { - "name": "testType", - "type": "string", - "required": true, - "description": "plu = API load (server-side, non-browser); blu = real-browser load; hybrid = BOTH in one test (one PLU leg + one BLU leg, supplied via children). These tokens are INTERNAL wire values \u2014 send them here but never show them to the user; the user-facing labels are 'API' (plu), 'Browser' (blu) and 'Hybrid' (hybrid). All three are valid, user-selectable options \u2014 hybrid is not a rare/advanced variant, offer it alongside the other two.", - "values": [ - "plu", - "blu", - "hybrid" - ] - }, - { - "name": "framework", - "type": "string", - "required": true, - "description": "The framework MUST match testType (the backend rejects a mismatched pair). Offer ONLY the frameworks for the already-chosen testType \u2014 read them from valuesByTestType[testType] below \u2014 and NEVER present the full `values` list across types. API (plu): k6, jmeter, gatling, locust. Browser (blu): playwright, selenium, webdriverio, nightwatch, lcncnightwatch. For a hybrid test this top-level framework is the Browser leg; the API leg's framework goes in children[].", - "scopedBy": "testType", - "valuesByTestType": { - "plu": [ - "k6", - "jmeter", - "gatling", - "locust" - ], - "blu": [ - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ], - "hybrid": [ - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ] }, - "values": [ - "k6", - "jmeter", - "gatling", - "locust", - "playwright", - "selenium", - "webdriverio", - "nightwatch", - "lcncnightwatch" - ] - }, - { - "name": "projectId", - "type": "integer", - "description": "Target project. Provide this OR projectName, not both." - }, - { - "name": "projectName", - "type": "string", - "description": "Target project by name; created if it does not exist. Provide this OR projectId." - }, - { - "name": "config", - "type": "object", - "description": "Test configuration incl. scriptRef {source: s3|s3_upload|existing_zip_id|sample, value|identifier}, loadProfile (constant|ramping|iterations|throughput), vus, vuRamp, durationSec, iterations, targetRps, maxVus, loadGeneratorLocations, slaThresholds, envVarKeys, tags. See the load-profile guidance for which fields each loadProfile needs (iterations + throughput are PLU-only). To supply a script from a local file or URL, use the pendingScriptUpload flow, then source='s3'. To create a test from the platform's built-in sample (no upload), use scriptRef:{source:'sample'} \u2014 for Selenium also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber)." - }, - { - "name": "children", - "type": "array", - "description": "Child scenarios for a hybrid test. A hybrid is exactly one PLU (API) leg + one BLU (browser) leg \u2014 each leg carries its own framework + scriptRef (a sample or an uploaded script). Send the BLU leg as the top-level testType='blu'/framework/config and put the PLU leg in children[] (one entry, testType='plu' with its framework + config.scriptRef). The backend requires exactly one PLU and one BLU leg and rejects any other composition." - }, - { - "name": "idempotencyKey", - "type": "string", - "description": "Dedupe key so a retried create does not duplicate the test." - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - }, - { - "name": "pendingScriptUpload", - "type": "object", - "description": "Request a presigned upload URL instead of creating; returns uploadUrl + s3Key to PUT the script to, then call create again with config.scriptRef.source='s3_upload'.", - "fields": [ - { - "name": "framework", - "type": "string" - }, - { - "name": "expectedContentType", - "type": "string" - } - ] - } - ], - "intent": "Create a new load test in a project \u2014 use this to define a test before running it. JSON body only (no multipart).", - "guidance": [ - "PRECONDITION \u2014 do NOT call this endpoint until THREE things are known: (1) testType (plu/blu/hybrid), (2) framework, (3) the script (a built-in sample or the user's own upload). These are MANDATORY and NOT defaultable \u2014 never infer or default them. If the user's request does not state one of the three, ASK the user for the missing ones and WAIT for their answer before creating. 'create a test' with no type/framework/script named means all three are missing \u2192 ask all three; do not silently pick k6/plu/sample. Only vus, duration, region and load profile are defaultable (see the DEFAULTS line) \u2014 the three above are not.", - "'plu' / 'blu' / 'hybrid' are INTERNAL wire values \u2014 send them in the request, but NEVER show them to the user. Do not print them in questions, option labels, confirmations, summaries, or prose (no 'Browser (BLU)', no '(plu+blu)'). User-facing labels are: plu \u2192 'API', blu \u2192 'Browser', hybrid \u2192 'Hybrid (both API and Browser)'. Frameworks (k6, jmeter, selenium, playwright, \u2026) are real product names and ARE shown to the user.", - "When you ASK the user to pick testType or framework, present the FULL, exact option set \u2014 never drop, merge, or cross-contaminate options. testType: offer all THREE, using the user-facing labels above \u2014 'API', 'Browser', and 'Hybrid'; do not omit Hybrid. framework (ask AFTER testType is chosen, and show only that type's frameworks \u2014 the authoritative per-type list is the `framework.valuesByTestType[testType]` array; present exactly those, each as its OWN separate option, and NEVER the full cross-type `values` list): for an API test offer exactly k6, jmeter, gatling AND locust (all four \u2014 do not drop locust); for a Browser test offer exactly playwright, selenium, webdriverio AND nightwatch (all four \u2014 never list the API frameworks under a Browser test, and never merge selenium and playwright into one option). For a Hybrid test, collect an API leg (its framework + script) AND a Browser leg (its framework + script) and assemble them per the children field \u2014 Hybrid needs both legs, so it takes more than the three questions a single-type test does.", - "Provide exactly one of projectId or projectName; projectName creates the project if absent.", - "Script upload is two-phase: send pendingScriptUpload to get a presigned uploadUrl + s3Key, PUT the file, then call create again with the scriptRef source and s3Key returned in the response's nextStep.", - "To spin up a test from a built-in sample with NO file handling (the dashboard's 'use sample script'), pass scriptRef:{source:'sample'} \u2014 the platform's bundled sample for the given framework is used. For Selenium, also pass scriptRef.subType (testng|junit|vanillajava|python|pytest|jest|serenity-cucumber) to pick the binding. testType and framework are still needed; the load profile defaults to constant 1 VU / 30s (see below).", - "Pass idempotencyKey so a retried create does not duplicate the test.", - "Only THREE things must be known before creating, and each is taken from the user's request when they've stated it \u2014 ask ONLY for the ones still missing, and ask about nothing else: (1) testType \u2014 plu (API load), blu (real-browser load) or hybrid (both); (2) framework \u2014 k6 / jmeter / gatling / locust for plu, playwright / selenium / webdriverio / nightwatch for blu; (3) the script \u2014 a built-in sample (scriptRef:{source:'sample'}; for Selenium also pass scriptRef.subType) OR the user's own script via the pendingScriptUpload flow. Do not guess a framework/testType, but do not re-ask for one the user already gave.", - "There is no clone capability. To duplicate a test, getLoadTest the source and copy its full config into this create \u2014 vuRamp/vus, durationSec, loadGeneratorLocations and slaThresholds included; nothing is inherited from the source, so anything you omit is dropped.", - "DEFAULTS apply ONLY after the three mandatory fields (testType, framework, script) are known \u2014 they never substitute for a missing one of those. Everything BEYOND those three \u2014 do not ask about it; apply the default unless the user explicitly specifies otherwise: loadProfile='constant', config.vus=1, config.durationSec=30, region us-east-1 (omit loadGeneratorLocations so the backend defaults it), and NO advanced features (do not enable log capture, response capture, artifacts, thresholds, etc.). Send config.vus=1 + config.durationSec=30 with no vuRamp for the default constant profile.", - "Only when the user explicitly asks for a different load shape, set config.loadProfile: 'ramping' (config.vuRamp: array of {vus, durationSec} stages; ramp up/down is inferred from adjacent VU deltas), 'iterations' (PLU only \u2014 config.iterations = fixed run count, plus config.vus), or 'throughput' (PLU and only JMeter/Gatling/locust/k6 \u2014 config.targetRps + config.maxVus + config.durationSec). A stage's own 'type' is NOT honored \u2014 every stage persists as a hold.", - "testType='hybrid' REQUIRES children[] with the PLU leg (top-level is the BLU leg). NEVER call create with testType='hybrid' and no children[] \u2014 it is rejected 'hybrid testType requires children[]'. If you don't yet have both legs' framework+script, ASK for them first; do not submit a hybrid without children to 'see what happens'.", - "On a 400, FIX the payload per the error message and only then resend \u2014 NEVER resubmit an unchanged payload (add children[] for hybrid, shorten name to <=255, supply testType+framework). Do not call getLoadTest after a FAILED create; there is nothing to fetch. Repeatedly retrying the same rejected body is the main source of wasted create calls.", - "Always send idempotencyKey, and reuse the SAME key across retries of one logical create, so a late success can't create a duplicate test." - ], - "returns": [ - "testId", - "name", - "projectId", - "version", - "dashboardLink" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "CreateLoadTestData" + "CompareLoadTestRunsData": { + "type": "object", + "properties": { + "baseline": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testId": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "candidate": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "testId": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "kpiDeltas": { + "type": "array", + "description": "Per-KPI baseline→candidate deltas.", + "items": { + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "baseline": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "candidate": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "absChange": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "pctChange": { + "type": "number", + "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", + "nullable": true + }, + "direction": { + "type": "string", + "enum": [ + "improved", + "regressed", + "unchanged", + "unknown" + ], + "description": "\"unknown\" when either run has no value for the metric." + } + } + } + }, + "transactionDeltas": { + "type": "array", + "description": "Per-transaction deltas on one metric, sorted by |pctChange| desc.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "metric": { + "type": "string" + }, + "baseline": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "candidate": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "absChange": { + "type": "number", + "description": "Null when either run lacks the value.", + "nullable": true + }, + "pctChange": { + "type": "number", + "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", + "nullable": true + }, + "regressed": { + "type": "boolean", + "description": "Null when either run lacks the transaction (unknown, not unregressed).", + "nullable": true + } + } + } + }, + "slaVerdictChanges": { + "type": "array", + "description": "SLA thresholds whose verdict changed between the runs.", + "items": { + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "type": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "threshold": {}, + "unit": { + "type": "string" + }, + "request": {}, + "from": { + "type": "string", + "description": "Null when the threshold is absent on that run.", + "nullable": true + }, + "to": { + "type": "string", + "description": "Null when the threshold is absent on that run.", + "nullable": true + }, + "diffStatus": { + "type": "string", + "enum": [ + "now_failing", + "now_passing", + "added", + "dropped" + ] + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "startLoadTestRun", - "method": "POST", - "path": "/api/v1/agent/loadTests/{testId}/runs", - "mode": "write", - "entity": "run", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "overrides", - "type": "object", - "description": "Per-run overrides.", - "fields": [ - { - "name": "vus", - "type": "integer" - }, - { - "name": "durationSec", - "type": "integer" - }, - { - "name": "loadGeneratorLocations", - "type": "array" - }, - { - "name": "envOverrides", - "type": "object" - } - ] - }, - { - "name": "dryRun", - "type": "boolean", - "description": "Return the VU-hour estimate and quota fit without starting a run." - }, - { - "name": "idempotencyKey", - "type": "string", - "description": "Dedupe key so a retried start does not launch a duplicate run." - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - } - ], - "intent": "Start a run of an existing load test \u2014 use this to execute a test, optionally overriding VUs, duration or locations.", - "guidance": [ - "testId is the numeric test id, not a run id.", - "dryRun:true returns the VU-hour estimate and quota fit without starting anything.", - "On success returns runId (UUID) + dashboardLink; poll getLoadTestRunStatus with the runId.", - "A run without dryRun generates real load and consumes VU-hours. Unless the user has already asked to run it now, dryRun:true first, show the estimate/quota fit and the parameters that will be used (VUs, duration, target), and start the real run only after the user confirms \u2014 do not treat your own confirmation as the user's." - ], - "returns": [ - "runId", - "status", - "startedAt", - "dashboardLink", - "estimatedVuHoursRange" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "StartLoadTestRunData" + }, + "CreateLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." + }, + "dashboardLink": { + "type": "string" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "listLoadTestRuns", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/runs", - "mode": "read", - "entity": "run", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "description": "Max items to return (1-100, default 50).", - "minimum": 1, - "maximum": 100 - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque pagination cursor from a previous response's nextCursor; pass back verbatim." - }, - { - "name": "status", - "type": "string", - "values": [ - "active", - "terminal", - "all" - ], - "description": "Lifecycle filter (default all)." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "verdict", - "type": "string", - "description": "Filter by run verdict." - }, - { - "name": "startedBy", - "type": "integer", - "description": "Filter by the user who started the run." - }, - { - "name": "hadSlaBreach", - "type": "boolean", - "description": "Only runs that breached an SLA." - }, - { - "name": "tag", - "type": "string", - "description": "Filter to runs carrying this tag (a run's own tags, or the tags it inherits from the test when it has none of its own)." - } - ], - "intent": "List the execution history of a test \u2014 use this to find past runs of a specific test or to get a runId.", - "guidance": [ - "testId is numeric; runId values returned are UUIDs.", - "Each run carries a tags array \u2014 its own run-level tags if set (via updateLoadTest with runId), otherwise the tags inherited from the test. Filter with the tag query param.", - "Filter with status / verdict / date range; page with cursor + limit.", - "To find a run by date, pass sinceIso/untilIso to narrow server-side \u2014 do not list the full history and scan it yourself. To compare two dated runs, make one narrowed call per date rather than one broad listing.", - "If a date resolves to more than one run, ask the user which; do not assume the first or the latest.", - "One call returns this test's run history \u2014 reuse it and page with cursor when needed; do not re-list the same window." - ], - "returns": [ - "runs", - "hasMore", - "nextCursor" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "ListLoadTestRunsData" + }, + "StartLoadTestRunData": { + "type": "object", + "properties": { + "runId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "dashboardLink": { + "type": "string" + }, + "estimatedVuHoursRange": { + "description": "Low/high VU-hour estimate for the started run." } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "paginated": true, - "max_page_size": 100 - }, - { - "name": "getLoadTestHistoricalTrends", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/trends", - "mode": "read", - "entity": "trend", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "windowRuns", - "type": "integer", - "description": "How many recent runs to aggregate (1-100, default 10)." - }, - { - "name": "aggregation", - "type": "string", - "values": [ - "perRun" - ], - "description": "Aggregation granularity. Only perRun is supported today." - }, - { - "name": "verdictFilter", - "type": "string", - "values": [ - "passed", - "failed", - "all" - ], - "description": "Restrict to runs of this verdict." - }, - { - "name": "metrics", - "type": "string", - "description": "Comma-separated dotted metric names or @ aliases (e.g. @vitals, @all). Resolve valid names via getLoadTestMetricsManifest." - }, - { - "name": "sinceIso", - "type": "string", - "description": "ISO-8601 lower bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - }, - { - "name": "untilIso", - "type": "string", - "description": "ISO-8601 upper bound. ISO-8601 \u2014 a date (2026-09-12) or a date-time (2026-09-12T10:30:00Z).", - "example": "2026-09-12" - } - ], - "intent": "Show how one test's metrics trend across its recent runs \u2014 use this for 'is this test getting slower over time?'", - "guidance": [ - "metrics accepts dotted names or @ aliases; @vitals / @all expand to metric sets.", - "windowRuns bounds how many recent runs are aggregated.", - "Returns the whole windowed series across metrics in one call \u2014 request all needed metrics together; do not call once per metric or re-fetch the same window." - ], - "returns": [ - "metrics" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestHistoricalTrendsData" + }, + "ListLoadTestRunsData": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "nullable": true + }, + "startedAt": { + "type": "string", + "nullable": true + }, + "durationSec": { + "type": "integer", + "nullable": true, + "description": "Wall-clock seconds; null while running or when timestamps are missing." + }, + "vus": { + "type": "integer", + "nullable": true + }, + "verdict": { + "type": "string", + "nullable": true + }, + "vuHours": { + "type": "number", + "nullable": true, + "description": "Billed VU-hours; null until billed." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Run-level tags, or the tags inherited from the test when the run has none." + } + } + } + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTestMetricsManifest", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}/metricsManifest", - "mode": "read", - "entity": "metrics", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "intent": "Discover which metrics, @ aliases and groupBy options a test supports \u2014 call this before requesting a report, trend or comparison so metric names are valid.", - "guidance": [ - "Depends only on the test's type; cache the result client-side.", - "Use the returned names/aliases in the metrics parameter of report, trends and compare." - ], - "returns": [ - "testType", - "metrics", - "aliases" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestMetricsManifestData" + }, + "GetLoadTestHistoricalTrendsData": { + "type": "object", + "properties": { + "metrics": { + "description": "Trend series payload; shape per getLoadTestMetricsManifest." } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "updateLoadTest", - "method": "PUT", - "path": "/api/v1/agent/loadTests/{testId}", - "mode": "write", - "entity": "loadTest", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "description": "New test name." - }, - { - "name": "config", - "type": "object", - "description": "Partial config update (tags, envVarKeys, slaThresholds, scriptRef, integrations)." - }, - { - "name": "ifVersion", - "type": "string", - "description": "Optimistic-concurrency guard (ISO timestamp of the version being edited).", - "format": "date-time" - }, - { - "name": "validationToken", - "type": "string", - "description": "Token from a prior validate step, when required." - }, - { - "name": "pendingScriptUpload", - "type": "object", - "description": "Two-phase script replacement, same flow as createLoadTest.", - "fields": [ - { - "name": "framework", - "type": "string" - }, - { - "name": "expectedContentType", - "type": "string" - } - ] - }, - { - "name": "runId", - "type": "string", - "description": "Tag a specific RUN instead of the test definition. When set, config.tags is applied to that run's own tags (the saved test is NOT changed); only config.tags may accompany runId. Omit it to edit the test." - } - ], - "intent": "Update an existing load test's name or configuration \u2014 use this to change tags, SLA thresholds, script or settings; also tags a specific run when runId is set.", - "guidance": [ - "Partial update: send only the fields to change.", - "Pass ifVersion for optimistic concurrency; a stale value returns 409 VERSION_CONFLICT.", - "Script replacement uses the same two-phase pendingScriptUpload flow as create.", - "config is a partial update, but each field it carries REPLACES that field wholesale \u2014 it does not merge. tags overwrites the entire tag set; it does not append. To add a tag to a test (or the same tag across several tests), getLoadTest each one first and send the union under config.tags.", - "To change the load profile, send config.loadProfile plus that profile's fields (see createLoadTest's load-profile guidance for the four shapes). Switching profiles clears the previous profile's fields \u2014 e.g. ramping\u2192throughput empties the stages and drops iterations. iterations and throughput are PLU-only.", - "To tag a specific RUN rather than the test, pass runId (a run UUID from listLoadTestRuns) together with config.tags \u2014 the tags apply to that one run and the saved test is untouched. A run inherits the test's tags until you set its own; setting run tags overrides (they also replace wholesale, so send the union to add). Omit runId to tag the test itself.", - "Tags live on the load test, not on its runs \u2014 there is no per-run tagging, so do not touch runs when asked to tag a test. To tag every test in a project, list them with listLoadTests and page through with cursor until hasMore is false, then updateLoadTest each one \u2014 do not stop after the first page or a subset.", - "Optimistic concurrency is opt-in: pass ifVersion (the version token from a prior get/create/update) so the edit fails with 409 CONFLICT if the test changed meanwhile; OMITTING ifVersion is a last-write-wins update that overwrites any concurrent change." - ], - "returns": [ - "testId", - "version" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "UpdateLoadTestData" + }, + "GetLoadTestMetricsManifestData": { + "type": "object", + "properties": { + "testType": { + "type": "string" + }, + "metrics": { + "type": "array" + }, + "aliases": { + "type": "object" } - } } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "409": { - "$response": "Conflict" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "getLoadTest", - "method": "GET", - "path": "/api/v1/agent/loadTests/{testId}", - "mode": "read", - "entity": "loadTest", - "path_params": [ - { - "name": "testId", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "include", - "type": "string", - "description": "Comma-separated sub-resources to expand \u2014 any combination of: config, thresholds, tags, children (e.g. \"config,thresholds\"). Ask for everything you need in ONE call (include=config,thresholds,tags) rather than one request per sub-resource." - }, - { - "name": "fields", - "type": "string", - "description": "Comma-separated response fields to include." - } - ], - "intent": "Get the full configuration of a single load test \u2014 use this to inspect a test's settings, script reference, SLA thresholds and children.", - "guidance": [ - "testId is numeric (from listLoadTests).", - "Use include to expand config / thresholds / tags / children.", - "A single call returns the complete configuration \u2014 VU/ramp profile, duration, load-generator regions and SLA thresholds (expand them with include=config,thresholds). Do not call again for the same testId within a task; reuse the result." - ], - "returns": [ - "testId", - "name", - "testType", - "framework", - "version", - "projectId", - "ownerUserId", - "config" - ], - "responses": { - "200": { - "description": "Standard success envelope; operation data under `data`.", - "schema": { - "allOf": [ - { - "$schema": "SuccessEnvelope" - }, - { - "properties": { - "data": { - "$schema": "GetLoadTestData" + }, + "UpdateLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." } - } - } - ] - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - } - ], - "schemas": { - "SuccessEnvelope": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "Operation payload; its fields are listed in each capability's `returns`." - } - }, - "required": [ - "success", - "data" - ] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "errorCode": { - "type": "string", - "example": "NOT_FOUND" - }, - "error": { - "type": "string", - "example": "The requested resource was not found." - } - }, - "required": [ - "success", - "error" - ] - }, - "GetLoadTestPlatformStatusData": { - "type": "object", - "properties": { - "platformState": { - "type": "string" - }, - "components": { - "type": "array" - }, - "checkedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "ListLoadTestProjectsData": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "testCount": { - "type": "integer", - "nullable": true - }, - "lastActivityAt": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" - } - } - }, - "ListLoadTestsData": { - "type": "object", - "properties": { - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true - }, - "testType": { - "type": "string", - "description": "plu (API), blu (Browser) or hybrid." - }, - "framework": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "owner": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "lastRun": { - "type": "object", - "description": "Most recent execution, when present.", - "properties": { - "runId": { - "type": "string", - "nullable": true + }, + "GetLoadTestData": { + "type": "object", + "properties": { + "testId": { + "type": "integer" }, - "status": { - "type": "string", - "nullable": true + "name": { + "type": "string" }, - "startedAt": { - "type": "string", - "nullable": true + "testType": { + "type": "string" + }, + "framework": { + "type": "string" + }, + "version": { + "type": "string", + "format": "date-time", + "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." }, - "verdict": { - "type": "string", - "nullable": true + "projectId": { + "type": "integer" + }, + "ownerUserId": { + "type": "integer" + }, + "config": { + "type": "object" } - } } - } } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" - } - } - }, - "SearchLoadTestsData": { - "type": "object", - "properties": { - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "testType": { - "type": "string" - }, - "framework": { - "type": "string", - "nullable": true + }, + "responses": { + "BadRequest": { + "description": "Invalid parameters or body.", + "schema": { + "$schema": "ErrorResponse" } - } - } - }, - "hasMore": { - "type": "boolean" - } - } - }, - "GetLoadTestProjectTrendsData": { - "type": "object", - "properties": { - "metrics": { - "description": "Trend series payload; shape per getLoadTestMetricsManifest." - } - } - }, - "ListProjectLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "status": { - "type": "string", - "nullable": true - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "durationSec": { - "type": "integer", - "nullable": true, - "description": "Wall-clock seconds; null while running or when timestamps are missing." - }, - "vus": { - "type": "integer", - "nullable": true - }, - "verdict": { - "type": "string", - "nullable": true - }, - "vuHours": { - "type": "number", - "nullable": true, - "description": "Billed VU-hours; null until billed." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." + }, + "Unauthorized": { + "description": "Missing or invalid credentials.", + "schema": { + "$schema": "ErrorResponse" } - } - } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" - } - } - }, - "ListActiveLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "testId": { - "type": "integer", - "nullable": true - }, - "testName": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "startedBy": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "nullable": true - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "elapsedSec": { - "type": "integer", - "nullable": true - }, - "vus": { - "type": "integer", - "nullable": true - }, - "vuHoursBurnedSoFar": { - "type": "number" - }, - "status": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." + }, + "NotFound": { + "description": "The referenced project, test or run was not found or not visible to the caller.", + "schema": { + "$schema": "ErrorResponse" } - } - } - } - } - }, - "GetLoadTestRunStatusData": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "elapsedSec": { - "type": "number" - }, - "remainingSec": { - "type": "number" - }, - "pollAfterSeconds": { - "type": "number" - }, - "slaBreachFlags": { - "type": "array" - } - } - }, - "GetLoadTestInsightSummaryData": { - "type": "object", - "properties": { - "state": { - "type": "string" - }, - "status": { - "type": "string" - }, - "report": { - "type": "object" - } - } - }, - "GetLoadTestRunReportData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testType": { - "type": "string" - }, - "status": { - "type": "string" - }, - "testName": { - "type": "string" - }, - "durationSec": { - "type": "number", - "description": "Run wall-clock duration in whole seconds. Present to the user as minutes + seconds (e.g. 5m 30s)." - }, - "kpis": { - "type": "object" - }, - "slaVerdicts": { - "type": "array" - }, - "transactions": { - "type": "array" - }, - "errorsByCategory": { - "type": "object" - }, - "meta": { - "type": "object" - } - } - }, - "StopLoadTestRunData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - }, - "stoppedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "GetLoadTestQuotaData": { - "type": "object", - "properties": { - "plan": { - "type": "object" - }, - "vuHours": { - "type": "object" - }, - "concurrency": { - "type": "object" - } - } - }, - "EstimateLoadTestRunCostData": { - "type": "object", - "properties": { - "estimatedVuHours": { - "type": "number" - }, - "estimationBasis": { - "type": "string" - }, - "rangeBasis": { - "type": "string" - }, - "rangeExplanation": { - "type": "string" - }, - "fitsInQuota": { - "type": "boolean" - }, - "remainingAfterEstimate": { - "type": "number" - } - } - }, - "CompareLoadTestRunsData": { - "type": "object", - "properties": { - "baseline": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testId": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "candidate": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "testId": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "kpiDeltas": { - "type": "array", - "description": "Per-KPI baseline\u2192candidate deltas.", - "items": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "baseline": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "candidate": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "absChange": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "pctChange": { - "type": "number", - "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", - "nullable": true - }, - "direction": { - "type": "string", - "enum": [ - "improved", - "regressed", - "unchanged", - "unknown" - ], - "description": "\"unknown\" when either run has no value for the metric." + }, + "Conflict": { + "description": "Optimistic-concurrency version conflict (ifVersion mismatch).", + "schema": { + "$schema": "ErrorResponse" } - } - } - }, - "transactionDeltas": { - "type": "array", - "description": "Per-transaction deltas on one metric, sorted by |pctChange| desc.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "metric": { - "type": "string" - }, - "baseline": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "candidate": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "absChange": { - "type": "number", - "description": "Null when either run lacks the value.", - "nullable": true - }, - "pctChange": { - "type": "number", - "description": "Signed percent change, candidate vs baseline. Null when either run lacks the value.", - "nullable": true - }, - "regressed": { - "type": "boolean", - "description": "Null when either run lacks the transaction (unknown, not unregressed).", - "nullable": true + }, + "ServiceUnavailable": { + "description": "A dependency (e.g. the AI insight backend) is temporarily unavailable.", + "schema": { + "$schema": "ErrorResponse" } - } - } - }, - "slaVerdictChanges": { - "type": "array", - "description": "SLA thresholds whose verdict changed between the runs.", - "items": { - "type": "object", - "properties": { - "metric": { - "type": "string" - }, - "type": { - "type": "string" - }, - "condition": { - "type": "string" - }, - "threshold": {}, - "unit": { - "type": "string" - }, - "request": {}, - "from": { - "type": "string", - "description": "Null when the threshold is absent on that run.", - "nullable": true - }, - "to": { - "type": "string", - "description": "Null when the threshold is absent on that run.", - "nullable": true - }, - "diffStatus": { - "type": "string", - "enum": [ - "now_failing", - "now_passing", - "added", - "dropped" - ] + }, + "InternalServerError": { + "description": "Unexpected server error.", + "schema": { + "$schema": "ErrorResponse" } - } } - }, - "warnings": { - "type": "array", - "items": { - "type": "string" + }, + "paging": { + "GET /api/v1/agent/projects": { + "page": "cursor", + "size": "limit" + }, + "GET /api/v1/agent/projects/{projectId}/loadTests": { + "page": "cursor", + "size": "limit" + }, + "GET /api/v1/agent/loadTests/{testId}/runs": { + "page": "cursor", + "size": "limit" } - } - } - }, - "CreateLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." - }, - "dashboardLink": { - "type": "string" - } - } - }, - "StartLoadTestRunData": { - "type": "object", - "properties": { - "runId": { - "type": "string", - "format": "uuid" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "dashboardLink": { - "type": "string" - }, - "estimatedVuHoursRange": { - "description": "Low/high VU-hour estimate for the started run." - } - } - }, - "ListLoadTestRunsData": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "runId": { - "type": "string" - }, - "status": { - "type": "string", - "nullable": true - }, - "startedAt": { - "type": "string", - "nullable": true - }, - "durationSec": { - "type": "integer", - "nullable": true, - "description": "Wall-clock seconds; null while running or when timestamps are missing." - }, - "vus": { - "type": "integer", - "nullable": true - }, - "verdict": { - "type": "string", - "nullable": true - }, - "vuHours": { - "type": "number", - "nullable": true, - "description": "Billed VU-hours; null until billed." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Run-level tags, or the tags inherited from the test when the run has none." - } - } + }, + "entities": { + "loadTest": { + "entity": "loadTest", + "title": "Load Test", + "description": "A saved load-test configuration (framework, script, load profile and thresholds); addressed by a numeric testId. Runs are executions of it.", + "aliases": [ + "test", + "load test", + "test configuration", + "test config", + "scenario", + "k6 test", + "jmeter test", + "gatling test" + ], + "id_convention": "testId is a numeric JobConfiguration id (integer). It is NOT the run id. Always discover it via listLoadTests for a project — never assume a value. A test also carries a version — an opaque ISO-8601 token that advances on each edit; pass it back verbatim as ifVersion for optimistic concurrency.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "belongs to" + }, + { + "entity": "run", + "via": "is executed as" + }, + { + "entity": "metrics", + "via": "declares" + } + ], + "capabilities": [ + "listLoadTests", + "searchLoadTests", + "getLoadTest", + "createLoadTest", + "updateLoadTest" + ] + }, + "run": { + "entity": "run", + "title": "Run", + "description": "A single execution of a load test; addressed by runId (a UUID, also called jobId). Carries status, metrics and a report.", + "aliases": [ + "execution", + "job", + "test run", + "load test run", + "run", + "job execution" + ], + "id_convention": "A run is addressed by runId (also called jobId), a UUID hashedId string — NOT the numeric testId. Discover run ids via listLoadTestRuns or listActiveLoadTestRuns; never assume or fabricate one.", + "parents": [ + "loadTest" + ], + "relations": [ + { + "entity": "loadTest", + "via": "executes" + }, + { + "entity": "report", + "via": "produces" + }, + { + "entity": "insight", + "via": "produces" + } + ], + "capabilities": [ + "startLoadTestRun", + "stopLoadTestRun", + "getLoadTestRunStatus", + "listLoadTestRuns", + "listActiveLoadTestRuns", + "listProjectLoadTestRuns" + ] + }, + "project": { + "entity": "project", + "title": "Project", + "description": "A container that groups related load tests under a group/account; addressed by a numeric projectId.", + "aliases": [ + "project", + "workspace", + "folder", + "test group" + ], + "id_convention": "projectId is a numeric Project id (integer). Discover it via listLoadTestProjects; a project can also be created implicitly by createLoadTest via projectName.", + "parents": [], + "relations": [ + { + "entity": "loadTest", + "via": "contains" + }, + { + "entity": "trend", + "via": "aggregates" + } + ], + "capabilities": [ + "listLoadTestProjects" + ] + }, + "report": { + "entity": "report", + "title": "Run Report", + "description": "The results of a completed run (KPIs, SLA verdicts, per-transaction rows, error breakdown); addressed by the run's runId.", + "aliases": [ + "report", + "result", + "results", + "metrics report", + "run report", + "summary", + "kpis" + ], + "id_convention": "A report is not independently identified — it is addressed by the run's runId (jobId). Fetch it only after a run reaches a terminal state.", + "parents": [ + "run" + ], + "relations": [ + { + "entity": "run", + "via": "belongs to" + } + ], + "capabilities": [ + "getLoadTestRunReport", + "compareLoadTestRuns" + ] + }, + "insight": { + "entity": "insight", + "title": "AI Insight", + "description": "AI-generated analysis of a completed run (root cause and recommendations); addressed by the run's runId, and may be pending until generated.", + "aliases": [ + "insight", + "ai insight", + "root cause", + "rca", + "analysis", + "recommendation", + "summary" + ], + "id_convention": "Addressed by the run's runId (jobId). Insights are AI-generated after a run completes; a non-terminal run returns an in_progress state rather than a report.", + "parents": [ + "run" + ], + "relations": [ + { + "entity": "run", + "via": "belongs to" + } + ], + "capabilities": [ + "getLoadTestInsightSummary" + ] + }, + "trend": { + "entity": "trend", + "title": "Historical Trend", + "description": "Historical time-series of a metric across a test's or project's runs; scoped by testId or projectId, not independently identified.", + "aliases": [ + "trend", + "trends", + "history", + "time series", + "over time", + "historical trends", + "regression trend" + ], + "id_convention": "Not independently identified — scoped by either a testId (per-test trend) or a projectId (project rollup). Resolve those ids first.", + "parents": [ + "loadTest", + "project" + ], + "relations": [ + { + "entity": "loadTest", + "via": "aggregates" + }, + { + "entity": "project", + "via": "aggregates" + } + ], + "capabilities": [ + "getLoadTestHistoricalTrends", + "getLoadTestProjectTrends" + ] + }, + "quota": { + "entity": "quota", + "title": "Quota & Cost", + "description": "The caller's VU-hour entitlement, usage and concurrency limits; account/group-scoped, with no id.", + "aliases": [ + "quota", + "vu hours", + "vuh", + "vu-hours", + "credits", + "entitlement", + "plan limits", + "concurrency", + "cost", + "estimate", + "budget" + ], + "id_convention": "Account/group-scoped — there is no id. Resolved from the authenticated caller's credentials; never pass a tenant or group id as a parameter.", + "parents": [], + "relations": [ + { + "entity": "run", + "via": "is consumed by" + } + ], + "capabilities": [ + "getLoadTestQuota", + "estimateLoadTestRunCost" + ] + }, + "metrics": { + "entity": "metrics", + "title": "Metrics Manifest", + "description": "The per-testType catalogue of available metric names, @-aliases and groupBy dimensions; a manifest, not an instance.", + "aliases": [ + "metric", + "metrics", + "kpi", + "measurement", + "manifest", + "available metrics", + "metric names", + "aliases" + ], + "id_convention": "Not an instance — a per-testType catalogue of the metric names, @ aliases and groupBy options a test supports. Scoped by testId; fetch once and cache client-side.", + "parents": [ + "loadTest" + ], + "relations": [ + { + "entity": "loadTest", + "via": "describes" + } + ], + "capabilities": [ + "getLoadTestMetricsManifest" + ] + }, + "platform": { + "entity": "platform", + "title": "Platform Status", + "description": "Health status of the Load Testing service and its dependencies; a singleton probe with no id.", + "aliases": [ + "platform", + "status", + "health", + "service status", + "availability", + "uptime" + ], + "id_convention": "Singleton — no id. A shallow health probe of the Load Testing service dependencies.", + "parents": [], + "relations": [], + "capabilities": [ + "getLoadTestPlatformStatus" + ] } - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string" - } - } - }, - "GetLoadTestHistoricalTrendsData": { - "type": "object", - "properties": { - "metrics": { - "description": "Trend series payload; shape per getLoadTestMetricsManifest." - } - } - }, - "GetLoadTestMetricsManifestData": { - "type": "object", - "properties": { - "testType": { - "type": "string" - }, - "metrics": { - "type": "array" - }, - "aliases": { - "type": "object" - } - } - }, - "UpdateLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." - } - } - }, - "GetLoadTestData": { - "type": "object", - "properties": { - "testId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "testType": { - "type": "string" - }, - "framework": { - "type": "string" - }, - "version": { - "type": "string", - "format": "date-time", - "description": "Optimistic-concurrency token: the test's updated_at as an ISO-8601 date-time (seconds precision). Pass it back unchanged as updateLoadTest's ifVersion." - }, - "projectId": { - "type": "integer" - }, - "ownerUserId": { - "type": "integer" - }, - "config": { - "type": "object" - } - } - } - }, - "responses": { - "BadRequest": { - "description": "Invalid parameters or body.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Unauthorized": { - "description": "Missing or invalid credentials.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "NotFound": { - "description": "The referenced project, test or run was not found or not visible to the caller.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Conflict": { - "description": "Optimistic-concurrency version conflict (ifVersion mismatch).", - "schema": { - "$schema": "ErrorResponse" - } - }, - "ServiceUnavailable": { - "description": "A dependency (e.g. the AI insight backend) is temporarily unavailable.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "InternalServerError": { - "description": "Unexpected server error.", - "schema": { - "$schema": "ErrorResponse" } - } - }, - "paging": { - "GET /api/v1/agent/projects": { - "page": "cursor", - "size": "limit" - }, - "GET /api/v1/agent/projects/{projectId}/loadTests": { - "page": "cursor", - "size": "limit" - }, - "GET /api/v1/agent/loadTests/{testId}/runs": { - "page": "cursor", - "size": "limit" - } - }, - "entities": { - "loadTest": { - "entity": "loadTest", - "title": "Load Test", - "description": "A saved load-test configuration (framework, script, load profile and thresholds); addressed by a numeric testId. Runs are executions of it.", - "aliases": [ - "test", - "load test", - "test configuration", - "test config", - "scenario", - "k6 test", - "jmeter test", - "gatling test" - ], - "id_convention": "testId is a numeric JobConfiguration id (integer). It is NOT the run id. Always discover it via listLoadTests for a project \u2014 never assume a value. A test also carries a version \u2014 an opaque ISO-8601 token that advances on each edit; pass it back verbatim as ifVersion for optimistic concurrency.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "belongs to" - }, - { - "entity": "run", - "via": "is executed as" - }, - { - "entity": "metrics", - "via": "declares" - } - ], - "capabilities": [ - "listLoadTests", - "searchLoadTests", - "getLoadTest", - "createLoadTest", - "updateLoadTest" - ] - }, - "run": { - "entity": "run", - "title": "Run", - "description": "A single execution of a load test; addressed by runId (a UUID, also called jobId). Carries status, metrics and a report.", - "aliases": [ - "execution", - "job", - "test run", - "load test run", - "run", - "job execution" - ], - "id_convention": "A run is addressed by runId (also called jobId), a UUID hashedId string \u2014 NOT the numeric testId. Discover run ids via listLoadTestRuns or listActiveLoadTestRuns; never assume or fabricate one.", - "parents": [ - "loadTest" - ], - "relations": [ - { - "entity": "loadTest", - "via": "executes" - }, - { - "entity": "report", - "via": "produces" - }, - { - "entity": "insight", - "via": "produces" - } - ], - "capabilities": [ - "startLoadTestRun", - "stopLoadTestRun", - "getLoadTestRunStatus", - "listLoadTestRuns", - "listActiveLoadTestRuns", - "listProjectLoadTestRuns" - ] - }, - "project": { - "entity": "project", - "title": "Project", - "description": "A container that groups related load tests under a group/account; addressed by a numeric projectId.", - "aliases": [ - "project", - "workspace", - "folder", - "test group" - ], - "id_convention": "projectId is a numeric Project id (integer). Discover it via listLoadTestProjects; a project can also be created implicitly by createLoadTest via projectName.", - "parents": [], - "relations": [ - { - "entity": "loadTest", - "via": "contains" - }, - { - "entity": "trend", - "via": "aggregates" - } - ], - "capabilities": [ - "listLoadTestProjects" - ] - }, - "report": { - "entity": "report", - "title": "Run Report", - "description": "The results of a completed run (KPIs, SLA verdicts, per-transaction rows, error breakdown); addressed by the run's runId.", - "aliases": [ - "report", - "result", - "results", - "metrics report", - "run report", - "summary", - "kpis" - ], - "id_convention": "A report is not independently identified \u2014 it is addressed by the run's runId (jobId). Fetch it only after a run reaches a terminal state.", - "parents": [ - "run" - ], - "relations": [ - { - "entity": "run", - "via": "belongs to" - } - ], - "capabilities": [ - "getLoadTestRunReport", - "compareLoadTestRuns" - ] - }, - "insight": { - "entity": "insight", - "title": "AI Insight", - "description": "AI-generated analysis of a completed run (root cause and recommendations); addressed by the run's runId, and may be pending until generated.", - "aliases": [ - "insight", - "ai insight", - "root cause", - "rca", - "analysis", - "recommendation", - "summary" - ], - "id_convention": "Addressed by the run's runId (jobId). Insights are AI-generated after a run completes; a non-terminal run returns an in_progress state rather than a report.", - "parents": [ - "run" - ], - "relations": [ - { - "entity": "run", - "via": "belongs to" - } - ], - "capabilities": [ - "getLoadTestInsightSummary" - ] - }, - "trend": { - "entity": "trend", - "title": "Historical Trend", - "description": "Historical time-series of a metric across a test's or project's runs; scoped by testId or projectId, not independently identified.", - "aliases": [ - "trend", - "trends", - "history", - "time series", - "over time", - "historical trends", - "regression trend" - ], - "id_convention": "Not independently identified \u2014 scoped by either a testId (per-test trend) or a projectId (project rollup). Resolve those ids first.", - "parents": [ - "loadTest", - "project" - ], - "relations": [ - { - "entity": "loadTest", - "via": "aggregates" - }, - { - "entity": "project", - "via": "aggregates" - } - ], - "capabilities": [ - "getLoadTestHistoricalTrends", - "getLoadTestProjectTrends" - ] - }, - "quota": { - "entity": "quota", - "title": "Quota & Cost", - "description": "The caller's VU-hour entitlement, usage and concurrency limits; account/group-scoped, with no id.", - "aliases": [ - "quota", - "vu hours", - "vuh", - "vu-hours", - "credits", - "entitlement", - "plan limits", - "concurrency", - "cost", - "estimate", - "budget" - ], - "id_convention": "Account/group-scoped \u2014 there is no id. Resolved from the authenticated caller's credentials; never pass a tenant or group id as a parameter.", - "parents": [], - "relations": [ - { - "entity": "run", - "via": "is consumed by" - } - ], - "capabilities": [ - "getLoadTestQuota", - "estimateLoadTestRunCost" - ] - }, - "metrics": { - "entity": "metrics", - "title": "Metrics Manifest", - "description": "The per-testType catalogue of available metric names, @-aliases and groupBy dimensions; a manifest, not an instance.", - "aliases": [ - "metric", - "metrics", - "kpi", - "measurement", - "manifest", - "available metrics", - "metric names", - "aliases" - ], - "id_convention": "Not an instance \u2014 a per-testType catalogue of the metric names, @ aliases and groupBy options a test supports. Scoped by testId; fetch once and cache client-side.", - "parents": [ - "loadTest" - ], - "relations": [ - { - "entity": "loadTest", - "via": "describes" - } - ], - "capabilities": [ - "getLoadTestMetricsManifest" - ] - }, - "platform": { - "entity": "platform", - "title": "Platform Status", - "description": "Health status of the Load Testing service and its dependencies; a singleton probe with no id.", - "aliases": [ - "platform", - "status", - "health", - "service status", - "availability", - "uptime" - ], - "id_convention": "Singleton \u2014 no id. A shallow health probe of the Load Testing service dependencies.", - "parents": [], - "relations": [], - "capabilities": [ - "getLoadTestPlatformStatus" - ] - } } - } } diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json index dc44450d..2f0aecb5 100644 --- a/capability/mobiledevicecontrol.capability-index.json +++ b/capability/mobiledevicecontrol.capability-index.json @@ -57,6 +57,7 @@ "method": "POST", "path": "/wd/hub/session", "mode": "write", + "timeout_seconds": 180, "entity": "app_session", "intent": "Start a live Appium session on a real Android or iOS device with an uploaded app \u2014 use this before reading the screen, tapping, typing or swiping in the app.", "guidance": { @@ -1089,6 +1090,7 @@ "method": "POST", "path": "/app-automate/upload", "mode": "write", + "timeout_seconds": 600, "entity": "app_build", "intent": "Upload an app build to the device-session store \u2014 the one sessions here install from, separate from App Live's \u2014 from a public download link, and get back the bs:// URL a session installs. Use this to make a new .apk, .aab or .ipa available to the devices an agent drives.", "guidance": [ @@ -2877,6 +2879,7 @@ "path": "/app-automate/upload", "host": "api", "mode": "write", + "timeout_seconds": 600, "entity": "app_build", "encoding": "multipart", "local_only": true, diff --git a/capability/tm.capability-index.json b/capability/tm.capability-index.json index 9250f2a3..4f731103 100644 --- a/capability/tm.capability-index.json +++ b/capability/tm.capability-index.json @@ -1,50426 +1,50420 @@ { - "schema_version": 1, - "version": "1.46", - "build_id": "cb63a4a5_2026-09-16T18:02:57Z", - "tm": { - "title": "Test Management", - "routing_terms": [ - "team", - "setting", - "workspace", - "suite" - ], - "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. A run is executed against a named configuration and environment, and records the app version and build its results came from. Destructive capabilities exist and always need explicit human approval.", - "capabilities": [ - { - "name": "assign_test_run_owner", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "owner", - "type": "integer", - "description": "The ID of the user to assign as the owner of the test run.", - "json_path": "/test_run/owner" - } - ], - "intent": "Assign a owner to the test run", - "returns": [ - "id", - "identifier", - "name", - "active_state", - "assignee_imported", - "created_at", - "description", - "environment", - "is_automation", - "is_dynamic", - "observability_url", - "owner" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__AssignTestRunOwner" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `owner` flat. The `test_run` wrapper is added for you from the field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "The reply's `tags` can be [] even though the run's stored tags are untouched \u2014 the owner assign does not clear them. Re-read the run before reporting its tags." - ] - }, - { - "name": "assign_test_run_test_cases", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "assign_to", - "type": "array", - "required": true, - "description": "One entry per case (or execution) to reassign. Top level \u2014 NOT wrapped in a test_run object, unlike the update calls on this run.", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "assignee", - "type": "string" - }, - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "execution_id", - "type": "integer" - } - ] - } - ], - "intent": "Hand specific test cases inside a run to the people who should execute them (or clear their assignee)", - "guidance": [ - "Queued, not applied: the 200 {success: true} only means the job was accepted. Re-read the run's test cases to confirm the assignees actually changed.", - "`assignee` is an email (or TM user id) resolved inside the run's group. An address that does not resolve is skipped silently \u2014 that case keeps its old assignee and nothing is reported.", - "Send `assignee: null` on an entry to unassign that case.", - "Each entry must carry `test_case_id` (TC-NNN) or `execution_id`; an entry with neither is dropped. Add `configuration_id` to target one configuration row when the case is mapped to several \u2014 omit it and every row for that case is reassigned.", - "The body is not validated before the 200: a missing or empty `assign_to` still answers 200 {success: true} and changes nothing.", - "Assignment is reversible \u2014 assign again (or to null) to undo." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "description": "Assignment job queued. Body is {success: true}; the assignments are applied asynchronously.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "description": "The job could not be enqueued; nothing was assigned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - } - } - }, - { - "name": "bulk_archive_test_cases_by_project", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-archive", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all` \u2014 the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder to scope the selection to, at top level. Merged into the search scope and it OVERWRITES `q.folder_id` when both are present. Do not confuse it with the DESTINATION, which for move lives at `test_case.destination_folder_id` and for copy at top-level `destination_folder_id`." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of `folder_id`. Compared as the string `true`. The UI sets it for consolidated (non-folder) views." - }, - { - "name": "ids", - "type": "array", - "required": true, - "description": "Integer ids of the cases to archive / retrieve.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "required": true, - "description": "Ids to exclude when `select_all` is true.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "required": true, - "description": "Apply to every case in scope, minus `de_selected_ids`.", - "json_path": "/test_case/select_all" - } - ], - "intent": "Bulk Archive Test Cases", - "guidance": [ - "Archiving hides cases from the working set without deleting them; they can be retrieved again. Prefer it to deletion when the user says 'remove' or 'clean up' without being explicit." - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkArchiveResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "bulk_archive_test_plans", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/bulk-archive", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_plan_ids", - "type": "array", - "required": true, - "minItems": 1, - "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids \u2014 the first one present wins, so do not send two. Every entry is run through to_i and anything that yields 0 is silently dropped, so a TP-NNN identifier is discarded without an error." - } - ], - "intent": "Archive test plans in bulk \u2014 the reversible way to get plans out of the active listing; use this instead of delete whenever the user might want them back", - "returns": [ - "success", - "async", - "unique_id", - "plans", - "count" - ], - "responses": { - "200": { - "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` \u2014 the plans are not archived yet.\n", - "schema": { - "$schema": "V1TestPlanBulkResponse__BulkArchiveTestPlans" - } - }, - "400": { - "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + "schema_version": 1, + "version": "1.46", + "build_id": "cb63a4a5_2026-09-16T18:02:57Z", + "tm": { + "routing_terms": ["team", "setting", "workspace", "suite"], + "summary": "BrowserStack Test Management: author and organise test cases, plan and track their execution, and report on the results. Everything is scoped to a project, which owns the folders, cases, runs, plans and reports beneath it. Search and filter cases; create and update cases, runs and plans; record results; bulk edit, move, copy, archive and delete; restructure folders; manage tags and custom-field definitions; assign, clone and close runs; review AI duplicate recommendations; read the activity feed and a case's version history; and create, schedule and download reports. Cases are authored and their outcomes recorded here, but tests are not RUN here: execution happens in your own runner or another BrowserStack product. Renaming and deleting a project are not exposed. A run is executed against a named configuration and environment, and records the app version and build its results came from. Destructive capabilities exist and always need explicit human approval.", + "capabilities": [ + { + "name": "assign_test_run_owner", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "owner", + "type": "integer", + "description": "The ID of the user to assign as the owner of the test run.", + "json_path": "/test_run/owner" + } + ], + "intent": "Assign a owner to the test run", + "returns": [ + "id", + "identifier", + "name", + "active_state", + "assignee_imported", + "created_at", + "description", + "environment", + "is_automation", + "is_dynamic", + "observability_url", + "owner" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__AssignTestRunOwner" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } }, - "errors": { - "type": "string" + "guidance": [ + "Send `owner` flat. The `test_run` wrapper is added for you from the field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "The reply's `tags` can be [] even though the run's stored tags are untouched — the owner assign does not clear them. Re-read the run before reporting its tags." + ] + }, + { + "name": "assign_test_run_test_cases", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "assign_to", + "type": "array", + "required": true, + "description": "One entry per case (or execution) to reassign. Top level — NOT wrapped in a test_run object, unlike the update calls on this run.", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "assignee", + "type": "string" + }, + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "execution_id", + "type": "integer" + } + ] + } + ], + "intent": "Hand specific test cases inside a run to the people who should execute them (or clear their assignee)", + "guidance": [ + "Queued, not applied: the 200 {success: true} only means the job was accepted. Re-read the run's test cases to confirm the assignees actually changed.", + "`assignee` is an email (or TM user id) resolved inside the run's group. An address that does not resolve is skipped silently — that case keeps its old assignee and nothing is reported.", + "Send `assignee: null` on an entry to unassign that case.", + "Each entry must carry `test_case_id` (TC-NNN) or `execution_id`; an entry with neither is dropped. Add `configuration_id` to target one configuration row when the case is mapped to several — omit it and every row for that case is reassigned.", + "The body is not validated before the 200: a missing or empty `assign_to` still answers 200 {success: true} and changes nothing.", + "Assignment is reversible — assign again (or to null) to undo." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "description": "Assignment job queued. Body is {success: true}; the assignments are applied asynchronously.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "description": "The job could not be enqueued; nothing was assigned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" + }, + { + "name": "bulk_archive_test_cases_by_project", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-archive", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all` — the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder to scope the selection to, at top level. Merged into the search scope and it OVERWRITES `q.folder_id` when both are present. Do not confuse it with the DESTINATION, which for move lives at `test_case.destination_folder_id` and for copy at top-level `destination_folder_id`." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of `folder_id`. Compared as the string `true`. The UI sets it for consolidated (non-folder) views." + }, + { + "name": "ids", + "type": "array", + "required": true, + "description": "Integer ids of the cases to archive / retrieve.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "required": true, + "description": "Ids to exclude when `select_all` is true.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "required": true, + "description": "Apply to every case in scope, minus `de_selected_ids`.", + "json_path": "/test_case/select_all" + } + ], + "intent": "Bulk Archive Test Cases", + "guidance": [ + "Archiving hides cases from the working set without deleting them; they can be retrieved again. Prefer it to deletion when the user says 'remove' or 'clean up' without being explicit." + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkArchiveResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Archiving is gated on a group feature flag. When the flag is off the call returns 404 with {success: false, errors: 'Feature not available'} \u2014 a 404 here means the feature is unavailable, NOT that the plans are missing. The same gate covers bulk-retrieve and both archived reads.", - "Permission is the test-plan DELETE right, not write. A user who can create and edit plans can still be 403'd here, and on restore and on the archived listing/count.", - "Ids are coerced with to_i: 'TP-12' and any non-numeric entry become 0 and are dropped silently. If every id drops the call is 400 {success: false, errors: 'Please provide test plan ids'} \u2014 the same error you get for an empty list.", - "More than 30 ids is handed to a background job and returns {success: true, async: true, unique_id} \u2014 NOTHING is archived yet at that moment. Re-read the plans (or the archived listing) to confirm; there is no job-status endpoint for this in the profile.", - "The 30 counts the ids YOU send. Sub-plan expansion happens upstream, so archiving a parent can touch far more plans than the count you get back.", - "Failures use the plural key `errors`, not `error` \u2014 {success: false, errors: '...'}. Branch on `success`, not on the key name.", - "Unlike the public/v2 archive route, this path is not JSON-schema validated, so the 1000-id cap that route enforces is not applied here. Batch large sets yourself.", - "To undo, call the bulk-retrieve capability with the same ids." - ] - }, - { - "name": "bulk_set_test_case_assignee_in_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/bulk_assign", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "When a folder_id is given, also include cases in its sub-folders when resolving a select_all." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the test-case listing returned AFTER the assignment. It does not affect which rows are assigned." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Page size of the listing returned after the assignment. Anything outside 1..100, or non-numeric, silently falls back to 30 rather than erroring." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort of the listing returned after the assignment." - } - ], - "body": [ - { - "name": "assignee_id", - "type": "integer", - "example": 2, - "description": "TM user id (not a BrowserStack user id, not an email) of the person to assign. The id must belong to the caller's group or the request 404s. Omit it entirely to clear the assignee on the selected rows \u2014 there is no separate 'unassign' call." - }, - { - "name": "mapping_ids", - "type": "array", - "example": [ - 999, - 991, - 1024, - 1019 - ], - "description": "TRTC MAPPING ids \u2014 the `mapping_id` field on the rows a test-run test-case listing returns \u2014 NOT test case ids. A case that runs against N configurations has N mapping ids, and passing the case's `id` here assigns nothing and still returns 200." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Assign every case the current filter resolves to, instead of an explicit mapping_ids list. The filter is `q` (plus folder_id / include_subfolders_tc); with no `q` this means the whole run." - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Mapping ids to exclude. Subtracted from mapping_ids, and from the resolved set when select_all is true." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Scope the selection \u2014 and the listing returned afterwards \u2014 to one folder of the run. Its presence also changes the response: the post-assign listing comes back from the folder view and carries an extra `folder` key." - }, - { - "name": "q", - "type": "object", - "description": "The same filter object the run's test-case listing takes. Only read when select_all is true; it is what select_all resolves against." - }, - { - "name": "automation", - "type": "boolean", - "default": false, - "description": "Automation-run path. When true the selection resolver is bypassed entirely: `test_run_ids` is taken as the id list and the operation is ALWAYS queued, whatever its size. Leave it off for manual runs." - }, - { - "name": "test_run_ids", - "type": "array", - "description": "Only read when automation is true; ignored otherwise." - } - ], - "intent": "Set the assignee on many test cases inside a test run in one call \u2014 pick the rows explicitly, or select every row matching the current filter and deselect a few", - "returns": [ - "success", - "info", - "test_cases", - "folder", - "async", - "unique_id", - "id", - "identifier", - "name", - "mapping_id", - "test_case_id", - "latest_status", - "result_status", - "test_assignee", - "last_executed_by", - "last_executed_at", - "defects", - "configuration", - "result_custom_fields", - "execution_eligible", - "owner", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "BulkAssignTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The ids here are mapping ids, not test case ids. Read the run's test cases first and take `mapping_id` off each row \u2014 `id` is the test case and assigning by it silently does nothing.", - "Above 300 selected rows the call switches to a queued write and returns {success:true, async:true, unique_id} with NO test_cases \u2014 the assignment happens later. Below that it applies inline and returns the re-listed page. Branch on `async` rather than assuming the list is there, and never retry on the async acknowledgement.", - "The selected mapping ids are locked for the duration. A concurrent bulk operation on any of the same rows comes back 400 {success:false, message:'TestCases in progress with another operation'} \u2014 that is contention, not bad input; wait and retry.", - "An empty resolution is refused, not silently accepted: 400 'No test cases to update.' when nothing is selected, and 422 'Could not resolve the selected test cases. Please retry.' when select_all counted rows it could not turn into ids.", - "assignee_id must be a TM user id inside the caller's group; an id from another group raises a 404 rather than being ignored. Omitting it clears the assignee.", - "The 200 body is the run's test-case listing re-read after the write, not a report of what changed \u2014 it is paged, so it can show fewer rows than you assigned. Re-read and check `test_assignee` if you need confirmation.", - "Needs the test-run write permission; on an automation run the automation variant of that permission is demanded instead and a role that only holds the manual one gets 403." - ], - "paginated": true, - "max_page_size": 100 - }, - { - "name": "bulk_copy_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-copy", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "description": "Copy every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default." - }, - { - "name": "folder_id", - "type": "string", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "destination_folder_id", - "type": "integer", - "required": true, - "description": "Target folder for the copies, at TOP level (bulk-move puts its destination inside `test_case` instead). Required in practice: the request is pre-checked by looking the folder up inside `destination_project_id`, and a folder that does not exist there is a bare 400 with no message. The only way to omit it is the `copy_folder` flow below, which mints the destination first." - }, - { - "name": "destination_project_id", - "type": "string", - "description": "Target project id. Only needed for a copy into ANOTHER project \u2014 it defaults to the source project when omitted, so a same-project copy can leave it out. Integer and string are both accepted. Ignored when `clone_in_place` is true, which always stays in the source project." - }, - { - "name": "clone_in_place", - "type": "boolean", - "description": "Duplicate each selected case into its OWN source folder (the product's Clone action) instead of into one shared destination. Never crosses projects. `destination_folder_id` is still pre-validated even though the copies do not land there, so it must still name a real folder." - }, - { - "name": "copy_folder", - "type": "boolean", - "description": "Compared as the string `true`. Creates the destination folder from `test_case_folder` before copying, and that new folder then wins over `destination_folder_id`. Leave it unset and pass an existing `destination_folder_id` for the normal case." - }, - { - "name": "test_case_folder", - "type": "object", - "description": "Only read when `copy_folder` is true: the destination folder to create. `test_case_folder_id` is the PARENT folder's integer id (0 with `root: true` creates a root folder); `prev`/`next` position it among its siblings.", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "notes", - "type": "string" - }, - { - "name": "test_case_folder_id", - "type": "integer" - }, - { - "name": "root", - "type": "boolean" - }, - { - "name": "prev", - "type": "integer" - }, - { - "name": "next", - "type": "integer" - } - ] - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Copy test cases into another folder or another project \u2014 by listing their ids, or by copying everything a filter matches \u2014 and clone cases in place.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "destination_path_folders", - "source_path_folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkCopyTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Selection is one of two modes and they do not mix: either `test_case.ids` (explicit integer ids), or `test_case.select_all: true`, which resolves the set from top-level `q` + `folder_id` and ignores `ids` entirely.", - "The destination is TOP-LEVEL `destination_folder_id` here. Bulk-move puts its destination inside `test_case` \u2014 copying the move body onto this route silently sends no destination and 400s.", - "The destination folder is looked up before anything is copied, inside `destination_project_id` (which defaults to the source project). A folder id that does not exist there comes back as a bare 400 with no message \u2014 that is 'no such folder', not 'bad request'.", - "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 30 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed SOURCE view (or the search results) plus folder counts \u2014 it does NOT contain the new copies or their ids. To get them, list the destination folder afterwards; `cases_count[]` only tells you the new total.", - "Copies are new test cases with new integer ids and new TC-NNN identifiers; the originals are untouched. There is no undo \u2014 the way back is to delete the copies." - ] - }, - { - "name": "bulk_delete_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-delete", - "mode": "destructive", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Delete every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Delete many test cases at once from the project-wide search/filter view \u2014 by id, or everything a filter matches.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "path_folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkDeleteTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Selection is either `test_case.ids` or `test_case.select_all: true` scoped by top-level `q` + `folder_id`. With select_all the `ids` you send are ignored, so a mis-typed filter key (which is dropped, not rejected) deletes the whole scope.", - "Whether this is recoverable depends on the workspace: with the move-to-bin feature enabled the cases go to the Bin and can be restored, otherwise the delete is permanent. Assume permanent unless you have confirmed the Bin is on for this group.", - "Deleting also drops the cases' results and unlinks them from schedules \u2014 those do not come back even when the case is restored from the Bin.", - "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed listing after the delete (`test_cases` + `info` + `cases_count`), not a report of what was deleted. The one exception: with `q.is_archived` set, the response is just `{success:true}`." - ], - "disabled": true - }, - { - "name": "bulk_delete_test_results", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-results/bulk-delete", - "mode": "destructive", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "result_ids", - "type": "array", - "required": true, - "description": "Result INTEGER ids. Non-empty, max 300.", - "minItems": 1, - "maxItems": 300 - } - ], - "intent": "Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`", - "returns": [ - "success", - "result", - "deleted_ids", - "skipped", - "id", - "reason" - ], - "responses": { - "200": { - "description": "Partial success is normal \u2014 `deleted_ids` is what actually went, `skipped` is what\ndid not (usually authorisation).\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "result": { - "type": "object", - "description": "Both lists are nested HERE, not at the top level. Reading\nresponse[\"deleted_ids\"] directly finds nothing and makes a successful\ndelete look like a no-op.\n", - "properties": { - "deleted_ids": { - "type": "array", - "items": { - "type": "integer" - } + }, + { + "name": "bulk_archive_test_plans", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/bulk-archive", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_plan_ids", + "type": "array", + "required": true, + "minItems": 1, + "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids — the first one present wins, so do not send two. Every entry is run through to_i and anything that yields 0 is silently dropped, so a TP-NNN identifier is discarded without an error." + } + ], + "intent": "Archive test plans in bulk — the reversible way to get plans out of the active listing; use this instead of delete whenever the user might want them back", + "returns": [ + "success", + "async", + "unique_id", + "plans", + "count" + ], + "responses": { + "200": { + "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` — the plans are not archived yet.\n", + "schema": { + "$schema": "V1TestPlanBulkResponse__BulkArchiveTestPlans" + } }, - "skipped": { - "type": "array", - "description": "Entries are OBJECTS, not bare ids \u2014 {\"id\": 13, \"reason\": \"not_found\"}.\nCommon reasons: not_found, or the caller is neither author nor project admin.\n", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "reason": { - "type": "string" - } + "400": { + "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } } - } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" } - } - } - } - } - }, - "400": { - "description": "`result_ids` missing or empty." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "description": "More than 300 ids in one request." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Partial success is the normal outcome and it still answers 200 with success:true. Both lists sit under a top-level `result` key: read result.deleted_ids and result.skipped. Never infer 'all deleted' from the status code \u2014 diff deleted_ids against the ids you sent and report the rest.", - "`skipped` entries are OBJECTS, not bare ids: {id, reason}. The only two reasons the handler produces are `not_found` and `not_authorised`.", - "Deletion is authorised per id: a project admin's whole batch is authorised wholesale, everyone else may only delete results they personally logged. A non-admin batch is resolved id by id against the recorded author.", - "The authorship lookup fails CLOSED. If that upstream call errors, you still get 200 / success:true with deleted_ids empty and EVERY id back in skipped with reason `not_found`. An all-not_found batch is as likely an upstream failure as genuinely missing ids \u2014 re-read the results before telling the user they do not exist.", - "`result_ids` is read from the top level of the body \u2014 it is not wrapped in an object. Ids are coerced with to_i and anything coercing to 0 (a non-numeric string, a null, 0 itself) is dropped silently; if that empties the list you get 400 'result_ids must be a non-empty array' even though you sent a non-empty array.", - "More than 300 ids is refused with 422 before anything is deleted \u2014 nothing partial happens, so just re-batch. Sending exactly the cap is fine.", - "There is no recycle bin for results. Deleting a case's latest result rolls that case's outcome in the run back to its previous attempt (or back to unexecuted if it was the only one); the deleted result itself is unrecoverable." - ], - "disabled": true - }, - { - "name": "bulk_replace_test_case_fields", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Edit every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "automation_status", - "type": "string", - "json_path": "/test_case/automation_status", - "description": "Automation state as an internal_name string (e.g. `not_automated`). Forwarded verbatim on this path \u2014 no name-to-id resolution happens. Prefer `automation_state` with its numeric option id." - }, - { - "name": "case_type", - "type": "integer", - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id \u2014 NOT the display name. Unlike single-case edit, this path always sends it as an id, so a name here is stored as a broken id. Resolve the id from the project's system field values first." - }, - { - "name": "custom_fields", - "type": "object", - "required": true, - "description": "Map of custom-field id to value. ALWAYS SEND THIS KEY \u2014 pass `{}` when changing no custom fields. Omitting it returns 500 (the server dereferences a nil hash), the same defect as on PATCH .../test-cases.", - "json_path": "/test_case/custom_fields" - }, - { - "name": "issues", - "type": "array", - "json_path": "/test_case/issues", - "description": "Linked issues, as `[{issue_id, issue_type}]` (bare strings are coerced to ``issue_id``). Read via a separate permit than the other fields, so it survives even though it is not in the edit allow-list. It is sent on EVERY bulk edit \u2014 as `[]` when you omit it \u2014 so treat it as a full replacement of the issue list, not an append." - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id (not a BrowserStack user id, not an email). An id that does not resolve to a user is not a validation error \u2014 it dereferences nil and returns 500.", - "json_path": "/test_case/owner" - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "json_path": "/test_case/priority", - "description": "Numeric priority option id \u2014 NOT the display name. Resolve it from the project's system field values first." - }, - { - "name": "status", - "type": "integer", - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "tags", - "type": "array", - "description": "REPLACES the entire tag list on every selected case. Read the existing tags and send the union if you mean to add.", - "json_path": "/test_case/tags" - }, - { - "name": "review_status", - "type": "string", - "json_path": "/test_case/review_status", - "description": "Review state applied to every selected case (e.g. `pending`, `in_review`, `approved`). Only meaningful on a Team-Pro workspace with review-approve enabled on the project." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_case/reviewers", - "description": "Reviewer TM user ids. Honoured only on a Team-Pro workspace with review-approve enabled on the project; otherwise dropped without any error." - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state", - "description": "Numeric automation-state option id. Takes precedence over `automation_status`." - }, - { - "name": "template", - "type": "string", - "json_path": "/test_case/template", - "description": "Step-shape template applied to every selected case. Validated against a closed set (`test_case_text`, `test_case_steps`, `bdd`, `test_case_bdd`) and DROPPED WITHOUT ERROR if the value is not one of them \u2014 a template id or a custom template name here is a silent no-op.", - "values": [ - "test_case_text", - "test_case_steps", - "bdd", - "test_case_bdd" - ] - }, - { - "name": "estimate", - "type": "string", - "json_path": "/test_case/estimate", - "description": "Forwarded to the backend on this path (unlike single-case edit, which drops it), but every read serialises `estimate` as null, so you cannot confirm it and should not report it as saved. There is no bulk equivalent of `estimated_duration_seconds`." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Free-text note carried with the bulk edit." - }, - { - "name": "folder_id", - "type": "integer", - "json_path": "/test_case/folder_id", - "description": "DANGER \u2014 inside `test_case` this MOVES every selected case into that folder. The top-level `folder_id` of the same name only scopes the selection. Omit this unless you mean to move them." - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Set the same field values \u2014 owner, priority, status, case type, tags, custom fields, review state \u2014 on many test cases at once from the project-wide search/filter view.", - "returns": [ - "success", - "test_cases", - "info", - "filter_details" - ], - "paginated": true, - "responses": { - "200": { - "description": "Applied synchronously: the refreshed search listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no test_cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkReplaceTestCaseFields" - } - }, - "filter_details": { - "type": "object", - "description": "Echo of the resolved filter; {} when no q was sent." + "guidance": [ + "Archiving is gated on a group feature flag. When the flag is off the call returns 404 with {success: false, errors: 'Feature not available'} — a 404 here means the feature is unavailable, NOT that the plans are missing. The same gate covers bulk-retrieve and both archived reads.", + "Permission is the test-plan DELETE right, not write. A user who can create and edit plans can still be 403'd here, and on restore and on the archived listing/count.", + "Ids are coerced with to_i: 'TP-12' and any non-numeric entry become 0 and are dropped silently. If every id drops the call is 400 {success: false, errors: 'Please provide test plan ids'} — the same error you get for an empty list.", + "More than 30 ids is handed to a background job and returns {success: true, async: true, unique_id} — NOTHING is archived yet at that moment. Re-read the plans (or the archived listing) to confirm; there is no job-status endpoint for this in the profile.", + "The 30 counts the ids YOU send. Sub-plan expansion happens upstream, so archiving a parent can touch far more plans than the count you get back.", + "Failures use the plural key `errors`, not `error` — {success: false, errors: '...'}. Branch on `success`, not on the key name.", + "Unlike the public/v2 archive route, this path is not JSON-schema validated, so the 1000-id cap that route enforces is not applied here. Batch large sets yourself.", + "To undo, call the bulk-retrieve capability with the same ids." + ] + }, + { + "name": "bulk_set_test_case_assignee_in_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/bulk_assign", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "When a folder_id is given, also include cases in its sub-folders when resolving a select_all." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the test-case listing returned AFTER the assignment. It does not affect which rows are assigned." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Page size of the listing returned after the assignment. Anything outside 1..100, or non-numeric, silently falls back to 30 rather than erroring." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort of the listing returned after the assignment." + } + ], + "body": [ + { + "name": "assignee_id", + "type": "integer", + "example": 2, + "description": "TM user id (not a BrowserStack user id, not an email) of the person to assign. The id must belong to the caller's group or the request 404s. Omit it entirely to clear the assignee on the selected rows — there is no separate 'unassign' call." + }, + { + "name": "mapping_ids", + "type": "array", + "example": [ + 999, + 991, + 1024, + 1019 + ], + "description": "TRTC MAPPING ids — the `mapping_id` field on the rows a test-run test-case listing returns — NOT test case ids. A case that runs against N configurations has N mapping ids, and passing the case's `id` here assigns nothing and still returns 200." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Assign every case the current filter resolves to, instead of an explicit mapping_ids list. The filter is `q` (plus folder_id / include_subfolders_tc); with no `q` this means the whole run." + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Mapping ids to exclude. Subtracted from mapping_ids, and from the resolved set when select_all is true." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Scope the selection — and the listing returned afterwards — to one folder of the run. Its presence also changes the response: the post-assign listing comes back from the folder view and carries an extra `folder` key." + }, + { + "name": "q", + "type": "object", + "description": "The same filter object the run's test-case listing takes. Only read when select_all is true; it is what select_all resolves against." + }, + { + "name": "automation", + "type": "boolean", + "default": false, + "description": "Automation-run path. When true the selection resolver is bypassed entirely: `test_run_ids` is taken as the id list and the operation is ALWAYS queued, whatever its size. Leave it off for manual runs." + }, + { + "name": "test_run_ids", + "type": "array", + "description": "Only read when automation is true; ignored otherwise." + } + ], + "intent": "Set the assignee on many test cases inside a test run in one call — pick the rows explicitly, or select every row matching the current filter and deselect a few", + "returns": [ + "success", + "info", + "test_cases", + "folder", + "async", + "unique_id", + "id", + "identifier", + "name", + "mapping_id", + "test_case_id", + "latest_status", + "result_status", + "test_assignee", + "last_executed_by", + "last_executed_at", + "defects", + "configuration", + "result_custom_fields", + "execution_eligible", + "owner", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "BulkAssignTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } }, - "folders": { - "type": "object" + "guidance": [ + "The ids here are mapping ids, not test case ids. Read the run's test cases first and take `mapping_id` off each row — `id` is the test case and assigning by it silently does nothing.", + "Above 300 selected rows the call switches to a queued write and returns {success:true, async:true, unique_id} with NO test_cases — the assignment happens later. Below that it applies inline and returns the re-listed page. Branch on `async` rather than assuming the list is there, and never retry on the async acknowledgement.", + "The selected mapping ids are locked for the duration. A concurrent bulk operation on any of the same rows comes back 400 {success:false, message:'TestCases in progress with another operation'} — that is contention, not bad input; wait and retry.", + "An empty resolution is refused, not silently accepted: 400 'No test cases to update.' when nothing is selected, and 422 'Could not resolve the selected test cases. Please retry.' when select_all counted rows it could not turn into ids.", + "assignee_id must be a TM user id inside the caller's group; an id from another group raises a 404 rather than being ignored. Omitting it clears the assignee.", + "The 200 body is the run's test-case listing re-read after the write, not a report of what changed — it is paged, so it can show fewer rows than you assigned. Re-read and check `test_assignee` if you need confirmation.", + "Needs the test-run write permission; on an automation run the automation variant of that permission is demanded instead and a role that only holds the manual one gets 403." + ], + "paginated": true, + "max_page_size": 100 + }, + { + "name": "bulk_copy_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-copy", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "description": "Copy every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default." + }, + { + "name": "folder_id", + "type": "string", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "destination_folder_id", + "type": "integer", + "required": true, + "description": "Target folder for the copies, at TOP level (bulk-move puts its destination inside `test_case` instead). Required in practice: the request is pre-checked by looking the folder up inside `destination_project_id`, and a folder that does not exist there is a bare 400 with no message. The only way to omit it is the `copy_folder` flow below, which mints the destination first." + }, + { + "name": "destination_project_id", + "type": "string", + "description": "Target project id. Only needed for a copy into ANOTHER project — it defaults to the source project when omitted, so a same-project copy can leave it out. Integer and string are both accepted. Ignored when `clone_in_place` is true, which always stays in the source project." + }, + { + "name": "clone_in_place", + "type": "boolean", + "description": "Duplicate each selected case into its OWN source folder (the product's Clone action) instead of into one shared destination. Never crosses projects. `destination_folder_id` is still pre-validated even though the copies do not land there, so it must still name a real folder." + }, + { + "name": "copy_folder", + "type": "boolean", + "description": "Compared as the string `true`. Creates the destination folder from `test_case_folder` before copying, and that new folder then wins over `destination_folder_id`. Leave it unset and pass an existing `destination_folder_id` for the normal case." + }, + { + "name": "test_case_folder", + "type": "object", + "description": "Only read when `copy_folder` is true: the destination folder to create. `test_case_folder_id` is the PARENT folder's integer id (0 with `root: true` creates a root folder); `prev`/`next` position it among its siblings.", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "notes", + "type": "string" + }, + { + "name": "test_case_folder_id", + "type": "integer" + }, + { + "name": "root", + "type": "boolean" + }, + { + "name": "prev", + "type": "integer" + }, + { + "name": "next", + "type": "integer" + } + ] + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Copy test cases into another folder or another project — by listing their ids, or by copying everything a filter matches — and clone cases in place.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "destination_path_folders", + "source_path_folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkCopyTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } }, - "workflow": { - "type": "object" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "ALWAYS send `test_case.custom_fields`, `{}` when you are changing none. Omitting the key dereferences a nil hash and returns 500 \u2014 this is the single most common failure on this route.", - "priority, status, case_type and automation_state must be NUMERIC option ids here. The single-case edit accepts a display name for some of them; this path does not, and a name is written through as a broken id rather than rejected.", - "`tags` REPLACES the whole tag list on every selected case. Read the existing tags and send the union if you mean to add. The same is true of `issues`, which is sent as `[]` when you omit it.", - "`test_case.folder_id` moves the cases; the top-level `folder_id` only scopes the selection. Two different meanings for one name in one body.", - "The editable set is narrow and everything outside it is dropped without error: owner, priority, status, case_type, automation_state/status, preconditions, template, review_status, reviewers, comment, custom_fields, tags, issues. `name`, `description`, `expected_result` and `test_case_steps` are NOT bulk-editable on this route.", - "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "Every successful edit writes a new version of each case, so an unwanted bulk edit can be undone case by case by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "bulk_edit_test_cases_in_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false - }, - { - "name": "status_id", - "type": "integer" - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "example": [], - "description": "Attachment blob ids to carry with the result. Declared as a scalar array \u2014 an array of objects is dropped by the permit filter and only reaches the re-architected mark path, so keep it to plain ids." - }, - { - "name": "de_selected_ids", - "type": "array", - "example": [], - "description": "Mapping ids to exclude when `select_all` is true. Optional." - }, - { - "name": "description", - "type": "string", - "example": "

something

", - "description": "HTML comment/note recorded against each result. @-mentions in it are resolved and notify those users." - }, - { - "name": "folder_id", - "type": "integer", - "example": 21, - "description": "Restrict the edit to the run's cases in this folder, and make the response the folder view instead of the whole run listing." - }, - { - "name": "issues", - "type": "array", - "example": [], - "description": "Defect ids to link to each result. Declared as a scalar array, so an array of objects is dropped by the permit filter \u2014 send plain ids. Issue display metadata is enriched asynchronously after the response." - }, - { - "name": "mapping_ids", - "type": "array", - "example": [ - 999, - 991, - 1024, - 1019 - ], - "description": "The run's per-case mapping ids (NOT test case ids and NOT TC-NNN) \u2014 the ids the run's test-case listing returns for each row. Ignored when `select_all` is true." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Apply to every case in the run (or in `folder_id`) that `q` matches, minus `de_selected_ids`. Compared as the string `true`." - }, - { - "name": "status_id", - "type": "integer", - "example": 21, - "description": "Numeric result-status id to mark each selected case with. Take it from the project's result statuses; a status NAME goes in `status` instead." - }, - { - "name": "test_run_ids", - "type": "array", - "example": [ - 1001, - 1002, - 1003 - ], - "description": "Automation BTCER ids the bulk edit applies to. Only read when `automation` is `true`." - }, - { - "name": "test_case_counts", - "type": "array", - "example": [ - 1, - 3, - 2 - ], - "description": "Per-entry case counts aligned positionally with `test_run_ids`. Only read when `automation` is `true`." - }, - { - "name": "status", - "type": "string", - "description": "Result status by DISPLAY NAME. Resolved to a status id server-side when `status_id` is absent; if it cannot be resolved the mark silently keeps the existing status. Prefer `status_id`." - }, - { - "name": "assignee_id", - "type": "integer", - "description": "TM user id to assign every selected execution to. Sending the key with `null` UNASSIGNS \u2014 omitting the key leaves the assignee alone, so null and absent are not the same thing. An id outside the group is rejected." - }, - { - "name": "planned_execution_date", - "type": "string", - "description": "Planned execution date for each selected execution." - }, - { - "name": "custom_fields", - "type": "object", - "description": "Result-level custom field values, keyed by custom field id." - }, - { - "name": "automation", - "type": "boolean", - "description": "Compared as the string `true`. Routes the edit down the automation path, which reads `test_run_ids`/`test_case_counts` instead of the manual selection." - }, - { - "name": "q", - "type": "object", - "description": "Filter that scopes `select_all` within the run (status, assignee, search, \u2026). Read from the request body on this route; array values are flattened to comma-separated strings before the search." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a `folder_id`-scoped selection into its subfolders. Compared as the string `true`." - }, - { - "name": "shared", - "type": "boolean", - "description": "Scope the selection to cases shared in from other projects. Compared as the string `true`." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the listing returned after the edit. Read from the body as well as the query string." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing returned after the edit. Read from the body as well as the query string." - } - ], - "intent": "Mark many of a test run's cases at once \u2014 set their result status, assign them, add a comment, link defects or set result custom fields.", - "returns": [ - "success", - "info", - "test_cases", - "folder", - "ancestors" - ], - "responses": { - "200": { - "$response": "TestRunCasesBulkEditResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "The selection is `mapping_ids` \u2014 the run's per-case mapping ids from the run's test-case listing. These are NOT test case ids and NOT TC-NNN identifiers; passing case ids here silently matches nothing.", - "The whole body is FLAT \u2014 there is no `test_case` wrapper on this route, unlike the project-wide bulk test-case capabilities, which all nest under a `test_case` wrapper.", - "Two ways to name a status: `status_id` (numeric, authoritative) or `status` (display name, resolved server-side). When the name cannot be resolved the mark proceeds and keeps the existing status, so prefer the id.", - "`assignee_id` distinguishes absent from null: send the key with `null` to unassign, omit it to leave the assignee unchanged.", - "`issues` and `attachments` are permitted as arrays of SCALARS. Arrays of objects are dropped before the write on the main path, so send plain ids.", - "Above the async threshold you get 200 `{success:true, async:true, unique_id}` and no listing; below it the 200 is the refreshed run listing (`test_cases` + `info`, plus `folder` and `ancestors` when `folder_id` was sent).", - "This writes execution results, not the test cases themselves \u2014 the cases' own fields are untouched. A result can be overwritten by marking again, but the previous result is not restorable.", - "The run id accepts both `TR-14` and the bare integer on this route, unlike the v2 run endpoints which take TR-NNN only." - ] - }, - { - "name": "bulk_edit_test_cases", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all` \u2014 the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Optional scope hint when editing within one folder." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "With `folder_id`, also include cases in its subfolders." - }, - { - "name": "ids", - "type": "array", - "required": true, - "description": "Integer ids of the cases to edit. One id is fine \u2014 this endpoint is also the cleanest way to add/remove a tag on a single case.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Ids to exclude when `select_all` is true.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Apply to every case in scope, minus `de_selected_ids`.", - "json_path": "/test_case/select_all" - }, - { - "name": "tags", - "type": "string", - "description": "Tag names. Supports `add` / `remove` \u2014 use those rather than `replace` unless the intent really is to discard the existing tags.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/tags" - }, - { - "name": "issues", - "type": "string", - "description": "Linked issues; each value item is `{issue_id, issue_type}`. Supports `add` / `remove`.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" + "guidance": [ + "Selection is one of two modes and they do not mix: either `test_case.ids` (explicit integer ids), or `test_case.select_all: true`, which resolves the set from top-level `q` + `folder_id` and ignores `ids` entirely.", + "The destination is TOP-LEVEL `destination_folder_id` here. Bulk-move puts its destination inside `test_case` — copying the move body onto this route silently sends no destination and 400s.", + "The destination folder is looked up before anything is copied, inside `destination_project_id` (which defaults to the source project). A folder id that does not exist there comes back as a bare 400 with no message — that is 'no such folder', not 'bad request'.", + "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 30 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed SOURCE view (or the search results) plus folder counts — it does NOT contain the new copies or their ids. To get them, list the destination folder afterwards; `cases_count[]` only tells you the new total.", + "Copies are new test cases with new integer ids and new TC-NNN identifiers; the originals are untouched. There is no undo — the way back is to delete the copies." ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "custom_fields", - "type": "object", - "required": true, - "description": "Keyed by custom-field id (numeric string), each entry `{\"value\": \u2026, \"operation\": \u2026}`. Collection-valued custom fields support `add` / `remove`; scalar ones take `replace` / `empty`.\nALWAYS SEND THIS KEY, even when changing no custom fields \u2014 pass `{}`. Omitting it makes the server dereference a nil hash and return 500 (`undefined method 'each' for nil`). The server's own request validator wrongly ", - "json_path": "/test_case/custom_fields" - }, - { - "name": "priority", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/priority" - }, - { - "name": "status", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/status" - }, - { - "name": "case_type", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/case_type" - }, - { - "name": "automation_state", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy internal_name string alias for `automation_state`.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/automation_status" - }, - { - "name": "owner", - "type": "string", - "description": "TM user id (`users[].id` from GET .../users-v2), not browserstack_user_id.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/owner" - }, - { - "name": "preconditions", - "type": "string", - "description": "Text value.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/preconditions" - }, - { - "name": "review_status", - "type": "string", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "string" - } - ], - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "string", - "description": "Reviewer TM user ids. Only honoured on a Team-Pro workspace with review-approve enabled.", - "fields": [ - { - "name": "operation", - "type": "string", - "required": true, - "values": [ - "add", - "remove", - "replace", - "empty", - "ignore" - ] - }, - { - "name": "value", - "type": "array" - } - ], - "json_path": "/test_case/reviewers" - } - ], - "intent": "Bulk edit test cases with per-field add / remove / replace operations (PREFERRED bulk write).", - "guidance": [ - "Applies one change to many cases at once. Confirm the selection resolves to what the user meant before sending \u2014 there is no per-case confirmation and no undo." - ], - "returns": [ - "async", - "unique_id" - ], - "paginated": true, - "responses": { - "200": { - "description": "Cases updated, or accepted asynchronously above the threshold.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean", - "description": "Present and true when the selection exceeded the sync threshold." - }, - "unique_id": { - "type": "string", - "description": "Async job id \u2014 poll/re-read to confirm (see `async-jobs`)." - }, - "info": { - "$schema": "PageInfo" - }, - "filter_details": { - "type": "object" - }, - "folders": { - "type": "object" - }, - "test_cases": { - "type": "object" - }, - "workflow": { - "type": "object" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "bulk_move_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-move", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." - }, - { - "name": "folder_id", - "type": "integer", - "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself \u2014 send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Move every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", - "json_path": "/test_case/select_all" - }, - { - "name": "destination_folder_id", - "type": "integer", - "description": "Target folder's integer id, INSIDE `test_case` (bulk-copy puts its destination at top level instead). Falls back to `test_case.folder_id` when omitted; if neither is sent the move is issued with no destination.", - "json_path": "/test_case/destination_folder_id" - }, - { - "name": "folder_id", - "type": "integer", - "description": "Legacy alias for `test_case.destination_folder_id`, used only when that is absent. Note this is the folder the cases move TO \u2014 the identically named TOP-LEVEL `folder_id` is the source scope.", - "json_path": "/test_case/folder_id" - }, - { - "name": "destination_project_id", - "type": "integer", - "description": "Set only for a cross-project move. Must be a JSON INTEGER \u2014 a numeric string is rejected up front with a missing-parameter 400. Sending it also requires a non-empty `ids` or `select_all: true`. Cases shared in from other projects cannot be moved across projects and come back as 422.", - "json_path": "/test_case/destination_project_id" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." - } - ], - "intent": "Move test cases into another folder, or into another project, by id or by filter.", - "paginated": true, - "responses": { - "200": { - "$response": "BulkMoveTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The destination lives INSIDE `test_case` on this route (`test_case.destination_folder_id`) \u2014 bulk-copy puts it at top level. Swapping the two bodies is the usual cause of a move that reports success and changes nothing.", - "The top-level `folder_id` scopes the SOURCE selection; `test_case.folder_id` is a legacy alias for the DESTINATION. Same name, opposite meaning, decided by nesting.", - "`destination_project_id` must be a real JSON integer. A string like \"42\" is rejected before anything runs, as a missing-parameter 400.", - "A cross-project move of shared cases is blocked by the backend: the response is 422 with `type: \"shared_tc_error\"` and a `shared_count`, and nothing moved.", - "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", - "The 200 body is the refreshed listing plus `cases_count`, `destination_path_folders` and `source_path_folders`; `moved_cases_count` only appears on a cross-project move. The cases keep their ids and TC-NNN identifiers, so a move can be undone by moving them back." - ], - "returns": [ - "success", - "test_cases", - "info", - "cases_count", - "destination_path_folders", - "source_path_folders", - "moved_cases_count" - ] - }, - { - "name": "bulk_restore_archived_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-retrieve", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "Accepted but IGNORED on this route: the archive scope is fixed to `is_archived: true` before the selection is resolved, so a filter here cannot narrow what is un-archived." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Accepted but ignored on this route \u2014 the selection scope is replaced with the archived scope before it is used." - }, - { - "name": "p", - "type": "integer", - "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." - }, - { - "name": "ids", - "type": "array", - "description": "Integer ids of the ARCHIVED cases to restore. Not required in itself \u2014 send either `ids` or `select_all: true` \u2014 but a selection that resolves to zero cases is a bare 400.", - "json_path": "/test_case/ids" - }, - { - "name": "de_selected_ids", - "type": "array", - "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional \u2014 omit it or send `[]`.", - "json_path": "/test_case/de_selected_ids" - }, - { - "name": "select_all", - "type": "boolean", - "description": "Restore EVERY archived case in the project, minus `de_selected_ids`. The scope is the whole archive \u2014 `q` and `folder_id` do not narrow it here. Compared as the string `true`.", - "json_path": "/test_case/select_all" - }, - { - "name": "folders", - "type": "object", - "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", - "json_path": "/test_case/folders" - } - ], - "intent": "Restore archived test cases back into their folders \u2014 the undo for a bulk archive.", - "returns": [ - "success", - "folders" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BulkRetrieveTestCasesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the un-archive, not a read. It takes ARCHIVED case ids: list them with the archived test cases call first, because a live case id here simply resolves to nothing.", - "The scope is forced to the archive before your selection is resolved, so `q` and `folder_id` are accepted and then ignored. `select_all: true` therefore means the WHOLE archive, not the filtered subset you may think you asked for.", - "Plan-gated: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and does nothing. Check `success`, not the status code.", - "The 200 body is `{success:true, folders:{...}}` \u2014 folder case counts, NOT the restored test cases. Nothing in the response names what was restored; re-read the folders to confirm.", - "An empty selection is a bare 400 `{success:false}` with no message \u2014 that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", - "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` \u2014 treat that body as an acknowledgement, not a result, and re-read afterwards.", - "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input." - ], - "disabled": true - }, - { - "name": "bulk_restore_archived_test_plans", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/bulk-retrieve", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_plan_ids", - "type": "array", - "required": true, - "minItems": 1, - "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids \u2014 the first one present wins. Entries are coerced with to_i and anything yielding 0 is silently dropped, so a TP-NNN identifier is discarded without an error." - } - ], - "intent": "Restore archived test plans in bulk \u2014 puts them back into the normal plan listing; this is the undo for a bulk archive", - "returns": [ - "success", - "async", - "unique_id", - "plans", - "count" - ], - "responses": { - "200": { - "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` \u2014 the plans are not restored yet.\n", - "schema": { - "$schema": "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans" - } - }, - "400": { - "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" - } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Find the ids to restore with the archived-plans listing \u2014 archived plans do not appear in the normal plan list at all.", - "Same group feature flag as archive: when it is off this returns 404 with {success: false, errors: 'Feature not available'}, which is a feature gap, not a missing plan.", - "Permission is the test-plan DELETE right, not write.", - "More than 30 ids goes async and returns {success: true, async: true, unique_id} \u2014 nothing is restored yet. Re-read the archived listing to confirm.", - "Ids are coerced with to_i and zeroes dropped; if every id drops you get 400 {success: false, errors: 'Please provide test plan ids'}.", - "Failures use the plural key `errors`.", - "Restoring does not undo a delete. Deleted plans are gone; only archived ones can come back this way." - ] - }, - { - "name": "bulk_set_test_result_status", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - } - ], - "body": [ - { - "name": "status", - "type": "string", - "required": true, - "minLength": 1, - "example": "Untested", - "description": "The single status every targeted case is moved to, by NAME as configured for the project. Matched case-insensitively against the configured internal name or display value. \"Reset\" is not a special mode \u2014 it is just naming an untested-style status." - }, - { - "name": "test_case_ids", - "type": "array", - "description": "Test case identifiers to target, e.g. [\"TC-101\",\"TC-102\"]. OMITTING this (or sending an empty array) targets EVERY test case in the run. Identifiers not in the run are skipped silently." - } - ], - "intent": "Move many test cases in a test run to one status in a single call \u2014 the bulk 'mark these passed' or 'reset the run to untested' action, which replaces each case's current outcome instead of adding a new one", - "guidance": [ - "NO UNDO. Each targeted case's current outcome is superseded in place by the status you name; the status that was there is not kept as a separate history entry and cannot be restored. Recovering means knowing the old status per case and setting it back by hand.", - "Omitting `test_case_ids` (or sending []) applies the status to EVERY case in the run. There is no cap, no dry run and no confirmation step on the server \u2014 send an explicit list unless you really mean the whole run.", - "`status` must be a name already configured for the project; an unconfigured name is rejected 400 before anything is queued. It is matched case-insensitively against either the configured internal name or the display value, so 'passed' and 'Passed' both resolve. Resolve the project's configured result statuses first.", - "Targets are resolved to test cases, not to cells. A case that appears in the run more than once \u2014 several configurations, or a duplicated mapping \u2014 is targeted once, and only one of its cells is moved; there is no configuration or mapping parameter to pick the other. On a multi-configuration run, read the run's cases back and expect to fix the stragglers individually.", - "Fire-and-forget: HTTP 200 {success: true} only means the job was queued. Identifiers that are not in the run are skipped with no error, and any failure inside the worker never reaches the caller \u2014 read results back to confirm.", - "No unrecognised keys are allowed in the body \u2014 `status` and `test_case_ids` only.", - "This only moves status. It records no description, defects, step results or attachments, and it deletes nothing \u2014 to log a real execution with detail, use create_test_results_for_test_run.", - "Requires the execute permission on the run; a caller without it gets 403 before anything is queued." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "V2BulkResultStatusAcceptedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "description": "The job could not be queued; nothing was applied.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - } - } - } - } - } - }, - { - "name": "bulk_update_test_run_test_cases", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "add_test_cases", - "type": "array", - "json_path": "/test_run/add_test_cases", - "fields": [ - { - "name": "test_case_ids", - "type": "array", - "description": "Non-empty list of TC-NNN strings. Must be strings \u2014 integers are rejected." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." - }, - { - "name": "source_project", - "type": "string", - "description": "Origin project identifier (PR-NNN) for shared cases \u2014 the readable alternative to source_project_id." - }, - { - "name": "configuration_ids", - "type": "array", - "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." - } - ], - "minItems": 1, - "description": "Case+configuration entries to add to the run. Send this OR remove_test_cases, never both." - }, - { - "name": "remove_test_cases", - "type": "array", - "json_path": "/test_run/remove_test_cases", - "fields": [ - { - "name": "test_case_ids", - "type": "array", - "description": "Non-empty list of TC-NNN strings. Must be strings \u2014 integers are rejected." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." - }, - { - "name": "source_project", - "type": "string", - "description": "Origin project identifier (PR-NNN) for shared cases \u2014 the readable alternative to source_project_id." - }, - { - "name": "configuration_ids", - "type": "array", - "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." - } - ], - "minItems": 1, - "description": "Case+configuration entries to take out of the run. Send this OR add_test_cases, never both. Removal deletes the recorded results for those mappings unless preserve_existing_results is true." - }, - { - "name": "preserve_existing_results", - "type": "boolean", - "default": false, - "json_path": "/test_run/preserve_existing_results", - "description": "Keep the results already recorded against the cases being removed. Defaults to FALSE, i.e. the results are deleted with the mapping. Must be a real boolean (or \"true\"/\"false\"); anything else is a 400." - } - ], - "intent": "Change which test cases a run covers \u2014 bulk-add cases to it, or bulk-remove them, optionally per configuration", - "guidance": [ - "Everything goes inside a `test_run` object, and exactly one of add_test_cases / remove_test_cases may be present and non-empty \u2014 both, or neither, is a 400.", - "Always asynchronous. A 202 {success, async, unique_id} means only that the job was queued; nothing has changed yet. There is no status endpoint for unique_id over HTTP (completion is announced on a websocket channel), so confirm by re-reading the run's test cases.", - "Removing a case DISCARDS its recorded results unless you send preserve_existing_results: true. Runs keep no version history, so those results cannot be restored \u2014 ask before removing cases from a run that has been executed.", - "At most 300 distinct test_case_ids per request, counted across all entries; a duplicate id within or across entries is a 400. Split larger changes into batches.", - "Only the shape is validated before the 202 \u2014 an id that does not exist, or is not shared with this project, fails inside the worker and is not reported back on this response.", - "Cross-project (shared) cases need source_project or source_project_id on the entry; supplying both is a 400.", - "This is the only call that changes a run's cases INCREMENTALLY, and the one to use to add cases: add_test_cases adds only the listed cases and leaves every existing case, its run row id and its assignee untouched. The run-update calls (edit_test_run, update_test_run, replace_test_run) do NOT union \u2014 the case list they are sent replaces the run's membership, silently dropping any case left out along with its results." - ], - "returns": [ - "success", - "async", - "unique_id" - ], - "responses": { - "202": { - "description": "Bulk change accepted and queued. Body is {success: true, async: true, unique_id} \u2014 the run is unchanged at this point.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + }, + { + "name": "bulk_delete_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-delete", + "mode": "destructive", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Delete every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Delete many test cases at once from the project-wide search/filter view — by id, or everything a filter matches.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "path_folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkDeleteTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } }, - "async": { - "type": "boolean" + "guidance": [ + "Selection is either `test_case.ids` or `test_case.select_all: true` scoped by top-level `q` + `folder_id`. With select_all the `ids` you send are ignored, so a mis-typed filter key (which is dropped, not rejected) deletes the whole scope.", + "Whether this is recoverable depends on the workspace: with the move-to-bin feature enabled the cases go to the Bin and can be restored, otherwise the delete is permanent. Assume permanent unless you have confirmed the Bin is on for this group.", + "Deleting also drops the cases' results and unlinks them from schedules — those do not come back even when the case is restored from the Bin.", + "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed listing after the delete (`test_cases` + `info` + `cases_count`), not a report of what was deleted. The one exception: with `q.is_archived` set, the response is just `{success:true}`." + ], + "disabled": true + }, + { + "name": "bulk_delete_test_results", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-results/bulk-delete", + "mode": "destructive", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "result_ids", + "type": "array", + "required": true, + "description": "Result INTEGER ids. Non-empty, max 300.", + "minItems": 1, + "maxItems": 300 + } + ], + "intent": "Delete test results in bulk — PARTIAL SUCCESS is normal, always read `skipped`", + "returns": [ + "success", + "result", + "deleted_ids", + "skipped", + "id", + "reason" + ], + "responses": { + "200": { + "description": "Partial success is normal — `deleted_ids` is what actually went, `skipped` is what\ndid not (usually authorisation).\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "result": { + "type": "object", + "description": "Both lists are nested HERE, not at the top level. Reading\nresponse[\"deleted_ids\"] directly finds nothing and makes a successful\ndelete look like a no-op.\n", + "properties": { + "deleted_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "skipped": { + "type": "array", + "description": "Entries are OBJECTS, not bare ids — {\"id\": 13, \"reason\": \"not_found\"}.\nCommon reasons: not_found, or the caller is neither author nor project admin.\n", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "`result_ids` missing or empty." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "description": "More than 300 ids in one request." + }, + "500": { + "$response": "InternalServerError" + } }, - "unique_id": { - "type": "string" - } - } - } - }, - "400": { - "description": "Synchronous validation failure. Body is {success: false, error, message}, where error is one of operation_required, only_one_operation_allowed, invalid_preserve_flag, invalid_entry_shape, invalid_test_case_id, invalid_configuration_ids, duplicate_test_case_id, max_test_cases_per_request_exceeded, validation_failed.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + "guidance": [ + "Partial success is the normal outcome and it still answers 200 with success:true. Both lists sit under a top-level `result` key: read result.deleted_ids and result.skipped. Never infer 'all deleted' from the status code — diff deleted_ids against the ids you sent and report the rest.", + "`skipped` entries are OBJECTS, not bare ids: {id, reason}. The only two reasons the handler produces are `not_found` and `not_authorised`.", + "Deletion is authorised per id: a project admin's whole batch is authorised wholesale, everyone else may only delete results they personally logged. A non-admin batch is resolved id by id against the recorded author.", + "The authorship lookup fails CLOSED. If that upstream call errors, you still get 200 / success:true with deleted_ids empty and EVERY id back in skipped with reason `not_found`. An all-not_found batch is as likely an upstream failure as genuinely missing ids — re-read the results before telling the user they do not exist.", + "`result_ids` is read from the top level of the body — it is not wrapped in an object. Ids are coerced with to_i and anything coercing to 0 (a non-numeric string, a null, 0 itself) is dropped silently; if that empties the list you get 400 'result_ids must be a non-empty array' even though you sent a non-empty array.", + "More than 300 ids is refused with 422 before anything is deleted — nothing partial happens, so just re-batch. Sending exactly the cap is fine.", + "There is no recycle bin for results. Deleting a case's latest result rolls that case's outcome in the run back to its previous attempt (or back to unexecuted if it was the only one); the deleted result itself is unrecoverable." + ], + "disabled": true + }, + { + "name": "bulk_replace_test_case_fields", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Edit every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "automation_status", + "type": "string", + "json_path": "/test_case/automation_status", + "description": "Automation state as an internal_name string (e.g. `not_automated`). Forwarded verbatim on this path — no name-to-id resolution happens. Prefer `automation_state` with its numeric option id." + }, + { + "name": "case_type", + "type": "integer", + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id — NOT the display name. Unlike single-case edit, this path always sends it as an id, so a name here is stored as a broken id. Resolve the id from the project's system field values first." + }, + { + "name": "custom_fields", + "type": "object", + "required": true, + "description": "Map of custom-field id to value. ALWAYS SEND THIS KEY — pass `{}` when changing no custom fields. Omitting it returns 500 (the server dereferences a nil hash), the same defect as on PATCH .../test-cases.", + "json_path": "/test_case/custom_fields" + }, + { + "name": "issues", + "type": "array", + "json_path": "/test_case/issues", + "description": "Linked issues, as `[{issue_id, issue_type}]` (bare strings are coerced to ``issue_id``). Read via a separate permit than the other fields, so it survives even though it is not in the edit allow-list. It is sent on EVERY bulk edit — as `[]` when you omit it — so treat it as a full replacement of the issue list, not an append." + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id (not a BrowserStack user id, not an email). An id that does not resolve to a user is not a validation error — it dereferences nil and returns 500.", + "json_path": "/test_case/owner" + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "json_path": "/test_case/priority", + "description": "Numeric priority option id — NOT the display name. Resolve it from the project's system field values first." + }, + { + "name": "status", + "type": "integer", + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "tags", + "type": "array", + "description": "REPLACES the entire tag list on every selected case. Read the existing tags and send the union if you mean to add.", + "json_path": "/test_case/tags" + }, + { + "name": "review_status", + "type": "string", + "json_path": "/test_case/review_status", + "description": "Review state applied to every selected case (e.g. `pending`, `in_review`, `approved`). Only meaningful on a Team-Pro workspace with review-approve enabled on the project." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_case/reviewers", + "description": "Reviewer TM user ids. Honoured only on a Team-Pro workspace with review-approve enabled on the project; otherwise dropped without any error." + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state", + "description": "Numeric automation-state option id. Takes precedence over `automation_status`." + }, + { + "name": "template", + "type": "string", + "json_path": "/test_case/template", + "description": "Step-shape template applied to every selected case. Validated against a closed set (`test_case_text`, `test_case_steps`, `bdd`, `test_case_bdd`) and DROPPED WITHOUT ERROR if the value is not one of them — a template id or a custom template name here is a silent no-op.", + "values": [ + "test_case_text", + "test_case_steps", + "bdd", + "test_case_bdd" + ] + }, + { + "name": "estimate", + "type": "string", + "json_path": "/test_case/estimate", + "description": "Forwarded to the backend on this path (unlike single-case edit, which drops it), but every read serialises `estimate` as null, so you cannot confirm it and should not report it as saved. There is no bulk equivalent of `estimated_duration_seconds`." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Free-text note carried with the bulk edit." + }, + { + "name": "folder_id", + "type": "integer", + "json_path": "/test_case/folder_id", + "description": "DANGER — inside `test_case` this MOVES every selected case into that folder. The top-level `folder_id` of the same name only scopes the selection. Omit this unless you mean to move them." + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Set the same field values — owner, priority, status, case type, tags, custom fields, review state — on many test cases at once from the project-wide search/filter view.", + "returns": [ + "success", + "test_cases", + "info", + "filter_details" + ], + "paginated": true, + "responses": { + "200": { + "description": "Applied synchronously: the refreshed search listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no test_cases.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkReplaceTestCaseFields" + } + }, + "filter_details": { + "type": "object", + "description": "Echo of the resolved filter; {} when no q was sent." + }, + "folders": { + "type": "object" + }, + "workflow": { + "type": "object" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } }, - "error": { - "type": "string" + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "ALWAYS send `test_case.custom_fields`, `{}` when you are changing none. Omitting the key dereferences a nil hash and returns 500 — this is the single most common failure on this route.", + "priority, status, case_type and automation_state must be NUMERIC option ids here. The single-case edit accepts a display name for some of them; this path does not, and a name is written through as a broken id rather than rejected.", + "`tags` REPLACES the whole tag list on every selected case. Read the existing tags and send the union if you mean to add. The same is true of `issues`, which is sent as `[]` when you omit it.", + "`test_case.folder_id` moves the cases; the top-level `folder_id` only scopes the selection. Two different meanings for one name in one body.", + "The editable set is narrow and everything outside it is dropped without error: owner, priority, status, case_type, automation_state/status, preconditions, template, review_status, reviewers, comment, custom_fields, tags, issues. `name`, `description`, `expected_result` and `test_case_steps` are NOT bulk-editable on this route.", + "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "Every successful edit writes a new version of each case, so an unwanted bulk edit can be undone case by case by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "bulk_edit_test_cases_in_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false + }, + { + "name": "status_id", + "type": "integer" + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "example": [], + "description": "Attachment blob ids to carry with the result. Declared as a scalar array — an array of objects is dropped by the permit filter and only reaches the re-architected mark path, so keep it to plain ids." + }, + { + "name": "de_selected_ids", + "type": "array", + "example": [], + "description": "Mapping ids to exclude when `select_all` is true. Optional." + }, + { + "name": "description", + "type": "string", + "example": "

something

", + "description": "HTML comment/note recorded against each result. @-mentions in it are resolved and notify those users." + }, + { + "name": "folder_id", + "type": "integer", + "example": 21, + "description": "Restrict the edit to the run's cases in this folder, and make the response the folder view instead of the whole run listing." + }, + { + "name": "issues", + "type": "array", + "example": [], + "description": "Defect ids to link to each result. Declared as a scalar array, so an array of objects is dropped by the permit filter — send plain ids. Issue display metadata is enriched asynchronously after the response." + }, + { + "name": "mapping_ids", + "type": "array", + "example": [ + 999, + 991, + 1024, + 1019 + ], + "description": "The run's per-case mapping ids (NOT test case ids and NOT TC-NNN) — the ids the run's test-case listing returns for each row. Ignored when `select_all` is true." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Apply to every case in the run (or in `folder_id`) that `q` matches, minus `de_selected_ids`. Compared as the string `true`." + }, + { + "name": "status_id", + "type": "integer", + "example": 21, + "description": "Numeric result-status id to mark each selected case with. Take it from the project's result statuses; a status NAME goes in `status` instead." + }, + { + "name": "test_run_ids", + "type": "array", + "example": [ + 1001, + 1002, + 1003 + ], + "description": "Automation BTCER ids the bulk edit applies to. Only read when `automation` is `true`." + }, + { + "name": "test_case_counts", + "type": "array", + "example": [ + 1, + 3, + 2 + ], + "description": "Per-entry case counts aligned positionally with `test_run_ids`. Only read when `automation` is `true`." + }, + { + "name": "status", + "type": "string", + "description": "Result status by DISPLAY NAME. Resolved to a status id server-side when `status_id` is absent; if it cannot be resolved the mark silently keeps the existing status. Prefer `status_id`." + }, + { + "name": "assignee_id", + "type": "integer", + "description": "TM user id to assign every selected execution to. Sending the key with `null` UNASSIGNS — omitting the key leaves the assignee alone, so null and absent are not the same thing. An id outside the group is rejected." + }, + { + "name": "planned_execution_date", + "type": "string", + "description": "Planned execution date for each selected execution." + }, + { + "name": "custom_fields", + "type": "object", + "description": "Result-level custom field values, keyed by custom field id." + }, + { + "name": "automation", + "type": "boolean", + "description": "Compared as the string `true`. Routes the edit down the automation path, which reads `test_run_ids`/`test_case_counts` instead of the manual selection." + }, + { + "name": "q", + "type": "object", + "description": "Filter that scopes `select_all` within the run (status, assignee, search, …). Read from the request body on this route; array values are flattened to comma-separated strings before the search." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a `folder_id`-scoped selection into its subfolders. Compared as the string `true`." + }, + { + "name": "shared", + "type": "boolean", + "description": "Scope the selection to cases shared in from other projects. Compared as the string `true`." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the listing returned after the edit. Read from the body as well as the query string." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing returned after the edit. Read from the body as well as the query string." + } + ], + "intent": "Mark many of a test run's cases at once — set their result status, assign them, add a comment, link defects or set result custom fields.", + "returns": [ + "success", + "info", + "test_cases", + "folder", + "ancestors" + ], + "responses": { + "200": { + "$response": "TestRunCasesBulkEditResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } }, - "message": { - "type": "string" + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "The selection is `mapping_ids` — the run's per-case mapping ids from the run's test-case listing. These are NOT test case ids and NOT TC-NNN identifiers; passing case ids here silently matches nothing.", + "The whole body is FLAT — there is no `test_case` wrapper on this route, unlike the project-wide bulk test-case capabilities, which all nest under a `test_case` wrapper.", + "Two ways to name a status: `status_id` (numeric, authoritative) or `status` (display name, resolved server-side). When the name cannot be resolved the mark proceeds and keeps the existing status, so prefer the id.", + "`assignee_id` distinguishes absent from null: send the key with `null` to unassign, omit it to leave the assignee unchanged.", + "`issues` and `attachments` are permitted as arrays of SCALARS. Arrays of objects are dropped before the write on the main path, so send plain ids.", + "Above the async threshold you get 200 `{success:true, async:true, unique_id}` and no listing; below it the 200 is the refreshed run listing (`test_cases` + `info`, plus `folder` and `ancestors` when `folder_id` was sent).", + "This writes execution results, not the test cases themselves — the cases' own fields are untouched. A result can be overwritten by marking again, but the previous result is not restorable.", + "The run id accepts both `TR-14` and the bare integer on this route, unlike the v2 run endpoints which take TR-NNN only." + ] + }, + { + "name": "bulk_edit_test_cases", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all` — the same filter shape as `V2SearchQueryRequest.q` (see the search-and-filter flow), sent as a SIBLING of `test_case`, not inside it. With `select_all: true` the server resolves the target set from this `q` (plus `folder_id`) and ignores `ids`; with `select_all: false` it is unused. DANGER: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta" + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Optional scope hint when editing within one folder." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "With `folder_id`, also include cases in its subfolders." + }, + { + "name": "ids", + "type": "array", + "required": true, + "description": "Integer ids of the cases to edit. One id is fine — this endpoint is also the cleanest way to add/remove a tag on a single case.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Ids to exclude when `select_all` is true.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Apply to every case in scope, minus `de_selected_ids`.", + "json_path": "/test_case/select_all" + }, + { + "name": "tags", + "type": "string", + "description": "Tag names. Supports `add` / `remove` — use those rather than `replace` unless the intent really is to discard the existing tags.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/tags" + }, + { + "name": "issues", + "type": "string", + "description": "Linked issues; each value item is `{issue_id, issue_type}`. Supports `add` / `remove`.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "custom_fields", + "type": "object", + "required": true, + "description": "Keyed by custom-field id (numeric string), each entry `{\"value\": …, \"operation\": …}`. Collection-valued custom fields support `add` / `remove`; scalar ones take `replace` / `empty`.\nALWAYS SEND THIS KEY, even when changing no custom fields — pass `{}`. Omitting it makes the server dereference a nil hash and return 500 (`undefined method 'each' for nil`). The server's own request validator wrongly ", + "json_path": "/test_case/custom_fields" + }, + { + "name": "priority", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/priority" + }, + { + "name": "status", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/status" + }, + { + "name": "case_type", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/case_type" + }, + { + "name": "automation_state", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy internal_name string alias for `automation_state`.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/automation_status" + }, + { + "name": "owner", + "type": "string", + "description": "TM user id (`users[].id` from GET .../users-v2), not browserstack_user_id.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/owner" + }, + { + "name": "preconditions", + "type": "string", + "description": "Text value.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/preconditions" + }, + { + "name": "review_status", + "type": "string", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "string" + } + ], + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "string", + "description": "Reviewer TM user ids. Only honoured on a Team-Pro workspace with review-approve enabled.", + "fields": [ + { + "name": "operation", + "type": "string", + "required": true, + "values": [ + "add", + "remove", + "replace", + "empty", + "ignore" + ] + }, + { + "name": "value", + "type": "array" + } + ], + "json_path": "/test_case/reviewers" + } + ], + "intent": "Bulk edit test cases with per-field add / remove / replace operations (PREFERRED bulk write).", + "guidance": [ + "Applies one change to many cases at once. Confirm the selection resolves to what the user meant before sending — there is no per-case confirmation and no undo." + ], + "returns": [ + "async", + "unique_id" + ], + "paginated": true, + "responses": { + "200": { + "description": "Cases updated, or accepted asynchronously above the threshold.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean", + "description": "Present and true when the selection exceeded the sync threshold." + }, + "unique_id": { + "type": "string", + "description": "Async job id — poll/re-read to confirm (see `async-jobs`)." + }, + "info": { + "$schema": "PageInfo" + }, + "filter_details": { + "type": "object" + }, + "folders": { + "type": "object" + }, + "test_cases": { + "type": "object" + }, + "workflow": { + "type": "object" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "bulk_move_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-move", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "SCOPE for `select_all`, and a SIBLING of `test_case` at top level, not a field inside it. Same filter shape as the search-and-filter flow's `q`. With `select_all: true` the target set comes from this `q` merged with top-level `folder_id` and `ids` is ignored; with explicit ids it is unused. An unrecognised key here is dropped, not rejected, so a typo widens the selection to the whole scope." + }, + { + "name": "folder_id", + "type": "integer", + "description": "SOURCE folder that scopes the selection, at TOP level (outside `test_case`). Merged into the search scope and it overwrites `q.folder_id` when both are sent. Do not confuse it with the destination: for copy that is top-level `destination_folder_id`, for move it is `test_case.destination_folder_id`, and for bulk edit a `test_case.folder_id` MOVES the cases." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer test case ids. Ignored when `select_all` is true. Not required in itself — send EITHER `ids` OR `select_all: true`; a selection that resolves to zero cases is rejected with a bare 400 `{success:false}` before any work happens.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Move every case the scope resolves to, minus `de_selected_ids`. Compared as the string `true`; anything else means \"use `ids`\". The scope is top-level `q` + `folder_id` (+ `include_subfolders_tc`), NOT the whole project by default.", + "json_path": "/test_case/select_all" + }, + { + "name": "destination_folder_id", + "type": "integer", + "description": "Target folder's integer id, INSIDE `test_case` (bulk-copy puts its destination at top level instead). Falls back to `test_case.folder_id` when omitted; if neither is sent the move is issued with no destination.", + "json_path": "/test_case/destination_folder_id" + }, + { + "name": "folder_id", + "type": "integer", + "description": "Legacy alias for `test_case.destination_folder_id`, used only when that is absent. Note this is the folder the cases move TO — the identically named TOP-LEVEL `folder_id` is the source scope.", + "json_path": "/test_case/folder_id" + }, + { + "name": "destination_project_id", + "type": "integer", + "description": "Set only for a cross-project move. Must be a JSON INTEGER — a numeric string is rejected up front with a missing-parameter 400. Sending it also requires a non-empty `ids` or `select_all: true`. Cases shared in from other projects cannot be moved across projects and come back as 422.", + "json_path": "/test_case/destination_project_id" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token for the listing that comes back in the response (`-ASC|DESC`, or `cf##-`). Malformed tokens are rejected with 400. It does not affect which cases the operation acts on." + } + ], + "intent": "Move test cases into another folder, or into another project, by id or by filter.", + "paginated": true, + "responses": { + "200": { + "$response": "BulkMoveTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The destination lives INSIDE `test_case` on this route (`test_case.destination_folder_id`) — bulk-copy puts it at top level. Swapping the two bodies is the usual cause of a move that reports success and changes nothing.", + "The top-level `folder_id` scopes the SOURCE selection; `test_case.folder_id` is a legacy alias for the DESTINATION. Same name, opposite meaning, decided by nesting.", + "`destination_project_id` must be a real JSON integer. A string like \"42\" is rejected before anything runs, as a missing-parameter 400.", + "A cross-project move of shared cases is blocked by the backend: the response is 422 with `type: \"shared_tc_error\"` and a `shared_count`, and nothing moved.", + "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 100 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input.", + "The 200 body is the refreshed listing plus `cases_count`, `destination_path_folders` and `source_path_folders`; `moved_cases_count` only appears on a cross-project move. The cases keep their ids and TC-NNN identifiers, so a move can be undone by moving them back." + ], + "returns": [ + "success", + "test_cases", + "info", + "cases_count", + "destination_path_folders", + "source_path_folders", + "moved_cases_count" + ] + }, + { + "name": "bulk_restore_archived_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-retrieve", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "Accepted but IGNORED on this route: the archive scope is fixed to `is_archived: true` before the selection is resolved, so a filter here cannot narrow what is un-archived." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Accepted but ignored on this route — the selection scope is replaced with the archived scope before it is used." + }, + { + "name": "p", + "type": "integer", + "description": "Page of the scoped view, as the UI sends it. Only consulted when `select_all` is true and no `q` is present (the unfiltered folder-count path)." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend the selection into subfolders of the top-level `folder_id`. Compared as the string `true`. Merged into the search scope, so it also changes what `select_all` resolves to." + }, + { + "name": "ids", + "type": "array", + "description": "Integer ids of the ARCHIVED cases to restore. Not required in itself — send either `ids` or `select_all: true` — but a selection that resolves to zero cases is a bare 400.", + "json_path": "/test_case/ids" + }, + { + "name": "de_selected_ids", + "type": "array", + "description": "Integer ids to exclude. Only meaningful with `select_all: true` (with explicit `ids` they are subtracted from your own list). Optional — omit it or send `[]`.", + "json_path": "/test_case/de_selected_ids" + }, + { + "name": "select_all", + "type": "boolean", + "description": "Restore EVERY archived case in the project, minus `de_selected_ids`. The scope is the whole archive — `q` and `folder_id` do not narrow it here. Compared as the string `true`.", + "json_path": "/test_case/select_all" + }, + { + "name": "folders", + "type": "object", + "description": "Cross-folder selection: a map keyed by folder id, each value `{select_all, ids, de_selected_ids}`. When present it REPLACES the flat `ids`/`select_all` selection and the union is resolved per folder. Unknown keys inside an entry are dropped.", + "json_path": "/test_case/folders" + } + ], + "intent": "Restore archived test cases back into their folders — the undo for a bulk archive.", + "returns": [ + "success", + "folders" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BulkRetrieveTestCasesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the un-archive, not a read. It takes ARCHIVED case ids: list them with the archived test cases call first, because a live case id here simply resolves to nothing.", + "The scope is forced to the archive before your selection is resolved, so `q` and `folder_id` are accepted and then ignored. `select_all: true` therefore means the WHOLE archive, not the filtered subset you may think you asked for.", + "Plan-gated: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and does nothing. Check `success`, not the status code.", + "The 200 body is `{success:true, folders:{...}}` — folder case counts, NOT the restored test cases. Nothing in the response names what was restored; re-read the folders to confirm.", + "An empty selection is a bare 400 `{success:false}` with no message — that is what you get when `ids` is empty, or when `select_all` is true but `q` matched nothing.", + "Above 300 selected cases the work is handed to a background job and you get 200 `{success:true, async:true, unique_id:\"\"}` with NO `test_cases` — treat that body as an acknowledgement, not a result, and re-read afterwards.", + "Concurrent bulk ops on the same cases are refused: 400 `{success:false, message:\"TestCases in progress with another operation\"}`. Retry rather than reading it as bad input." + ], + "disabled": true + }, + { + "name": "bulk_restore_archived_test_plans", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/bulk-retrieve", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_plan_ids", + "type": "array", + "required": true, + "minItems": 1, + "description": "Plan INTEGER ids. Three body shapes are accepted and tried in this order: test_plan.ids, then ids, then test_plan_ids — the first one present wins. Entries are coerced with to_i and anything yielding 0 is silently dropped, so a TP-NNN identifier is discarded without an error." + } + ], + "intent": "Restore archived test plans in bulk — puts them back into the normal plan listing; this is the undo for a bulk archive", + "returns": [ + "success", + "async", + "unique_id", + "plans", + "count" + ], + "responses": { + "200": { + "description": "Sync (<= 30 ids): `{success, plans, count}`. Async (above the threshold): `{success, async: true, unique_id}` — the plans are not restored yet.\n", + "schema": { + "$schema": "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans" + } + }, + "400": { + "description": "No usable ids after coercion: `{success: false, errors: \"Please provide test plan ids\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Find the ids to restore with the archived-plans listing — archived plans do not appear in the normal plan list at all.", + "Same group feature flag as archive: when it is off this returns 404 with {success: false, errors: 'Feature not available'}, which is a feature gap, not a missing plan.", + "Permission is the test-plan DELETE right, not write.", + "More than 30 ids goes async and returns {success: true, async: true, unique_id} — nothing is restored yet. Re-read the archived listing to confirm.", + "Ids are coerced with to_i and zeroes dropped; if every id drops you get 400 {success: false, errors: 'Please provide test plan ids'}.", + "Failures use the plural key `errors`.", + "Restoring does not undo a delete. Deleted plans are gone; only archived ones can come back this way." + ] + }, + { + "name": "bulk_set_test_result_status", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "minLength": 1, + "example": "Untested", + "description": "The single status every targeted case is moved to, by NAME as configured for the project. Matched case-insensitively against the configured internal name or display value. \"Reset\" is not a special mode — it is just naming an untested-style status." + }, + { + "name": "test_case_ids", + "type": "array", + "description": "Test case identifiers to target, e.g. [\"TC-101\",\"TC-102\"]. OMITTING this (or sending an empty array) targets EVERY test case in the run. Identifiers not in the run are skipped silently." + } + ], + "intent": "Move many test cases in a test run to one status in a single call — the bulk 'mark these passed' or 'reset the run to untested' action, which replaces each case's current outcome instead of adding a new one", + "guidance": [ + "NO UNDO. Each targeted case's current outcome is superseded in place by the status you name; the status that was there is not kept as a separate history entry and cannot be restored. Recovering means knowing the old status per case and setting it back by hand.", + "Omitting `test_case_ids` (or sending []) applies the status to EVERY case in the run. There is no cap, no dry run and no confirmation step on the server — send an explicit list unless you really mean the whole run.", + "`status` must be a name already configured for the project; an unconfigured name is rejected 400 before anything is queued. It is matched case-insensitively against either the configured internal name or the display value, so 'passed' and 'Passed' both resolve. Resolve the project's configured result statuses first.", + "Targets are resolved to test cases, not to cells. A case that appears in the run more than once — several configurations, or a duplicated mapping — is targeted once, and only one of its cells is moved; there is no configuration or mapping parameter to pick the other. On a multi-configuration run, read the run's cases back and expect to fix the stragglers individually.", + "Fire-and-forget: HTTP 200 {success: true} only means the job was queued. Identifiers that are not in the run are skipped with no error, and any failure inside the worker never reaches the caller — read results back to confirm.", + "No unrecognised keys are allowed in the body — `status` and `test_case_ids` only.", + "This only moves status. It records no description, defects, step results or attachments, and it deletes nothing — to log a real execution with detail, use create_test_results_for_test_run.", + "Requires the execute permission on the run; a caller without it gets 403 before anything is queued." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "V2BulkResultStatusAcceptedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "description": "The job could not be queued; nothing was applied.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + } + } + } + } + } + }, + { + "name": "bulk_update_test_run_test_cases", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "add_test_cases", + "type": "array", + "json_path": "/test_run/add_test_cases", + "fields": [ + { + "name": "test_case_ids", + "type": "array", + "description": "Non-empty list of TC-NNN strings. Must be strings — integers are rejected." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." + }, + { + "name": "source_project", + "type": "string", + "description": "Origin project identifier (PR-NNN) for shared cases — the readable alternative to source_project_id." + }, + { + "name": "configuration_ids", + "type": "array", + "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." + } + ], + "minItems": 1, + "description": "Case+configuration entries to add to the run. Send this OR remove_test_cases, never both." + }, + { + "name": "remove_test_cases", + "type": "array", + "json_path": "/test_run/remove_test_cases", + "fields": [ + { + "name": "test_case_ids", + "type": "array", + "description": "Non-empty list of TC-NNN strings. Must be strings — integers are rejected." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project for shared (cross-project) cases. Mutually exclusive with source_project." + }, + { + "name": "source_project", + "type": "string", + "description": "Origin project identifier (PR-NNN) for shared cases — the readable alternative to source_project_id." + }, + { + "name": "configuration_ids", + "type": "array", + "description": "Integer configuration ids this entry applies to. Omit (or send []) to target the null-configuration mapping. Duplicates are a 400." + } + ], + "minItems": 1, + "description": "Case+configuration entries to take out of the run. Send this OR add_test_cases, never both. Removal deletes the recorded results for those mappings unless preserve_existing_results is true." + }, + { + "name": "preserve_existing_results", + "type": "boolean", + "default": false, + "json_path": "/test_run/preserve_existing_results", + "description": "Keep the results already recorded against the cases being removed. Defaults to FALSE, i.e. the results are deleted with the mapping. Must be a real boolean (or \"true\"/\"false\"); anything else is a 400." + } + ], + "intent": "Change which test cases a run covers — bulk-add cases to it, or bulk-remove them, optionally per configuration", + "guidance": [ + "Everything goes inside a `test_run` object, and exactly one of add_test_cases / remove_test_cases may be present and non-empty — both, or neither, is a 400.", + "Always asynchronous. A 202 {success, async, unique_id} means only that the job was queued; nothing has changed yet. There is no status endpoint for unique_id over HTTP (completion is announced on a websocket channel), so confirm by re-reading the run's test cases.", + "Removing a case DISCARDS its recorded results unless you send preserve_existing_results: true. Runs keep no version history, so those results cannot be restored — ask before removing cases from a run that has been executed.", + "At most 300 distinct test_case_ids per request, counted across all entries; a duplicate id within or across entries is a 400. Split larger changes into batches.", + "Only the shape is validated before the 202 — an id that does not exist, or is not shared with this project, fails inside the worker and is not reported back on this response.", + "Cross-project (shared) cases need source_project or source_project_id on the entry; supplying both is a 400.", + "This is the only call that changes a run's cases INCREMENTALLY, and the one to use to add cases: add_test_cases adds only the listed cases and leaves every existing case, its run row id and its assignee untouched. The run-update calls (edit_test_run, update_test_run, replace_test_run) do NOT union — the case list they are sent replaces the run's membership, silently dropping any case left out along with its results." + ], + "returns": [ + "success", + "async", + "unique_id" + ], + "responses": { + "202": { + "description": "Bulk change accepted and queued. Body is {success: true, async: true, unique_id} — the run is unchanged at this point.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" + } + } + } + }, + "400": { + "description": "Synchronous validation failure. Body is {success: false, error, message}, where error is one of operation_required, only_one_operation_allowed, invalid_preserve_flag, invalid_entry_shape, invalid_test_case_id, invalid_configuration_ids, duplicate_test_case_id, max_test_cases_per_request_exceeded, validation_failed.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "clone_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/clone", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true + } + ], + "intent": "Copy an existing exploratory session — its description, timebox, configurations and custom fields, plus its log entries, and optionally its tags, linked issues and owner — to re-run the same exploration", + "responses": { + "201": { + "$response": "ExploratorySessionCloneSyncResponse" + }, + "202": { + "$response": "ExploratorySessionCloneAsyncResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The clone body is FLAT — it is not wrapped in an `exploratory_session` object, unlike create and update on this entity.", + "There are two completely different success shapes and you must branch on the source session's size. Fewer than 50 logs: 201 with {success, async:false, unique_id:null, exploratory_session:{...}} and the copy fully built. 50 logs or more: 202 with {success, async:true, unique_id} and NO session object — the copy exists but you are not told its id.", + "Read session_log_count on the source session first to know which branch you will get. If it is 50 or more, plan to find the new session by listing sessions afterwards (its default title is ' - Copy').", + "unique_id is a realtime broadcast channel name, not a job id — there is no endpoint that reports the clone's progress or result. The only way to confirm an async clone is to re-read the sessions list.", + "The charter and the folder are NOT copied. The clone lands at the project root with an empty charter even though description, timebox, configurations and custom field values are copied. Set them with an update afterwards if they matter.", + "copy_tags, copy_issues and copy_session_owner all default to FALSE, so a plain clone arrives with no tags, no linked issues and nobody assigned. copy_issues also controls whether each copied log keeps its own linked defects.", + "The clone is always created in the active state, even when the source session is closed.", + "The three copy_* flags must be real JSON booleans — the request schema rejects the strings \"true\"/\"false\" with 400.", + "If the asynchronous log copy fails, the newly created session is deleted again, so a failed clone leaves nothing behind — and nothing over this API tells you it happened.", + "The source session is read before anything is created, so an unknown session_id fails as a bare {success:false} 404 with no session created." + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Checkout flow exploration - rerun", + "description": "Title for the new session. Sent at the top level of the body, not inside an exploratory_session wrapper. Defaults to ' - Copy' when omitted — which is also how you find the copy after an async clone.", + "json_path": "/name" + }, + { + "name": "copy_tags", + "type": "boolean", + "default": false, + "description": "Copy the source session's tags. Must be a real JSON boolean; the string \"true\" is rejected with 400. Defaults to false, so the clone is untagged unless you ask.", + "json_path": "/copy_tags" + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false, + "description": "Copy the linked issues — both the session's own issue links AND the defects on every copied log entry. Must be a real JSON boolean. Defaults to false.", + "json_path": "/copy_issues" + }, + { + "name": "copy_session_owner", + "type": "boolean", + "default": false, + "description": "Keep the source session's assignee. Must be a real JSON boolean. Defaults to false, which leaves the clone unassigned.", + "json_path": "/copy_session_owner" + } + ], + "returns": [ + "success", + "async", + "unique_id", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "session_state", + "timebox_duration", + "session_log_count", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "created_at", + "updated_at" + ] + }, + { + "name": "clone_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/clone", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/test_plan/name", + "description": "Name of the NEW plan. Required — there is no server-generated copy name. Omit it (or send blank) and the call is 400 {success: false, message: 'Name field cannot be blank'}." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Description of the new plan. Sanitised against the rich-text allowlist server-side, so the stored value can differ from what you sent. Not copied from the source plan — omit it and the clone has an empty description." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "json_path": "/test_plan/start_date", + "description": "ISO-8601 date for the new plan. Not inherited from the source." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "json_path": "/test_plan/end_date", + "description": "ISO-8601 date for the new plan. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and clones nothing." + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_plan/owner", + "description": "Owner as the internal TM user id (the `id` from the project users read, NOT a BrowserStack user id or an email). Omitted means the caller. An id that does not resolve inside the caller's group is swallowed rather than rejected and the clone ends up with no real owner." + }, + { + "name": "tags", + "type": "array", + "json_path": "/test_plan/tags", + "description": "Plain tag strings for the new plan. If ANY entry is not a string the whole list is silently replaced with [] — no error." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_plan/reviewers", + "description": "Reviewer user ids for the new plan. OMIT the key to copy the source plan's reviewers; send [] to clone with no reviewers. Validated against the project's allowed-reviewer list." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_plan/attachments", + "description": "Attachment ids already uploaded via the attachments surface." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_plan/custom_fields", + "description": "Custom-field values for the new plan, keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: } before any cloning starts." + }, + { + "name": "parent_plan_id", + "type": "integer", + "json_path": "/test_plan/parent_plan_id", + "description": "Set it to make the clone a SUB-plan of that parent (integer id). Omitted/null gives a top-level plan. This is fixed at clone time — a plan cannot be promoted or demoted later." + }, + { + "name": "copy_all_runs", + "type": "boolean", + "default": false, + "json_path": "/test_plan/copy_all_runs", + "description": "Copy every test run linked to the source plan. When true, test_runs is not consulted." + }, + { + "name": "test_runs", + "type": "array", + "json_path": "/test_plan/test_runs", + "description": "Clone only these runs of the source plan, as run ids. Ignored when copy_all_runs is true; send neither and the clone is created with no runs." + }, + { + "name": "copy_all_sub_plans", + "type": "boolean", + "default": false, + "json_path": "/test_plan/copy_all_sub_plans", + "description": "Clone every sub-plan of the source plan under the new plan." + }, + { + "name": "sub_plan_ids", + "type": "array", + "json_path": "/test_plan/sub_plan_ids", + "description": "Clone only these sub-plans (integer ids). Ignored when copy_all_sub_plans is true." + }, + { + "name": "status_ids", + "type": "array", + "json_path": "/test_plan/status_ids", + "description": "Run-level filter: keep only source runs that contain at least one test case in one of these result-status ids. Entries must be integers — anything else is 400 {success: false, error: 'status_ids must be a list of integers'}. It filters RUNS, not cases: every case inside a kept run is copied." + } + ], + "intent": "Copy a test plan into a brand-new plan, bringing its test runs (and optionally its sub-plans) with it — the copy is built in the background, so this hands back a job id rather than the new plan", + "responses": { + "200": { + "$response": "TestPlanCloneAcceptedResponse" + }, + "400": { + "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`), non-integer status_ids, or a rejected custom field (`{success: false, error: ...}`). Also used for a body with no `test_plan` wrapper.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is ASYNC. A 200 means the clone job was queued, not that the plan exists — the body is {success: true, unique_id} and carries no plan id, no identifier and no name. The source plan is untouched either way.", + "unique_id is only a correlation id for logs; there is no job-status capability for it in this profile. To find the new plan, poll the project's plan listing for the name you sent.", + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own `test_plan` object is rejected before the call as an unknown body key.", + "name is REQUIRED even though this is a clone. There is no automatic 'Copy of ...' name.", + "Nothing except the runs/sub-plans is inherited: description, dates, tags, owner and custom fields all come from THIS request, so a clone sent with only a name produces an otherwise empty plan.", + "Choose the run scope explicitly — copy_all_runs: true, or an explicit test_runs list. With neither, the job creates the plan with no runs at all and still returns success.", + "Copied runs come over whole: the job forces copy_all_cases, and keeps each case's assignee, issues and tags. status_ids narrows which RUNS are copied, never which cases inside them.", + "Date validation is a silent trap: end_date before start_date returns HTTP 200 with {success: false, error: 'Please check date params'}. Check the `success` field, not the status code.", + "Needs the test-plan write permission." + ], + "returns": [ + "success", + "unique_id" + ] + }, + { + "name": "clone_test_run_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/clone", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Cloned Test Run - 2025", + "description": "Name for the new run. Leave it out and the run is auto-named, which is how projects end up with several identically named clones." + }, + { + "name": "copy_all_cases", + "type": "boolean", + "default": false, + "description": "Copy every case from the source run. WITHOUT this (and without status_id) the clone is created EMPTY — the default is a bare run, not a copy of the case list." + }, + { + "name": "status_id", + "type": "array", + "example": [ + 1, + 2 + ], + "description": "Copy only the cases whose latest result is one of these numeric status ids (resolve them via the project's result-status field values). Numeric ids only — this is the filter that is honoured on every backend flow." + }, + { + "name": "status", + "type": "array", + "example": [ + "passed", + "failed" + ], + "description": "Status NAMES to filter the copied cases by. Honoured only on the legacy backend flow; on the current (build-backed) flow it is dropped and a clone whose only case selector was `status` copies nothing and still returns 200. Send status_id instead." + }, + { + "name": "copy_tc_assignee", + "type": "boolean", + "default": false, + "description": "Carry each copied case's assignee across. Only meaningful together with copy_all_cases / status_id — it applies to the copied rows, not to the run." + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false, + "description": "Carry the linked defects across onto the copied case rows." + }, + { + "name": "copy_tags", + "type": "boolean", + "default": false, + "description": "Copy the source run's tags onto the new run." + }, + { + "name": "source_build_id", + "type": "integer", + "description": "Optional explicit source build to copy cases from, for callers that already resolved it. Normally omitted — it is looked up from the source run." + } + ], + "intent": "Copy an existing test run into a new one — optionally carrying over its cases, their assignees, tags and linked defects — to re-execute the same scope without rebuilding the selection", + "returns": [ + "data", + "success", + "test_run", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "attachments", + "metadata", + "project_id", + "created_at", + "updated_at", + "created_by", + "observability_url", + "links" + ], + "responses": { + "200": { + "$response": "TestRunResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A clone with no case selector produces an EMPTY run. Send copy_all_cases:true, or status_id, or you get a 200 and a run with zero cases — this is the single most common surprise on this endpoint.", + "Filter the copied cases by status_id (numeric), not by `status` (names). The name form is only read on the legacy backend flow; on the current one it is ignored and the clone silently comes back empty.", + "The clone always starts in run_state `new_run` regardless of the source run's state, and its description is always copied. Tags, defects and per-case assignees are copied only when you ask. Configurations, the owner and the dynamic-filter rule are NOT carried over — set them afterwards with the edit call.", + "Cloning a run created before the current ingest flow, while asking for its cases, is refused with 422 'The source run has no test cases available to clone. Retry without case selection.' Retrying without copy_all_cases/status_id gives you an empty clone of the header.", + "Population is not synchronous on every path: the run exists as soon as the call returns, but re-read its cases rather than trusting an immediate empty list, and never retry the clone — that makes a second run.", + "Take `uuid` (the integer) from the response, not `id` — `id` is the TR-NNN string here as everywhere else on runs.", + "The new run comes back under `data.test_run` (with an underscore). The write endpoints on this entity — create, edit, assign, close, delete — use `data.testrun` instead. Do not reuse one reader for both.", + "Needs the test-run write permission; cloning an automation run demands the automation variant of it and 403s for a role that only holds the manual one." + ] + }, + { + "name": "clone_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/clone", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "test_case_selection", + "type": "string", + "values": [ + "all", + "by_results" + ], + "default": "all", + "description": "Which cases of the source run the copy gets: every case, or only those whose latest result matches `statuses`. Any other value is a 400." + }, + { + "name": "statuses", + "type": "array", + "fields": [ + { + "name": "status_name", + "type": "string" + } + ], + "example": [ + "failed", + "blocked" + ], + "description": "Result status NAMES (case-insensitive) — not ids. Required and non-empty when test_case_selection is \"by_results\"; an unknown name is a 400 naming it. Defaults plus any custom status configured for the group are accepted." + }, + { + "name": "copy_tc_assignee", + "type": "boolean", + "default": true, + "description": "Carry per-case assignees over. Defaults to TRUE here (the v1 clone defaults it to false)." + }, + { + "name": "copy_tags", + "type": "boolean", + "default": true, + "description": "Carry the source run's tags over. Defaults to TRUE here (the v1 clone defaults it to false)." + }, + { + "name": "copy_issues", + "type": "boolean", + "default": false + }, + { + "name": "name", + "type": "string", + "example": "Sprint 24 - Regression Re-Run", + "description": "Name for the new run. Omitted or blank gives \" - Copy\"." + } + ], + "intent": "Copy an existing run into a brand-new run — the usual way to re-execute just the failed or blocked cases from the last cycle", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Body is flat — these fields sit at the top level, not inside a `test_run` object like the update calls.", + "Identity is synchronous, data is not: the response carries the new run as soon as it exists, but its case mappings are filled in by a background worker. Reading the new run's test cases immediately can legitimately return zero rows — retry before concluding the clone was empty.", + "A 202 (instead of 200) means the run was cloned and one bookkeeping leg is still settling: do NOT retry, or you get a second clone.", + "Test plan / sub-plan links are NOT carried over; link the copy afterwards if you need it under a plan.", + "A dynamic source run passes its is_dynamic flag on, but the filter is not re-evaluated, so the copy starts from the source's case list.", + "Cloning an automation run is blocked by the write-permission gate (403) — this path is for manual runs.", + "The copy is a new run, so nothing about the source changes; discard it by deleting the copy.", + "The clone comes back at the top level as `test_run`, not under `data`. A 202 means the copy is still populating.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunV2Response" + }, + "202": { + "description": "Run cloned, background bookkeeping still in progress. Body is {success: true, build_pending: true, message, test_run}. Treat as success; retrying would create a duplicate run.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "build_pending": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "test_run": { + "$schema": "TestRun" + } + } + } + }, + "400": { + "description": "Clone options failed validation — unknown test_case_selection, missing statuses for \"by_results\", or an unknown status name. Body is {success: false, message}.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "close_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/close", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Mark an exploratory testing session finished so it leaves the active listing and is archived as completed work", + "responses": { + "200": { + "description": "Exploratory session closed successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This call takes no body at all. The handler's actual_duration pass-through is commented out, so posting a duration here returns 200 and changes nothing. Write actual_duration with the session update call BEFORE closing.", + "Closing is one-way through the API: nothing accepts session_state, and there is no reopen endpoint. Treat it as irreversible even though it is not classed destructive.", + "After closing, the session disappears from the sessions listing unless you pass q[session_state]=closed — the listing defaults to active.", + "Closing also changes who may delete the session afterwards: deletion of a closed session is gated by the closed-test-run delete permission rather than the ordinary one, so a caller who could have deleted it a moment earlier may now get 403.", + "The response is only {success:true}. Re-read the session to see session_state, closed_at and closed_by.", + "Closing an already-closed session is not rejected here; whatever the store does with it surfaces, if at all, as the generic 400 {success:false, message:'The API request is invalid or improperly formed'}.", + "A session id that does not exist returns a bare {success:false} with 404 and no message." + ], + "returns": [ + "success" + ] + }, + { + "name": "close_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/close", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "intent": "Close a test run — freeze its results, mark the cycle finished and drop it out of the default run listing", + "returns": [ + "data", + "success", + "testrun", + "project", + "skipped_test_case_ids", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__CloseTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No body at all. This is a fixed transition to run_state `closed`; anything you send is ignored.", + "Closing is final. A snapshot of every result is taken and the run's linked build in Test Observability is closed too, and nothing sets the run back to an active state afterwards — confirm with the user before closing a run that may need more testing.", + "A closed run disappears from the default project run listing — it is only visible through the closed-runs listing or a listing that explicitly asks for it. Deleting it afterwards also demands a different permission from deleting an open run.", + "A closed run cannot be reopened. The partial run update answers 404 'Test Run ID is invalid' for a closed run, and the full edit returns success while leaving it closed — do not report either as a reopen. To keep testing, clone the run (clone_test_run with copy_all_cases) and add cases to the copy; re-link it to its plan afterwards, since the clone does not carry the plan over.", + "The run comes back under `data.testrun` — one word, no underscore — alongside `data.project`. The read endpoints use `data.test_run`. The 200 also carries a TOP-LEVEL `skipped_test_case_ids` array (a sibling of `data`, not inside it).", + "Client errors from the backing service come back as `{data: {success: false, error: }}` with the 4xx status, not as the flat error envelope other endpoints use — read `data.success` before trusting `data.testrun`.", + "Needs the test-run write permission; an automation run demands the automation variant and 403s otherwise." + ] + }, + { + "name": "copy_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folders/copy", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "source_folder_id", + "type": "integer", + "required": true, + "example": 2, + "description": "Integer id of the folder to copy. Must exist in the project named in the path. Blank/absent returns 400 {error:'source_folder_id is required'}." + }, + { + "name": "destination_folder_id", + "type": "integer", + "example": 3, + "description": "Parent folder the copy is placed under, in destination_project_id. NOT required: omitting it (or sending null) is what switches the call into copy-to-root mode, exactly as copy_to_root:true does." + }, + { + "name": "destination_project_id", + "type": "integer", + "example": 10000, + "description": "Destination project. NOT required — defaults to the project in the path. Cross-project copies are allowed here (unlike cross-project moves, which are blocked when the folder has shared test cases)." + }, + { + "name": "copy_to_root", + "type": "boolean", + "default": false, + "description": "Place the copy at the destination project's root instead of under a parent folder. Accepts the boolean true or the literal string \"true\". It is also forced on whenever destination_folder_id is absent." + } + ], + "intent": "Duplicate a folder — its whole subtree of subfolders and every test case in them — into another folder, or to the root of any project you can reach; always runs as a background job", + "returns": [ + "success", + "async", + "unique_id", + "message" + ], + "responses": { + "202": { + "$response": "FolderCopyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + } + }, + "guidance": [ + "This endpoint only queues work. It answers 202 Accepted (not 200) with {success:true, async:true, unique_id, message} the moment validation passes — nothing has been copied yet when you get the response.", + "There is no way to poll the job over HTTP. `unique_id` is just the X-Request-ID header echoed back (a fresh UUID if you sent none), and progress is pushed over a websocket channel the API does not expose. Confirm the copy by re-reading the destination folder's contents after a delay.", + "`async` and `copy_test_cases` are not read anywhere in the handler or the worker. The copy is always asynchronous, and it always copies the full subtree — every nested subfolder and every test case. You cannot ask for a folders-only copy.", + "Name clashes are resolved silently, not rejected: an existing folder of the same name in the destination makes the copy land as `copy-of-`, then `copy-of-2-`, `copy-of-3-` and so on. Match on the returned tree, not on the name you sent.", + "Copying a folder into itself or into one of its own descendants is refused with 400 {error:'Cannot copy folder into itself or its subfolder'} — but only for same-project copies that name a destination folder. Copy-to-root and cross-project copies skip that check entirely.", + "Missing source or destination is 404 with a bare {error:'...'} and no `success` key — this endpoint does not use the usual {success:false} envelope for that case.", + "If the worker fails partway it rolls the whole copy back, so a failed run should leave no half-copied tree — but you only learn that by re-reading, not from this response.", + "Requires the folder-create permission on the project in the path." + ] + }, + { + "name": "count_run_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/selection-count", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "select_all", + "type": "boolean", + "required": true, + "example": false, + "description": "Select every case in the project. Marked required because the request body must contain a `selection` object at all — sending only `configurations` is rejected 400 before anything is counted, and this is the cheapest field that materialises `selection`. Pass false alongside `folders` for a folder-scoped count.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 2, + "description": "The caller's own count of distinct cases in the selection. Echoed through; it does not constrain the answer.", + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. Case ids must be integers — strings are rejected by the schema.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Map of project ids to their shared test-case selection, same inner shape as `selection`. Top level, NOT inside `selection`." + }, + { + "name": "configurations", + "type": "array", + "required": true, + "example": [], + "description": "Configuration ids (integers). Required by the schema — send an empty array when there are none; omitting the key is a 400." + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "description": "Per-configuration case mappings. An ARRAY of objects — an object here is rejected. Unknown keys inside an entry are rejected too.", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Ask how many test cases a folder/case selection will actually resolve to — including the multiplication by configurations — before committing it to a run create or edit", + "shape": "discovered", + "responses": { + "200": { + "description": "The number of test-case x configuration rows the selection resolves to.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "count": { + "type": "integer", + "example": 166 + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is schema-validated with unknown top-level keys REJECTED. Only `selection`, `shared_selection`, `configurations` and `trtc_configuration_mappings` are accepted; anything else — including run fields like `name` or `filters` — returns 400 {error: 'Invalid JSON data', details: [...]} before any counting happens.", + "`selection` and `configurations` are both mandatory. A body with a selection but no `configurations` key is refused; pass `configurations: []` when the run has none.", + "The answer is the number of case-times-configuration rows the run would get, not the number of distinct test cases — with two configurations selected the count is roughly double `unique_test_case_count`. That is the point of the call.", + "Read-only and side-effect free: it resolves the selection but creates nothing. Use it before a create or edit when the user needs to see the size of what they are about to commit.", + "The body must be sent as application/json; a form-encoded body skips the schema check entirely and then fails further in with a less useful error.", + "The response is flat — {success, count} with no `data` envelope." + ], + "returns": [ + "success", + "count" + ] + }, + { + "name": "create_bulk_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_cases", + "type": "array", + "required": true, + "description": "One UNWRAPPED object per case (no per-item `test_case` key), max 10 on the synchronous path. Several fields are read from the FIRST item only and applied to all — see guidance. Send `template` and `test_case_steps` on every item.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body as well as in the path; the path folder is not substituted in." + }, + { + "name": "attachments", + "type": "array", + "description": "Blob ids. Taken from the FIRST item only and copied onto EVERY case in the batch. A blob belonging to another group fails the whole request with 400." + }, + { + "name": "automation_state", + "type": "integer", + "description": "NOT accepted on the bulk path — dropped by the allow-list. Use `automation_status` here, or edit the cases afterwards." + }, + { + "name": "automation_status", + "type": "string" + }, + { + "name": "case_type", + "type": "integer" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Taken from the FIRST item only and applied to EVERY case in the batch. Per-item custom field values are silently discarded — create cases with differing custom fields one at a time, or edit them afterwards." + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted by the allow-list and then never written; every read serialises `estimate` as null. Do not report an estimate as saved." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "NOT accepted on the bulk path — dropped by the allow-list. Set it with an edit after creation." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean" + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer" + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer" + }, + { + "name": "review_status", + "type": "string", + "description": "NOT accepted on the bulk path — dropped by the allow-list. The created cases take the project's default review state." + }, + { + "name": "reviewers", + "type": "array", + "description": "NOT accepted on the bulk path — dropped by the allow-list. Set reviewers with an edit after creation." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "NOT accepted on the bulk path — dropped by the allow-list." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "NOT accepted on the bulk path — dropped by the allow-list." + }, + { + "name": "status", + "type": "integer" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Also decides, from the FIRST item only, which system template id every case in the batch falls back to (`test_case_steps` when the first item omits it) — so send it on every item, and keep the batch homogeneous." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string", + "description": "NOT accepted on the bulk path — dropped by the allow-list. Use `template`." + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is dropped and the case is created with no steps, still 200." + }, + { + "name": "metadata", + "type": "string", + "description": "JSON-encoded string of opaque metadata (this is where an AI prompt reference rides). Unparseable JSON degrades to `{}` rather than erroring." + } + ] + }, + { + "name": "unique_id", + "type": "string", + "description": "Client-supplied idempotency key. When present AND the group has the async bulk-create flag on, the request is queued instead: 202 with the echoed `unique_id`, the ≤10 item limit is replaced by a 500-item cap, and completion arrives over the WebSocket channel. Without the flag it is inert." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Create (or reuse) a subfolder named `scenario_name` under the path folder and put every case in the batch there instead. Requires `scenario_name`. If the batch then fails, a subfolder created for it is deleted again." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is set." + } + ], + "intent": "Create several test cases in one folder in a single call (up to 10) — the call to use whenever you have more than one case to add.", + "returns": [ + "success", + "data", + "test_cases", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "description": "Test cases created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__CreateBulkTestCases" + } + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "PathFolder__CreateBulkTestCases" + } + }, + "request_trace_id": { + "type": "string" + } + } + } + } + } + }, + "202": { + "description": "Accepted for asynchronous processing (feature-flag gated; requires unique_id). Completion is broadcast on common_channel_ with operation bulk_create and the echoed unique_id.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" + }, + "scenario_name": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Items are UNWRAPPED — `test_cases` is a plain array of case objects with no per-item `test_case` key. A non-array, or more than 10 items, is a 400 naming the problem.", + "Not everything single-create accepts survives here: `automation_state`, `review_status`, `reviewers`, `send_for_review`, `shared_precondition_id`, `template_step_type` and `estimated_duration_seconds` are all dropped by this route's allow-list, without error. Create then edit if you need them.", + "`custom_fields` and `attachments` are read from the FIRST item and applied to all of them. A batch where cases differ in custom field values will silently come out wrong — create those one at a time.", + "`test_case_folder_id` is required inside each item even though the folder is already in the path; omitting it is the usual cause of a 422 wrapping an upstream 404.", + "Steps only come from `test_case_steps`. A `steps` key is dropped, and the cases are still created — with no steps and a 200 — so verify against the response.", + "The created cases are at `data.test_cases`, with `data.path_folders` and a `data.request_trace_id` for the AI enrichment that runs afterwards. Take each new case's integer `id` and TC-NNN `identifier` from there.", + "With `unique_id` and the async flag enabled, you instead get 202 `{success, async, unique_id, count}` and NO cases — the work finishes on a WebSocket channel you cannot read through this API, so poll the folder listing instead.", + "Any unexpected failure inside this route is flattened to 500 `{success:false, message:\"Failed to create test cases.\"}` — that message covers backend rejections too, so do not read it as an outage." + ] + }, + { + "name": "create_configuration", + "method": "POST", + "path": "/api/v1/projects/{project_id}/configurations", + "mode": "write", + "entity": "configuration", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Create a named configuration — an OS / browser / device combination that test runs track their per-case results against", + "returns": [ + "success", + "configuration", + "id", + "name", + "group_id", + "record_status", + "is_suggested", + "created_at" + ], + "responses": { + "200": { + "$response": "ConfigurationCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Display name of the configuration, e.g. \"Chrome 120 on Windows 11\". This is the ONLY field the handler reads — os, os_version, browser, device and every other attribute the read returns cannot be set here and are silently ignored if sent." + } + ], + "guidance": [ + "`name` is the only input. The structured attributes (os, browser, device, versions) that come back on the listing cannot be set through this call; anything else you put in the body is dropped without an error.", + "Group-scoped, not project-scoped: `project_id` matches the route but is never sent onward, so the configuration is created at workspace level and appears in EVERY project in the group. There is no such thing as a project-private configuration here.", + "No request-body schema is registered for this action, so an omitted or empty `name` is not caught locally — whatever the backing service does with it is what you get, and the failure arrives without a field name.", + "The created record comes back under `configuration` (not `data`) beside `success:true`. Carry `configuration.id` — that integer is what a test run's configuration list takes; the run APIs want configuration ids, not configuration objects or names.", + "Check the configuration listing first. Configurations accumulate at workspace level and duplicates by name are not prevented here, so search before creating.", + "There is no delete-configuration or rename-configuration capability in this profile — a configuration created by mistake has to be cleaned up in the Test Management UI.", + "This action is missing from the RBAC permission map, so it is not permission-gated even though the rest of the configurations controller is (the listing needs test_run_view and the group/option writes need test_run_write)." + ] + }, + { + "name": "create_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces from storage." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id the option is added to. Not validated by this handler." + } + ], + "body": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label. Must be present — a null or missing value is a 400 'Invalid Params'. Must be unique within the dataset; a duplicate is rejected with the upstream message relayed." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "REQUIRED — a missing or null value is a 400. Use false for every option of a multi_dropdown, and true for at most one option of a dropdown; this layer does not enforce either rule." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "nested_dropdown children only — the option id this one hangs under, from the dataset options listing. Forwarded only when non-empty; meaningless on a flat dropdown." + } + ], + "intent": "Add one more choice to a dropdown-style custom field — a new value in an existing dataset, for every project that dataset covers", + "responses": { + "200": { + "$response": "CustomFieldAdminOptionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`is_default` is required and must be present, not null: omitting it (or option_value) is a 400 {success:false, error:'Invalid Params'}, whatever else is wrong.", + "The new option appears in every project the dataset is linked to, not just one. Check the dataset's project list before adding.", + "Only set is_default true for a single-select `dropdown`, and only for one option; for a multi_dropdown every option must be is_default false. Nothing in this layer enforces it — a second default is accepted here and the behaviour downstream is undefined.", + "`parent_option_id` is forwarded only when non-empty, and only means anything for a nested_dropdown. Get the parent id from the dataset options listing.", + "The created option comes back under `data` with its `id` — capture it, that is what the update and delete calls take.", + "A duplicate option label is rejected upstream and relayed verbatim ('Value with same name already exists. Use a unique name.'); other upstream failures are masked as 400 'The API request is invalid or improperly formed'." + ], + "returns": [ + "success", + "data", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id" + ] + }, + { + "name": "create_custom_field_dataset", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id to hang the dataset off. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "body": [ + { + "name": "linked_projects", + "type": "array", + "description": "Integer project ids this dataset applies to — this is what makes the field visible in a project. Must be an array (or omitted) or the call is a 400. Note the spelling differs from the project-mapping call, which uses link_projects / unlink_projects." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "default": false, + "description": "Auto-link projects created after this dataset. Must be a literal true/false; any other value is a 400." + }, + { + "name": "options", + "type": "array", + "description": "The allowed values, for dropdown-style fields. Each entry needs option_value and a non-null is_default or the call is a 400.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "Must be present (not null). False for every option of a multi_dropdown; at most one true for a dropdown." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "For nested_dropdown children only — the option this one hangs under." + } + ] + } + ], + "intent": "Attach another option set and project scope to an existing custom field — the way to give one field different dropdown values in different projects", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A dataset is the pairing of an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, is invisible in every project even though the definition exists.", + "`linked_projects` takes INTEGER project ids, not PR-NNN identifiers. A non-array value is a 400 'Invalid Params'; a valid array of ids that are not real projects fails upstream and comes back masked.", + "`linked_to_all_projects` is NOT read by this endpoint — it is silently ignored. It works only inside the `datasets` entries of the create-a-field call. To cover every project from here, enumerate the ids, or use the dataset project-mapping call with select_all.", + "A project can belong to only one dataset per field. Linking a project that is already on another dataset of the same field fails upstream, and this endpoint does NOT relay the reason — you get 400 {success:false, message:'The API request is invalid or improperly formed'}. Read the field's existing datasets first.", + "Options are only meaningful for dropdown / multi_dropdown / nested_dropdown fields, but nothing rejects them on other types. Each option needs option_value and a non-null is_default; for a multi_dropdown every is_default must be false, and for a dropdown at most one may be true.", + "The response is the created dataset under `data`; take `data.id` — it is the dataset_id every option and project-mapping call needs.", + "This is additive and reversible by deleting the dataset, but deleting it later destroys the values stored against its options." + ], + "returns": [ + "success", + "data", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects" + ] + }, + { + "name": "create_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields", + "mode": "write", + "entity": "custom_field", + "body": [ + { + "name": "field_name", + "type": "string", + "required": true, + "maxLength": 256, + "example": "Test Environment", + "description": "Display label. Stripped of surrounding whitespace before the reserved-name check and before storage. Blank or longer than 256 characters → 400 'Invalid Params'." + }, + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Data type. Use THIS vocabulary — the legacy `field_*` spellings that the project form-fields read returns (field_dropdown, field_string, …) are rejected here. `url` and `nested_dropdown` are not valid when entity_type is TestCaseStep." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Which entity the field hangs off; a field belongs to exactly one and cannot be moved later. Any other value → 400 'Invalid Params'. TestCaseStep additionally requires the group's step-custom-fields flag." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "description": "Whether the field must be filled. Must be a literal JSON boolean — omitting it, or sending \"true\" as a string, is a 400." + }, + { + "name": "datasets", + "type": "array", + "required": true, + "description": "REQUIRED for every field type — omitting it (or sending a non-array) is a 400. Carries the option values and the project scope. An empty array passes validation but leaves the field linked to nothing.", + "fields": [ + { + "name": "linked_projects", + "type": "array", + "description": "Integer project ids to link. Every entry must be a positive integer; PR-NNN identifiers are rejected." + }, + { + "name": "unlinked_projects", + "type": "array", + "description": "Integer project ids to exclude; only read when linked_to_all_projects is true." + }, + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "true makes the handler resolve every project in the workspace (max 3000) into linked_projects before sending upstream." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Auto-link projects created later." + }, + { + "name": "options", + "type": "array", + "description": "Option values for dropdown-style types. Each needs option_value (non-blank) and a non-null is_default; nested_dropdown entries may carry a `children` array of the same shape." + } + ] + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "Hint text shown in the form. Stripped before storage; longer than 256 characters → 400." + }, + { + "name": "default_value", + "type": "boolean", + "description": "Honoured ONLY when field_type is `boolean`, and then it must be a literal true/false. For every other field type the handler overwrites it with null, so a default cannot be set at create time." + }, + { + "name": "levels", + "type": "array", + "description": "REQUIRED when field_type is `nested_dropdown`: at least 2 entries, each with a non-blank `name` and `level_type`. Ignored for every other type.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true + }, + { + "name": "level_type", + "type": "string", + "required": true + } + ] + }, + { + "name": "parent_custom_field_id", + "type": "integer", + "description": "Adds a child level under an existing nested_dropdown field. When present the handler validates ONLY field_name and forwards the rest untouched — field_type, is_required and datasets are not checked at all." + } + ], + "intent": "Define a new custom field for the workspace — pick its data type, whether it is mandatory, and (via datasets) which projects get it and what option values it offers", + "responses": { + "200": { + "$response": "CustomFieldAdminWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`datasets` is required for EVERY field type, dropdown or not. The validator only checks it is an array, so `datasets: []` passes — and creates a field linked to no project, invisible everywhere. Send one entry carrying `linked_projects` if you want the field to appear.", + "`is_required` must be a literal JSON boolean. The string \"true\" fails the check and the whole call comes back 400 {success:false, error:'Invalid Params'} with no indication of which field was wrong — that one error covers field_type, field_name, is_required, place_holder_text, default_value, levels and datasets alike.", + "`default_value` is read ONLY when field_type is `boolean`; for every other type the handler forces it to null before sending upstream. Setting a default on a string/int/date field through this call is impossible.", + "field_name is stripped before both the duplicate check and storage, so \" Priority \" is stored as \"Priority\" and collides with the reserved name. Reserved names (Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status) return 400 'Field with same name already exists. Use a unique name.'; TestPlan and ExploratorySession fields may reuse 'Automation Status' and 'Type of Test Case'.", + "A duplicate-name rejection from storage is relayed verbatim (400 with the upstream message). Every OTHER upstream failure on this call is masked to {success:false, message:'The API request is invalid or improperly formed'} — do not try to parse a reason out of it.", + "Inside a `datasets` entry, `linked_to_all_projects: true` makes the handler expand the link list to every project in the workspace (capped at 3000) before sending. That flag works HERE but is ignored by the standalone add-a-dataset call.", + "entity_type `TestCaseStep` is accepted only when the group has step custom fields enabled; otherwise 400 'Step custom fields are not enabled for this group.'. Step fields also cannot be `url` or `nested_dropdown`.", + "The response echoes only the stored field attributes — no options, no project links. Read the field back, or read the project's form fields, to confirm what the datasets actually produced.", + "Nothing here is project-scoped: the definition is created for the whole workspace and the datasets decide where it shows up. Name the projects you are about to affect before creating." + ], + "returns": [ + "success", + "data", + "id", + "field_name", + "field_type", + "entity_type", + "is_required", + "default_value", + "place_holder_text" + ] + }, + { + "name": "create_dataset", + "method": "POST", + "path": "/api/v1/projects/{project_id}/datasets", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "json_path": "/dataset/name", + "description": "Dataset name. Must be unique within the project: a collision comes back as 400 {success:false, message:'Dataset name already exists.'}, which is the one 400 here that is NOT a schema rejection." + }, + { + "name": "variables", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 40, + "json_path": "/dataset/variables", + "description": "The columns, in order — row_data positions map to this array by index. Duplicate names (compared case-insensitively) fail the call. A column with a blank name, or one empty in every row, is SILENTLY DROPPED with its slice of every row, so the stored column list can be shorter than what you sent. 40 is the default cap; groups on the raised-limit flag get 100.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", + "description": "Must start with a letter and contain only letters, digits and underscores — no spaces, hyphens or leading digits." + }, + { + "name": "uuid", + "type": "string", + "description": "Omit on create; the server mints it. Present only so the value read back can be sent to update unchanged." + } + ] + }, + { + "name": "rows", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 100, + "json_path": "/dataset/rows", + "description": "The data rows. Heavily normalised before the write: all-blank rows are dropped, cell values are whitespace-trimmed, rows whose trimmed values are identical are de-duplicated to the first occurrence, and row_number is then REASSIGNED 1..N in the order you sent them — the numbers you supply are ignored. 100 rows is the default cap; groups with the raised-limit flag get 200.", + "fields": [ + { + "name": "row_number", + "type": "integer", + "required": true, + "description": "Required by the schema but overwritten server-side with the row's final 1-based position. Send anything valid; do not rely on it round-tripping." + }, + { + "name": "row_data", + "type": "array", + "required": true, + "minItems": 1, + "description": "Cell values, positional against `variables`. Every element must be a STRING — a number or null is a schema rejection. After empty columns are pruned the remaining length must equal the remaining column count, else 422 'Row data size mismatch with column count.'" + }, + { + "name": "uuid", + "type": "string", + "description": "Omit on create; the server mints it." + } + ] + }, + { + "name": "tags", + "type": "array", + "maxItems": 20, + "json_path": "/dataset/tags", + "description": "Free-text labels on the dataset. Plain strings, each at most 50 characters, at most 20. Accepted but undocumented in the spec.", + "fields": [ + { + "name": "tag", + "type": "string", + "maxLength": 50 + } + ] + }, + { + "name": "metadata", + "type": "object", + "json_path": "/dataset/metadata", + "description": "Free-form key/value bag stored with the dataset, at most 20 top-level keys. Only settable here — the update endpoint accepts it through validation and then drops it, so metadata cannot be changed after creation through this API." + } + ], + "intent": "Create a dataset — a named table of columns (variables) and rows that drives data-driven execution, so one test case runs once per row with the row's values substituted in", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "tags", + "metadata", + "created_at", + "created_by" + ], + "responses": { + "201": { + "description": "Dataset created. The handler renders 201 Created, not 200.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__CreateDataset" + } + } + } + }, + "400": { + "description": "Either a request-schema rejection ({error:'Invalid JSON data', details:[...]}) or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:...}. 'Feature not available' = the group lacks Team Pro (entitlement, not payload). Any other message is a dataset-shape validation failure." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key.", + "Nothing outside the declared keys is allowed: the request schema sets additionalProperties:false at the top level, inside `dataset`, and inside each variable and row object. A misspelled or extra key fails the whole call with 400 {error:'Invalid JSON data', details:[...]} rather than being ignored.", + "Send Content-Type: application/json. The schema check is skipped for any other content type, and the handler then crashes on a missing `variables` instead of returning a clean 400.", + "What you get back is not what you sent: blank rows and all-blank columns are stripped, duplicate rows collapsed, cells trimmed, and row_number renumbered 1..N. Read `dataset.variables` and `dataset.rows` from the response rather than assuming your payload was stored verbatim.", + "422 is overloaded on every dataset endpoint. {message:'Feature not available'} means the group lacks the Team Pro plan — stop, do not retry with a different body. Any other 422 message ('Maximum 40 columns are allowed', 'Duplicate variable names found: ...', 'Row data size mismatch with column count.', 'Please add non null/ valid data.') is a payload problem you can fix.", + "Success is 201, not 200, and the dataset is at the top level as `dataset` — there is no `data` envelope.", + "Carry `dataset.uuid` forward: it is the id every other dataset call takes, and the integer id used internally is never exposed.", + "Needs the test-case write permission (tc_write), not a dataset-specific one." + ] + }, + { + "name": "create_exploratory_session", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "example": "Checkout flow exploration", + "description": "Title of the exploratory session. The only field the store requires. Send it as `title` — `name` is accepted by the request schema and then silently dropped.", + "json_path": "/exploratory_session/title" + }, + { + "name": "description", + "type": "string", + "example": "Explore edge cases in the checkout flow.", + "description": "Optional free-text description.", + "json_path": "/exploratory_session/description" + }, + { + "name": "charter", + "type": "string", + "example": "Focus on payment error handling", + "description": "Testing charter describing the scope and goals. Note that cloning a session does NOT copy the charter.", + "json_path": "/exploratory_session/charter" + }, + { + "name": "timebox_duration", + "type": "integer", + "example": 60, + "description": "Planned duration of the session. TM's own exported reports render this value as minutes, while the upstream store documents the field as seconds — it is passed through unconverted, so agree the unit with the product before relying on it.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/timebox_duration" + }, + { + "name": "owner", + "type": "integer", + "example": 42, + "description": "TCM user id of the session owner. This is the field to use: it is translated to the upstream user id here. An id matching no user in this group is accepted and stored as no assignee.", + "json_path": "/exploratory_session/owner" + }, + { + "name": "assignee", + "type": "integer", + "example": 42, + "description": "NOT a safe alias on create — unlike `owner` it is forwarded without translation to an upstream user id, so it sets the wrong user. Use `owner` instead. Ignored entirely when `owner` is also present.", + "json_path": "/exploratory_session/assignee" + }, + { + "name": "folder_id", + "type": "integer", + "example": 456, + "description": "Folder to place this session in; a numeric string is also accepted. Omit to create it at the project root.", + "json_path": "/exploratory_session/folder_id" + }, + { + "name": "test_plan_id", + "type": "integer", + "example": 77, + "description": "Integer id of the test plan to link this session to (not a TP-NNN identifier). Only reachable through the wrapped body — the unwrapped fallback does not permit it.", + "json_path": "/exploratory_session/test_plan_id" + }, + { + "name": "duration", + "type": "integer", + "example": 45, + "description": "Tracked duration. Accepted on create but normally left to be set later by the update call.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/duration" + }, + { + "name": "actual_duration", + "type": "integer", + "example": 50, + "description": "Final recorded duration. Accepted on create but normally set by the update call before closing — the close call does not accept it.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/actual_duration" + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "mobile" + ], + "description": "Plain tag strings. The request schema rejects any non-string entry.", + "json_path": "/exploratory_session/tags" + }, + { + "name": "configurations", + "type": "array", + "example": [ + 1, + 3 + ], + "description": "Configuration ids (integers, or numeric strings) — not the configuration objects returned on read. An id that does not exist in this project surfaces only as the generic upstream 400.", + "json_path": "/exploratory_session/configurations" + }, + { + "name": "attachments", + "type": "array", + "example": [ + 101, + 102 + ], + "description": "Integer blob ids from a prior generic attachment upload — never file content. A blob already used by another entity is refused with 422. Unknown ids error only when none of them resolve; otherwise they are silently skipped.", + "json_path": "/exploratory_session/attachments" + }, + { + "name": "issues", + "type": "array", + "description": "Issues to link. Each entry is {issue_id, issue_type} and may also carry metadata {url, ticket_id, title, display_category, issue_category}. A list of bare id strings is coerced to jira issues. A list of anything else is silently reduced to no issues. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. Linking is applied asynchronously after the 201.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/exploratory_session/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "101": "Regression", + "102": 42 + }, + "description": "Custom field values as a map of {custom_field_id: value}, converted server-side into the upstream write shape. This is NOT the array shape the read endpoints return. Resolve the ids for entity type ExploratorySession first; a misspelled key is dropped rather than rejected.", + "json_path": "/exploratory_session/custom_fields" + } + ], + "intent": "Start a new exploratory testing session in a project — give it a charter, a timebox, an owner, tags, configurations and a folder — so that observations can be logged against it", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "created_at", + "updated_at" + ], + "responses": { + "201": { + "description": "Exploratory session created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 naming a missing `exploratory_session` means the body reached the server empty, not that you must nest it.", + "`title` is the only field the store requires. Everything else is optional.", + "Set the assignee with `owner` (a TCM user id) — NOT with `assignee`. On this create path only `owner` is translated to the upstream user id; `assignee` is forwarded raw and lands as a wrong or invalid user. On update both are translated, so the two endpoints disagree.", + "An `owner` that resolves to no user in this group does not fail: the session is created 201 with no assignee at all. Re-read the session if the assignment matters.", + "`issues` is forgiving in dangerous ways. A list of bare strings is accepted and coerced to {issue_id, issue_type:'jira'}. Jira ids are validated against ^[A-Z][A-Z0-9]*-[0-9]+$ and a bad one fails the whole call. But a list whose entries are neither strings nor objects carrying both issue_id and issue_type is silently replaced with an empty list — the session is created with no issues and no error. Each entry may also carry a `metadata` object ({url, ticket_id, title, display_category, issue_category}) even though only issue_id/issue_type are declared.", + "Issue linking happens asynchronously after the 201, so the session in the response does not yet show the links. Re-read it, or read the session's defects, to confirm.", + "`attachments` are integer ActiveStorage blob ids from a prior generic attachment upload — not file content, and not URLs. A blob already attached to another entity is refused with 422 'the attachment has been referenced elsewhere'. Unknown ids only error when NONE of them exist; a mixed list quietly attaches the ids that resolve and drops the rest.", + "`session_state` and `name` pass the request schema and are then dropped by the parameter filter — a session cannot be created closed, and the title must be sent as `title`.", + "`custom_fields` goes in as a map of {custom_field_id: value} and comes back as the normalised array shape; the two are not symmetric.", + "Take `exploratory_session.id` (integer) from the response and carry it as session_id into every log, defect, close, clone, update and delete call for this session.", + "Any upstream validation failure — an unknown configuration id, a folder in another project, a malformed value — arrives as 400 {success:false, message:'The API request is invalid or improperly formed'}. The message is the same for every cause, so it never tells you which field was wrong." + ] + }, + { + "name": "create_exploratory_session_log", + "method": "POST", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "body": [ + { + "name": "content", + "type": "string", + "example": "

Tapped checkout — spinner appeared but no order was created.

", + "description": "Rich-text HTML for the entry. Sanitised on write: anchors and images pointing outside this project/group are stripped. Reference already-uploaded images by blob id in tags here — this is the only way to attach media to a log.", + "json_path": "/session_log/content" + }, + { + "name": "status", + "type": "string", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "example": "fail", + "description": "Outcome recorded for this step. `skipped` is valid. Any other value is rejected by the request schema with 400 before the handler runs.", + "json_path": "/session_log/status" + }, + { + "name": "elapsed_time", + "type": "integer", + "example": 120, + "description": "Time spent on this step, in seconds. Must be an integer — a numeric string is rejected by the request schema.", + "json_path": "/session_log/elapsed_time" + }, + { + "name": "defects", + "type": "array", + "description": "Issues raised by this observation. Each entry is {issue_id, issue_type} and may carry a metadata object. Bare id strings are coerced to jira issues; a list whose entries lack issue_id or issue_type is silently reduced to no defects. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. Linked asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/session_log/defects" + } + ], + "intent": "Record one observation during an exploratory session — a note about what was tried and what happened, its pass/fail/bug outcome, the time it took and any defects it raised", + "returns": [ + "success", + "session_log", + "id", + "session_id", + "hashed_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "attachment_relations", + "created_at", + "updated_at" + ], + "responses": { + "201": { + "description": "Session log created successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_log": { + "$schema": "ExploratorySessionLog" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 saying 'session_log is required' means the body was empty, not that you must nest it.", + "This is a child of a session: the session_id in the path is the parent, and the `id` you get back is the log_id you must carry — together with the same session_id — into the update and delete calls. A log id from one session is not addressable under another.", + "`status` accepts pass, fail, bug, retest, block, note and skipped. `skipped` is real and is missing from older declarations of this field; anything outside the seven is rejected by the request schema with 400.", + "Every field is optional — a log with no content, status or elapsed_time is created successfully. Nothing here rejects an empty observation.", + "`content` is rich-text HTML and is sanitised on write: anchors and images pointing outside this project or group are stripped, so the stored content can differ from what you sent.", + "Screenshots are not uploaded through this call. Upload the image first through the generic attachment upload, then reference the returned blob id in an tag inside `content`; the handler derives the attachment links from the blob ids it finds in the HTML. If ANY referenced blob id is not visible to your group or project, EVERY attachment link for the call is dropped and the log is still created 201 with no images attached.", + "`defects` behaves like the session's issues: bare id strings are coerced to jira issues, entries missing issue_id or issue_type silently reduce the list to empty, and a jira id must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. A per-entry metadata object is accepted even though only issue_id and issue_type are declared.", + "Defect linking is applied asynchronously after the 201, so the log echoed back may not show the links yet — re-read the logs, or the session's defects, to confirm.", + "`elapsed_time` is seconds.", + "There is no bulk log-create on this path; one call per observation." + ] + }, + { + "name": "create_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/filter", + "mode": "write", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "description": "Display name of the saved view. Must be unique for this project and entity — a duplicate is rejected by the backing service as a 400 'Filter name already exists'. There is also a server-side maximum length, also reported as a 400." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Which list this view filters. Validated by a request-body schema before the handler runs, so an unlisted value returns 400 'Invalid JSON data' with the accepted set echoed back. Cannot be inferred — it is required even though the conditions live in `filters`." + }, + { + "name": "is_project_level", + "type": "boolean", + "default": false, + "description": "true publishes the view to everyone on the project; false/omitted keeps it to you. Must be a real JSON boolean — the string \"true\" fails schema validation with 400." + }, + { + "name": "filters", + "type": "object", + "required": true, + "description": "The condition set the view applies, in the product UI's filter-state shape — NOT the flat id lists the entity search takes. The UI only applies a view whose conditions are in this shape: {systemFields: {priority: [{labelInnerText: 'High', label: 'High', value: 9262}]}, extraSystemFields: {issue_type: {label: 'Jira', value: 'jira'}}, matchAllMatchAny: {global: {value: 'all'}, priority: {value: 'any'}}}. Each selected option carries the project's numeric option id as `value` (resolve it with get_project_form_fields or get_system_field_values — the ids differ per project) and its display name as `label` and `labelInnerText`. `extraSystemFields.issue_type` is REQUIRED even when the view does not filter on linked issues: without it the view saves but the UI does not apply it. matchAllMatchAny carries `global` ('all' = match every filter, 'any' = match any one) plus one entry per filtered field ('any' = contains any one value, 'all' = contains all values). Nothing inside is validated, so a wrong shape still saves with a success response. Sent only when non-empty: `{}` is treated as absent and the backing service then rejects the create with 400 'Filters cannot be empty'." + } + ], + "intent": "Save a reusable named filter view on a project — the condition set behind a test case, test run, test plan or exploratory-session list — so the same selection can be reapplied later or shared with everyone on the project", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is flat — there is no wrapper object. It is also closed: any key other than name, entity, is_project_level and filters is rejected with 400 'Invalid JSON data' by the request-body schema before the handler sees it.", + "name AND entity are both required on create even though the conditions live in `filters`. entity is what binds the view to a list type; it is not derived from the contents of `filters`.", + "`filters: {}` is dropped as blank before the request is built, so an empty object behaves exactly like omitting the key and the create then fails downstream. Send at least one real condition.", + "Conditions are not checked against the project's custom fields at write time. A condition on a custom field that does not exist is accepted here and is silently pruned — permanently — the first time anyone reads the filter back.", + "Carry `data.id` from the response: that integer is the filter_id every later read, update and delete takes.", + "`extraSystemFields.issue_type` names the issue tracker the view's linked-issues condition searches, and must always be present. Send {label: 'Jira', value: 'jira'} unless the view filters on linked issues from another tracker. The allowed values are the product's own tracker names — jira, azure, asana, linear, clickup, devrev, github, gitlab, zoho, trello — not the integration route names (`jira-app` and the rest), which are a different vocabulary. Nothing checks that the project has that tracker connected; get_project_settings shows which trackers a project uses. To filter on another tracker's linked issues, take the value from the `issue_type` those issues carry on the test case.", + "Only the backing service's 400 is translated into `{success:false, message:...}`; any other failure from it is re-raised and surfaces as a 500, so a 500 here is not necessarily a server bug.", + "Not permission-gated — the filter controller has no entry in the RBAC map, so anyone with product access to the project can create a project-level view." + ] + }, + { + "name": "create_project", + "method": "POST", + "path": "/api/v1/projects", + "mode": "write", + "entity": "project", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Checkout Regression", + "json_path": "/project/name", + "description": "Project name. Leading/trailing whitespace is stripped. Not checked for uniqueness — a duplicate name is accepted." + }, + { + "name": "description", + "type": "string", + "json_path": "/project/description", + "description": "Free text. HTML is escaped server-side, so what comes back can differ from what you sent." + }, + { + "name": "access_type", + "type": "string", + "values": [ + "private", + "restricted", + "org_wide" + ], + "json_path": "/project/access_type", + "description": "Visibility of the new project. Only honoured when project-level RBAC is enabled for the group — otherwise accepted and ignored. When it IS enabled, a value outside this set fails the whole request with 400 'Invalid access type'." + }, + { + "name": "users", + "type": "array", + "fields": [ + { + "name": "user", + "type": "string" + }, + { + "name": "role", + "type": "string" + } + ], + "json_path": "/project/users", + "description": "Per-user grants applied at creation, as [{user, role}]. Only read when access_type is also sent AND project-level RBAC is on; ignored entirely otherwise." + } + ], + "intent": "Create a new project — the top-level container every folder, test case, run, plan and report lives under; returns both the integer id the v1 routes take and the PR-NNN identifier the v2 routes take", + "returns": [ + "data", + "success", + "project", + "id", + "identifier", + "name", + "description", + "created_at", + "thProjectId", + "normalisedName", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "starred", + "jira_mapped", + "user_role", + "permissions", + "links", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project created. The project is at data.project — same envelope and same serializer as the v1 project-detail read.", + "schema": { + "$schema": "V1ProjectDetail" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `project` wrapper is added for you from each field's json_path, and sending your own `project` object is rejected before the call as an unknown body key.", + "`name` is the only field that must be present. It is stripped of surrounding whitespace, and name uniqueness is not enforced here — creating a second project with an existing name succeeds and gives you two projects that are indistinguishable by name.", + "`description` is HTML-escaped server-side, so the stored value can differ from what you sent.", + "`access_type` and `users` are read only when project-level RBAC is enabled for the group; with that off both keys are accepted and silently ignored, and the project is created with the group default. With it on, an access_type outside private/restricted/org_wide fails the whole call with 400 'Invalid access type'.", + "The new project is at `data.project` — this v1 route wraps its payload in a `data` envelope. Carry `data.project.id` (integer) for every other the v1 surface project route and `data.project.identifier` (PR-NNN) for anything under the v2 surface; the two id forms are not interchangeable.", + "Requires the project_create permission under RBAC; without it the call is 403 'This action is not permitted for the user'.", + "There is no delete-project capability in this index, so a project created by mistake cannot be removed through the registry — only through the UI." + ] + }, + { + "name": "create_report_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "projectId", + "type": "integer", + "example": 10000, + "description": "Integer project id. Alias for `project_id`; either satisfies the project requirement on create, and the project in the path is used when both are absent." + }, + { + "name": "project_id", + "type": "integer", + "example": 10000, + "description": "Integer project id the report is primarily filed under. Must be one of `project_ids` when that is also sent." + }, + { + "name": "project_ids", + "type": "array", + "example": [ + 10000, + 10001 + ], + "description": "Integer project ids for a cross-project report. Max 60 (max 5 for User Workload Report), and only accepted for the seven multi-project types — any other type with more than one id is 400. The first entry becomes the primary project when `project_id` is omitted." + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary", + "description": "Report type as its DISPLAY NAME, not a slug. `Defects Summary` and `Defects Detailed Report` are accepted but have no server-side data branch, so such a report always reads back `report_data: null` — for defect figures use `Test Run Summary` and its `issues_by_priority` / `issues_by_status` sections. `Test Plan Detailed Report` is feature-flagged: an unflagged group gets 403." + }, + { + "name": "title", + "type": "string", + "example": "Weekly Test Case Activity" + }, + { + "name": "description", + "type": "string", + "example": "Testing" + }, + { + "name": "report_timeframe", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "The window the report covers, and the value of `reportTimeRange` on the summary-by-type read. The four relative windows and `custom` compute dates; `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500, not a 422). The remaining five carry no dates — each selects entities through one matching `report_filters` key." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "example": "custom_report", + "description": "`system_generated` is the one-click report the product creates for you; `custom_report` is user-configured. For a Requirement Traceability Report, `system_generated` makes the server auto-populate `report_filters.requirements` with every requirement in the project — do not also send that filter." + }, + { + "name": "grain", + "type": "string", + "values": [ + "test_case", + "test_plan" + ], + "default": "test_case", + "description": "Requirement Traceability Report only: `test_plan` renders Requirement to Test Plan to Sub-Test Plan rows. Any other report type is 400, and it is single-project only. Immutable — a later edit that changes it is refused 400 (resending the same value is fine)." + }, + { + "name": "is_dynamic", + "type": "boolean", + "description": "Whether the report re-resolves its selection each run. Omit and the server infers it from whether any `dynamic_filters` value is non-blank." + }, + { + "name": "test_run", + "type": "object", + "fields": [ + { + "name": "created_at", + "type": "string", + "required": true + }, + { + "name": "status", + "type": "array", + "required": true + }, + { + "name": "owner", + "type": "array", + "required": true + }, + { + "name": "automation_status", + "type": "array", + "required": true + }, + { + "name": "type", + "type": "array", + "required": true + } + ], + "json_path": "/dynamic_filters/test_run", + "description": "Dynamic (re-evaluated each run) run selection. Accepted under either `dynamic_filters` or `filters`; presence of any non-blank value here is what flips the report to dynamic when `is_dynamic` is omitted." + }, + { + "name": "status", + "type": "array", + "json_path": "/report_filters/status" + }, + { + "name": "priority", + "type": "array", + "json_path": "/report_filters/priority" + }, + { + "name": "case_type", + "type": "array", + "json_path": "/report_filters/case_type" + }, + { + "name": "assignee", + "type": "array", + "json_path": "/report_filters/assignee" + }, + { + "name": "automation_status", + "type": "array", + "json_path": "/report_filters/automation_status" + }, + { + "name": "automation_state", + "type": "array", + "json_path": "/report_filters/automation_state" + }, + { + "name": "test_runs", + "type": "array", + "description": "Run ids, and they must be INTEGERS — a quoted id or a TR-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_runs.", + "json_path": "/report_filters/test_runs" + }, + { + "name": "test_plans", + "type": "array", + "description": "Plan ids, and they must be INTEGERS — a quoted id or a TP-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_plans.", + "json_path": "/report_filters/test_plans" + }, + { + "name": "test_cases", + "type": "object", + "description": "Case selection — pairs with report_timeframe=specific_test_cases. Must be an OBJECT (an array is rejected 400 'Invalid report filters'), folder-keyed like SelectionData. Folder ids are STRING keys; case ids are INTEGERS (the numeric `id`, not TC-NNN). A wrong-but-object shape saves an UNSCOPED project-wide report at 200.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "required": true + }, + { + "name": "folders", + "type": "object", + "required": true + }, + { + "name": "unique_test_case_count", + "type": "integer", + "required": true + } + ], + "json_path": "/report_filters/test_cases" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cross-project case selection: { \"\": } for the report's non-primary projects. A project id outside `project_ids` is dropped silently.", + "json_path": "/report_filters/shared_selection" + }, + { + "name": "session_ids", + "type": "array", + "description": "Exploratory session ids — pairs with report_timeframe=specific_sessions.", + "json_path": "/report_filters/session_ids" + }, + { + "name": "requirements", + "type": "object", + "description": "{ issue_type: [issue_id, ...] } — pairs with report_timeframe=requirements. Must be an object; an array is rejected 400. Auto-populated by the server when report_creation_mode is system_generated on a traceability report.", + "json_path": "/report_filters/requirements" + }, + { + "name": "test_plan_selection", + "type": "object", + "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. `select_all` is required on each entry and must be a real boolean; a malformed entry is silently discarded rather than rejected.", + "json_path": "/report_filters/test_plan_selection" + }, + { + "name": "test_runs_by_project", + "type": "object", + "description": "Cross-project run selection: { \"\": [run_id, ...] }. Keys must be numeric strings and values arrays of numeric ids or the request is 400. When present it REPLACES the flat `test_runs` key, which is then ignored.", + "json_path": "/report_filters/test_runs_by_project" + }, + { + "name": "test_plans_by_project", + "type": "object", + "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Same validation as test_runs_by_project, and it replaces the flat `test_plans` and `test_plan_selection` keys.", + "json_path": "/report_filters/test_plans_by_project" + }, + { + "name": "builds", + "type": "array", + "json_path": "/report_filters/builds", + "description": "Internal build ids paired 1:1 with `test_runs`. Sent back by the read path for round-tripping an edit; do not invent values." + }, + { + "name": "projects", + "type": "array", + "json_path": "/report_filters/projects" + }, + { + "name": "folder", + "type": "array", + "json_path": "/report_filters/folder" + }, + { + "name": "test_case_tags", + "type": "array", + "json_path": "/report_filters/test_case_tags" + }, + { + "name": "tc_custom_fields", + "type": "object", + "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` — on the other types the server never reads it and drops it silently. Persisted, and read back, under the name `custom_fields`.", + "json_path": "/report_filters/tc_custom_fields" + }, + { + "name": "custom_date_range", + "type": "string", + "example": "2025-04-16 2025-04-24", + "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED whenever report_timeframe is `custom` — omitting it is a 500, not a validation error. Ignored (and nulled on edit) for every other timeframe." + }, + { + "name": "users", + "type": "array", + "description": "TM user ids to mail the report to. Every id must belong to your group or the whole request is 400 'Invalid mail to users'. Only persisted when the report is scheduled.", + "json_path": "/mail_to/users" + }, + { + "name": "external_mails", + "type": "array", + "description": "Email addresses outside the group. Only persisted when the report is scheduled.", + "json_path": "/mail_to/external_mails" + }, + { + "name": "frequency", + "type": "string", + "values": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly", + "description": "Scheduling cadence. Send `frequency` and `frequency_details` together or omit BOTH — omitting both creates a valid unscheduled, on-demand report (and `mail_to` is then dropped silently). The enum is daily/weekly/monthly only: `once` passes body validation and then raises 'Unsupported frequency type' as a 500." + }, + { + "name": "time", + "type": "string", + "example": "08:00", + "description": "24-hour HH:MM, UTC. Required for every cadence; missing or malformed is 400 'Invalid report frequency'.", + "json_path": "/frequency_details/time" + }, + { + "name": "day", + "type": "string", + "example": "monday", + "description": "Required for weekly — a lowercase English day name, nothing else — and for monthly, where it is an integer 1-28 (29-31 are rejected). Omit for daily.", + "json_path": "/frequency_details/day" + } + ], + "intent": "Save a report definition over a project's runs, plans, cases, sessions or requirements — scheduled to email on a cadence, or unscheduled and read on demand", + "returns": [], + "responses": { + "200": { + "$response": "CreateReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Four body fields are required by the validator, not just the three the spec marks: `report_type`, `report_timeframe`, `report_creation_mode`, and a project (either `project_id`/`projectId` in the body or the project in the path). Any one missing comes back 400 `Validation error. Invalid report type|timeframe|creation mode.` — the message names which.", + "This returns HTTP 200, not 201, despite creating a record. The new report is at `data`; carry `data.id` (an integer) forward as the `report_id` for every other report call. `data.identifier` is the SC-NNN display string and is NOT accepted anywhere as an id.", + "Creating the report does NOT compute it. The response `data` is the definition only; there is no `report_data` here. Read the report afterwards to get figures, and name the sections you want.", + "`report_timeframe` and `report_filters` must agree. The five entity timeframes each read exactly one filter key — specific_test_runs reads `test_runs`, specific_test_plans reads `test_plans`, specific_test_cases reads `test_cases`, specific_sessions reads `session_ids`, requirements reads `requirements`. Naming the timeframe and leaving its filter empty saves a report that resolves to nothing, at 200.", + "`report_filters.test_runs` and `.test_plans` must be arrays of INTEGERS. A string id (\"123\") or a TR-NNN/TP-NNN identifier fails the whole request with 400 `Validation error. Invalid report filters.`, and `test_cases` / `requirements` must be objects, not arrays.", + "Scheduling is inferred, never declared: the report is scheduled iff BOTH `frequency` and `frequency_details` are present. `mail_to` is only persisted for a scheduled report — recipients sent on an unscheduled one are dropped silently and `next_run_at` stays null. Use the send-email-now capability to mail an unscheduled report.", + "`report_timeframe: custom` without `custom_date_range` is an unhandled 500, not a 422 — the server splits the value unconditionally. Send \"YYYY-MM-DD YYYY-MM-DD\", space separated.", + "`frequency: once` is accepted by body validation and then raises 'Unsupported frequency type' as a 500. Omit both scheduling fields for a one-off report instead.", + "Cross-project: send `project_ids` (integers). At most 60, and only for Test Run Summary, Test Plan Summary, Test Run Detailed Report, Test Plan Detailed Report, Test Case Activity, Requirement Traceability Report and User Workload Report — anything else is 400. User Workload Report caps at 5 projects. When both are sent, `project_id` must be one of them; when only `project_ids` is sent the first entry becomes the primary project.", + "`report_creation_mode: system_generated` on a Requirement Traceability Report makes the server fetch every linked requirement in the project and populate `report_filters.requirements` for you — do not also send that filter.", + "`grain: test_plan` is Requirement Traceability Report only, single-project only, and immutable after creation (a later edit that changes it is refused 400). Omit it for the normal test-case grain.", + "Requires the report_create permission on the project; 403 otherwise. `Test Plan Detailed Report` additionally needs a per-group feature flag and returns 403 `Operation not permitted` without it.", + "The write also syncs the report to Test Observability. If that sync fails the created row is deleted again and the call errors — a 5xx here means nothing was kept." + ] + }, + { + "name": "create_root_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folders", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Root Folder A", + "json_path": "/folder/name", + "description": "Folder name. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." + }, + { + "name": "notes", + "type": "string", + "example": "This folder contains all regression test cases", + "json_path": "/folder/notes", + "description": "Free-text description, HTML allowed. It is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. Note the key is `notes`: `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Send the literal string \"true\" to have a clashing name auto-suffixed instead of rejected. Any other value, and any boolean, is treated as false — the comparison is against the string \"true\"." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Integer ids of already-uploaded attachment blobs to bind to the folder. Each must belong to this group/project and must not already be attached to anything else — a reused blob fails the whole call with 422 and the bare string \"the attachment has been referenced elsewhere\"." + } + ], + "intent": "Create a top-level folder in a project — the first thing you need before test cases can be filed anywhere", + "returns": [ + "data", + "success", + "folder", + "project", + "id", + "name", + "notes", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__CreateRootFolder" + }, + "400": { + "$response": "BadRequest" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send `name` and `notes` flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", + "Uniqueness is always enforced against sibling root folders. A duplicate name comes back with the entity store's own message and status relayed verbatim — this is one of the few folder endpoints that does NOT mask the upstream text. Send auto_rename:\"true\" if you want it renamed instead.", + "The created folder is at `data.folder`, not at the top level. `data.project` carries the project it landed in. The id you carry forward is `data.folder.id`, a plain integer.", + "Passing the literal path segment `new` as project_id CREATES a default project and puts the folder in it. Only do that deliberately.", + "Requires the folder-create permission; 403 otherwise.", + "Folders keep no version history, so a folder created here can only be undone by deleting it — and folder deletion is itself not reversible through this API." + ] + }, + { + "name": "create_shared_step", + "method": "POST", + "path": "/api/v1/projects/{project_id}/shared-steps", + "mode": "write", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the shared step block. Only presence of the key is checked here — an empty string passes this layer and is rejected by the backing service, so a blank title fails late with a different error than a missing one." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Shared-field folder to file the block under. The key is forwarded only when present; omit it (or send null) to leave the block at the root (Unassigned)." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "description": "Plain tag name strings on the shared step itself (separate from the tags on the test cases that embed it). Accepted here but previously undeclared." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The ordered step rows. Required — omitting the key entirely fails local validation with a flat 400 'Validation Error' that names no field. An empty array IS accepted and creates a titled block with no steps.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "minimum": 1, + "description": "1-based position. Every row needs order >= 1; a missing or 0 order fails the whole request, not just that row." + }, + { + "name": "step", + "type": "string", + "description": "Step body, HTML. Each row must carry at least one of step or result or the whole request is rejected." + }, + { + "name": "result", + "type": "string", + "description": "Expected result, HTML. Each row must carry at least one of step or result." + }, + { + "name": "test_data", + "type": "string" + } + ] + } + ], + "intent": "Create a reusable shared step block in a project — a titled set of step/result rows that many test cases can then embed by reference instead of copying", + "returns": [ + "success", + "data", + "id", + "title", + "step_count", + "test_case_count", + "tags", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedStepsPostResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "render_image_urls", + "type": "boolean", + "default": false, + "description": "When true, inline image attachments referenced in the step/result HTML are returned as resolved URLs rather than raw references." + } + ], + "guidance": [ + "The body is flat — title, folder_id, tags and shared_step_details sit at the top level with no wrapper.", + "shared_step_details is required even when empty. Every row must have order >= 1 and at least one of step/result; one bad row fails the entire call with a bare 400 `{data:null, success:false, message:\"Validation Error\"}` that does not say which row or which field.", + "Success is 200 with `{data, success:true}` — not 201, and the body is not empty.", + "Carry `data.id`. That integer is the shared_step_id you put on a test case's `test_case_steps[].shared_step_id`, and the id for every later read, update and delete of this block.", + "Creating a shared step attaches it to nothing. Embedding is done from the test case side; this call only makes the block available.", + "step and result are HTML. Inline attachment references are validated server-side and an unresolvable one fails the call with 400 'attachment in text is invalid'.", + "Requires tc_write — the same permission as editing a test case." + ] + }, + { + "name": "create_step_result", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/step/{step_id}", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (this route only matches digits; a PR-NNN identifier never reaches the API)." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run id. This v1 route accepts both the bare integer and the TR-NNN identifier." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1 + }, + { + "name": "step_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric id of the step on the test case (from the case detail), not its position in the step list. The route only matches digits." + } + ], + "body": [ + { + "name": "status", + "type": "string", + "values": [ + "untested", + "passed", + "failed", + "retest", + "blocked", + "skipped", + "in_progress" + ], + "example": "passed", + "json_path": "/test_run_step_result/status", + "description": "Step outcome as a NAME. This is the value that is actually written. A same-named key at the top level of the body is validated but never written." + }, + { + "name": "status_id", + "type": "integer", + "json_path": "/test_run_step_result/status_id", + "description": "Numeric status id. Accepted and forwarded, but the step surface is name-based — prefer `status` unless you know the backing service expects an id here." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run_step_result/description", + "description": "Note for this step. Rich text; links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent. Also copied onto the case-level result." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_run_step_result/attachments", + "example": [ + 10288829 + ], + "description": "Integer blob ids from a prior generic attachment upload (or {id: } objects) — NOT filenames and NOT file bytes. An unresolvable or already-used blob fails the whole call with 422." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_run_step_result/custom_fields", + "description": "Custom field values keyed by custom field id. Copied onto the case-level result as well as the step." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run_step_result/issues", + "description": "Linked defects. Each entry must be an OBJECT; a bare string is dropped by the parameter filter with no error, so the link is silently never created." + } + ], + "intent": "Log the outcome of ONE step inside a test case's execution in a run — the per-step pass/fail you record while working through a manual test case, as opposed to the case's overall result", + "responses": { + "200": { + "description": "The full test result for the case, at the top level of the body (no envelope). The step's own outcome is nested under `test_run_step_result`.", + "schema": { + "$schema": "TestResult__CreateStepResult" + } + }, + "400": { + "description": "Either a duplicate request inside the one-second lock ({success:false, message:'Request is already being processed'}) or a top-level `status` outside the supported step statuses ({error:'Validation error'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Caller lacks execute permission on this test run ({success:false, message:\"You don't have permission to execute test cases in this test run\"})." + }, + "422": { + "description": "An attachment blob id could not be resolved or is already referenced elsewhere ({base: [reason]})." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `test_run_step_result` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. That object is forwarded to the backing service essentially verbatim, so only the keys below are acted on locally and unrecognised keys are passed through rather than rejected.", + "The step's status travels as a NAME inside the wrapper (`status`), not as the numeric status id the case-level result endpoints take. The supported names are untested, passed, failed, retest, blocked, skipped, in_progress.", + "There is a decoy: a TOP-LEVEL `status` key is validated against that same list and 400s {error:'Validation error'} when it is not in it — but it is not the value that gets written. Sending only a top-level status passes validation and logs nothing.", + "Repeat calls for the same project/run/case/step within one second are refused with 400 {success:false, message:'Request is already being processed'} by a one-second lock. That is a de-duplication guard, not a validation failure — pause and retry.", + "Logging a step result also rewrites the CASE's result in that run: if the wrapper carries any of description, attachments, custom_fields or issues, those are copied onto the case-level result and its status is taken from the step write. A status-only step write leaves the case result's fields alone.", + "`attachments` here are integer blob ids from a prior generic attachment upload — not filenames and not file content. An id that cannot be resolved, or a blob already attached elsewhere, fails the whole call with 422.", + "Requires execute permission on the run (403 otherwise). `step_id` is the numeric id of the step on the test case — read it from the case detail; it is not the step's 1-based position.", + "The 200 body is the full CASE result object at the top level — no envelope, no `success` key, and no separate step-result record. The step's own outcome is nested under `test_run_step_result` on it." + ], + "query": [ + { + "name": "configuration_id", + "type": "integer", + "description": "Target the execution for one configuration when the case runs against a configuration matrix. Forwarded as configId." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target one specific run/case row. Also selects the newer write path, on which the response is an optimistic payload built from the submitted body rather than a re-read of the stored result." + } + ], + "returns": [ + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "attachments", + "issues", + "test_run_step_result", + "links" + ] + }, + { + "name": "create_sub_folder", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mkdir", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/folder/name", + "description": "Name of the new subfolder. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." + }, + { + "name": "notes", + "type": "string", + "json_path": "/folder/notes", + "description": "Free-text description, HTML allowed; sanitised server-side so the stored value can differ from what you sent. The key is `notes` — `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Integer ids of already-uploaded attachment blobs. Each must belong to this group/project and must be unattached; a reused blob fails the call with 422 and the bare string \"the attachment has been referenced elsewhere\"." + } + ], + "intent": "Create a folder inside an existing folder — use this to build out a nested test-case tree one level at a time", + "returns": [ + "data", + "success", + "folder", + "id", + "name", + "notes", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__CreateSubFolder" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", + "`folder_id` in the path is the PARENT. It is fetched first, so a folder id from another project comes back as a bare 404 {success:false} with no message telling you which id was wrong.", + "folder_id must be a positive integer. Literal 0 raises an unhandled ArgumentError and surfaces as a 500, not a 400.", + "Sibling-name uniqueness is always checked and the upstream message is relayed verbatim on failure (unusually — most folder endpoints mask it). auto_rename:\"true\" turns the rejection into a silent rename.", + "The new folder is at `data.folder`; there is no `folder` at the top level and no `project` key on this action (unlike root creation). Carry `data.folder.id` forward.", + "Requires the folder-create permission." + ] + }, + { + "name": "create_template", + "method": "POST", + "path": "/api/v2/templates", + "mode": "write", + "entity": "template", + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Template name. Must be unique within the workspace for the entity type; a duplicate is rejected upstream with a 400 carrying the reason.", + "minLength": 1, + "example": "Regression Template" + }, + { + "name": "entity", + "type": "string", + "description": "What the template applies to. Only test-case templates are supported today; leave this out and it defaults to TestCase.", + "values": [ + "TestCase" + ], + "default": "TestCase" + }, + { + "name": "description_fields", + "type": "array", + "description": "Ordered fields placed in the template's description section. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean", + "description": "True for a built-in field, false or absent for a custom field." + }, + { + "name": "is_mandatory", + "type": "boolean", + "description": "Whether a test case using this template must fill the field." + } + ] + }, + { + "name": "properties_fields", + "type": "array", + "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields — the template gets exactly the ids you send. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + }, + { + "name": "linked_projects", + "type": "object", + "description": "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", + "fields": [ + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Automatically link projects created later.", + "default": false + }, + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "Link every existing project.", + "default": false + }, + { + "name": "link_projects", + "type": "array", + "description": "Integer project ids to link. These are the numeric project ids, not the PR- identifiers." + }, + { + "name": "unlink_projects", + "type": "array", + "description": "Integer project ids to unlink. Meaningless on a create." + } + ] + } + ], + "intent": "Create a new test-case template — define which description and property fields a test case gets and, optionally, which projects it applies to.", + "guidance": [ + "NON-REMOVABLE SYSTEM FIELDS 17 AND 18 MUST BE INCLUDED or the create fails with 400 `templateFieldValidationError: Non-removable System fields not present in template.` These are the Review & Approve pair. You cannot discover them by reading an existing template: get_template DROPS them from its response (`field_count` 16 vs 14 entries returned, on every template including one just created with all 16), so a read-then-clone round trip always fails. Add {id:17,...} and {id:18,...} to properties_fields explicitly.", + "Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", + "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", + "Paid plans only. On a free plan the gate answers with HTTP 200 and a body of success:false / 'This Feature is only for paid customers'. Check the success flag, not the status code, before reporting the template as created.", + "Nothing is validated locally — name, field ids and project ids all go straight upstream, so a malformed body surfaces as a 400 with a single message rather than a per-field error list.", + "A create does not auto-inject the Review & Approve system fields, but a later update of properties_fields will. The two calls therefore do not behave the same way on the same field list.", + "The 201 body carries the full template including its fields — read it rather than re-fetching, and keep the returned id.", + "Success is 201 with the full template echoed, linked to the named project. A 403 'This action is not permitted for the user' is a permission gap on the calling credential, not the paid-plan gate." + ], + "returns": [ + "success", + "template", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at", + "description_fields", + "properties_fields", + "step_fields" + ], + "responses": { + "201": { + "$response": "TemplateDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_case_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Create one test case at the project level — the call to use to seed the FIRST case in a project that has no folders yet, via create_at_root.", + "returns": [ + "success", + "data", + "test_case", + "folder", + "project", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "$schema": "V1TestCaseCreateResponse__CreateTestCaseByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This route has no folder in its path, so you must supply one: either `test_case.test_case_folder_id` (an existing folder) plus a top-level `folder_id` naming the same folder, or `create_at_root: true` to have the folder minted.", + "Without a resolvable folder the create still reaches the backend and then fails while building the response, surfacing as a 422 rather than a clear validation error. Resolve the folder id first.", + "`create_at_root: true` only works on a project with ZERO folders — it creates a folder called 'New Folder' and puts the case there. On a project that already has folders it is 400 'Root Folder Already Exists'.", + "For any project that already has folders, prefer the folder-scoped create instead; this route exists for the empty-project case.", + "Steps only come from `test_case_steps`, and only when `template`/`template_step_type` selects a step-shaped template — the default is `test_case_text`, which stores no steps and still returns 200.", + "The new case comes back at `data.test_case`; take its integer `id` and its TC-NNN `identifier` from there. `data.folder`, `data.project` and `data.path_folders` describe where it landed.", + "If the project has e-signature enabled and you set `review_status: in_review` (or `send_for_review`), the call needs a `test_case.reauth_token`; without it the response is 401 `{success:false, error_code:\"reauth_required\"}`.", + "A test case keeps version history, so an edit is recoverable — but nothing undoes a create except deleting the case." + ], + "body": [ + { + "name": "test_case", + "type": "object", + "required": true, + "description": "The case to create. Everything about the case goes inside this wrapper — the handler requires it, so a flat body is a 400 before any field is read.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body — the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." + }, + { + "name": "automation_state", + "type": "integer" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." + }, + { + "name": "case_type", + "type": "integer", + "description": "Numeric case-type option id, or the project's display name for it." + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Ignored on create — dataset mappings are always regenerated from `test_case_dataset`." + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer", + "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Dropped on create — the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." + }, + { + "name": "status", + "type": "integer", + "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here — it is written through as the id and breaks the field." + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string" + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." + } + ] + }, + { + "name": "create_at_root", + "type": "boolean", + "description": "Create the project's first folder ('New Folder') and put this case in it. Only valid when the project has NO folders — otherwise 400 'Root Folder Already Exists'." + }, + { + "name": "folder_id", + "type": "integer", + "description": "TOP-LEVEL folder id, outside `test_case`. This is what the ownership check reads and what the success response's folder block is built from; without it (and without `create_at_root`) the response build fails and you get a 422." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there. The subfolder is deleted again if the create then fails." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." + } + ] + }, + { + "name": "create_test_cases", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_case", + "type": "object", + "required": true, + "description": "The case to create. Everything about the case goes inside this wrapper — the handler requires it, so a flat body is a 400 before any field is read.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Case title. Required." + }, + { + "name": "test_case_folder_id", + "type": "string", + "required": true, + "description": "Target folder's integer id. Required in the body — the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." + }, + { + "name": "automation_state", + "type": "integer" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." + }, + { + "name": "case_type", + "type": "integer", + "description": "Numeric case-type option id, or the project's display name for it." + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "estimate", + "type": "string", + "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." + }, + { + "name": "expected_result", + "type": "string" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Ignored on create — dataset mappings are always regenerated from `test_case_dataset`." + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "owner", + "type": "integer", + "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "priority", + "type": "integer", + "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Dropped on create — the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." + }, + { + "name": "status", + "type": "integer", + "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here — it is written through as the id and breaks the field." + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "template", + "type": "string", + "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." + }, + { + "name": "template_id", + "type": "integer" + }, + { + "name": "template_step_type", + "type": "string" + }, + { + "name": "test_case_dataset", + "type": "object" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." + } + ] + }, + { + "name": "create_at_root", + "type": "boolean", + "description": "Not useful on this route — the folder is already in the path. It only has an effect on a project with zero folders, where it mints 'New Folder'; otherwise it turns the call into a 400 'Root Folder Already Exists'." + }, + { + "name": "create_subfolder", + "type": "boolean", + "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there instead of in the path folder." + }, + { + "name": "scenario_name", + "type": "string", + "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." + } + ], + "intent": "Create one test case in a folder — the normal single-case create. For more than one case, use the bulk create instead.", + "returns": [ + "success", + "data", + "test_case", + "folder", + "project", + "path_folders", + "request_trace_id" + ], + "responses": { + "200": { + "$schema": "V1TestCaseCreateResponse__CreateTestCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the plural name this creates exactly ONE case: the body is a single `test_case` object, not a list. The bulk route is the one that takes an array.", + "`test_case.test_case_folder_id` is required in the BODY as well as in the path — the path folder is not substituted in, and omitting it is the most common create failure (a 422 wrapping an upstream 404).", + "Steps only come from `test_case_steps`, and only when the template is step-shaped. Neither `template` nor `template_step_type` sent means `test_case_text`, which stores no steps and still returns 200 — verify `test_case_steps` in the response.", + "priority and case_type accept either the numeric option id or the project's display name; `status` does NOT — a status name is written through as an id. Resolve the ids from the project's system field values first.", + "`review_status` is never left alone on create: omitted, it is set from the project's review-approve setting, falling back to `pending`. `reviewers` is only honoured on a Team-Pro workspace with review-approve enabled, and including the owner is a 422.", + "The new case is at `data.test_case` — its integer `id` and TC-NNN `identifier` are both there. `data.request_trace_id` refers to the AI enrichment that continues after the response.", + "Linked `issues` are processed asynchronously after the response, so a 200 does not mean the links exist yet." + ] + }, + { + "name": "create_test_plan_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/test_plan/name", + "description": "Plan name. REQUIRED — a missing or blank name is 400 {success: false, message: 'Name field cannot be blank'}, and nothing else in the body is looked at." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text. Sanitised against the RTE allowlist and rewritten server-side, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "description": "ISO-8601 date. Stored as midnight UTC. An unparseable value is silently dropped rather than rejected.", + "format": "date", + "json_path": "/test_plan/start_date" + }, + { + "name": "end_date", + "type": "string", + "description": "ISO-8601 date. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and creates nothing.", + "format": "date", + "json_path": "/test_plan/end_date" + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "ACCEPTED BUT NEVER APPLIED ON CREATE — it is not forwarded upstream, so every new plan starts as `new`. Move it on with a follow-up plan update.", + "json_path": "/test_plan/plan_status" + }, + { + "name": "owner", + "type": "integer", + "description": "Owner as the internal TM user id (the `id` from the project users read — NOT a BrowserStack user id and not an email). Omit the key to own it yourself. An id that does not resolve inside the caller's group is swallowed, not rejected, and the plan is created with no real owner.", + "json_path": "/test_plan/owner" + }, + { + "name": "parent_plan_id", + "type": "integer", + "description": "Parent plan's INTEGER id. Set it to create a SUB-plan — the result carries an STP-NNN identifier. Null/omitted means a top-level plan. This choice is effectively permanent: a plan can never later be demoted into a sub-plan or promoted out of one.", + "json_path": "/test_plan/parent_plan_id" + }, + { + "name": "tags", + "type": "array", + "description": "Plain tag strings. If ANY entry is not a string the whole list is silently replaced with [] — the plan is created with no tags and no error.", + "json_path": "/test_plan/tags" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer user ids. Attach-only — adding reviewers does NOT send the plan for review, and reviewers are only emailed once the plan is actually in review. Rejected if the project restricts reviewers and one is not on the allowed list.", + "json_path": "/test_plan/reviewers" + }, + { + "name": "attachments", + "type": "array", + "description": "Ids of attachments already uploaded via the attachments surface. An id that does not verify against this group returns 422 with the verifier's own body, not the usual {success, error} envelope.", + "json_path": "/test_plan/attachments" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom-field values keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: }.", + "json_path": "/test_plan/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked tracker issues. Structured — NOT a flat array of keys. A bare string entry is coerced to {issue_id: }, losing the tracker type. Only url and ticket_id are kept inside metadata; other metadata keys are dropped.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_plan/issues" + }, + { + "name": "test_runs", + "type": "array", + "description": "Test runs to link, as run INTEGER ids (a run's `uuid`, not its TR-NNN `id`). Linking MOVES a run: it leaves whatever plan held it, with nothing in the 200 to say so.", + "json_path": "/test_plan/test_runs" + } + ], + "intent": "Create a test plan to group test runs for a release or milestone — or create a SUB-plan under an existing plan by setting parent_plan_id", + "responses": { + "200": { + "description": "The created plan under a top-level `test_plan` key (no `data` wrapper). `identifier` is the `TP-NNN` (or `STP-NNN` for a sub-plan) and `id` is the integer the other v1 routes take.\n", + "schema": { + "$schema": "V1TestPlanDetail__CreateTestPlanByIntegerId" + } + }, + "400": { + "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`) or a rejected custom field (`{success: false, error: ...}`). A body with no `test_plan` wrapper lands here too.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Also used for an upstream payload rejection: `{success: false, error: \"Invalid Params\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Every field goes inside a `test_plan` object. A flat body is rejected before any field is read.", + "name is the only required field, and it is checked first — a 400 {success: false, message: 'Name field cannot be blank'} means exactly that and nothing about the rest of the body.", + "plan_status is accepted and then ignored. A new plan is always `new`; drive it to started/completed with the plan update capability, which needs the matching date in the same request.", + "Bad dates are a 200 trap: end_date < start_date returns HTTP 200 carrying {success: false, error: 'Please check date params'}. Branch on `success`, never on the status code.", + "An upstream rejection of the payload is reported as HTTP 404 with {success: false, error: 'Invalid Params'} — a 404 from this endpoint usually means a bad body, not a missing project.", + "Linked issues are processed on a background job, so they are usually absent from the plan you get back in this response even though the call succeeded. Re-read the plan to see them.", + "Carry `test_plan.id` (the integer) forward for every other v1 plan route; `test_plan.identifier` is the TP-NNN (STP-NNN for a sub-plan) used for display and by the newer API surface.", + "The reply is the re-read plan under a top-level `test_plan` key — there is no `data` wrapper." + ], + "returns": [ + "success", + "test_plan", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "start_date", + "end_date", + "owner", + "assignee", + "created_by", + "tags", + "issues", + "attachments", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "total_test_runs_count", + "test_plan_progress_v2", + "record_status", + "project_id", + "created_at", + "updated_at", + "links" + ] + }, + { + "name": "create_test_result_for_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "status_id", + "type": "integer", + "description": "Result status id — resolve the name (\"Passed\", \"Failed\") to an id first; this field takes the id. Effectively required, though a missing value is accepted and leaves the result unstatused.", + "json_path": "/test_result/status_id" + }, + { + "name": "description", + "type": "string", + "json_path": "/test_result/description" + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_result/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects. Each entry is an OBJECT — a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_result/issues" + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_result/attachments" + }, + { + "name": "elapsed_time", + "type": "integer", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration this result is for. TOP level — the server does not read it from inside `test_result`, so nesting it silently logs the result against the default configuration." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target a specific run/case mapping. Top level." + }, + { + "name": "test_case_count", + "type": "integer", + "description": "Total number of test cases linked to the automation execution" + } + ], + "intent": "Create a new test result for a test case in a test run", + "guidance": [ + "This is how a pass or fail is recorded against a case in a run. Editing the run or the case does not record an outcome." + ], + "returns": [ + "id", + "status", + "backtrace", + "configuration_id", + "created_at", + "created_by_imported", + "custom_fields", + "description", + "error_description", + "expanded", + "failure_type", + "finished_at" + ], + "responses": { + "201": { + "$response": "TestResultCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_results_for_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + } + ], + "body": [ + { + "name": "results", + "type": "array", + "minItems": 1, + "description": "Bulk form: one entry per test case. Mutually exclusive with the single form below — send `results` OR `test_result`, never both. Each entry needs `test_result` plus `test_case_id` or `execution_id`. No other keys are allowed on an entry.", + "fields": [ + { + "name": "test_case_id", + "type": "string", + "description": "Test case identifier, e.g. TC-101. Required unless execution_id is given." + }, + { + "name": "execution_id", + "type": "string", + "description": "Automation execution id, as an alternative target to test_case_id." + }, + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "test_result", + "type": "object", + "description": "The outcome itself: status (name), description, issues, step_results. Same shape as the single-result `test_result` below." + }, + { + "name": "attachments", + "type": "array", + "maxItems": 10, + "description": "Multipart file part filenames for this entry. Ignored on a plain JSON request." + } + ] + }, + { + "name": "status", + "type": "string", + "description": "Result status by NAME as configured for the project (\"Passed\", \"Failed\", \"Untested\"). v1's numeric status_id is NOT accepted here. Required unless step_results is sent instead.", + "json_path": "/test_result/status" + }, + { + "name": "description", + "type": "string", + "description": "Notes on the outcome. Rich text is accepted. Silently truncated at 16 KB rather than rejected.", + "json_path": "/test_result/description" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects as plain KEY STRINGS (e.g. [\"JIRA-12\"]) — unlike v1, which takes objects. Pair with issue_tracker to type them.", + "json_path": "/test_result/issues" + }, + { + "name": "issue_tracker", + "type": "object", + "description": "Types the linked issues, e.g. {\"name\": \"jira\"}. Only read from inside test_result.", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "json_path": "/test_result/issue_tracker" + }, + { + "name": "elapsed_time", + "type": "integer", + "description": "Execution time in seconds. Stored as time_elapsed.", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Result custom fields keyed by field name; resolved against the project's TestResult custom fields.", + "json_path": "/test_result/custom_fields" + }, + { + "name": "step_results", + "type": "array", + "minItems": 1, + "description": "Per-step outcomes. Send these INSTEAD of a top-level status; each entry needs `status` plus `step_id` or `step_index`. The parent case status is rolled up from the steps.", + "fields": [ + { + "name": "step_id", + "type": "number" + }, + { + "name": "step_index", + "type": "number" + }, + { + "name": "status", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "issues", + "type": "array" + }, + { + "name": "created_at", + "type": "string" + }, + { + "name": "updated_at", + "type": "string" + } + ], + "json_path": "/test_result/step_results" + }, + { + "name": "test_case_id", + "type": "string", + "example": "TC-101", + "description": "Single form: which case in the run this result is for. TOP level — not inside test_result. One of test_case_id, execution_id or shared_test_case_ids is required." + }, + { + "name": "execution_id", + "type": "string", + "description": "Single form: target an automation execution instead of a case identifier. Top level." + }, + { + "name": "shared_test_case_ids", + "type": "object", + "description": "Single form: target a case shared from another project, as {project identifier: [test case identifiers]}. Must resolve to EXACTLY ONE case — zero or several gives 400 \"Test Case ID is invalid\"." + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration this result is for. TOP level. Omitting it logs against the default configuration." + }, + { + "name": "attachments", + "type": "array", + "maxItems": 10, + "description": "Filenames of multipart file parts to attach to this result. Only binds on a multipart/form-data request whose JSON body rides in a `payload` part; on a plain JSON request it is accepted and silently does nothing. Each entry is a bare filename string of 1-255 characters." + }, + { + "name": "validate_tc", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Bulk only. \"true\" pre-checks every test_case_id exists in the project and 400s listing the invalid ones. Rejected outright when the request carries 300 or more unique test case ids." + } + ], + "intent": "Record the outcome of running test cases in a test run — pass/fail/blocked with notes, per-step results, linked defects and attachments — for one case or for many in a single call", + "guidance": [ + "Two shapes on one endpoint: `results: [...]` for many cases, or `test_result` plus one target (`test_case_id`, `execution_id` or `shared_test_case_ids`) for one. Sending both, neither, or any unrecognised top-level key is rejected 400 as invalid params.", + "The single form answers with `test-result` for an ordinary submission, but a submission carrying `step_results` answers with `test-run-step-results` and `overall-result-status` INSTEAD — there is no `test-result` key on that response at all. Branch on which key is present rather than assuming.", + "Status is a NAME configured for the project, not an id. An unconfigured name fails that entry with INVALID_STATUS_VALUE.", + "Each submission ADDS a result to the case's history in this run; it never replaces the previous one. To move many cases to one status instead, use bulk_set_test_result_status.", + "The bulk form is queued, not applied inline: 200 means accepted. Read `results[]` — every entry is ACCEPTED or FAILED. A mix of good and bad entries still returns HTTP 200 but with `success: false` and `status: \"PARTIAL\"`; only an all-bad request returns 400. Treating 200 as 'all logged' will misreport.", + "`test-result.issues` comes back as an array of plain issue KEY STRINGS, matching the way issues are sent in, not as objects.", + "`test-result.urls.self` points at the case's ROW IN THE RUN, so its last path segment is the run row's id, not the id of the result just created. Use `test-result.id` for anything that addresses the result itself.", + "A project can restrict which review statuses may be executed. Blocked cases come back with a REVIEW_STATUS_GATE error and are never logged, whatever the status you sent.", + "Attachments require a multipart/form-data request: JSON body in a `payload` part, each file in its own part, and every name in `attachments` must match a part's filename or the request is rejected. Limits are 10 files per result, 100 per request, 50 MB per file, and filenames must be unique across the request.", + "The `attachments` array in the response is the bind result, not the stored attachment records — each entry carries the attachment id, the filename, a time-limited url and the expiry of that url. `attachment_errors` appears only when a bind failed, and `dropped_attachments` only on a bulk request where a rejected entry or an unresolvable inline reference left files behind.", + "Attachments cannot be combined with step_results on the same bulk entry — that request is rejected 400.", + "Success is HTTP 200 here, not the 201 the v1 create returns.", + "Requires the execute permission on the run; a caller without it gets 403 before anything is written." + ], + "returns": [ + "success", + "test-result", + "test-run-step-results", + "overall-result-status", + "results", + "status", + "message", + "attachments", + "attachment_errors", + "dropped_attachments" + ], + "responses": { + "200": { + "$response": "V2TestRunResultSubmitResponse" + }, + "400": { + "description": "Invalid params, unconfigured status for every entry, every case blocked by the review-status gate, or a multipart/attachment rejection. Bulk bodies carry per-case `results` entries with an `error.code` such as INVALID_STATUS_VALUE or REVIEW_STATUS_GATE.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "results": { + "type": "array", + "items": { + "$schema": "V2BulkResultOutcome" + } + }, + "dropped_attachments": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Rich-text description referenced an attachment that could not be resolved.", + "schema": { + "type": "string", + "example": "attachment in rich text editor is invalid" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "filters", + "type": "object", + "example": { + "priority": [ + "High" + ], + "folder_ids": [ + 105 + ] + }, + "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body — not inside `test_run`. This does NOT select cases: it never back-fills existing ones, so a create whose only case-bearing key is `filters` returns 200 with an EMPTY run. Use `test_case_ids` or `selection` to put cases in the run, and send `filters` only in addition, when the user asked the run to stay in sync.\nSending a non-empty " + }, + { + "name": "dynamic_filter", + "type": "object", + "example": { + "owner": [], + "status": [], + "priority": [] + }, + "description": "Filter criteria (backward compatible format - use `filters` instead). Top level, same as `filters`, including that it selects no cases and flips the run dynamic." + }, + { + "name": "test_case_ids", + "type": "array", + "example": [ + 2336 + ] + }, + { + "name": "auto_assign", + "type": "boolean" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Selection of cases shared in from other projects. Top level, same as `selection`." + }, + { + "name": "run_state", + "type": "string", + "required": true, + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "new_run", + "description": "MANDATORY. The v1 endpoint validates this against the enum and hard-rejects anything else (including a missing key) with `400 \"invalid data\"`. Use `new_run` for a freshly created run. Note: v2 defaulted this to `new_run` server-side; v1 does NOT.", + "json_path": "/test_run/run_state" + }, + { + "name": "name", + "type": "string", + "example": "Regression – Login", + "json_path": "/test_run/name" + }, + { + "name": "description", + "type": "string", + "example": "", + "json_path": "/test_run/description" + }, + { + "name": "owner", + "type": "integer", + "example": 2, + "description": "TM user id of the run owner. Unresolvable ids are silently treated as unassigned rather than erroring.", + "json_path": "/test_run/owner" + }, + { + "name": "is_dynamic", + "type": "boolean", + "example": false, + "description": "Keep the run's membership live against `filters`. Must be sent INSIDE `test_run` — v1 does not read a top-level `is_dynamic` and will silently create a static run if you put it there.", + "json_path": "/test_run/is_dynamic" + }, + { + "name": "configurations", + "type": "array", + "example": [], + "description": "Configuration ids to run against — ids, not the configuration objects returned on read.", + "json_path": "/test_run/configurations" + }, + { + "name": "test_plans", + "type": "array", + "example": [], + "description": "Test plan ids. Only the first entry is linked; a run belongs to at most one plan.", + "json_path": "/test_run/test_plans" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login" + ], + "json_path": "/test_run/tags" + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues" + }, + { + "name": "environment", + "type": "string", + "json_path": "/test_run/environment" + }, + { + "name": "attachments", + "type": "array", + "json_path": "/test_run/attachments" + }, + { + "name": "metadata", + "type": "object", + "json_path": "/test_run/metadata" + }, + { + "name": "build_group_id", + "type": "integer", + "json_path": "/test_run/build_group_id" + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 83, + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "json_path": "/selection/folders" + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Create a test run for a project", + "guidance": [ + "Discovering cases does not put them in a run. Carry the case ids you found into this call — a create whose only case-bearing field is a filter rule succeeds and creates an EMPTY run.", + "run_state is required and is validated before the selection, so a bare 'invalid data' error means a bad run field, not a bad selection.", + "A filter rule also makes the run keep syncing as cases change, even if you did not ask for that. Create runs static unless the user explicitly wants a run that keeps updating.", + "Dynamic runs match on priority, status, category, owner, automation status, automation state, folder, tags and issues only. Creation and update dates are echoed back but never matched, so a 'last 7 days' dynamic run silently drops the date window and keeps collecting.", + "Preview the selection first if the count matters — it reports what the selection resolves to before anything is created." + ], + "returns": [ + "id", + "identifier", + "name", + "active_state", + "assignee_imported", + "created_at", + "description", + "environment", + "is_automation", + "is_dynamic", + "observability_url", + "owner" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__CreateTestRunByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "delete_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id holding the option. Not validated by this handler." + }, + { + "name": "option_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric option id to delete, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." + } + ], + "intent": "Permanently remove one choice from a dropdown-style custom field, dropping that value from every case that had it selected", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible, and it takes data with it: every case, result or plan holding this option loses that value, across every project the dataset covers. There is no bin.", + "If the value is simply wrong, rename it through the option update instead — a rename keeps the option id, so nothing stored is lost.", + "On a nested_dropdown, deleting a parent option orphans the children that pointed at it; list the options first and delete the subtree deliberately.", + "None of the three path ids is validated in this layer, so a wrong or mismatched triple reaches storage and returns a bare 404 {success:false} with no message — indistinguishable from a permissions failure.", + "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change after the call returns.", + "Success is only `{success: true}`; capture the option's label and id beforehand if you need to report what went." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_custom_field_dataset", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler — a wrong value surfaces as a bodyless 404 from storage." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Not validated by this handler; a dataset that belongs to a different field returns the same bodyless 404." + } + ], + "intent": "Remove one option set and its project scope from a custom field — the field survives, but every value its projects stored through this dataset is gone", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. The dataset, all of its options, and the values recorded against those options in every project the dataset linked are removed together. There is no bin.", + "Count and name the affected projects first (read the dataset, or the field with its datasets). Callers routinely think a dataset belongs to one project.", + "Neither path id is validated before the upstream call, so a wrong or mismatched pair reaches storage and returns a bare 404 {success:false} with no message. Confirm the pair with a dataset read first.", + "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape after the call returns.", + "If you only want to stop one project seeing the field, unlink that project through the dataset project-mapping call instead — narrower, though it also drops that project's stored values.", + "Success is only `{success: true}`; nothing about what was removed is echoed back." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/delete", + "mode": "destructive", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id, as returned by the workspace field list. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Permanently remove a custom field from the workspace, together with every value stored against it in every project that used it", + "responses": { + "200": { + "$response": "CustomFieldAdminAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible and workspace-wide. The field, its datasets, its options and every value stored on every test case / result / plan in every linked project go at once. There is no bin and no restore.", + "Read the field first and report how many projects its datasets link to before asking to proceed — the caller usually thinks of a field as belonging to one project.", + "A side effect reaches outside the field: saved report filters that referenced this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape minutes after the call returns.", + "The path id is the numeric custom field id. The route only matches digits, and an id of 0 returns 400 'Invalid Params'. A well-formed id that does not exist comes back as a bare 404 {success:false} with no message — the same body a permissions failure would give.", + "Success is only `{success: true}`; nothing is echoed back, so capture the field's name and id before deleting if you need them for the report." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_dataset", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "destructive", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string. There is no integer-id form of this route." + } + ], + "intent": "Permanently delete a dataset and all of its rows", + "responses": { + "200": { + "description": "Dataset deleted.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "DatasetV2 deleted successfully" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project; nothing was deleted." + }, + "422": { + "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible and unguarded: there is no soft delete, no recycle bin and no restore endpoint for datasets, and the handler performs no check for test cases still bound to this dataset — it deletes and reports success either way.", + "Check what depends on it first with the dataset's linked-test-cases listing, and read the dataset itself if the contents matter, because neither is recoverable afterwards.", + "An unknown uuid is 404 {success:false, message:'Dataset not found.'} — the lookup happens before the delete, so a 404 means nothing was removed.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad request.", + "The success body is {success:true, message:'DatasetV2 deleted successfully'} — the internal 'DatasetV2' name is not a typo you can rely on staying.", + "Needs the test-case write permission (tc_write)." + ], + "returns": [ + "success", + "message" + ], + "disabled": true + }, + { + "name": "delete_exploratory_session", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "destructive", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Permanently remove an exploratory session and everything recorded inside it — every log entry and every defect link goes with it", + "responses": { + "200": { + "description": "Exploratory session deleted successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Forbidden. Insufficient permissions to delete this session.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You don't have permission to delete this exploratory session" + } + } + } + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "There is no recycle bin for exploratory sessions. Unlike test cases, a deleted session has no restore path: it and all of its log entries are gone.", + "Permission depends on the session's state, and the handler reads the session before deciding: a closed session needs the closed-test-run delete permission, an active one the ordinary test-run delete permission. The refusal is 403 {success:false, message:\"You don't have permission to delete this exploratory session\"} and nothing is deleted.", + "Because the session is fetched first, an unknown session_id fails as a bare {success:false} 404 with no message.", + "Delete the logs individually first only if you want them removed selectively — deleting the session needs no cleanup of its children.", + "The response is only {success:true}; there is no echo of what was removed, so capture anything you need from the session before calling this." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_exploratory_session_log", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", + "mode": "destructive", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + }, + { + "name": "log_id", + "type": "integer", + "required": true, + "example": 789 + } + ], + "intent": "Permanently remove one observation from an exploratory session, discarding its note, outcome, elapsed time and defect links", + "responses": { + "200": { + "description": "Session log deleted successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Both ids are integers and both must match: log_id is only addressable under the session_id that owns it.", + "There is no bin or restore for session logs — the entry, its embedded screenshots' links and its defect links are gone.", + "This removes one log only. It does not touch the issues linked at session level, so a defect raised here can still appear in the session's defects listing through the session-level link.", + "The session's session_log_count drops, which can move a session back below the 50-log threshold that pushes a clone onto the asynchronous path.", + "The response is only {success:true}. An unknown log id surfaces as a bare {success:false} 404 with no message.", + "Deleting a log needs the test-run delete permission — the same permission as deleting a whole session, with no separate closed-session variant at log level." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_filter", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "destructive", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete a saved filter view from a project permanently", + "responses": { + "200": { + "$response": "FilterDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Removes the saved view only. The test cases, runs or plans it selected are untouched — this deletes a query, not data.", + "There is no undo and no bin for filter views, and no version history to restore from. Recreating one means re-authoring the whole condition set, so read the filter first if you might want it back.", + "A project-level view is deleted for everyone on the project, not just for you, and the filter controller is not RBAC-gated — any user with product access to the project can delete a view someone else created.", + "The success body is `{success:true}` and nothing else: no id echo, no copy of what was deleted, nothing to carry forward.", + "Deleting an id that does not exist comes back as 400 `{success:false, message:...}` rather than 404 — only the backing service's 400 is translated here, and its other failures surface as a 500." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_folder_and_contents", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm", + "mode": "destructive", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Permanently delete a folder together with everything beneath it — every nested subfolder and every test case in the whole subtree", + "returns": [ + "data", + "success", + "folder", + "path_folders", + "id", + "name", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "description": "Folder and its contents deleted successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder" + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is recursive and total. It removes the folder, all of its descendant folders, and every test case in that entire subtree — not just the cases filed directly in it. There is no option to keep the contents or to delete the folder only.", + "Run the folder remove-summary dry run first. It is the only way to see, before committing, how many active test cases, archived test cases, subfolders and low-code test recordings this call will destroy.", + "Recoverability depends on a group feature flag you cannot read from this API. When Move to Bin is enabled for the group, the folder's TEST CASES land in the project recycle bin and can be listed and restored from there until they auto-purge. When it is not enabled, the test cases are deleted outright.", + "The FOLDERS themselves are never recoverable, under either setting. Nothing in this API restores a deleted folder — the bin holds test cases only, and restored cases do not bring their folder tree back with them. Treat the folder structure as gone and be ready to recreate it.", + "Do not plan on the undo-remove endpoint as your way back. It is a dead alias that re-issues this same delete; it does not restore anything (see the flag on delete_folder_and_subtree).", + "Linked low-code test recordings belong to a different product and are destroyed too. The summary's has_other_product_entity flag is true exactly when the subtree contains such recordings — check it before deleting if anyone else depends on them.", + "Requires the folder-delete permission (403 otherwise), which is a higher bar than the edit permission most other folder writes need.", + "On success the deleted folder is echoed at `data.folder` with cases_count and total_cases_count forced to 0 — those zeros are cosmetic, not a report of what was removed. `data.path_folders` is the surviving breadcrumb of the deleted folder's parent, useful for re-pointing a UI.", + "Failure does not use the usual envelope: an upstream rejection is relayed as the raw upstream body with the upstream status code, so do not expect {success:false, message} to be there to parse.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." + ], + "disabled": true + }, + { + "name": "delete_report_schedule", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/reports/schedules/{schedule_id}", + "mode": "destructive", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "schedule_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[query]", + "type": "string", + "description": "Only filters the listing returned AFTER the delete (matches report type, title, or owner name) — it does not scope what is deleted." + }, + { + "name": "q[scheduled]", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Only the exact string `true` does anything (restricts the returned listing to scheduled reports). `false` or any other value is ignored and returns everything." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the listing returned after the delete. Fixed 30 per page; past the last page returns 400." + } + ], + "intent": "Permanently delete a report — the whole saved report and its filters, not just its email schedule", + "returns": [ + "success", + "data", + "scheduled_report_count", + "total_report_count", + "info" + ], + "responses": { + "200": { + "$response": "DeleteScheduleResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This deletes the report record itself. Despite the path, there is no way to remove only the schedule: to stop the emails but keep the report, edit it and omit `frequency` / `frequency_details` instead.", + "The delete is unconditional and silent. A `schedule_id` that does not exist, or belongs to another group, still returns 200 with `success: true` — there is no 404 and no count of what was removed. Confirm by checking that the returned `data` list no longer contains the id.", + "The report is matched on group + id only; the project in the path does not scope it, so an id belonging to a sibling project in the same group IS deleted.", + "The response body is not the deleted report — it is the CURRENT page of the caller's remaining reports, plus `scheduled_report_count` and `total_report_count`. Treat it as a fresh listing.", + "That embedded listing is paged at a fixed 30 per page, honours `p`, and `p` past the last page returns 400 `page_out_of_bounds`. Do not page it by re-issuing the delete — call list_schedules instead.", + "Deleting also removes the report's filter rows and mail recipients and propagates a delete to Test Observability; there is no undo and no restore endpoint. Requires the report_delete permission (403 otherwise)." + ], + "disabled": true + }, + { + "name": "delete_shared_step", + "method": "DELETE", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "destructive", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete a shared step from a project permanently — every test case that embeds the block loses it, in one operation", + "responses": { + "200": { + "$response": "SharedStepDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the widest single delete in the shared-step surface. A shared step is embedded by reference by many test cases at once, so removing it takes that block out of all of them together. The blast radius is not visible from the id — read the shared step, or find its row in the shared-step listing, and look at `test_case_count`. That number is exactly how many test cases this affects; anything above zero means you are editing that many cases, not deleting an unused fragment.", + "The delete is not blocked by references. A block used by hundreds of cases deletes as readily as an unused one, and nothing warns you.", + "There is no undo: shared steps have no bin, no archive and no version history. Recreating a block with the same title and steps does NOT re-link the test cases that referenced the old one — their references are gone and each case has to be re-edited by hand. Capture the block's steps (and, if it matters, which cases used it) before deleting.", + "Deleting the block is not the way to stop one test case using it — edit that test case's steps instead. This call is for retiring a fragment project-wide.", + "Success is 200 with `{success:true}`, not 204 with an empty body. Do not treat a body-less 204 as the success signal.", + "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}` before any call is made — and the route segment only matches digits, so a non-numeric id never reaches the handler at all.", + "Requires tc_write, the same permission as editing a test case; there is no separate shared-step delete permission, so anyone who can edit cases can do this." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_template", + "method": "DELETE", + "path": "/api/v2/templates/{id}", + "mode": "destructive", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id — a plain integer with no prefix, discovered from the template list.", + "pattern": "^[0-9]+$" + } + ], + "intent": "Permanently delete a template together with its field and project links — there is no bin and no undo; disable it instead if you might want it back.", + "guidance": [ + "This removes the template record itself, along with its field mappings and its project mappings. Test Management exposes no restore for a deleted template.", + "If the goal is only to stop people picking the template for new test cases, DISABLE it instead — disabling is reversible and keeps the fields and project links intact.", + "System templates and the workspace default template cannot be deleted; the attempt comes back as a 400 with the reason.", + "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is deleted — check the flag, not the status code.", + "Success returns only success:true, with no record of what was removed. Read the template first if you need its name or field list for the record." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TemplateWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "delete_test_case_attachment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments/{attachment_id}/delete", + "mode": "destructive", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "attachment_id", + "type": "string", + "required": true + } + ], + "intent": "Remove one file from a test case and purge the stored blob — takes a screenshot, log or document off the case for good", + "returns": [ + "data", + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "$response": "TestCaseAttachmentDeleteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "LIKELY BROKEN, and dangerous to retry. It matches the attachment by comparing a stored integer id against the id from the path, which arrives as text, so the match fails and the next line dereferences nothing — a bare 500. Because the purge is queued before the response, treat a 500 here as an UNKNOWN outcome rather than a failure, and re-read the case before assuming nothing was removed.", + "The response is wrapped in `data`: read data.attachments for the removed record and data.success. This action wraps; the attachment list and upload on the same path do not. Reading a top-level `attachments` finds nothing.", + "Irreversible. The link is removed AND the underlying stored file is scheduled for purge, so the file itself goes, not just the association. There is no undo and no recycle bin for attachments.", + "`attachment_id` must be an id from this case's own attachment listing. The handler looks it up inside the case's in-memory attachment list and does not return a clean 404 when it is absent, so verify the id with a list read first rather than probing.", + "`folder_id` is in the route but is NOT used to find the case — the lookup is by project and test case id, so a stale folder id after a move still resolves.", + "Note the shape: a POST to .../delete, not a DELETE.", + "The v2 attachment surface is the maintained one for listing and reading attachments; use this only when you specifically need to remove a v1 test-case attachment." + ], + "disabled": true + }, + { + "name": "delete_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", + "mode": "destructive", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Permanently delete a test plan or sub-plan — there is no recycle bin and no undo; archive it instead if there is any chance it is wanted back", + "responses": { + "200": { + "description": "Plan deleted: `{success: true}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No such plan: `{success: false, error: \"Test Plan not found\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. Plans have no bin and no version history, so a deleted plan cannot be restored by any capability here — the reversible option is bulk-archive, which keeps the plan and can be undone with bulk-retrieve.", + "The path segment is the plan's INTEGER id. A TP-NNN / STP-NNN identifier does not match the route and never reaches the handler.", + "The only miss signal is 404 {success: false, error: 'Test Plan not found'}; a success is a bare {success: true} with no echo of what was removed. Read the plan first if you need to tell the user what is about to go.", + "Needs the test-plan DELETE permission, which is separate from the write permission used by create/update.", + "Deleting emits an activity-feed event and a personal alert to the plan's owner, so the side effect is visible outside this API.", + "What happens to the plan's sub-plans and to the test runs it held is decided upstream and is not echoed in the response — read the sub-plans and linked runs first if that matters." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_test_result", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-results/{test_result_id}/delete", + "mode": "destructive", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_result_id", + "type": "integer", + "required": true + } + ], + "intent": "Delete one logged test result by its id — removes that single execution record while leaving the case's other attempts in the run intact", + "responses": { + "200": { + "description": "Result deleted.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Caller is neither the result's author nor a project admin." + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Authorship-gated: only the user who logged the result may delete it, unless the caller is a project admin. Anyone else gets 403 {success:false, message:\"You can delete only results logged by you, unless you're a project admin\"} and nothing is removed.", + "Irreversible — there is no recycle bin for results. After the delete the case's outcome in that run rolls back to its previous attempt, or to unexecuted if this was the only result.", + "Note the shape: a POST to .../delete, not a DELETE, and `test_result_id` must be numeric — the route only matches digits, so a TR-/TC-style identifier 404s at routing.", + "To remove several results use the bulk delete instead: it authorises per id and reports what it skipped, whereas this one refuses the entire call on the first authorisation failure.", + "Success is only {success: true} — the deleted record is not echoed back, so capture anything you need from it first." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "delete_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/delete", + "mode": "destructive", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "intent": "Permanently delete a test run and every result recorded in it — there is no bin and no restore", + "returns": [ + "data", + "success", + "testrun", + "project", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "project_id", + "created_at", + "updated_at", + "links" + ], + "responses": { + "200": { + "$response": "TestRunWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Irreversible. A test run has no version history and no recycle bin: the run, its case mappings and every result recorded against them go, and nothing in this API brings them back. Confirm with the user before calling.", + "Deleting a CLOSED run needs a different permission (closed-run delete) from deleting an open one — a role that can delete working runs can be 403'd on a finished one. Automation runs demand the automation variant again.", + "The run's build in Test Observability is archived FIRST, and a rejection there aborts the delete, so a failure can leave the run intact. Re-read before assuming it is gone.", + "The run is also stripped out of every saved report filter that referenced it; those reports silently narrow rather than erroring.", + "The deleted run is echoed back under `data.testrun` — one word, no underscore — with `data.project`. Do NOT read `data.test_run` here; that key does not exist on this response.", + "The path accepts either the TR-NNN identifier or the integer id, but the body key and every downstream reference wants the integer from `uuid`." + ], + "disabled": true + }, + { + "name": "discard_duplicate", + "method": "PUT", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", + "mode": "write", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id, from the `id` of a listing row. Here it is a path segment — unlike the resolve call, which takes it in the body." + } + ], + "body": [ + { + "name": "discard_reason", + "type": "string", + "description": "Short reason code or label for why this is not a duplicate. Flat in the body (no wrapper object). Optional and unvalidated — no closed set is enforced, and omitting it stores null." + }, + { + "name": "user_feedback", + "type": "string", + "description": "Free-text feedback. Flat in the body. Stored under a different name (`discard_feedback`) than the one you send, so do not expect it echoed back under this key." + } + ], + "intent": "Dismiss a duplicate suggestion as wrong — removes it from the review queue and feeds the reason back to the model, without touching either test case", + "responses": { + "200": { + "description": "Duplicate discarded. Body is {success:true} only.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "The caller lacks the test-case write permission (tc_write)." + }, + "404": { + "description": "{success:false, error:'Duplicate not found.'} — no such pair for this project, or it is no longer in `suggested` status." + }, + "409": { + "description": "{success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} — a dedupe data migration is in progress. Nothing was discarded; retry later." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the 'not a duplicate' verdict. Neither test case is touched — nothing is archived, merged, commented on or tagged. Use the resolve call when a case should actually go away.", + "The body is flat: `discard_reason` and `user_feedback` sit at the top level, with no wrapper object and no request-schema check, so a misspelled key is silently dropped and you get 200 with the feedback lost.", + "One-way. The pair moves to `discarded` and leaves every listing; there is no un-discard endpoint and the pair can no longer be read, so confirm the verdict before calling.", + "Suggested-only: a pair already merged, archived or discarded is 404 {success:false, error:'Duplicate not found.'} — usually 'already handled' rather than 'never existed'.", + "Undeclared until now, and retryable: while a dedupe data migration is in progress this is refused with 409 {success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. Nothing was discarded; retry later.", + "Note the envelope differs from the resolve call, which uses `errors` for its failures where this one uses `error`.", + "Needs the test-case write permission (tc_write) — lighter than the delete permission the resolve call demands." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "download_report", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/download", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv", + "pdf", + "xlsx" + ], + "example": "csv", + "description": "Export format, as a single STRING. Anything outside the three values is 422 `Invalid file_type.`. Note the same-named field on send-email-now is an ARRAY." + }, + { + "name": "required", + "type": "object", + "description": "Optional column selection, honoured only for `Test Run Detailed Report` and `Test Plan Detailed Report`; ignored elsewhere. Shape `{ \"\": { \"fields\": [\"id\",\"title\",...] } }`; `fields` may also be a comma-separated string, and `[\"all\"]` expands to every supported column. Invalid names are NOT rejected here — the export silently reverts to defaults." + }, + { + "name": "columns", + "type": "object", + "description": "Alias for `required`, and it WINS when both are sent. Same shape and same silent-fallback behaviour." + } + ], + "intent": "Kick off an async export of a report to CSV, PDF or XLSX — returns a job channel, not the file", + "returns": [ + "channel", + "success" + ], + "responses": { + "200": { + "$response": "DownloadReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This returns immediately with `{ channel: , success: true }` and nothing else. The export runs in a background worker and the finished file's URL is pushed over a realtime channel named from that uuid — there is NO endpoint to poll it and no way to fetch the artefact through the API. If you need the file itself, use the send-email-now capability and have it mailed, or hand the channel to a UI client.", + "No bytes come back from this call and no download URL appears anywhere in the response — a `channel` that you cannot resolve is the whole result, so do not report the export as delivered.", + "`file_type` is a single STRING here (`csv` / `pdf` / `xlsx`). The same-named field on send-email-now is an ARRAY — they are not interchangeable. An unsupported value is 422 `Invalid file_type.`, not 400.", + "Column selection (`required`, or its alias `columns`, which wins when both are sent) is only honoured for `Test Run Detailed Report` and `Test Plan Detailed Report`. On every other report type it is ignored. Shape is `{ \"\": { \"fields\": [...] } }` with entities test_run_test_case / test_run / test_result (and test_plan for the plan report).", + "Unlike the on-screen read, this path does NOT validate your column list: a misspelled field name is not an error here — the worker silently falls back to the report's default columns, so the export arrives missing what you asked for. Verify field names against the report read first.", + "`fields: [\"all\"]` for an entity expands to every supported column for that entity.", + "The report is matched on group + id, so the project in the path does not scope the lookup; an unknown id is 404 `Report not found.`. A `Test Plan Detailed Report` on a group without the feature flag is 403 `Operation not permitted`. Requires the report_view permission." + ], + "disabled": true + }, + { + "name": "edit_test_case_partial", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit-v2", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append — any currently-attached id missing from the array is detached. Read the case's existing attachments first and send them back alongside the new ids.", + "json_path": "/test_case/attachments" + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, given as an internal_name string (e.g. `not_automated`, `automated`). Applied only when `automation_state` is omitted.", + "json_path": "/test_case/automation_status" + }, + { + "name": "case_type", + "type": "integer", + "example": 490, + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "123": "option_value", + "456": [ + "multi", + "select" + ] + }, + "json_path": "/test_case/custom_fields", + "description": "Map of custom-field id to value, applied only when the key is present AND non-empty — `{}` is a no-op here (it is the BULK edit that 500s when the key is missing). Values replace the field's current value." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description" + }, + { + "name": "estimate", + "type": "string", + "description": "ACCEPTED BUT NOT PERSISTED — passes validation and is then dropped before the write, on this path as well as create. Use `estimated_duration_seconds`; never report an estimate as saved.", + "json_path": "/test_case/estimate" + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS; `null` clears it. This is the field that actually stores an estimate.", + "json_path": "/test_case/estimated_duration_seconds" + }, + { + "name": "expected_result", + "type": "string", + "description": "Overall expected outcome, distinct from a step's own `result`.", + "json_path": "/test_case/expected_result" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Must be `true` for a `test_case_dataset` change to be applied; with any other value the dataset payload is ignored.", + "json_path": "/test_case/is_dataset_modified" + }, + { + "name": "issues", + "type": "array", + "example": [ + { + "issue_id": "TM-1234", + "issue_type": "jira" + } + ], + "description": "Linked issues/defects. Processed ASYNCHRONOUSLY after the response returns, so a 200 does not mean the links exist yet — re-read the case to confirm.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "name", + "type": "string", + "example": "Verify login functionality", + "description": "The name/title of the test case.", + "json_path": "/test_case/name" + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_case/owner", + "description": "TM user id. Unlike the POST edit path, an id that does not resolve is silently skipped rather than rejected, so the owner is left unchanged with a 200." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "example": 483, + "json_path": "/test_case/priority", + "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state, e.g. `pending` / `in_review` / `approved`. Unlike POST .../edit, omitting it here leaves the stored value untouched.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled on the project — otherwise silently ignored. Including the owner is rejected with 422 'Owner cannot be a reviewer'. This path does NOT run the send-for-review checks or send review emails.", + "json_path": "/test_case/reviewers" + }, + { + "name": "status", + "type": "integer", + "example": 496, + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "steps", + "type": "string", + "description": "LEGACY — use `test_case_steps` instead. This param skips the request allowlist and is read with JSON.parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200.", + "json_path": "/test_case/steps" + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "smoke" + ], + "description": "Tag names. `[]` removes all tags; omit the field to keep the existing ones.", + "json_path": "/test_case/tags" + }, + { + "name": "template", + "type": "string", + "description": "Template NAME (the same values the create paths accept) — not an id. Sending an integer is rejected with 422 \"Invalid template value \"; use `template_id` for the numeric custom-template reference.", + "json_path": "/test_case/template" + }, + { + "name": "template_id", + "type": "integer", + "description": "Custom-template id.", + "json_path": "/test_case/template_id" + }, + { + "name": "template_step_type", + "type": "string", + "description": "Takes precedence over `template` when both are sent.", + "json_path": "/test_case/template_step_type" + }, + { + "name": "test_case_dataset", + "type": "object", + "description": "Dataset binding for a data-driven case, as an OBJECT — the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, read it and send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as returned. It is a full replace of the case's binding, and on this path it is applied only when `is_dataset_modified` is true. The edit answers only {success:true}, so re-read the case (or the dataset's linked-test-cases listing) to confirm the binding landed.", + "fields": [ + { + "name": "variables", + "type": "array" + }, + { + "name": "rows", + "type": "array" + } + ], + "json_path": "/test_case/test_case_dataset" + }, + { + "name": "test_case_folder_id", + "type": "string", + "description": "Move the case to a different folder (integer id). Dropped server-side when it matches the current folder, so passing the existing folder is a safe no-op.", + "json_path": "/test_case/test_case_folder_id" + }, + { + "name": "test_case_steps", + "type": "array", + "example": [ + { + "order": 1, + "step": "Navigate to the login page", + "result": "The login page is displayed" + }, + { + "order": 2, + "step": "Submit valid credentials", + "result": "The user lands on the dashboard" + } + ], + "description": "The structured step list — same shape as the create body. `order` is filled in by position when omitted. `[]` removes all steps; omit the field to keep them.", + "fields": [ + { + "name": "order", + "type": "integer" + }, + { + "name": "step", + "type": "string" + }, + { + "name": "result", + "type": "string" + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps" + }, + { + "name": "third_party_identifier", + "type": "string", + "json_path": "/test_case/third_party_identifier", + "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." + }, + { + "name": "isMergedDuplicate", + "type": "boolean", + "json_path": "/test_case/isMergedDuplicate", + "description": "Compared to the literal boolean `true`. Tells the backend to keep the case's existing attachments rather than reconciling them against `attachments`. Used by the duplicate-merge flow." + } + ], + "intent": "Change just the fields you name on one test case, leaving everything you omit untouched — the safe path for an inline single-field edit.", + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TestCaseWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "The response is `{success: true}` and NOTHING ELSE. It does not return the updated case, so re-read the case if you need to confirm or show the new values. (The POST .../edit sibling does return `data.test_case`.)", + "Only the keys present in `test_case` are applied — this is the partial-update path. The POST .../edit on the same case rebuilds more of the record, including resetting `review_status`.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "`steps` is legacy and is read with a JSON parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200. Use `test_case_steps`.", + "`attachments` is the complete set the case should end up with, not an append — anything currently attached and missing from the array is detached. `test_case_steps: []` and `tags: []` likewise clear; omit the key to keep what is there.", + "A workflow that forbids the status transition comes back as 422 with `code: \"transition_not_allowed\"` and a `violations` list — that is a rule rejection, not bad input.", + "This path does not accept `send_for_review` or `shared_precondition_id`, and does not run the review-payload checks; use the POST .../edit path for a send-for-review flow.", + "Every successful edit writes a new version, so an edit is recoverable by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "edit_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "attachments", + "type": "array", + "json_path": "/test_case/attachments", + "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append — anything currently attached and missing from the array is detached. Read the case's existing attachments first." + }, + { + "name": "automation_state", + "type": "integer", + "json_path": "/test_case/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Legacy alias for `automation_state`, taken as an internal_name string (e.g. `not_automated`, `automated`). Only applied when `automation_state` is omitted. Prefer `automation_state`.", + "json_path": "/test_case/automation_status" + }, + { + "name": "case_type", + "type": "integer", + "json_path": "/test_case/case_type", + "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "custom_fields", + "type": "object", + "json_path": "/test_case/custom_fields", + "description": "Map of custom-field id to value. Values replace the field's current value; a field id the project does not define is rejected by the backend with a field-named 400 message." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description" + }, + { + "name": "estimate", + "type": "string", + "description": "ACCEPTED BUT NOT PERSISTED — the field passes request validation and is then dropped before the write on both the create and the edit paths. Use `estimated_duration_seconds` instead; do not report an estimate as saved.", + "json_path": "/test_case/estimate" + }, + { + "name": "estimated_duration_seconds", + "type": "integer", + "description": "Per-case estimated execution time in SECONDS. `null` clears it. This is the field that actually persists an estimate.", + "json_path": "/test_case/estimated_duration_seconds" + }, + { + "name": "expected_result", + "type": "string", + "description": "Overall expected outcome (distinct from a step's own `result`). Rich-text field — send plain text/HTML, not a JSON document.", + "json_path": "/test_case/expected_result" + }, + { + "name": "is_dataset_modified", + "type": "boolean", + "description": "Set with `test_case_dataset` on an edit to signal the dataset changed; on create the mappings are always regenerated and this is ignored.", + "json_path": "/test_case/is_dataset_modified" + }, + { + "name": "issues", + "type": "array", + "json_path": "/test_case/issues", + "description": "Linked issues as `[{issue_id, issue_type, metadata?}]` (bare strings are coerced to ``issue_id``). Processed ASYNCHRONOUSLY after the response, so a 200 does not mean the links exist yet." + }, + { + "name": "name", + "type": "string", + "json_path": "/test_case/name" + }, + { + "name": "owner", + "type": "integer", + "json_path": "/test_case/owner", + "description": "TM user id. An id that is not a member of the group is silently replaced with the caller rather than rejected." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions" + }, + { + "name": "priority", + "type": "integer", + "json_path": "/test_case/priority", + "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." + }, + { + "name": "review_status", + "type": "string", + "description": "Review state, e.g. `pending` / `in_review` / `approved`. When omitted it is set from the project's review-approve setting, falling back to `pending` — it is never left untouched on create or on POST .../edit.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer TM user ids (emails also resolve). Only honoured when the workspace is on a Team-Pro plan AND the project has review-approve enabled; otherwise silently ignored. Including the `owner` is rejected with 422 \"Owner cannot be a reviewer\".", + "json_path": "/test_case/reviewers" + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "EDIT PATHS ONLY — drives the per-reviewer review-request email. Dropped without error on create (the create flow already notifies from `reviewers` + `review_status`) and not accepted by PATCH .../edit-v2.", + "json_path": "/test_case/send_for_review" + }, + { + "name": "shared_precondition_id", + "type": "integer", + "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body.", + "json_path": "/test_case/shared_precondition_id" + }, + { + "name": "status", + "type": "integer", + "json_path": "/test_case/status", + "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." + }, + { + "name": "tags", + "type": "array", + "json_path": "/test_case/tags" + }, + { + "name": "template", + "type": "string", + "json_path": "/test_case/template", + "description": "Step-shape template NAME, not an id. When neither this nor `template_step_type` is sent it DEFAULTS to `test_case_text` on this path — which silently discards `test_case_steps`. Always send it on an edit that touches steps." + }, + { + "name": "template_id", + "type": "integer", + "json_path": "/test_case/template_id" + }, + { + "name": "template_step_type", + "type": "string", + "description": "Step shape — `test_case_steps` | `test_case_text` | `test_case_bdd`. OPTIONAL; when sending `template_id`, use that template's own `step_type` from the templates listing rather than assuming.", + "json_path": "/test_case/template_step_type" + }, + { + "name": "test_case_dataset", + "type": "object", + "description": "Dataset binding for a data-driven case, as an OBJECT — the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as the dataset read returns them. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here; on an edit, send `is_dataset_modified: true` alongside it.", + "fields": [ + { + "name": "variables", + "type": "array" + }, + { + "name": "rows", + "type": "array" + } + ], + "json_path": "/test_case/test_case_dataset" + }, + { + "name": "test_case_folder_id", + "type": "string", + "description": "Target folder's integer id. On create this is REQUIRED IN THE BODY as well as in the path — omitting it is the most common create failure (422 wrapping an upstream 404). Integer or string are equivalent; the server coerces with .to_i, so the distinction does not matter.", + "json_path": "/test_case/test_case_folder_id" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "The ONLY key the server reads for steps — on single create, bulk create and the update paths alike. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is silently ignored and the create still returns 200 for a case with NO steps, so verify against `test_case_steps` in the response.", + "json_path": "/test_case/test_case_steps" + }, + { + "name": "steps", + "type": "string", + "json_path": "/test_case/steps", + "description": "LEGACY — use `test_case_steps`. Read with a JSON parse, so it must be a JSON-encoded STRING. A real JSON array parses to `[]` and WIPES the case's steps while still returning 200." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "description": "One-shot re-authentication token, required only when the project has e-signature on send-for-review enabled AND this edit sets `send_for_review` or `review_status: in_review`. Missing or expired is 401 with `error_code: \"reauth_required\"`." + }, + { + "name": "third_party_identifier", + "type": "string", + "json_path": "/test_case/third_party_identifier", + "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." + }, + { + "name": "isMergedDuplicate", + "type": "boolean", + "json_path": "/test_case/isMergedDuplicate", + "description": "Compared to the literal boolean `true`. Keeps the case's existing attachments instead of reconciling them against `attachments`. Used by the duplicate-merge flow." + } + ], + "intent": "Edit one test case through the full edit form — rename it, rewrite its steps, preconditions and expected result, retag it, or send it for review.", + "returns": [ + "success", + "data", + "test_case" + ], + "responses": { + "200": { + "$response": "V1TestCaseWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", + "This is the FULL edit path, not a patch: `review_status` is recomputed on every call (omitted, it falls back to the project's review-approve default), and `template` defaults to `test_case_text` when you omit it, which discards any `test_case_steps` you sent. Send the case's current template unless you mean to change it.", + "For a targeted single-field change prefer the PATCH .../edit-v2 sibling, which applies only the keys you send.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "Array fields REPLACE rather than append: `attachments`, `tags` and `test_case_steps` are each the complete intended set, and `[]` clears. Read the case first and send the union.", + "Reviewers and the owner cannot overlap: this path rejects it up front with 400 `code: \"OWNER_AS_REVIEWER\"`, and sending `send_for_review` with no reviewers is 400 `code: \"NO_REVIEWERS\"`. (Create, which skips that check, surfaces the same conflict as a 422.)", + "If the project has e-signature enabled, an edit that sets `send_for_review` or `review_status: in_review` needs `test_case.reauth_token`; without it the answer is 401 `{success:false, error_code:\"reauth_required\"}`.", + "The updated case comes back at `data.test_case` — note the `data` envelope, and note that `estimate` always serialises as null regardless of what you sent.", + "Every successful edit writes a new version, so this is recoverable by restoring the previous version — subject to the workspace's plan, which may only keep 30 days of history." + ] + }, + { + "name": "edit_test_run", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "body": [ + { + "name": "run_state", + "type": "string", + "required": true, + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "description": "MANDATORY on every edit, even one that is not changing the state — resend the run's current value. It is validated against the enum before anything else, so a missing or misspelled value returns 400 {success:false, message:'invalid data'} with no hint about which field was wrong. `closed` is one-way: a closed run cannot be reopened, and this call does not do it. The full edit: on a closed run it returns success and leaves the run closed.", + "json_path": "/test_run/run_state" + }, + { + "name": "name", + "type": "string", + "example": "Regression - Login", + "description": "Only applied when non-empty. Sending \"\" leaves the existing name rather than clearing it.", + "json_path": "/test_run/name" + }, + { + "name": "description", + "type": "string", + "example": "", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent. Unlike name, an empty string here DOES clear the description.", + "json_path": "/test_run/description" + }, + { + "name": "owner", + "type": "integer", + "example": 2, + "description": "Run owner as a TM user id (a BrowserStack user id is also accepted and resolved). An id that matches nobody in the group is silently treated as unassigned — the run's owner is cleared rather than the call failing.", + "json_path": "/test_run/owner" + }, + { + "name": "is_dynamic", + "type": "boolean", + "example": false, + "description": "Keep the run's membership live against `filters`. On EDIT a TOP-LEVEL `is_dynamic` is also read and OVERRIDES this one; on create only the nested form exists. Send it in one place only. Omit the key entirely to leave the run's current dynamic flag alone — sending false on a full resend flips a dynamic run back to static.", + "json_path": "/test_run/is_dynamic" + }, + { + "name": "configurations", + "type": "array", + "example": [], + "description": "Configuration ids (integers) the run executes against — not the configuration objects returned on read.", + "json_path": "/test_run/configurations" + }, + { + "name": "test_plans", + "type": "array", + "example": [], + "description": "Test plan ids. Only the FIRST entry is used; a run belongs to at most one plan. Send the plan's integer id.", + "json_path": "/test_run/test_plans" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login" + ], + "description": "Plain tag strings. The list you send REPLACES the run's tags — omit the key to leave them alone, since there is no history to restore a cleared list from.", + "json_path": "/test_run/tags" + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "description": "Linked defects. Each entry is an object; a bare string is coerced to {issue_id: ...} and loses the tracker type. Linking kicks off an asynchronous fetch of the ticket metadata, so titles appear on a later read.", + "json_path": "/test_run/issues" + }, + { + "name": "environment", + "type": "string", + "json_path": "/test_run/environment" + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment blob ids to attach to the run. Sending [] to remove every attachment only works where the clear-all behaviour is enabled for the group; elsewhere an empty array is a no-op. An id that does not verify fails the whole edit with 422 'attachment is invalid'.", + "json_path": "/test_run/attachments" + }, + { + "name": "metadata", + "type": "object", + "description": "Free-form metadata. Only forwarded when non-empty; sending {} does not clear it.", + "json_path": "/test_run/metadata" + }, + { + "name": "build_group_id", + "type": "integer", + "description": "Only forwarded when present; there is no way to unset it through this call.", + "json_path": "/test_run/build_group_id" + }, + { + "name": "filters", + "type": "object", + "example": { + "priority": [ + "High" + ], + "folder_ids": [ + 105 + ] + }, + "fields": [ + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "automation_status", + "type": "array" + }, + { + "name": "automation_state", + "type": "array" + }, + { + "name": "folder_ids", + "type": "array" + }, + { + "name": "issue_ids", + "type": "array" + }, + { + "name": "reviewers", + "type": "array" + }, + { + "name": "review_status", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body — NOT inside `test_run`. It selects nothing and never back-fills, so an edit whose only case-bearing key is `filters` returns 200 having added none. To add cases use bulk_update_test_run_test_cases with add_test_cases — test_case_ids and selection here replace the run's membership rather than adding to it. Every `owner`/`reviewers` entry must resolve to a user in this group or the edit 400s. Labels are resolved to ids for you." + }, + { + "name": "dynamic_filter", + "type": "object", + "example": { + "owner": [], + "status": [], + "priority": [] + }, + "description": "Backward-compatible alias of `filters`, same top-level position and same semantics. `filters` wins when both are sent." + }, + { + "name": "test_case_ids", + "type": "array", + "example": [ + 2336 + ], + "description": "Test case ids — but NOT added to the run: together with `selection` this becomes the run's COMPLETE case list, so any case already in the run and missing from what you send is REMOVED, along with its results and assignee, and the call still succeeds. To add cases, use bulk_update_test_run_test_cases with add_test_cases, which leaves existing cases untouched. Top level, not inside `test_run`. Must be an array — a scalar is rejected." + }, + { + "name": "auto_assign", + "type": "boolean", + "description": "Top level. Forwarded to the backing service to auto-assign the newly added cases." + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cases shared in from other projects, as a map of project id to a selection object with the same inner shape as `selection`. Top level." + }, + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Make every case in the project the run's case list. Like the rest of the selection this is not additive — it replaces the run's membership, so it only adds cases in the sense that the whole project ends up in the run. To add specific cases, use bulk_update_test_run_test_cases with add_test_cases.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 83, + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "trtc_configuration_mappings", + "type": "array", + "description": "Per-configuration case mappings, at the TOP level of the body. An ARRAY of objects.", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + }, + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "linked_test_cases", + "type": "array" + }, + { + "name": "unlinked_test_cases", + "type": "array" + } + ] + } + ], + "intent": "Rewrite a test run through the full edit form — rename it, move its state on, change owner, tags, configurations or linked plan. NOT the way to add cases: the case selection you send becomes the run's entire membership, so add cases with bulk_update_test_run_test_cases (add_test_cases) instead", + "returns": [ + "data", + "success", + "testrun", + "project", + "skipped_test_case_ids", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "attachments", + "metadata", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "project_id", + "created_at", + "updated_at", + "created_by", + "updated_by", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunWriteResponse__EditTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send every field flat, including the case-selection keys. Each field's json_path decides where it lands — the run's own fields go inside a `test_run` object and the selection keys (test_case_ids, selection, shared_selection, filters, dynamic_filter, trtc_configuration_mappings, auto_assign, is_dynamic) stay beside it at the top level. Building either level yourself is rejected before the call as an unknown body key.", + "run_state is mandatory on every call, including edits that only rename the run. It is validated first, so a bare 400 'invalid data' almost always means a missing or misspelled run_state — not a bad selection.", + "NOT ADDITIVE: the case selection you send (test_case_ids + selection) REPLACES the run's membership, and run fields you omit — the owner among them — are cleared. The call still returns success. ALWAYS call get_test_run_edit_selection first and resend what it returns — `selection`, `shared_selection`, `trtc_configuration_mappings` and every run field — changing only what you mean to change, after checking the stored selection still matches the run's current cases (it is not updated by the add-only and remove calls, so a stale one re-adds removed cases); that read-then-resend is how the product UI edits a run without losing anything. To only add cases, use bulk_update_test_run_test_cases with add_test_cases instead, which adds without disturbing existing cases, their row ids or assignees. Results on removed cases cannot be restored.", + "There is no undo. A test run keeps no version history, so an overwritten name, a replaced tag list or a cleared description cannot be restored — confirm destructive-looking edits with the user first.", + "Changing which cases the run covers is queued: the call returns before the new cases are attached, and an immediate re-read of the run's cases can be short. Re-read rather than retrying, which can double-add.", + "`filters` is a sync rule, not a selection. An edit carrying only `filters` returns 200 and adds nothing, while quietly marking the run dynamic if you also sent is_dynamic.", + "is_dynamic is read from BOTH the top level and inside `test_run`, and the top-level one wins on this endpoint. Pick one place. Omitting it entirely is the only way to leave the run's current mode untouched.", + "An owner id that matches nobody in the group unassigns the run instead of erroring — but an unresolvable user inside `filters.owner` or `filters.reviewers` hard-fails the whole edit with 400 'invalid data'. The two behave oppositely.", + "The updated run comes back under `data.testrun` — one word, no underscore — with `data.project`, plus a TOP-LEVEL `skipped_test_case_ids` array listing cases the review gate refused to add. Check it: those cases are silently missing from the run.", + "Backing-service client errors are returned as `{data: {success: false, error: }}` with the 4xx status, so read `data.success` before reading `data.testrun`.", + "Needs the test-run write permission, with the automation variant demanded on automation runs." + ] + }, + { + "name": "get_test_run_edit_selection", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit/selection", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Either the TR-NNN identifier or the bare integer — the v1 run routes accept both forms in the path." + } + ], + "intent": "Read a test run's stored case selection and editable fields — the exact state to resend when editing the run through the full edit form, so the edit does not drop cases, configurations or the owner", + "returns": [ + "data", + "success", + "selection", + "shared_selection", + "test_run", + "dynamic_filter", + "filters", + "trtc_configuration_mappings" + ], + "responses": { + "200": { + "$response": "TestRunEditSelectionResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read this before every edit_test_run call. The full edit replaces whatever it is not sent, so resend `data.selection` as `selection`, `data.shared_selection` as `shared_selection` and `data.trtc_configuration_mappings` as `trtc_configuration_mappings` unchanged, together with every field of `data.test_run` — name, description, run_state, owner, is_dynamic, configurations, tags, issues and test_plans.", + "`configurations` and `test_plans` come back on `data.test_run` as objects; the edit takes plain integer ids, so map each to its `id` before resending. Tags are sent as their plain strings.", + "`selection` carries a top-level `select_all`, a `unique_test_case_count` and `folders`. `selection.folders` is keyed by folder id AS A STRING. In each entry `test_case_select_all: true` means every case in that folder — including cases added to it later — with `deselected_test_cases` as the exceptions; `false` means only the ids in `selected_test_cases`. Do not rebuild this from the run's case listing: whether a folder was stored as all-or-some exists only here, and getting it wrong adds or drops cases.", + "To add cases through the edit, merge the new case ids into `selection.folders[\"0\"].selected_test_cases` (create that entry with test_case_select_all: false and empty lists if it is absent) — the product resolves each case's real folder. When the goal is only to add cases, bulk_update_test_run_test_cases with add_test_cases is simpler and cannot remove anything.", + "`selection` can be `{}`, and a run with no configurations still returns one default entry in `trtc_configuration_mappings` — {configuration_id: null, select_all: true, linked_test_cases: [], unlinked_test_cases: []}. Resend both as returned rather than omitting them.", + "`dynamic_filter` and `filters` carry the same rule twice. For a dynamic run, resend it under both keys plus a top-level `is_dynamic`; for a static run they are null and should be left out.", + "The stored selection is NOT kept in step with bulk_update_test_run_test_cases or update_test_run: cases removed or added that way can still be listed here, or be missing, and a folder can still be stored as select-all. Before resending it, compare `selection.unique_test_case_count` with `test_run.test_cases_count` and the run's case listing; when they disagree, resending as-is re-adds removed cases and pulls in whole folders — rebuild the folders entries from the run's current cases, or avoid the full edit.", + "Everything is under `data`, including `success` — there is no top-level `success` on this response. Closed runs can be read.", + "A read only: it changes nothing and needs no permission beyond access to the project." + ] + }, + { + "name": "export_dashboard_analytics", + "method": "POST", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/download", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "csv" + ], + "example": "csv", + "json_path": "/q/type", + "description": "Export format. Only the literal 'csv' is accepted — any other value is dropped and the export record is created with no file type, while the worker still writes CSV. Must be nested under `q`; a top-level `type` is never read." + }, + { + "name": "created_at", + "type": "string", + "example": "3_month", + "json_path": "/q/created_at", + "description": "Relative window on creation date, _[_gte]. 'all_time' is honoured here and applies no bound. Send this explicitly whenever you send any other filter." + }, + { + "name": "closed_at", + "type": "string", + "example": "3_month", + "json_path": "/q/closed_at", + "description": "Relative window on close date, same token format. Only read when created_at is absent or 'all_time'. 'all_time' is NOT honoured on this key — it becomes 1_year." + }, + { + "name": "owner", + "type": "string", + "example": "3700,3701", + "json_path": "/q/owner", + "description": "Comma-separated TM user ids. Unresolvable ids are dropped silently; if none resolve the filter is omitted rather than erroring." + } + ], + "intent": "Queue a CSV export of the whole project dashboard (active + closed runs, issue counts, case-type split, automation stats) and get back an export id — the file itself is delivered out of band", + "returns": [ + "success", + "export_id" + ], + "responses": { + "200": { + "$response": "DashboardExportInitiatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The inputs are the same q[...] filter object the dashboard reads take, NOT flat body fields. The handler reads params[:q]; a top-level {\"type\":\"csv\"} is ignored and the export runs unfiltered. On this POST the object may be sent in the JSON body as {\"q\": {...}} or as a query string — both land in the same place.", + "CSV is the only format. Anything other than the literal 'csv' is discarded by the handler, and the worker writes a CSV regardless — there is no PDF or Excel export behind this route despite what the format name suggests.", + "You cannot collect the file through this API. The worker uploads the CSV to S3 and pushes the URL over a websocket channel addressed to the requesting user; it never attaches the file to the export record and never advances the export's status past 'pending'. The generic export status/download endpoints therefore report pending forever and 404 on download for this export type, and neither is exposed as a capability here. Treat the returned export_id as a correlation token, not as something you can redeem.", + "Date filtering follows the dashboard rules: q[created_at] wins over q[closed_at], and sending any q key without an explicit q[created_at] defaults both to 'all_time', which on the closed_at path becomes the last 1 year — so an owner-only filter silently produces a 12-month export.", + "The response is immediate and says nothing about whether the export succeeded; a failure happens later in the background and is only signalled on the websocket.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API." + ] + }, + { + "name": "get_active_test_runs_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/active-test-runs-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart the result-status breakdown across the project's currently active test runs — how many results are passed / failed / blocked / untested right now", + "shape": "discovered", + "responses": { + "200": { + "$response": "ActiveTestRunsInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The payload is an OBJECT with a `data` array, not a bare array: read `data`, not the response root.", + "When more than six result statuses exist, the response is collapsed: only the top six by count survive and everything else is merged into a single row named 'Other'. That 'Other' row has NO `result_status` object — it carries a `links.filter` URL instead — so code that reads row.result_status.id must tolerate its absence.", + "Counts are results across active runs, not runs and not test cases. There is no drill-down here; to see the runs themselves use the test-run listings.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ], + "returns": [ + "data", + "name", + "y", + "result_status", + "links" + ] + }, + { + "name": "list_archived_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/archived_test_cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List the test cases that have been archived in a project — the source of the ids the bulk-retrieve (un-archive) call needs.", + "returns": [ + "success", + "test_cases", + "info", + "cases_count" + ], + "responses": { + "200": { + "$response": "ArchivedTestCasesListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-gated, and it fails OPEN-LOOKING: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and no test cases. Check `success`, not the status code, before concluding the archive is empty.", + "Project-wide only — there is no folder scoping on this route. Each row carries its original folder path so you can tell where a case will be restored to.", + "Take the integer `id` of each row: that is what bulk-retrieve takes to un-archive them.", + "Paged with `p`; there is no per_page on this route, so walk pages until `info.next` is null.", + "`cases_count` in the response is keyed by folder id, and on this route that key is null because no folder was scoped — read `info.count` for the total instead." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no per_page here; the page size is the server default." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort token (`-ASC|DESC`). `case_type` is translated to the backend's `category` for you." + } + ], + "paginated": true + }, + { + "name": "get_archived_test_plan_count", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/archive_count", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "How many test plans in this project are archived — the badge number for the archived view; use the archived-plans listing when you need the plans themselves", + "shape": "discovered", + "responses": { + "200": { + "description": "Archived plan count: `{success, count}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "count": { + "type": "integer" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a fixed literal route on the project's plan collection, not a plan-scoped read — it takes no plan id and no filters. Counting archived plans is all it does.", + "Counts top-level archived plans only, matching the archived listing; archived sub-plans are not included, so the number can look low against what a user expects.", + "Gated on the same group archive flag as archive/restore: when it is off the call is 404 {success: false, errors: 'Feature not available'} — a feature gap, not an empty project.", + "Needs the test-plan DELETE permission, not view. Someone who can read plans normally may still be 403'd here.", + "The read is forced onto the primary store, so the number is correct immediately after an archive or restore rather than lagging behind it.", + "A count the upstream page info does not carry comes back as 0 rather than an error, so 0 can mean 'none archived' or 'upstream returned no paging info'." + ], + "returns": [ + "success", + "count" + ] + }, + { + "name": "get_automation_stats", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/automation-stats", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Get the project's automation coverage — what percentage of its test cases are automated, with the automated / manual / total counts behind it", + "returns": [ + "success", + "automated_coverage", + "automated_test_cases", + "manual_test_cases", + "total_test_cases", + "empty_data" + ], + "responses": { + "200": { + "$response": "AutomationStats" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the one dashboard chart assembled in this service rather than passed through, so the field names below are exact: `automated_coverage` is a percentage (0–100, a float), and the three counts are test cases.", + "`manual_test_cases` is derived as total minus automated, so every automation state that is not 'automated' — including 'in progress' or unset — is counted as manual.", + "When the project has no test cases, `empty_data` is true and `automated_coverage` is computed from a zero denominator; treat empty_data:true as 'no data' and ignore the coverage number entirely rather than rendering it.", + "Returns counts only — there is no list of which cases are automated. Use the test case search with an automation_state filter for that.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_closed_test_runs", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/closed", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List a project's finished (closed) test runs — the ones the default run listing leaves out — to report on completed cycles", + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "build_meta", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListClosedTestRuns" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no page-size parameter here: the page is fixed at 30 rows, so walk `info` rather than asking for more." + } + ], + "guidance": [ + "This is the counterpart to the main run listing, which hides closed runs. Neither one alone is 'all our runs' — answering that question means reading both.", + "Page size is fixed at 30 and cannot be raised; only `p` moves. Stop when `info.next` is null.", + "Each run's `id` is the TR-NNN STRING and the integer lives in `uuid`. Carry `uuid` anywhere a numeric run id is needed — passing the string through reaches the backing service uncoerced and fails there.", + "The response is flat: {success, test_runs, info, count} with no `data` envelope, unlike the single-run write endpoints on this entity.", + "`count` is an object of per-state totals for the whole project, not the size of this page — the page size is in `info`.", + "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent. Do not read a zeroed `overall_progress` as 'nothing was executed'." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_closed_test_runs_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how many test runs were closed per month over the selected window — the project's throughput trend", + "returns": [ + "data", + "empty_data" + ], + "responses": { + "200": { + "$response": "ClosedTestRunsInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Reads the CLOSE date, so q[closed_at] is the filter that matters here, not q[created_at]. Sending q[created_at] shadows q[closed_at] entirely (created_at wins) and you get a creation-window chart instead.", + "Each entry in `data` is a positional pair — period label first, count second — not a {month, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", + "`empty_data` is the reliable 'nothing to plot' signal — the series is padded, so a non-empty `data` array does not mean there were any closed runs.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_closed_test_runs_split", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-split", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart closed test runs per month split by result status — a stacked view of how each month's closed runs finished", + "returns": [ + "data", + "date_list", + "empty_data", + "name", + "result_status" + ], + "responses": { + "200": { + "$response": "ClosedTestRunsSplit" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each entry in `data` is one status series: `name` plus a `data` array of per-period counts aligned positionally with the top-level `date_list`. The Nth number in a series belongs to the Nth label in date_list; there is no month key on the numbers themselves.", + "Filters on close date — q[closed_at] is the meaningful key, and q[created_at] shadows it when both are sent.", + "This route caches for six hours for one specific large project, so a freshly closed run may not appear immediately there.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_configurations", + "method": "GET", + "path": "/api/v1/projects/{project_id}/configurations", + "mode": "read", + "entity": "configuration", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "List the configurations a project's test runs can execute against — search by name or fetch specific ids, to resolve the configuration_id a run needs", + "returns": [ + "success", + "configurations", + "info", + "id", + "name", + "record_status", + "is_suggested", + "group_id", + "created_at" + ], + "responses": { + "200": { + "description": "Successfully retrieved configurations", + "schema": { + "$schema": "ConfigurationListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search. `name` is an accepted alias and `q` WINS when both are sent." + }, + { + "name": "name", + "type": "string", + "description": "Alias for `q`. Only used when `q` is absent." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `page` is an accepted alias and `p` WINS. Only `p` is validated — a non-positive or non-numeric `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`, while a bad `page` is forwarded as-is." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page. `count` is an accepted alias and `count` WINS when both are sent. Forwarded unchanged to the backing service, which owns the real ceiling — nothing bounds it here." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "description": "Alias for `per_page`, and the one that wins when both are sent." + }, + { + "name": "ids", + "type": "string", + "example": "101,102,103", + "description": "Comma-separated configuration ids to fetch, as a STRING — not a JSON array. Split on commas before being forwarded." + }, + { + "name": "type", + "type": "string", + "values": [ + "system", + "custom", + "all" + ], + "description": "Configuration source filter, validated locally: anything else returns 400 `{success:false, error:\"Invalid type parameter. Must be one of: system, custom, all\"}`." + }, + { + "name": "custom", + "type": "boolean", + "default": false, + "description": "CHANGES THE RESPONSE KEY. When 'true' the rows come back under `custom_configurations` instead of `configurations`; everything else about the response is identical. Code that always reads `configurations` gets nothing back." + }, + { + "name": "is_suggested", + "type": "boolean", + "description": "Forwarded unchanged to the backing service to filter on whether the configuration is a suggested/default one. Ignored on the `custom=true` branch." + } + ], + "guidance": [ + "The `custom=true` branch renames the array: rows arrive under `custom_configurations`, not `configurations`. Read the key that matches the request you made — this is the easiest way to get an apparently empty result from a call that worked.", + "Group-scoped, not project-scoped: the list is the workspace's configurations and `project_id` only matches the route. The same set comes back for every project in the group.", + "`ids` is a comma-separated string, not an array; sending `ids[]=101&ids[]=102` does not filter.", + "Alias pairs and which one wins: q beats name, p beats page, count beats per_page. Only `p` is validated, so a typo'd `page` is forwarded and ignored rather than rejected.", + "Rows carry os, os_version, device, browser, browser_version and is_valid_on_bstack in addition to id/name/group_id/record_status/is_suggested — more than the Configuration schema in this index describes. `is_valid_on_bstack` is what tells you the combination is still runnable on BrowserStack.", + "Carry `id` from the row: the run APIs take configuration ids, not names or objects.", + "Paged via `info` {page, page_size, count, prev, next}; stop when `next` is null.", + "Requires test_run_view — configurations are gated on the test-run permissions, not the test-case ones." + ], + "paginated": true + }, + { + "name": "get_custom_field_dataset", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id, from the field read or from the create-dataset response. 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Read one dataset of a custom field — which projects it covers and, on request, the option values it holds", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Options are NOT returned by default. Pass fetch_options=true or you get project links only and will wrongly conclude the dataset has no values.", + "There is no `info` in this response even though the linked-projects list is paged: `p` is honoured, the page size is fixed at 30, and you get no total and no next-page marker. Use `linked_project_count` on the dataset to tell whether more pages exist.", + "`linked_to_all_projects` is computed here by comparing linked_project_count against the workspace project count — it is derived per request, not stored.", + "Both path ids must be positive integers; 0 in either position is a 400 'Invalid Params'. A well-formed id that does not exist is a bare 404 {success:false} with no message.", + "Nothing checks that the dataset actually belongs to the field in the path before the upstream call — a mismatched pair surfaces as that same bodyless 404." + ], + "query": [ + { + "name": "fetch_options", + "type": "boolean", + "default": false, + "description": "Include the dataset's option values in the response. Omit it and `options` is absent entirely — which reads identically to a dataset that has none." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the dataset's linked projects. Page size is fixed at 30 and `per_page` is ignored; no page metadata is returned." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects", + "options" + ] + }, + { + "name": "get_custom_field_definition", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." + } + ], + "intent": "Read one custom field's stored definition together with its datasets and the projects each dataset is linked to — the call to make before any edit, since edits here are full replaces", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this to build an update body: the edit call requires field_name, field_type and is_required together and clears place_holder_text and default_value when they are omitted.", + "`entity_type` is NOT in this response. If you need to know whether the field belongs to TestCase, TestResult, TestPlan or ExploratorySession, take it from the workspace field list instead.", + "The response carries datasets with their project links but NOT their option values. Read a dataset's options through the dataset options list.", + "Paging is per dataset row and is controlled only by `p`; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata.", + "`linked_to_all_projects` on a dataset is computed by comparing its linked project count against the workspace project count — it is derived, not a stored flag.", + "A field id that does not exist comes back as a bare 404 {success:false} with no message; id 0 is a 400 'Invalid Params'." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the field's datasets. The only pagination control — `per_page` is read but discarded, the page size is always 30." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "info", + "id", + "field_name", + "field_type", + "system_name", + "is_required", + "place_holder_text", + "is_bulk_editable", + "is_filterable", + "default_value", + "datasets", + "levels", + "type" + ] + }, + { + "name": "get_custom_field_values", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route also matches the literal 'new', which returns an empty 200 rather than an error. PR-NNN identifiers do not match the route." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-case", + "test-result", + "test-plan", + "exploratory-session" + ], + "description": "Hyphenated, lower-case entity segment. Anything outside this set fails the route constraint and 404s. It must match the field's own entity type or the lookup returns nothing." + }, + { + "name": "field_id", + "type": "string", + "required": true, + "example": "4821", + "description": "Numeric custom field id, from the workspace field list or the project's form fields. Whatever you send here becomes the top-level key of the response, verbatim." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Substring search over the option labels (or user names for a `user` field). Forwarded upstream as the value filter." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, forwarded unchanged. There is no per-page control on this endpoint — the backing service owns the page size; read it back from `info`." + }, + { + "name": "parent_option_id", + "type": "integer", + "description": "nested_dropdown only: return the child options of this parent instead of the level's own values. Get the parent id from a previous call on the parent level." + } + ], + "intent": "List the values one custom field offers inside a given project — the dropdown choices, or the users for a user field — so you can pick a valid value before writing it onto a test case or result", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "CustomFieldValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + } + }, + "guidance": [ + "This is the project-scoped value lookup, not a definition read. Use it to turn a value a human named into something you can send; the field's definition, type and project scope live in the workspace field list.", + "The response has no fixed top-level key: the payload sits under the field id exactly as you spelled it in the path (`{\"4821\": {field_type, values}, info, success}`). Read it back by that same string, not by a constant name.", + "`field_type` comes back in the LEGACY spelling — field_dropdown, field_multi_dropdown, field_string, field_user, field_boolean … — not the canonical vocabulary the admin write calls take. Translate before feeding it anywhere else.", + "A nested_dropdown field has no legacy spelling, so its `field_type` comes back null. Do not treat that as 'field not found'.", + "For a nested_dropdown, pass `parent_option_id` to get the children of one parent; without it you get the level's own values.", + "A field that does not exist, or any upstream failure, returns 400 {success:false, error:'Something went wrong'} — there is no 404 here and no way to tell a bad field id from a backend outage.", + "A project_id the route accepts but which is not numeric (the literal 'new') returns 200 with field_type null, values null and info null — an empty success, not an error.", + "The entity_type segment is hyphenated and lower-case (test-case / test-result / test-plan / exploratory-session); anything else does not match the route and 404s before reaching the API.", + "Boolean fields have their values converted to real true/false before rendering; every other type returns them as stored." + ], + "returns": [ + "success", + "info", + "field_type", + "values" + ] + }, + { + "name": "get_dataset", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits, so a PR-NNN identifier never reaches the API." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string, as returned by the list or create calls. There is no integer-id form of this route." + } + ], + "intent": "Read one dataset in full — its columns, every row of data, tags and metadata — by dataset uuid", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "created_at", + "created_by" + ], + "responses": { + "200": { + "description": "The dataset with its full column and row contents.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__GetDataset" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" + }, + "422": { + "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan. Entitlement, not payload." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the only dataset call that returns the actual data: the list endpoint returns counts and metadata but no `variables` or `rows`.", + "Read this before any update — update is a full replace with no history, so the response here is the only copy of the prior contents you will get.", + "`rows_count` is computed from the rows in this payload, so it always agrees with `rows.length`.", + "`created_by` is resolved against users in the caller's group; a creator who has left resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", + "An unknown uuid is 404 {success:false, message:'Dataset not found.'} — note the doubled space in the message if you are matching on it.", + "422 {message:'Feature not available'} means the group lacks the Team Pro plan. That is an entitlement answer, not a bad request; do not retry.", + "The dataset is at the top level as `dataset`; there is no `data` envelope." + ] + }, + { + "name": "list_dataset_linked_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}/test-cases", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The dataset's uuid string, from the dataset list or a create response." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, forwarded as-is to the backing store. A non-numeric or non-positive value is rejected globally with 400 {success:false, error:'Invalid Params'}. No page-size parameter is exposed." + } + ], + "intent": "List the test cases bound to a dataset — check what a dataset drives before you edit or delete it", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "A page of linked test cases. Note there is no `success` key in this envelope.", + "schema": { + "type": "object", + "required": [ + "test_cases", + "info" + ], + "properties": { + "test_cases": { + "type": "array", + "description": "Test cases linked to the dataset, forwarded from the backing store; the per-row field set is not shaped by this service.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Either 'Dataset not found.' (no such dataset) or 'No test cases found for dataset .' (the backing store returned nothing)." + }, + "422": { + "description": "`Feature not available` — the group lacks the Team Pro plan. Not a payload error; do not retry with a different body." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Run this before deleting or rewriting a dataset — nothing else warns you that test cases depend on it, and the delete gives no preview.", + "This response has NO `success` key, unlike every other dataset endpoint: it is exactly {test_cases, info}. Do not branch on `success` here.", + "Two different 404s: an unknown dataset uuid fails in the lookup with 'Dataset not found.', while a dataset the backing store returns nothing at all for fails with 'No test cases found for dataset .'. Neither distinguishes 'exists but has no linked cases' reliably — treat a 404 as 'nothing to read', not as proof the dataset is gone.", + "The test-case rows are passed through from the backing store unchanged; their field set is not shaped by this service and is not declared here.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint." + ], + "returns": [ + "test_cases", + "info" + ] + }, + { + "name": "get_dataset_variables", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets/variables", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[name]", + "type": "string", + "description": "Partial, case-insensitive match on the column name. This is the search this endpoint exists for." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}. There is no page-size parameter; read `info.page_size` for what you got." + } + ], + "intent": "List every dataset column defined anywhere in the project, each with the dataset it belongs to — use this to find which dataset holds a variable name you saw in a test case", + "returns": [ + "success", + "variables", + "info", + "uuid", + "name", + "dataset" + ], + "paginated": true, + "responses": { + "200": { + "description": "A page of dataset columns, each with its owning dataset, plus paging metadata under `info`.", + "schema": { + "$schema": "DatasetVariablesResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is project-wide, not per dataset. `variables` on this path is a fixed route segment, not a dataset uuid — it lists columns across every dataset in the project. To see one dataset's columns, read that dataset instead.", + "Each row carries its owning dataset inline as `dataset` {uuid, name, count_rows}, so this call resolves a bare variable name to the dataset uuid you need for every other dataset operation.", + "Column names are not unique across datasets — the same name in two datasets is two rows here. Disambiguate on `dataset.uuid`.", + "The page size is set by the backing store and cannot be requested from here; page with `p` and read `info` for page/page_size/next.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint.", + "`success`, `variables` and `info` are all top level — `variables` is the array itself, not a wrapper object." + ] + }, + { + "name": "get_dedupe_status", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/status", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + } + ], + "intent": "Check whether duplicate detection has ever run for a project and where the latest scan got to — the only way to tell 'dedupe is off or has never run' from 'it ran and found nothing'", + "returns": [ + "status", + "job_id", + "scheduled_run_time", + "actual_end_time", + "updated_at", + "message" + ], + "responses": { + "200": { + "description": "The latest dedupe job for the project. When no job has ever run, only {status:'NOT_ENABLED', message:'No dedupe job found for this project'} is returned and the job fields are absent.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Latest job status, or the synthesised 'NOT_ENABLED' when no job exists.", + "values": [ + "NOT_ENABLED", + "QUEUED", + "PENDING", + "IN_PROGRESS", + "CLUSTER", + "CLUSTERING", + "COMPLETED", + "FAILED", + "RETRY", + "RETRY_FAILED", + "STATISTICAL_FAILED", + "STATISTICAL_RETRY", + "CLUSTERING_FAILED", + "CLUSTERING_RETRY", + "CANCELLED", + "BACKFILLING", + "LLM_ANALYSIS_SKIPPED", + "CLUSTERING_LIMIT" + ] + }, + "job_id": { + "type": "integer", + "description": "Pipeline job id. Absent when status is NOT_ENABLED." + }, + "scheduled_run_time": { + "type": "string", + "format": "date-time", + "description": "When the scan was scheduled to run. Nullable; absent when status is NOT_ENABLED." + }, + "actual_end_time": { + "type": "string", + "format": "date-time", + "description": "When the scan finished. Null while the job is still in flight; absent when status is NOT_ENABLED." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Last status change. Absent when status is NOT_ENABLED." + }, + "message": { + "type": "string", + "description": "Present only with NOT_ENABLED: 'No dedupe job found for this project'." + } + } + } + }, + "400": { + "description": "{error:'Missing project_id'} — unreachable through this route, where project_id is a required path segment." + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Call this first whenever a duplicates listing comes back empty. An empty listing is returned both when the feature is off and when a completed scan found nothing; only this endpoint separates them.", + "`NOT_ENABLED` is synthesised, not stored: it means no scan job row exists for this project at all, and it arrives with an explanatory `message` and none of the job fields. Every other value is a real job status and comes with job_id and timestamps.", + "Only the MOST RECENT job is reported, by last update. There is no history and no way to page back to earlier runs.", + "Statuses are not just running/done. COMPLETED, FAILED, CANCELLED, RETRY_FAILED, STATISTICAL_FAILED, CLUSTERING_FAILED, CLUSTERING_LIMIT and LLM_ANALYSIS_SKIPPED are terminal; QUEUED, PENDING, IN_PROGRESS, CLUSTER, CLUSTERING, BACKFILLING, RETRY, STATISTICAL_RETRY and CLUSTERING_RETRY mean a scan is still in flight and the duplicates listing may be incomplete.", + "This is a plain read of the last job record — it does not start a scan, and nothing in this API can. Scans are scheduled by the dedupe pipeline.", + "Unlike the rest of this family, no role permission is checked here; project access is enough.", + "The documented 400 for a missing project id is unreachable through this route, since the id is a required path segment." + ] + }, + { + "name": "get_duplicate_source_test_case", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}/source_tc", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id of an ALREADY-MERGED pair. The route only matches digits." + } + ], + "intent": "Read the pre-merge snapshot of the test case that was folded away when a duplicate was merged — the only record of what the archived case contained at merge time", + "shape": "discovered", + "responses": { + "200": { + "description": "The pre-merge snapshot, wrapped in a `data` envelope: {data:{success:true, test_case:{...}}}. `test_case` is the frozen full test-case serialisation stored at merge time (steps, attachments with signed URLs, template); its field set is whatever was captured then and is not shaped by this endpoint.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_case": { + "type": "object", + "description": "Frozen pre-merge serialisation of the source test case." + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "description": "Two distinct bodies: {data:{success:false, error:'Source test case snapshot not available'}} when the pair merged but retained no snapshot, and {success:false, error:'Duplicate not found.'} (no `data` key) when the pair is unknown or was never merged." + }, + "422": { + "description": "{success:false, message:'Invalid source_tc data'} — the stored snapshot could not be parsed." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This works only for a pair whose status is `merged` — the exact opposite of the read/resolve/discard calls, which work only while a pair is `suggested`. Calling it on a live suggestion is a 404.", + "The snapshot is written only by the merge path. A pair resolved by archiving instead of merging is also marked merged but stores no snapshot, so it answers 404 with the message 'Source test case snapshot not available'. Treat that as 'not retained', not as a retryable error.", + "The two 404s have DIFFERENT envelopes. Missing snapshot: {data:{success:false, error:'Source test case snapshot not available'}}. Unknown or unmerged pair: {success:false, error:'Duplicate not found.'} with no `data`. Check for `data` before reading `error`.", + "Success is wrapped: the test case is at `data.test_case`, not at the top level. This is the only duplicate endpoint with a `data` envelope.", + "The snapshot is a frozen serialisation taken at merge time, including steps, attachments (with freshly signed, short-lived URLs) and the template — it is not re-read from the live test case, so it will not reflect anything that happened afterwards.", + "A stored snapshot that cannot be parsed comes back 422 {success:false, message:'Invalid source_tc data'} — a data fault, not something a different request fixes.", + "To find the merged pair id in the first place: it is no longer in the duplicates listing, so you need the id you saw before resolving, or the id embedded in the merge comment left on the surviving test case." + ], + "returns": [ + "data", + "success", + "test_case" + ], + "disabled": true + }, + { + "name": "get_duplicate", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id; this route only matches digits." + }, + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer duplicate id, taken from the `id` of a row in the duplicates listing or search. The route only matches digits." + } + ], + "intent": "Read one suggested duplicate pair — the two test cases side by side with their titles, descriptions, folders and owners, plus the confidence score and the model's reason — so a human can decide how to resolve it", + "shape": "discovered", + "responses": { + "200": { + "description": "The duplicate pair with both test cases resolved, at the top level as `duplicate`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "duplicate": { + "$schema": "V1DuplicatePair" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "{errors:'Duplicate not found.'} — no such pair in this project, or its status is no longer `suggested`. Or {errors:'Test case not found.'} — the pair exists but a member test case could not be resolved." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Suggested-only. A pair that has already been merged, archived or discarded answers 404 {errors:'Duplicate not found.'} — here a 404 usually means 'already handled', not 'never existed'. Check the listing before concluding the id is wrong.", + "The error key is `errors` (plural), not `error` or `message`, on both 404 paths from this endpoint.", + "A second 404, {errors:'Test case not found.'}, means the pair exists but one of its two test cases could not be resolved (deleted or moved out of reach). The pair is unusable — it is also silently dropped from the listings for the same reason.", + "Unlike the listings, this has NO feature-flag gate: a single pair stays readable by id even for a group whose dedupe listing returns empty.", + "The pair is at the top level as `duplicate`; there is no `data` envelope. Inside it, `test_cases` always holds exactly two entries and each entry's `id` is the integer test-case id you pass to the resolve call as source or target.", + "`owner` is a resolved display name and is an empty string when the owner cannot be resolved; `owner_id` is the raw BrowserStack user id. `description` is '' rather than null when absent.", + "`ai_score` is the model's confidence and `duplicate_type` is exact vs semantic — read both before merging, since an exact pair and a low-confidence semantic pair warrant different treatment." + ], + "returns": [ + "success", + "duplicate", + "id", + "ai_score", + "reason", + "duplicate_type", + "test_cases", + "identifier", + "folder_id", + "folder_path", + "title", + "description", + "owner_id", + "owner" + ] + }, + { + "name": "list_entity_filter_details", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity}/filter-details", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "trtc", + "test-runs" + ], + "example": "test-cases", + "description": "Which entity's filter vocabulary to resolve against — this also decides which user fields are resolved. 'test-plans' is allowed by the route but rejected by the handler with 400." + } + ], + "query": [ + { + "name": "q[status]", + "type": "string", + "example": "9319,9321", + "description": "Comma-separated numeric status IDs to resolve. Returned under filter_details.status as objects with id, field_value, internal_name and colour." + }, + { + "name": "q[priority]", + "type": "string", + "example": "550376,9261", + "description": "Comma-separated numeric priority IDs to resolve; returned under filter_details.priority." + }, + { + "name": "q[case_type]", + "type": "string", + "example": "9379,9375", + "description": "Comma-separated numeric case-type IDs to resolve; returned under filter_details.case_type." + }, + { + "name": "q[automation_state]", + "type": "string", + "example": "automated", + "description": "Numeric ids, or the literal word 'automated' / 'manual' which is expanded to this project's ids first; returned under filter_details.automation_state." + }, + { + "name": "q[automation_status]", + "type": "string", + "example": "passed,failed", + "description": "Comma-separated values; split on commas and echoed back as an array, not resolved against anything." + }, + { + "name": "q[folder_ids]", + "type": "string", + "example": "3306878,3669741", + "description": "Comma-separated folder ids; returned under filter_details.folders as a folder tree with paths and ancestors. At most 600 folders are resolved." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids. Resolved for entity=test-cases and entity=test-runs only; ignored for trtc." + }, + { + "name": "q[reviewers]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=test-cases only." + }, + { + "name": "q[assignee]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=trtc only." + }, + { + "name": "q[last_executed_by]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids. Resolved for entity=trtc only." + }, + { + "name": "q[test_case_tags]", + "type": "string", + "example": "[\"regression\",\"smoke\"]", + "description": "Must be a JSON-encoded string containing an array of tag names — it is JSON.parse'd and echoed back. A bare comma-separated list raises rather than filtering. `q[tags]` is not a recognised key." + }, + { + "name": "q[custom_fields]", + "type": "string", + "example": "{\"45\":[\"abc\"]}", + "description": "Custom-field selections keyed by field id; returned resolved under filter_details.custom_fields. Date custom fields echo back the raw value you sent rather than the normalised one." + }, + { + "name": "q[result_custom_fields]", + "type": "string", + "example": "{\"88\":[\"a\",\"b\"]}", + "description": "Test-result custom-field selections; resolved separately and returned under filter_details.result_custom_fields." + }, + { + "name": "q[configuration_id]", + "type": "string", + "example": "101,102", + "description": "Comma-separated configuration ids; resolved to configuration objects under filter_details.configurations." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Echoed back verbatim under filter_details.created_at — not parsed, not validated." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "3_month", + "description": "Echoed back verbatim under filter_details.updated_at." + }, + { + "name": "q[latest_status]", + "type": "string", + "example": "passed,failed", + "description": "Comma-separated values, split and echoed back as an array." + } + ], + "intent": "Resolve a set of filter IDs you already hold into their display objects — turn priority/status/case-type/custom-field ids, folder ids and user ids into names, colours and folder paths so a saved filter can be rendered; it does NOT list the filter options available in a project", + "returns": [ + "success", + "filter_details", + "priority", + "status", + "case_type", + "automation_state", + "automation_status", + "custom_fields", + "result_custom_fields", + "folders", + "configurations", + "created_at", + "updated_at", + "test_case_tags", + "latest_status", + "owner", + "reviewers", + "assignee", + "last_executed_by" + ], + "responses": { + "200": { + "description": "Everything is nested under `filter_details`; only the keys you supplied under q are present, and an empty q yields filter_details:{}.", + "schema": { + "type": "object", + "required": [ + "success", + "filter_details" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "filter_details": { + "type": "object", + "description": "Resolved form of the filters that were sent. Keys absent from the request are absent here.", + "properties": { + "priority": { + "type": "array", + "description": "Resolved priority options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } + }, + "status": { + "type": "array", + "description": "Resolved status options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } + }, + "case_type": { + "type": "array", + "description": "Resolved case-type options for the ids that were sent.", + "items": { + "$schema": "ResultStatus__ListEntityFilterDetails" + } + }, + "automation_state": { + "type": "array", + "description": "Resolved automation-state options.", + "items": { + "$schema": "ResultStatus" + } + }, + "automation_status": { + "type": "array", + "description": "The q[automation_status] value split on commas and echoed back unresolved.", + "items": { + "type": "string" + } + }, + "test_case_tags": { + "type": "array", + "description": "The JSON-decoded q[test_case_tags] value, echoed back.", + "items": { + "type": "string" + } + }, + "latest_status": { + "type": "array", + "description": "The q[latest_status] value split on commas and echoed back.", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "nullable": true, + "description": "Echo of q[created_at], unparsed." + }, + "updated_at": { + "type": "string", + "nullable": true, + "description": "Echo of q[updated_at], unparsed." + }, + "custom_fields": { + "type": "object", + "description": "Resolved custom-field values keyed by field id. Date fields carry the raw value that was sent, not the normalised one." + }, + "result_custom_fields": { + "type": "object", + "description": "Resolved test-result custom-field values keyed by field id." + }, + "configurations": { + "type": "array", + "description": "Resolved configuration objects for q[configuration_id].", + "items": { + "$schema": "Configuration" + } + }, + "owner": { + "type": "array", + "description": "Resolved users for q[owner] — test-cases and test-runs only.", + "items": { + "$schema": "User__ListEntityFilterDetails" + } + }, + "reviewers": { + "type": "array", + "description": "Resolved users for q[reviewers] — test-cases only.", + "items": { + "$schema": "User" + } + }, + "assignee": { + "type": "array", + "description": "Resolved users for q[assignee] — trtc only.", + "items": { + "$schema": "User" + } + }, + "last_executed_by": { + "type": "array", + "description": "Resolved users for q[last_executed_by] — trtc only.", + "items": { + "$schema": "User" + } + }, + "folders": { + "$schema": "FilterDetailsFolderNode" + } + } + } + } + } + }, + "400": { + "description": "Unsupported entity (including test-plans, which the route allows but the handler rejects), or malformed filter values." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is an echo/hydration endpoint, not a discovery one. It returns only the keys you actually passed under `q`: send q[status]=9319 and you get back `status` resolved; send nothing and you get {success:true, filter_details:{}} with a 200. To enumerate the options a project offers, use the system/custom field value capabilities instead.", + "The result is nested one level down under `filter_details` — read filter_details.status, filter_details.priority, filter_details.folders. The keys are not at the top level.", + "Only three entity values work here: test-cases, trtc and test-runs. 'test-plans' passes the route constraint but is not in the handler's map and returns 400 — the route and the handler disagree.", + "Which user fields get resolved depends on the entity: owner and reviewers for test-cases, assignee and last_executed_by for trtc, owner for test-runs. A user id sent under the wrong key for that entity is ignored without comment.", + "Filter keys are renamed internally (q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id) but you send the un-suffixed names. q[automation_state] additionally accepts the literal words 'automated' / 'manual'.", + "q[test_case_tags] must be a JSON-ENCODED STRING of an array; it is JSON.parse'd and echoed back verbatim. `q[tags]` is not a key this endpoint knows and is dropped silently.", + "q[created_at] and q[updated_at] are echoed back exactly as sent — they are not parsed or normalised here.", + "Folder resolution caps out at 600 folders per call, so a very wide folder selection comes back truncated rather than paged.", + "q[query] has no effect on this endpoint — it is sanitised and then ignored." + ] + }, + { + "name": "get_exploratory_session", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "intent": "Read one exploratory session in full — its charter, state, timings, assignee, tags, configurations, linked test plan, linked issues, custom fields and how many logs it holds — before reading its logs or changing it", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "closed_by", + "closed_at", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "timeline", + "created_at", + "updated_at", + "project_id", + "group_id", + "owner", + "record_status", + "attachment_relations" + ], + "responses": { + "200": { + "description": "Successfully retrieved the exploratory session.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "session_id is a plain integer. The route segment only matches digits, so the display identifier (ES-NNN) never reaches the API — it fails as a routing 404, not as an API error.", + "Read `session_log_count` first: it tells you how many log pages to walk, and it is the same number that decides whether a clone of this session runs synchronously or asynchronously (50 or more logs switches clone to the async path).", + "custom_fields is always present on this read — normalised into the flat TCM shape, and an empty array when the session has none. That normalisation is applied here, on create, update and clone, but NOT on the sessions listing.", + "assignee, created_by and closed_by are expanded into {id, email, full_name, group_id} objects by looking the user up locally. If the stored user is not a TM user in this group the field comes back null even though a user is set upstream.", + "A session id that does not exist comes back as a bare {success:false} with the 404 status and no message — the upstream error text is deliberately not relayed.", + "The session is at the top level under `exploratory_session`. There is no `data` envelope on any endpoint of this entity.", + "Requires the test-run view permission: exploratory sessions are gated by the test run permission set, not by a permission of their own.", + "The path takes the INTEGER session id, not the ES-NNN identifier — ES-NNN returns 404. The session's display field is `title`; there is no `name`. `summary` and `test_plan` come back from the LIST route only, not from this one." + ] + }, + { + "name": "get_exploratory_session_summary", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/exploratory-session-summary", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "mode", + "type": "string", + "values": [ + "time_period", + "session_selection" + ], + "default": "time_period", + "description": "`time_period` scopes by dates and ignores session_ids; `session_selection` scopes by session_ids and ignores the dates. Mismatched parameters are dropped silently, not rejected." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2025-05-01", + "description": "Inclusive start, YYYY-MM-DD. Only read in time_period mode; omitted entirely from the upstream call when absent." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2025-05-31", + "description": "Inclusive end, YYYY-MM-DD. Only read in time_period mode." + }, + { + "name": "session_ids", + "type": "string", + "example": "101,102,103", + "description": "Comma-separated session ids as a STRING, not an array. Only read in session_selection mode." + } + ], + "intent": "Get exploratory-session testing figures for a project directly — sessions, outcomes and top testers over a date range or a chosen set of sessions, with no saved report needed", + "shape": "discovered", + "responses": { + "200": { + "description": "Summary figures under `data`.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "description": "Exploratory-session summary payload — session counts, outcome breakdown, `sessions` (with `assignee` expanded to a user object) and `top_testers` (each `{ user, bug_count }`, `user` null when the tester is no longer in the group)." + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this for an ad-hoc question about exploratory testing. It computes live from the project and needs no report to exist; the saved equivalent is an `Exploratory Session Summary` report read through the report capabilities.", + "The figures are under `data`, not at the top level. `data.top_testers[].user` and `data.sessions[].assignee` are expanded from ids into user objects server-side, and come back `null` for a tester who is no longer a TM user.", + "`mode` decides which of the other parameters matter: `time_period` (the default) uses `start_date` / `end_date` and ignores `session_ids`; `session_selection` uses `session_ids` and ignores the dates. Sending the wrong pair for the mode is not an error — it is silently ignored and you get an unintended scope.", + "`session_ids` is a comma-separated STRING here, not an array. Dates are plain YYYY-MM-DD.", + "Omitted parameters are dropped rather than defaulted locally, so the upstream service picks the window — always send explicit dates when the range matters.", + "This route only exists under a project; it reads the project from the path and requires the report_view permission (403 otherwise)." + ], + "returns": [ + "success", + "data" + ] + }, + { + "name": "get_filter", + "method": "GET", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "read", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one saved filter view — its name, scope and the exact condition set it applies — before reusing, editing or deleting it", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This read is not side-effect free. The handler validates every custom-field condition in the saved view against the project's current custom fields and, when any no longer exist, it rewrites the stored filter without them. Reading a filter can permanently change it.", + "So `data.filters.customFields` can legitimately be a subset of what was saved, and a second read will not put the pruned conditions back. Treat what you get as the new truth.", + "The entity_type to custom-field-entity map has no entry for `testrun`, so a run filter is validated against TEST CASE custom fields and conditions that were valid for runs can be pruned. Be cautious about reading run filters you do not want altered.", + "Known defect: if the custom-field lookup or the write-back fails with a 400, the action returns without rendering anything at all — you get an empty/500 response rather than the filter. Fall back to the filter listing, which does no validation.", + "The filter is under `data` with a sibling `success`; the conditions are at `data.filters` and the list type at `data.entity_type` (not `entity`, which is the request-side spelling).", + "Only a 400 from the backing service is translated to `{success:false, message:...}`; anything else from it surfaces as a 500, so an unknown filter_id does not reliably produce a 404." + ], + "disabled": true + }, + { + "name": "get_folder_remove_summary", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm-summary", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Dry-run a folder deletion — count the test cases, subfolders and cross-product recordings that would be destroyed — before committing to it", + "returns": [ + "success", + "has_other_product_entity", + "deleted_entity_count", + "active_test_case_count", + "archived_test_case_count", + "sub_folders", + "test_recordings_count" + ], + "responses": { + "200": { + "$response": "RemoveFolderSummaryResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read-only despite the delete machinery behind it: the handler calls the delete path with dry_run set, so nothing is removed. This is the safety check to run before deleting a folder.", + "It is nonetheless gated on the folder-DELETE permission, not the view permission. A caller who can read folders but not delete them gets 403 here.", + "The counts are nested under `deleted_entity_count` — active_test_case_count, archived_test_case_count, sub_folders and test_recordings_count all live inside that object, not at the top level.", + "`has_other_product_entity` is derived from one thing only: it is true exactly when test_recordings_count is greater than zero, meaning the subtree holds low-code test recordings owned by another product that the delete would take with it.", + "The folder is not fetched before the dry run, so a folder id that does not exist in this project surfaces as a bare 404 {success:false} with no message identifying the id.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." + ] + }, + { + "name": "get_folder_tree", + "method": "GET", + "path": "/api/v1/projects/{project_id}/repository/mapping", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Fetch a project's entire folder tree in one call — every root folder with its children nested under `contents` — to resolve a folder name to the integer id every other folder and test-case call needs", + "shape": "discovered", + "responses": { + "200": { + "description": "Folder tree.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "structure": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "401": { + "description": "Unauthorized." + } + }, + "query": [ + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Sibling ordering. Comma-separated combinations are accepted and each element is validated; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." + }, + { + "name": "shared", + "type": "boolean", + "description": "Any truthy presence switches the tree to the shared-folder view. Presence alone is what counts — sending shared=false still enables it, because only presence is tested." + } + ], + "guidance": [ + "This returns the WHOLE tree in one response and is not paginated — no page or per-page parameter is read. On a large project it is a big payload; use the per-folder listing instead if you only need one level.", + "The tree is at `structure`, a top-level array. There is no `data` envelope on this endpoint. Children hang off each node's `contents` array, recursively.", + "Folder ids in the tree are plain integers, and they are what every other folder capability expects in its path.", + "An unresolvable project (including the literal path segment `new`) returns 200 with {structure: [], success: true}, not a 404. An empty structure is therefore ambiguous between 'no folders' and 'no such project' — confirm the project separately.", + "When Review & Approve is switched on for the project, the per-folder test-case counts in the tree are silently narrowed to review-eligible statuses. If you also pass a review_status filter, it is intersected with the project's eligible set — and when the intersection is empty your filter is discarded and the project's set is used instead, so you can get counts you did not ask for rather than an error.", + "Counts here are aggregates over the tree; they are not a list of test cases. Read test cases through the test-case listings." + ], + "returns": [ + "success", + "structure" + ] + }, + { + "name": "list_group_users", + "method": "GET", + "path": "/api/v1/group/users-v2", + "mode": "read", + "entity": "user", + "query": [ + { + "name": "q", + "type": "string", + "description": "As a bare string: filters on FULL NAME only, as a SQL LIKE '%value%'. Email is not matched by this form and there is no partial-email search. Whitespace is not trimmed." + }, + { + "name": "q[email]", + "type": "string", + "example": "qa@acme.com", + "description": "Exact email lookup — no partial matching. Returns at most one user, and this branch OMITS the `info` pagination block entirely, so a reader that always dereferences info.next breaks on it." + }, + { + "name": "q[id]", + "type": "string", + "example": "14,27", + "description": "Comma-separated Test Management user ids. This does NOT filter — it only re-orders, putting the named ids first and returning the rest of the workspace behind them. It also raises the page size to the number of ids when that exceeds 30." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Previously undeclared. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Has no effect on the q[email] branch, which is unpaginated." + } + ], + "intent": "List or look up the people in this workspace — search by name, resolve an exact email, or read your own identity — to get the user id that owner and assignee fields take", + "returns": [ + "success", + "users", + "myself", + "info", + "id", + "browserstack_user_id", + "email", + "full_name", + "group_id", + "onboarded", + "reports_and_notification_enabled" + ], + "responses": { + "200": { + "$schema": "UsersV2Response__ListGroupUsers" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Scope is the whole WORKSPACE (group), not a project — every active user in the group is returned regardless of which projects they can see, and no project id can be supplied. This is not a way to answer 'who is on this project'.", + "`q[id]` is a sort, not a filter. Passing ids re-orders the full user list so those ids come first; you still get everyone. Filtering to specific people is not supported here — match on the ids yourself after reading.", + "The response shape varies by branch: the default, name-search and id branches return `{users, myself, success, info}`, but the `q[email]` branch returns `{users, myself, success}` with NO `info` at all.", + "`id` is the Test Management user id; `browserstack_user_id` is the separate BrowserStack account id for the same person, and the two are different numbers. Owner and assignee fields on cases and runs take the TM `id`; some listing filters take browserstack_user_id instead. Sending the wrong one usually resolves to nobody rather than erroring, so check which the target capability wants.", + "Only active users are returned. A deactivated person cannot be resolved here, which means an assignee who has left the workspace cannot be looked up to explain an existing assignment.", + "`myself` is the calling user as {id, full_name, email, reports_and_notification_enabled} — the cheapest way to get your own TM user id, with no search needed.", + "Page size is fixed at 30 (the q[id] branch excepted, where it becomes the number of ids); there is no per_page parameter.", + "No project is involved, so this works without a project_id and is not project-permission gated." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_issues_count_info", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/issues-count-info", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how many defects/issues were linked from this project per period — the defect-discovery trend", + "returns": [ + "data", + "empty_data" + ], + "responses": { + "200": { + "$response": "IssuesCountInfo" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each entry in `data` is a positional pair — period label first, count second — not a {label, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", + "This counts issues per period; it does not list them. There is no way to reach the individual defects from this response.", + "`empty_data` is the 'nothing to plot' flag; the series is padded with zeroes otherwise.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_project_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Get a project by INTEGER id (v1) — full detail + its PR-NNN identifier", + "returns": [ + "data", + "success", + "project", + "id", + "identifier", + "name", + "description", + "created_at", + "thProjectId", + "normalisedName", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "starred", + "jira_mapped", + "links", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project detail; data.project.identifier is the PR-NNN form.", + "schema": { + "$schema": "V1ProjectDetail__GetProjectByIntegerId" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No project with that integer id, OR the project exists but is not visible to this user — both render as 404 {success:false}." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The path segment only matches digits (or the literal string 'new'), so a PR-NNN identifier never reaches this action — it is a routing 404, not an API error.", + "This is the integer-id -> PR-NNN translation step: read `data.project.identifier` and carry it into any the v2 surface route, which will not accept the integer.", + "The project sits under a `data` envelope: `data.project`. The paginated/minified project listings do NOT use `data` — they return `projects` at the top level. Do not reuse a path across the two.", + "A project that exists but is not visible to this caller comes back as 404 {success:false} — the underlying 403 is deliberately rewritten. You cannot distinguish 'no such project' from 'no access' on this route.", + "`test_cases_count`, `test_runs_count` and `test_plans_count` are fetched with the detail here (they are absent from the lean listings), which is the reason to use this call rather than filtering a listing.", + "`permissions` is the caller's own permission list for this project and `user_role` their role name — both reflect the requesting user, not the project.", + "Requires the project_view permission under RBAC." + ] + }, + { + "name": "get_project_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/form-fields-v2", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. PR-NNN identifiers do not match the route; the literal 'new' matches the route but is rejected with 400 'Invalid Params: project_id must be a valid integer'." + } + ], + "intent": "Get the test-case form for one project — the custom fields it shows, the built-in system fields, and the pick lists behind the standard priority / state / type / automation-status dropdowns", + "returns": [ + "success", + "custom_fields", + "default_fields", + "system_fields", + "id", + "field_name", + "field_type", + "entity_type", + "field_user_name", + "optional", + "is_required", + "is_bulk_editable", + "is_filterable", + "links", + "placeholder", + "default_value", + "option_values", + "field_values", + "sort_options" + ], + "responses": { + "200": { + "$response": "ProjectFormFieldsV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the read to make before creating or editing a test case: it tells you which fields that project actually shows, which are mandatory, and what the standard dropdowns accept.", + "The response is three lists at the top level — `custom_fields`, `default_fields`, `system_fields` — with NO `data` envelope and no pagination. Every custom field the project has comes back in one call.", + "`field_type` inside `custom_fields` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean …), not the canonical vocabulary the admin create/update calls take. Translate in both directions.", + "TestCase only. Result, plan, exploratory-session and step custom fields are served by different endpoints and never appear here.", + "`optional` and `is_required` are both present and are inverses of one another; prefer is_required.", + "Each row carries `links.self` — the ready-made path to that field's values in this project (or to the project user list for a `user` field). Follow it rather than building the path.", + "`option_values` / `field_values` appear only when the upstream row carried them, so an absent key does not mean the field has no options — follow links.self to be sure.", + "project_id must be numeric. The route also matches the literal 'new', but this handler rejects a non-numeric value with 400 'Invalid Params: project_id must be a valid integer'.", + "`automation_status` under default_fields is assembled in this layer, not read from storage, and the automation-status custom field is omitted for groups without that feature." + ] + }, + { + "name": "get_project_settings", + "method": "GET", + "path": "/api/v1/projects/{project_id}/settings", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "in_review_count", + "type": "string", + "values": [ + "true" + ], + "description": "Compute meta_data.in_review_count and meta_data.test_plan_in_review_count for the CALLING user. Without it both are returned as 0 rather than omitted, so an unrequested 0 is not evidence of an empty queue." + }, + { + "name": "fetch_dedupe_button", + "type": "string", + "values": [ + "true" + ], + "description": "Append a duplicate-review marker string to the project_settings array ('show_review_duplicates', 'disable_review_duplicates' or 'hide_review_duplicates'). Any failure computing it is swallowed and no marker is appended." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the underlying settings rows (30 per page) before they are flattened into project_settings[0]." + } + ], + "intent": "Read a project's configuration flags — review/approval gating, the custom test-case id prefix, time-tracking, Part 11 re-auth, Jira/Azure linking — and optionally the caller's own pending-review counts", + "returns": [ + "success", + "project_settings", + "info", + "meta_data", + "in_review_count", + "test_plan_in_review_count" + ], + "responses": { + "200": { + "description": "project_settings[0] is a flat attribute->value object (all values are strings); later elements, when present, are bare marker strings. info and meta_data are omitted entirely when the project has no stored settings.", + "schema": { + "type": "object", + "required": [ + "success", + "project_settings" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "project_settings": { + "type": "array", + "description": "Element 0 is a single object mapping each stored setting name to its string value. Any further elements are duplicate-review marker strings appended when fetch_dedupe_button=true. Empty array when nothing is stored.", + "items": { + "description": "Either the flattened settings object or a marker string." + } + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the underlying settings rows. Absent when the project has no stored settings." + }, + "meta_data": { + "type": "object", + "description": "Per-caller review counts. Present whenever settings exist; both fields are 0 unless in_review_count=true was passed.", + "properties": { + "in_review_count": { + "type": "integer", + "description": "Test cases in review where the calling user is a reviewer.", + "example": 4 + }, + "test_plan_in_review_count": { + "type": "integer", + "description": "Test plans in review for the calling user. Best-effort — a backend error yields 0.", + "example": 2 + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`project_settings` is an ARRAY, but not a list of settings. Element 0 is a single flat object holding every stored setting as attribute -> value; any further elements are bare marker STRINGS ('show_review_duplicates' / 'disable_review_duplicates' / 'hide_review_duplicates') appended only when fetch_dedupe_button=true. Read project_settings[0] for the settings and expect mixed types in the rest of the array.", + "A project with no stored settings returns {success:true, project_settings:[]} with NO `info` and NO `meta_data` at all — not an object of defaults. Absence of a key means 'never set', and the effective behaviour is the group default, which is not visible here.", + "Every value is stored as a string (the settings are an EAV store), so booleans come back as \"true\"/\"false\" and numbers as strings. Compare against strings, not native types.", + "`meta_data` is always present when any setting exists, but both counts are hard-zero unless you pass in_review_count=true — a 0 you did not ask for means 'not computed', not 'nothing in review'.", + "in_review_count counts test cases where the CALLER is a reviewer and the case is in review; it is per-user, not per-project. test_plan_in_review_count is computed on a separate path and is best-effort — a backend hiccup silently yields 0.", + "The underlying rows are paged at 30 per page (`p` selects the page) before being flattened, so a project with an unusually large settings set can produce an incomplete first-page object.", + "This read is not feature-gated, but the matching write is: update_project_settings returns 422 unless the new-project-settings flag is on for the group or user." + ] + }, + { + "name": "list_project_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users-v2", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "example": "jane", + "description": "Flat string: case-sensitive LIKE match on full_name (substring). Paginated, 30 per page." + }, + { + "name": "q[email]", + "type": "string", + "example": "jane@acme.com", + "description": "Exact email lookup, at most one row. This branch returns NO `info` block — do not read pagination from it. An unknown address returns an empty `users` array, not a 404." + }, + { + "name": "q[id]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids. This does NOT filter — it moves those users to the front of the full group list. Page size grows to the id count when more than 30 ids are given." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number (the param is `p`, not `page`). Overflow returns the last page rather than an empty one." + } + ], + "intent": "Find the users in this group — by name fragment, by exact email, or by id — and get the caller's own identity back alongside them; use this to resolve a person's name or email to the numeric user id that owner/assignee/reviewer filters and writes require", + "returns": [], + "responses": { + "200": { + "$schema": "UsersV2Response__ListProjectUsers" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the path, this is GROUP-scoped, not project-scoped: it lists every active user in the caller's group and never filters by the project in the URL. The project_id only has to be a real project the caller can see.", + "`q` is polymorphic and the three forms behave differently. `q=jane` (a flat string) is a name LIKE match and is paginated. `q[email]=jane@acme.com` is an exact lookup that returns at most one user AND OMITS the `info` block entirely. `q[id]=12,34` reorders those ids to the front of the full list rather than filtering to them — it is a sort, not a filter.", + "Page size is fixed at 30 and there is no size parameter. The one exception is `q[id]` with more than 30 ids, where the page grows to fit the id list.", + "Use `p` for the page number (not `page`). An out-of-range page returns the LAST page rather than an empty list, so never treat a non-empty response as proof the page number was valid.", + "`myself` is the caller's own record and is always present, but it is a reduced shape — id, full_name, email, reports_and_notification_enabled only, with no browserstack_user_id.", + "The `id` on each user is the TM user id. That is what the owner/assignee/reviewer search filters and test-run writes expect; `browserstack_user_id` is a different number and is only correct where a capability explicitly says BrowserStack id.", + "Only active users are returned — a deactivated user who still owns test cases will not appear here, so an unresolvable owner id is not necessarily invalid." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_projects_basic", + "method": "GET", + "path": "/api/v1/projects/basic", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Validated as an integer; a non-integer returns 400." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page. Validated as an integer here but NOT bounded in this service before being forwarded to the backing store; 300 is the documented maximum." + }, + { + "name": "q[query]", + "type": "string", + "example": "checkout", + "description": "Free-text match on project name." + }, + { + "name": "q[identifier]", + "type": "string", + "example": "PR-60863", + "description": "Exact PR-NNN identifier lookup. Note the matched rows do not echo `identifier` back." + } + ], + "intent": "Look up projects by name or by PR-NNN identifier, or page through them cheaply — the lean listing, and the only one with an identifier search; note the rows it returns do NOT include the identifier itself", + "returns": [], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectsPaginatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`q` must be a structured object here — q[query] for a name match, q[identifier] for an exact PR-NNN lookup. A flat `q=foo` string returns 400 'Invalid Params: q must be an object with identifier/query keys'. This is the exact opposite of list_projects, which demands a flat string.", + "Asymmetry worth planning around: you can FILTER by q[identifier], but the rows come back without `identifier` — the serializer here omits it. If you need the PR-NNN form back, use list_projects or the single-project read.", + "Only q[query] and q[identifier] are read; any other key under q is dropped silently and the search runs unfiltered, which reads as 'matched everything' rather than as an error.", + "Default page size is 30 (not 300). `count` is forwarded to the backing service and is not bounded in this codebase — 300 is the documented ceiling, so treat larger values as unsupported rather than assuming they are clamped.", + "Rows carry `permissions` (the caller's own rights on that project) but no counts — test_cases_count/test_runs_count are absent from this payload. Fetch the single project if you need them.", + "The response is `projects` + `info` at the top level; there is no `data` envelope." + ] + }, + { + "name": "get_projects_minify", + "method": "GET", + "path": "/api/v1/projects/minify", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 300, + "description": "Rows per page. Clamped to 300 — a larger value is silently reduced, not rejected. Omitting it gives 300, not 30." + } + ], + "intent": "Page through every project in the group as a flat dropdown list, 300 at a time — the fastest way to enumerate all projects, but it cannot search and it returns no pagination metadata", + "returns": [], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectMinifyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "There is no search on this route at all — no `q` in any form. To find a project by name or identifier use get_projects_basic.", + "The response has NO `info` block: `{success, projects}` only. You cannot tell from one page whether more exist — keep requesting pages until one comes back with fewer than `count` rows (or empty).", + "Default page size is 300, not 30, and `count` is hard-clamped to 300: a larger value is silently reduced rather than rejected. That makes this the cheapest full enumeration in the index.", + "Rows are the same lean dropdown shape as get_projects_basic — id, name, description, starred, the caller's `permissions` — with no identifier and no test case / run counts.", + "`p` is validated globally: a non-positive or non-numeric page returns 400 'Invalid Params'." + ] + }, + { + "name": "get_report_attachment_url", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/attachments/{attachment_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. Part of the lookup key — an attachment filed under a different project is a 404 even with the correct id." + }, + { + "name": "attachment_id", + "type": "integer", + "required": true, + "description": "Integer stored-file id for the attachment. Not a report id and not an export channel id; no report capability returns one." + } + ], + "intent": "Exchange a report attachment's id for a short-lived direct download URL", + "returns": [ + "url", + "success" + ], + "responses": { + "200": { + "description": "Successfully retrieved report attachment URL", + "schema": { + "type": "object", + "required": [ + "url", + "success" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "example": "https://example.com/download/report.pdf" + }, + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns a presigned URL you can fetch yourself — the bytes never pass through this API. It expires in 100 minutes, so fetch it promptly rather than storing it.", + "`attachment_id` is a stored-file id, not a report id and not a channel id. No capability in this index hands one out: the report reads do not return attachment ids, and the export path only broadcasts its result over a realtime channel. In practice you need the id from an attachment link the product rendered elsewhere.", + "A miss returns HTTP 404 with a normal-looking body `{ url: null, success: false }` rather than an error envelope — check `success`, not just the status, and never treat `url: null` as a valid URL.", + "The lookup is scoped by BOTH the attachment id and the `project_id` in the path (plus your group), so the right id under the wrong project is a 404. Requires the report_view permission." + ], + "disabled": true + }, + { + "name": "get_report_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "sections", + "type": "string", + "example": "test_case_created_priority,test_case_top_creators", + "description": "Comma-separated widget names to compute, from the report type's allow-list. Anything else fails the whole call 400 `Invalid sections passed`. Omit for the report's default (often nearly empty) payload." + }, + { + "name": "is_print", + "type": "boolean", + "description": "Send `true` to compute EVERY section for `Test Run Summary` and `Test Plan Summary` in one call, ignoring `sections`. Ignored by the other report types. Compared as the literal string 'true'." + }, + { + "name": "report_data_only", + "type": "boolean", + "description": "Return ONLY the `report_data` object, dropping the definition and filters. Any non-empty value is truthy — `report_data_only=false` also strips the envelope. Omit it entirely for the full body." + }, + { + "name": "widget_type", + "type": "string", + "description": "Required when `sections` names a `*_drilldown` widget; the allowed set differs per drilldown (see get_report_section). Missing or unknown is 400." + }, + { + "name": "segment", + "type": "string", + "description": "Drilldown sub-slice, sent as a JSON-ENCODED STRING (e.g. `{\"outcome\":\"bugs_found\"}`), not a nested object. Unparseable values are ignored rather than rejected." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for the row-bearing sections (drilldowns, trtc_details, traceability rows). Does not page the definition." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table. Ignored by the other report types, which use their own page size." + }, + { + "name": "today", + "type": "string", + "example": "2025-05-13", + "format": "date", + "description": "Overrides the server's notion of 'today' for Test Plan Summary overdue/near-completion maths. Unparseable values fall back to the real date silently." + }, + { + "name": "issue_categories", + "type": "string", + "example": "epic,story", + "description": "Requirement Traceability Report only: restrict to these issue types. Accepts a comma-separated string or a repeated array param. The catch-all value `other` is stripped before the upstream call." + } + ], + "intent": "Read one saved report — its definition, resolved filters, and computed figures — the general-purpose report read", + "returns": [ + "data", + "report_filters", + "report_data", + "dynamic_filters", + "project" + ], + "paginated": true, + "responses": { + "200": { + "$response": "GetReportDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the read to reach for when you have a report id and want everything: the definition is at `data`, the resolved filter selection at `report_filters`, and the numbers at `report_data`. Use get_report_section instead when you want one widget and nothing else.", + "`report_data` is computed per report type and is mostly EMPTY unless you name sections. A bare read fills a Test Case Activity report's headline counts but leaves Test Run Summary's issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null. A null section means NOT COMPUTED — never read it as 'no data'. Request the section, then judge.", + "Shortcut: `is_print=true` forces ALL sections for `Test Run Summary` and `Test Plan Summary` in a single call, which is usually what you want rather than guessing section names. User Workload Report already returns every section when `sections` is omitted.", + "`report_data_only` is a trap: any non-empty value is truthy, INCLUDING the string \"false\". Sending `report_data_only=false` collapses the response to the bare `report_data` object with no `data`, no `report_filters` and no envelope. Omit the parameter entirely when you want the full body.", + "A section name not in the report type's allow-list is 400 `Invalid sections passed` for the whole call. Do not answer that by retrying without `sections` — you get a stripped body, not the data. Fix the name (see get_report_section for the per-type lists).", + "A report saved with `report_timeframe: specific_test_plans` whose plan filters have since gone returns 422 `This report is invalid. Please create a new Report`. That report cannot be read at all; recreate it.", + "`Defects Summary` and `Defects Detailed Report` have no data branch on the server — they always read back `report_data: null` no matter what you ask for. For defect figures use a Test Run Summary and its issues_by_priority / issues_by_status sections.", + "The report is resolved by group + id; the project in the path does not scope the lookup. 404 `Report Not Found` means no such report in your group. Requires the report_view permission.", + "`*_drilldown` sections (tr_drilldown, tc_activity_drilldown, es_drilldown, tp_drilldown) return PAGINATED ROWS and additionally require `widget_type` — never tally those rows into a total, read the aggregate section instead." + ] + }, + { + "name": "get_report_section", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/{section_name}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + }, + { + "name": "section_name", + "type": "string", + "required": true, + "example": "test_case_created_priority", + "description": "One widget name from the report type's allow-list (see guidance for the per-type lists). A name outside the list is 400 `Invalid sections passed`; report types with no list accept anything and return a default payload." + } + ], + "query": [ + { + "name": "widget_type", + "type": "string", + "description": "Required for the `*_drilldown` sections and validated against a set that differs per drilldown (see guidance). Missing or unknown is 400 `invalid widget_type`." + }, + { + "name": "segment", + "type": "string", + "example": "{\"outcome\":\"bugs_found\"}", + "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object. For es_drilldown + session_outcomes the `outcome` value is validated (400 otherwise); elsewhere an unparseable value is ignored." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for row-bearing sections." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table; ignored elsewhere." + } + ], + "intent": "Fetch one report widget on its own — a single chart, table or drill-down slice from a saved report, without the report definition", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "The bare section payload (the report read's `report_data` object) — no success flag and no envelope. `null` for report types with no data branch.", + "schema": { + "type": "object", + "properties": { + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "issues_by_priority": { + "type": "string", + "nullable": true + }, + "issues_by_status": { + "type": "string", + "nullable": true + }, + "issues_data": { + "type": "string", + "nullable": true + }, + "test_cases_status": { + "type": "string", + "nullable": true + }, + "test_run_data": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 44 + }, + "closed": { + "type": "integer", + "example": 1 + }, + "test_cases": { + "type": "integer", + "example": 158 + }, + "test_results": { + "type": "integer", + "example": 9 + } + } + }, + "test_run_status": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "string", + "nullable": true + }, + "user_result_stats": { + "type": "string", + "nullable": true + } + } + } + }, + "400": { + "description": "Section name not in the report type's allow-list, or a missing/invalid widget_type or segment on a drilldown section." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "The report's saved plan selection no longer resolves — the report must be recreated." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is the BARE widget payload. This route runs the report read with `report_data_only` forced on, so there is no `success`, no `data` and no `report_filters` — just the section object keyed by the section name.", + "The section name must be in the report type's allow-list or the call is 400 `Invalid sections passed`. Test Run Summary: test_run_data, issues_data, test_run_status, test_cases_status, user_result_stats, issues_by_priority, issues_by_status, time_tracking, estimated_vs_actual, tr_drilldown. Test Run Detailed Report: trtc_details, trtc_filter_options, test_run_data, issues_data, trtc_performance, tr_drilldown.", + "Test Case Activity: test_case_created_count, test_case_updated_count, test_case_deleted_count, test_case_automation_coverage, test_case_top_creators, test_case_trend, test_case_created_priority, test_case_ai_coverage, test_case_list, tc_activity_drilldown. Test Plan Summary: test_run_data, test_run_status, test_plans_progress, test_plans_near_completion, overdue_test_plans, test_plans_execution_trend_list, test_plans_execution_trend_graph, test_result_user_stats, test_run_requirements, test_result_defects, defects_by_status, defects_by_priority, tp_drilldown. Exploratory Session Summary: es_drilldown. User Workload Report: cards, user_table, workload_distribution, execution_trend, status_distribution.", + "Requirement Traceability Report, Test Plan Detailed Report, Defects Summary and Defects Detailed Report have NO allow-list, so any section name is accepted and forwarded — a typo there returns an empty or default payload at 200 instead of a 400.", + "On `Defects Summary` / `Defects Detailed Report` the body is literally `null`: those types have no data branch at all. That is the report type, not a failure of your section name.", + "Drill-down sections need `widget_type` and reject anything outside their own set. tr_drilldown: active_runs, closed_runs, tr_performance, total_test_cases, linked_issues, requirements_linked, runs_by_state, defects_linked, assignee_breakdown, tcs_by_status. tc_activity_drilldown: created_test_cases, updated_test_cases, deleted_test_cases, automation_coverage, ai_coverage, trend, priority, top_creators. es_drilldown: total_sessions, linked_test_plans, session_outcomes. tp_drilldown has no allow-list and passes through.", + "For `es_drilldown` with widget_type `session_outcomes`, `segment` must carry an `outcome` from bugs_found, issues_found, needs_followup, skipped, no_issues, no_activity — anything else is 400.", + "Drill-downs return PAGINATED ROWS, not aggregates. Page them with `p`; never sum rows into a count — read the matching aggregate section instead.", + "Every precondition of the full report read still applies: 404 for an unknown report, 422 for a specific_test_plans report whose plan filters are gone, and the report_view permission.", + "The section name occupies a wildcard path segment. Two-segment paths with a reserved word (for example `detail`) are matched by other routes first, so only use real section names here." + ] + }, + { + "name": "get_report_summary_by_type", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "test_runs_summary", + "test_runs_details", + "test_plan_details", + "requirement_traceability_report" + ], + "description": "Which renderer to run. This is NOT checked against the report's stored report_type — a mismatch still computes and returns 200. Any other value is 400 `Report type not found`." + } + ], + "query": [ + { + "name": "reportTimeRange", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "Window to compute over. Required for test_runs_summary / test_runs_details — missing or unrecognised is an unhandled 500, not a 400. `custom` reads dates off the REPORT's stored custom_date_range. Ignored by requirement_traceability_report; optional for test_plan_details." + }, + { + "name": "sections", + "type": "string", + "example": "test_run_data,test_run_status", + "description": "Comma-separated widgets. test_runs_details defaults to `test_run_data,issues_data,trtc_performance` when omitted. Not allow-list-checked on this route." + }, + { + "name": "widget_type", + "type": "string", + "values": [ + "active_runs", + "closed_runs", + "tr_performance", + "total_test_cases", + "linked_issues", + "requirements_linked", + "runs_by_state", + "defects_linked", + "assignee_breakdown", + "tcs_by_status" + ], + "description": "Only read when `sections` includes `tr_drilldown`, and then required — an unknown value is 400 `invalid widget_type`." + }, + { + "name": "segment", + "type": "string", + "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number for the row-bearing renderers (test_runs_details, test_plan_details, traceability)." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page for the test_runs_details table. The upstream ceiling is not enforced here." + }, + { + "name": "required", + "type": "object", + "description": "Column selection for test_runs_details / test_plan_details: `{ \"\": { \"fields\": [...] } }`, entities test_run_test_case / test_run / test_result (plus test_plan). `fields` may be a comma-separated string, and `[\"all\"]` expands to every supported column. Unknown names are 400 here." + }, + { + "name": "issue_categories", + "type": "string", + "example": "epic,story", + "description": "requirement_traceability_report only: restrict to these issue types. Comma-separated string or repeated array param; the catch-all `other` is stripped before the upstream call." + }, + { + "name": "today", + "type": "string", + "example": "2025-05-13", + "format": "date", + "description": "Overrides 'today' for plan overdue/near-completion maths. Unparseable values fall back silently." + } + ], + "intent": "Render a saved report through one of four fixed renderers — run summary, run detail table, test plan detail table, or requirement traceability", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "ReportSummaryResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The `report_type` path segment selects a RENDERER, not the report's own type, and the server never checks the two agree. Pointing `test_runs_summary` at a Test Case Activity report computes a run summary from that report's filters and returns 200 — so the figures may not be the report the user asked about. Prefer get_report_detail unless you specifically want one of these four renderings.", + "The path segment takes four values, one more than the spec lists: `test_runs_summary`, `test_runs_details`, `test_plan_details` and `requirement_traceability_report`. Anything else is 400 `Report type not found`, as is omitting it.", + "`reportTimeRange` is genuinely required for `test_runs_summary` and `test_runs_details`: omitting it, or sending a value outside the timeframe enum, is an unhandled 500 rather than a validation error. `test_plan_details` falls back to the report's saved timeframe, and `requirement_traceability_report` ignores it entirely.", + "`reportTimeRange=custom` reads the dates off the REPORT's stored `custom_date_range`, not from the request — on a report saved without one it is a 500. Use the report's own timeframe value unless you know it has a custom range.", + "The response is the renderer's payload merged onto `{ success: true }` — the keys differ per renderer, so read what came back rather than assuming a fixed shape.", + "`test_runs_details` defaults `sections` to `test_run_data,issues_data,trtc_performance`, pages with `p` / `per_page`, and accepts a column selection under `required` which IS validated here (an unknown field name is 400 `Invalid fields: ... for `).", + "`test_plan_details` is feature-flagged: a group without the flag gets 403 `Operation not permitted`.", + "`requirement_traceability_report` accepts `issue_categories` (comma-separated or repeated) to restrict issue types; the catch-all value `other` is stripped before the upstream call.", + "`widget_type` here is the tr_drilldown set, and only applies when `sections` includes `tr_drilldown`. 404 `Report Not Found` for an unknown id; requires the report_view permission." + ], + "returns": [ + "success" + ] + }, + { + "name": "list_root_folders", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folders", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "folder_name", + "type": "string", + "example": "Folder_123", + "description": "Name search across the project's folders, not just root ones. It is trimmed and ignored when blank, so an all-whitespace value quietly returns the unfiltered root listing instead of an empty result." + }, + { + "name": "include_folder_hierarchy", + "type": "boolean", + "default": false, + "description": "Adds a `folder_hierarchy` map of folder id to its ancestor chain — the only way to learn a matched folder's path from this endpoint. Enabled only by the literal string \"true\" (case-insensitively); any other value is false." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "description": "Page number, and the switch that turns paging on at all. With `p` absent NO page parameters are sent upstream, `per_page` and `case_count` are ignored entirely, and you get the upstream default page. Always send `p` if you intend to page." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "description": "Rows per page. Read only when `p` is also present, and forwarded upstream unchanged — this service enforces no ceiling of its own, so the real maximum is the upstream one (documented as 100; not enforceable from this codebase). Trust `info` for what you actually got." + }, + { + "name": "case_count", + "type": "boolean", + "default": true, + "description": "Send the literal string \"false\" to drop the expensive per-folder test-case counts and the total count, keeping only subfolder counts — a worthwhile speedup on wide projects. Read only when `p` is present; any value other than \"false\" leaves counts on." + }, + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Ordering. Comma-separated combinations are accepted; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." + } + ], + "intent": "List a project's root folders, or search folders anywhere in the project by name — the paginated way in when the full tree is too big to pull at once", + "returns": [ + "folders", + "info", + "folder_hierarchy", + "id", + "name", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "$response": "RootFolderDetailsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Without `folder_name` or a filter this returns ROOT folders only — it is not a flat list of every folder in the project. To reach nested folders either search by name, ask for the hierarchy, or walk the tree.", + "Paging is conditional on `p`. Sending only `per_page` does nothing at all: the handler never builds page parameters unless `p` is present, so you silently get the upstream default page size.", + "The payload is {folders: [...], info: {...}} with no `data` envelope, and `folder_hierarchy` appears only when you asked for it. Page forward using `info`, not by counting rows.", + "Folder ids are plain integers. `parent_id` is null on root folders.", + "Test-case filters passed under `q` narrow which folders come back (folders whose cases match). Using a 'contains only' match type without the feature flag returns 403 {success:false, message:'This feature is not available'} — that is a feature gap, not a permission problem.", + "Requires the folder-view permission." + ] + }, + { + "name": "get_selected_report_testcases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/selection/edit", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "intent": "Read a report's saved test-case selection in the folder-keyed shape the editor uses — the value to round-trip back when editing the report", + "returns": [ + "success", + "selection_data", + "selection", + "shared_selection" + ], + "responses": { + "200": { + "$response": "GetReportTestCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Use this before editing a report's case selection. The report edit treats `report_filters.test_cases` as authoritative and deletes what you omit, and a flat id list cannot express folder-level or project-level 'select all' — this returns the exact object to send back.", + "`selection` is the primary project's saved selection (`{}` when nothing is saved); `shared_selection` is `{ \"\": }` for the report's other projects on a cross-project report, and `{}` otherwise. `selection_data` is the project's full folder/case tree used to render the picker, not the saved selection — do not confuse the two.", + "Despite the name it is not traceability-specific: it works for any report type. It simply returns an unfiltered project selection tree when the report has no case filters saved.", + "Unlike the other report reads, the `project_id` in the path IS used — `selection_data` is built for that project, so pass the report's own project or the tree will not match the saved ids.", + "Case ids inside the selection are INTEGERS (the numeric id), not TC-NNN identifiers.", + "404 `Report Not Found` for an id that is not in your group. Requires the report_view permission." + ] + }, + { + "name": "get_shared_step", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "read", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the block's step ROWS (not over shared steps). A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 150, + "description": "Step rows per page. Forwarded unchanged to the backing service, which owns the ceiling — it is not bounded here. Previously undeclared, which is why long blocks silently truncated at 150." + }, + { + "name": "paginated", + "type": "boolean", + "default": false, + "description": "Does NOT control whether the rows are paginated — they always are. It only controls whether `page_info` is included in the response. Send 'true' or you get a page of rows with no way to tell that more exist." + } + ], + "intent": "Read one shared step with its full ordered step/result rows — the block that every referencing test case renders, and the row ids those references point at", + "returns": [ + "success", + "data", + "id", + "title", + "created_by", + "updated_by", + "shared_step_details", + "page_info" + ], + "paginated": true, + "responses": { + "200": { + "$schema": "SharedStepsIDResponse__GetSharedStep" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The rows are always fetched a page at a time (150 by default) regardless of `paginated`. What `paginated=true` buys you is the `data.page_info` block; without it a block with 400 steps returns its first 150 and looks complete. Always send paginated=true when you care about the whole list.", + "Each row in `data.shared_step_details` carries its own `id` — that integer is the `shared_step_detail_id` a referencing test-case step holds. Carry the row id, not its `order`, when you need to line up a test case's steps with this block.", + "Send these rows back verbatim (with their ids) when updating the block; the update replaces the whole array, so anything you did not read here is lost on the next write.", + "`test_case_count` and `step_count` appear only when the backing service returns them on this path — do not rely on them here. The shared-step listing always carries both.", + "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}`; a block that does not exist returns 404 `{data:null, success:false}`.", + "`created_by`/`updated_by` are null when the original author is no longer a user in this workspace.", + "Requires tc_view." + ] + }, + { + "name": "get_shared_steps", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-steps", + "mode": "read", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. There is no per_page here — the page size is whatever the backing service defaults to." + }, + { + "name": "q[query]", + "type": "string", + "description": "Free-text match on the shared step TITLE only — it does not search step or result text. A bare scalar ``q` foo` is ignored rather than rejected, so the nested form is required." + }, + { + "name": "q[tags]", + "type": "string", + "description": "Filter to shared steps carrying this tag. Match mode is fixed to 'any' server-side; a match_object you send is not read." + }, + { + "name": "q[empty][system_fields][]", + "type": "array", + "values": [ + "tags" + ], + "description": "Find blocks with the named field empty. Only `tags` is honoured — any other field name is filtered out silently and the condition simply disappears." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Restrict to one shared-field folder. Forwarded only when present." + }, + { + "name": "include_sub_collections", + "type": "boolean", + "default": false, + "description": "Include blocks in sub-folders of folder_id. Only the literal string 'true' turns it on." + }, + { + "name": "field_type", + "type": "string", + "description": "Shared-field type filter, shared with the shared-components listing. Leave unset for ordinary shared steps." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort column, forwarded unchanged to the backing service, which owns the accepted set and the default." + }, + { + "name": "pre_fetch", + "type": "boolean", + "default": false, + "description": "Inline the first row's step details under `pre_fetched_shared_step`. Only takes effect on page 1 and only for the first row — it is a UI optimisation, not a way to fetch details in bulk." + } + ], + "intent": "List a project's shared steps with how many steps each holds and how many test cases embed it — use this to find a shared_step_id and to size the blast radius before editing or deleting a block", + "returns": [ + "success", + "data", + "info", + "pre_fetched_shared_step", + "id", + "title", + "step_count", + "test_case_count", + "created_by", + "updated_by" + ], + "paginated": true, + "responses": { + "200": { + "$response": "SharedStepsGetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`test_case_count` on each row is the number of test cases embedding that block. It is the number to read before any edit or delete, and this listing is the cheapest place to get it.", + "`step_count` is how many step rows the block holds; the rows themselves are not in this response — read the block by id for those.", + "Search is title-only via `q[query]`. Sending `q` as a bare string is silently ignored, so a search that returns everything usually means the parameter was flat instead of nested.", + "Page with `p`; there is no page-size parameter on this listing at all, so you cannot pull it in fewer, larger pages. Walk until `info.next` is null.", + "`created_by` and `updated_by` are resolved from BrowserStack user ids to `{id, full_name}` where that person still exists in this workspace, and come back **null** when they do not. A null author means a missing or removed user, not a missing field.", + "Requires tc_view." + ] + }, + { + "name": "get_system_field_values", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/{field_name}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "field_name", + "type": "string", + "required": true, + "values": [ + "priority", + "status", + "case_type", + "automation_state", + "defects" + ] + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Filter the options by display value (substring match)." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + } + ], + "intent": "List the option ids and display names a project defines for one system field (priority, status, case type, automation state) — resolve these before writing any of them.", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "SystemFieldValuesResponse" + }, + "401": { + "$response": "Unauthorized" + } + }, + "guidance": [ + "This is the lookup that makes writes correct: priority, status, case type and automation state are per-project configurable, and the bulk-edit paths require the NUMERIC id, not the display name.", + "The list is keyed by the field name you asked for, so the options are at `.values`, not at a fixed key. Each entry is `{value: , name: }` — `value` is the id to send.", + "`field_name` is one of priority, status, case_type, automation_state, defects. Any other name is not rejected by the route; it resolves to nothing and you get an empty list back with `success: true`.", + "`q` filters the options by value text and `p` pages them — a project with many options needs paging, so check `info` rather than assuming one page is the whole vocabulary.", + "Custom fields are not here; this covers system fields only." + ], + "returns": [ + "success", + "values", + "value", + "name", + "info" + ] + }, + { + "name": "get_template", + "method": "GET", + "path": "/api/v2/templates/{id}", + "mode": "read", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id — a plain integer with no prefix. Discover it from the template list; a non-numeric value does not reach this endpoint at all.", + "pattern": "^[0-9]+$" + } + ], + "intent": "Get one template in full, including every description, property and step field it carries — read this before editing a template so you can resend the complete field set.", + "guidance": [ + "This is the only read that returns the field layout; the list returns metadata and counts only.", + "The field count is not calculated on this read and always comes back empty here, even though the same field is populated on the template list. Take the count from the list when you need it; do not read the empty value as a template with no fields.", + "The Review & Approve property fields (reviewers, review status) are always filtered out of this response, on every workspace, even when the template stores them. Do not conclude from a read that they are absent.", + "The properties list contains a synthetic extra row: whenever the automation-status field is present, a duplicate of it is emitted with its internal name rewritten to the automation-state spelling, sharing the original's mapping id, field id and position. It is one stored field presented as two rows.", + "A field update replaces the whole section, so treat this response as the baseline: drop what you want gone, keep everything else, then send the full array back. Two things make that unsafe to do literally - the duplicated automation row above, and the Review & Approve fields that were filtered out - so reconcile both before resending rather than echoing the arrays verbatim.", + "The step-field list is emptied for workspaces without step custom fields enabled, while the key itself is always present. An empty list therefore means either 'not enabled here' or 'none configured', and this response cannot tell you which.", + "For a custom field, the field definition reports its type twice under different names: one is the product-facing type name, the other is the stored type name. Both are text - neither is a number - and for a small number of workspaces the product-facing one can be replaced with a nested-dropdown type that does not appear in the ordinary set.", + "There are two independent required-ness signals and they can disagree: the mandatory flag on the placement row, and a required flag inside the field definition itself. A field can be required by its own definition while the placement row says it is not.", + "An unknown or out-of-workspace id comes back as a 404 saying the template was not found, not as an empty success." + ], + "returns": [ + "success", + "template", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at", + "description_fields", + "properties_fields", + "step_fields" + ], + "responses": { + "200": { + "$response": "TemplateDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "list_test_case_attachments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List the files attached to one test case — id, filename, MIME type and size per file, so you can see what evidence is on the case and pick an id to remove", + "returns": [ + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "description": "Attachments retrieved successfully", + "schema": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentResponse" + } + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DOES NOT WORK. Returns a bare 500 on any request. The handler calls a collection method on a value that is a plain array on this path, so it raises before serialising. Read a case's attachments through the entity-attachments listing instead.", + "Metadata only: id, filename, content_type, byte_size. There is NO url and no download endpoint on this v1 surface — if you need to read the file, use the v2 attachment listing, which returns a readable URL per file.", + "`attachments` and `success` are at the top level; there is no `data` envelope here, even though the sibling delete on the same path wraps its payload in one.", + "Not paginated and not filterable — every attachment on the case comes back in one array.", + "`folder_id` is part of the route but is not used to locate the case; the lookup is by project and test case id, so a stale folder id after a move still resolves.", + "An empty array means the case has no attachments; it is not an error." + ], + "disabled": true + }, + { + "name": "get_test_case_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one test case by its INTEGER id inside a folder — the v1 lookup, and the way to translate an integer id into the TC-NNN identifier the v2 surface needs.", + "returns": [ + "success", + "data", + "test_case", + "id", + "identifier", + "name" + ], + "responses": { + "200": { + "description": "Test case detail; data.test_case.identifier is the TC-NNN form.", + "schema": { + "$schema": "V1TestCaseDetail" + } + }, + "401": { + "description": "Unauthorized — authentication missing or invalid." + }, + "404": { + "description": "No test case with that integer id in that folder (or not visible)." + } + }, + "guidance": [ + "All three path segments are plain integers here, and the route only matches digits — a TC-NNN or PR-NNN identifier never reaches the API and 404s at the router.", + "The folder id must be the case's CURRENT folder. A correct case id under the wrong folder is a 404, not a redirect.", + "The case is at `data.test_case` — there is a `data` envelope on this v1 read. `data.test_case.identifier` is the TC-NNN form (or the workspace's third-party identifier when the workspace issues project-level ids); the field is called `name`, not `title`.", + "`estimate` always comes back null on every read path — the stored estimate, if any, is in `estimated_duration_seconds`.", + "This returns the case record only. For its linked runs, results and issue metadata use the detail read instead." + ] + }, + { + "name": "get_test_case_count_trend", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-count-trend", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how the project's test case count grew month by month, broken down by case type — use this for 'how fast is the suite growing'", + "returns": [ + "data", + "empty_data", + "order" + ], + "responses": { + "200": { + "$response": "TestCaseCountTrend" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`data` is keyed BY CASE TYPE NAME, not by month — the keys are whatever case types this group has defined (including custom ones), so treat it as an open map and iterate it rather than looking up fixed names.", + "The monthly numbers sit one level deeper, under each entry's `graphData`, keyed by three-letter month abbreviation (Jan…Dec) for the trailing 12 months. `data.` itself is not the series.", + "Read `order` for the intended display sequence of those keys; object key order is not meaningful.", + "`Total` is a sibling key in the same map alongside the real case types (the CSV exporter special-cases it), so summing every key double-counts.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "get_test_case_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/detail", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "include", + "type": "string", + "values": [ + "folder_path" + ], + "example": "folder_path", + "description": "Comma-separated extras. `folder_path` is the only value read; anything else is ignored silently." + }, + { + "name": "exclude", + "type": "string", + "example": "results,defects", + "description": "Comma-separated relations to leave out (`results`, `defects`, `issue_metadata`). Passing it also switches off the heavier slide-over aggregation, so the response is smaller in more ways than the names suggest." + }, + { + "name": "hasDuplicates", + "type": "boolean", + "description": "When true, populates `duplicates_tc_count` from the dedupe store. Left null otherwise, and degraded to 0 rather than erroring if that store is unavailable." + } + ], + "intent": "Read one test case with its full slide-over detail — steps, custom fields, template, attachments and its linked runs, results and defects.", + "returns": [ + "data", + "success", + "test_case", + "is_archived", + "linked_issues", + "pagy" + ], + "responses": { + "200": { + "$response": "V1TestCaseDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The case is at `data.test_case`, and `success` is INSIDE `data` alongside it, not at the top level. `data.is_archived` tells you the case is archived rather than live.", + "By default this returns counts, not the run results themselves. Pass `exclude` to switch into the lighter mode, and read the paginated runs-results / runs-issues endpoints when you need the actual rows.", + "`include=folder_path` adds `data.test_case.folder_path` — the human-readable folder path. It is the only supported value.", + "All three path segments are plain integers and the route only matches digits; the folder id must be the case's current folder or it is a 404.", + "The template is resolved for you: `data.test_case.testcase_template` carries the field layout, and `using_fallback_template` is set when the case's own template could not be found and a system template was substituted.", + "`estimate` is always null here; the real value is `estimated_duration_seconds`." + ] + }, + { + "name": "list_test_case_histories", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List a test case's revision trail — who changed it, when, which fields moved, and the ids you need to view, diff or restore an earlier version", + "returns": [ + "success", + "histories", + "info", + "plan_restriction", + "id", + "entity_id", + "entity_type", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "user", + "user_id", + "comment", + "created_at", + "project_id", + "group_id", + "links" + ], + "responses": { + "200": { + "description": "Successfully retrieved test case histories", + "schema": { + "$schema": "HistoryListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only the FIRST record on each page carries the per-field `modified` map (old/new values). Every other record has `modified_fields` (the field NAMES only). To describe what actually changed in any other revision you must read that revision individually — one extra call per revision.", + "`plan_restriction: true` means the caller is not on Enterprise or Team Pro and the response was clamped to the last 30 days of revisions. Older revisions are simply absent with no other marker — never report 'no earlier changes' while that flag is true.", + "`version_name` (V1, V2, …) is computed from the record's POSITION within the visible total, so labels renumber as old revisions age out of a restricted window. Carry the numeric `id` forward to the single-revision read, the diff and the restore — never the V label.", + "Page size is fixed at 30; `p` is the only paging control. Counts and page metadata are under `info`.", + "`user` is resolved from the actor's id on the way out, and `links.self` points at the single-revision read for that record.", + "Revisions track edits to the test case itself — not executions of it. Result history lives on the test run.", + "A `source` of 'create' marks the original revision; it has no diff to fetch and is skipped by the enrichment above." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Page size is fixed at 30 server-side; there is no per_page on this endpoint." + } + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "get_test_case_history_diff", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/diff", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "versions[]", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two NUMERIC history record ids from the revision listing, old first. MUST BE SENT AS A REPEATED PARAMETER - `versions[]=141552670&versions[]=104806106`. A comma-joined single value (`versions[]=141552670,104806106`) is NOT parsed and the route answers a bare 404 {success:false}, which reads as 'case or revision not found' when the ids are perfectly valid.", + "style": "repeated" + } + ], + "intent": "Compare two revisions of a test case field by field — what the name, description, preconditions, steps, status, priority, owner, tags, defects, attachments, reviewers and custom fields looked like in each", + "returns": [ + "success", + "diff", + "links" + ], + "responses": { + "200": { + "$response": "HistoryDiffResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type does not support comparison ({success:false, message:'Not supported for build'})." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SEND `versions[]` AS TWO REPEATED PARAMETERS, not one comma-joined value. The joined form returns a bare 404 that looks like a missing record; the repeated form works.", + "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is compared — fall back to reading the two revisions individually, which is not plan-gated.", + "`versions[]` takes exactly two NUMERIC history record ids from the revision listing, in the order old then new: the first becomes `old` in every entry. The V1/V2 labels are not accepted here.", + "Omitting `versions[]` is not validated — both ids silently become 0 and the call fails downstream instead of telling you the parameter was missing. Always send two real ids.", + "Only fields that actually differ appear under `diff`, and only from a fixed comparable set: name, description, preconditions, automation_status, category, status, priority, owner, assignee, folder, tags, issues, custom_fields, test_case_steps, attachments, reviewers, review_status, review_sent_date. Anything outside that set is invisible here regardless of whether it changed.", + "Values differing only by type (1 versus \"1\") are treated as unchanged, so the diff will not show spurious id edits.", + "Steps come back as whole step arrays under old and new rather than a line-by-line diff — turning that into a readable 'what changed' summary is the caller's job.", + "`links` maps each version id to its single-revision read, which is where the rest of that snapshot lives." + ] + }, + { + "name": "get_test_case_history", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "history_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one revision of a test case in full — the per-field before/after for that single change and who made it; the follow-up read after finding a revision in the trail", + "returns": [ + "success", + "history", + "id", + "entity_id", + "entity_type", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "user", + "user_id", + "comment", + "created_at", + "project_id", + "group_id", + "links" + ], + "responses": { + "200": { + "description": "Successfully retrieved test case history", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "history": { + "$schema": "History__GetTestCaseHistory" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`history_id` is the numeric `id` from the revision listing, not the V1/V2 position label the listing also renders.", + "This read ALWAYS carries the full `modified` map. The listing only populates it for the first record on each page, so this is the call to make whenever you need actual old/new values for a specific revision.", + "The record is at the top level as `history` alongside `success` — no `data` envelope.", + "Unlike the diff and the restore, this read is not plan-gated. The listing that discovers ids is windowed to 30 days on non-Enterprise/Team Pro plans, though, so an older revision may simply be unreachable rather than forbidden.", + "Rows with no data on either side are dropped from `modified`, and the `modified_fields` banner is reconciled against what survived — so the two always agree, and a field named in neither genuinely did not change." + ] + }, + { + "name": "get_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/tags", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "List the tag names currently on one test case — to see what it is labelled with before filtering, grouping or re-tagging", + "returns": [], + "responses": { + "200": { + "$response": "TestCaseTagListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a BARE JSON ARRAY of the case's tags. There is no `data`, no `success` and no test case object in it — anything reading `data.test_case` from this call gets nothing. Read the array itself.", + "Despite the path segments, this returns tags only. For the test case's own fields (name, description, owner, status, steps) use a test-case read capability instead.", + "All three path ids are required and folder_id must be the folder the case actually lives in — the case is looked up by the triple, so a stale folder_id resolves to no test case rather than to the case in its new folder. Re-read the case's folder after any move.", + "Tags are referenced by NAME everywhere in this product; there are no tag ids in this response and none are needed to filter by tag.", + "This is read-only. To change a case's tags, write the test case's `tags` field — there is no add/remove-tag capability in this profile.", + "The `tags_show` action is absent from the RBAC permission map, so unlike the rest of the test-case surface it is not permission-gated." + ] + }, + { + "name": "get_test_case_type_split", + "method": "GET", + "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-type-split", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart how the project's test cases divide across case types (functional, regression, smoke, custom types…) — the current composition of the suite", + "returns": [ + "data", + "empty_data", + "name", + "y", + "value", + "field" + ], + "responses": { + "200": { + "$response": "TestCaseTypeSplit" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Each row carries both `y` and `value`; they are the same count under two names (`y` is the chart's series value). `field` is the underlying field key and `name` its display label.", + "Case types are group-configurable, so the set of rows is whatever this group has defined — never assume a fixed list.", + "This is a snapshot of the current suite, not a trend; use get_test_case_count_trend for growth over time.", + "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' — so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", + "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", + "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 — 'no such project' and 'no access' are indistinguishable here." + ], + "query": [ + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." + }, + { + "name": "q[closed_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' — created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." + } + ] + }, + { + "name": "list_test_run_test_cases_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Rows per page. Anything outside 1..100, or non-numeric, silently falls back to 30 instead of erroring — a request for 500 returns 30 rows and looks like a short run." + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority,tags,status", + "default": "owner,automation_status,automation_state,priority,tags,issues,reviewers", + "description": "COMMA-SEPARATED LIST OF COLUMNS to include on each row — not a mode. Omit it and the default column set above is used. `case_type` and `defects` are accepted as aliases of the stored column names." + }, + { + "name": "required[custom_fields]", + "type": "string", + "description": "Comma-separated test-case custom field ids to include on each row." + }, + { + "name": "required[result_custom_fields]", + "type": "string", + "description": "Comma-separated test-result custom field ids to include on each row." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-asc", + "description": "Column and direction, as `-`. TRAP: the column must also appear in required[fields] or the call fails with 'Sort by column is not present in required fields'. Custom fields (cf#) are exempt." + }, + { + "name": "shared", + "type": "boolean", + "default": false, + "description": "Return ONLY the cases shared into this run from other projects. Default (false) returns the run's full list including shared ones." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Restrict the list to the slice of the run contributed by one source project. Digits only; anything else is rejected." + }, + { + "name": "q", + "type": "object", + "description": "Filter object (same shape the run-detail search takes) applied to the rows. Sent as bracketed query params." + } + ], + "intent": "List the test cases inside a test run with their latest result, assignee and configuration — the execution grid for the run, and the source of the mapping ids the bulk operations take", + "shape": "discovered", + "responses": { + "200": { + "description": "The run's test cases for the requested page. Flat body: {success, info, test_cases}, plus a top-level skipped_test_case_ids array when the review gate excluded any case. Each row is one case x configuration mapping and carries both `id` (test case) and `mapping_id` (the run mapping).", + "schema": { + "$schema": "TestCaseListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are per case-times-configuration mapping, not per test case: a case running against three configurations appears three times, with the same `id` and three different `mapping_id` values. Count distinct `id` if you want a case count.", + "`mapping_id` is the id every bulk operation on this run takes — assigning, bulk-editing results, unlinking. `id` is the test case. Passing `id` where `mapping_id` is expected returns 200 and changes nothing.", + "sort_by silently depends on required[fields]: sorting by a column you did not ask for errors out. Add the column to required[fields] whenever you sort by it.", + "per_page tops out at 100 and out-of-range values fall back to 30 rather than erroring, so always read `info.page_size` for what you actually got and `info.count` for the total.", + "The 200 can carry a top-level `skipped_test_case_ids` array — cases the review gate excluded from the run. Their absence from the list is not a data error.", + "`latest_status` is 'untested' for a case that has never been executed; it is never blank.", + "The response is flat — {success, info, test_cases} with no `data` envelope.", + "Needs the test-run view permission, with the automation variant demanded on automation runs." + ], + "returns": [ + "success", + "info", + "test_cases", + "skipped_test_case_ids", + "id", + "identifier", + "name", + "mapping_id", + "test_case_id", + "test_run_id", + "latest_status", + "result_status", + "result_custom_fields", + "result_notes", + "defects", + "configuration", + "test_assignee", + "last_executed_by", + "last_executed_at", + "planned_execution_date", + "execution_eligible", + "owner", + "priority", + "tags", + "created_at", + "updated_at" + ], + "paginated": true, + "max_page_size": 100, + "disabled": true + }, + { + "name": "list_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Walk pages until `info.next` is null; `p` advances by the page size, not by how many rows came back." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." + }, + { + "name": "required[custom_fields]", + "type": "string", + "example": "121,119", + "description": "Comma-separated custom field ids to include on each row." + }, + { + "name": "all_folders", + "type": "string", + "values": [ + "true" + ], + "example": "true", + "description": "Send the string `true` to list the WHOLE project. Without it this route is scoped to no folder and comes back effectively empty — this is the single flag that makes the endpoint do what its name suggests. It also adds a `folders` block to the response." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-ASC", + "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." + } + ], + "intent": "List a project's test cases across every folder — the project-wide repository read. Requires all_folders=true.", + "returns": [ + "success", + "test_cases", + "info", + "folders" + ], + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "description": "Successfully retrieved test cases", + "schema": { + "$schema": "TestCaseConsolidatedListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`all_folders` is compared as the STRING `true` — a JSON boolean does not match. It is what makes this read project-wide; without it no folder scope is applied and the response is not the project's case list, while still reporting `success: true`.", + "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else — including a value above 100 — so `info.page_size` is what you actually got, not what you asked for.", + "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", + "Walk `p` until `info.next` is null. `p` advances by the page size rather than by the number of rows returned, so a partial read cannot be recovered by paging further.", + "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null on every row.", + "A backend 404 is swallowed into an empty list with `success: true` rather than surfacing — another reason a zero count is not proof the project is empty." + ] + }, + { + "name": "get_test_plan_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Read one test plan or sub-plan in full by its integer id — name, dates, status, owner, tags, custom fields, reviewers, progress rollup, and its TP-NNN / STP-NNN identifier", + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "assignee", + "created_by", + "test_runs_count", + "total_test_runs_count", + "test_results_trend_v2", + "links" + ], + "responses": { + "200": { + "description": "Test plan detail; `test_plan.identifier` (no `data` wrapper) is the TP-NNN / STP-NNN form.", + "schema": { + "$schema": "V1TestPlanDetail__GetTestPlanByIntegerId" + } + }, + "401": { + "description": "Unauthorized — authentication missing or invalid." + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No test plan with that integer id, not visible to this user, or archived and read without is_archived=true. Body is a bare `{success: false}` with no message.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "is_archived", + "type": "boolean", + "description": "Opt in to reading an ARCHIVED plan. Without it an archived plan is 404 — the read is active-only by default. Pass the literal string 'true'." + } + ], + "guidance": [ + "The path segment is the plan's INTEGER id; the route only matches digits, so a TP-NNN / STP-NNN identifier gives a routing 404 and never reaches the handler. Resolve the integer from the plan listing first.", + "This is the read for one plan. Two sibling routes on the same collection path are NOT plan reads and take no id: the archived listing and the archived count.", + "An archived plan reads as 404 unless you pass is_archived=true. If a plan a user named looks missing, retry with that flag before concluding it was deleted.", + "There is no `data` wrapper: the plan is at the top level under `test_plan`, and `test_plan.identifier` is the TP-NNN / STP-NNN form while `test_plan.id` is the integer every v1 route wants.", + "A 404 body is a bare {success: false} with no message — it carries nothing that distinguishes 'no such plan' from 'not visible to you'.", + "test_plan_progress is ALWAYS an empty object; the real rollup is test_plan_progress_v2 (counts keyed by result-status id). Same for test_results_trend vs test_results_trend_v2.", + "custom_fields comes back as [] (an empty array, not an object) when the plan has none.", + "parent_plan is present only on a sub-plan; on a top-level plan parent_plan_id is null and sub_plans_count tells you whether it has children.", + "review_status, review_sent_date, reviewers and review_settings appear only when the plan review workflow is configured for the project — do not treat their absence as 'not reviewed'.", + "Sub-plans and their runs are not embedded. Use the plan's test-runs listing (with include_sub_test_plan_runs) to see what is under it." + ] + }, + { + "name": "get_test_plan_execution_trend", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/execution-trend", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "test_plan_id", + "type": "integer", + "description": "Scope the trend to this plan (INTEGER id). Mutually exclusive with test_run_id — exactly one of the two must be present." + }, + { + "name": "test_run_id", + "type": "integer", + "description": "Scope the trend to this run instead of a plan. Mutually exclusive with test_plan_id." + }, + { + "name": "today", + "type": "string", + "format": "date", + "description": "Anchor date the trend window ends on. Defaults to the server's current date; an unparseable value silently falls back to it rather than erroring." + } + ], + "intent": "Execution-trend chart series (results over time) for exactly ONE test plan or ONE test run — the Insights trend graph; scope it with test_plan_id or test_run_id, never both", + "shape": "discovered", + "responses": { + "200": { + "description": "`{success, data}` — the requested section sits at `data.test_plans_execution_trend_graph`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "test_plans_execution_trend_graph": { + "type": "object", + "description": "The trend series for the scoped plan or run." + } + } + } + } + } + }, + "400": { + "description": "Neither or both of test_plan_id / test_run_id supplied. `{success: false, message: ...}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a project-level chart route, not a plan read: the plan or run is chosen by QUERY parameter, and the path carries only the project. It is a different endpoint from the plan detail read even though it sits on the same collection path.", + "Exactly one scope. Both ids → 400 {success: false, message: 'Provide either test_plan_id or test_run_id, not both'}; neither → 400 {success: false, message: 'test_plan_id or test_run_id is required'}. Note the error key is `message`, not `error`.", + "The series is nested: read data.test_plans_execution_trend_graph. `data` is the report envelope and carries only the section that was requested.", + "Ids are forwarded to the reporting backend uncoerced — send the INTEGER plan id / run id, not TP-NNN or TR-NNN.", + "There is nothing to create, update or delete here; this returns chart data only. For the burndown view there is a separate chart route that accepts a plan and rejects a run.", + "Needs the test-plan VIEW permission (unlike the archived reads, which need delete)." + ], + "returns": [ + "success", + "data", + "test_plans_execution_trend_graph" + ] + }, + { + "name": "get_test_plan_linked_sessions_chart_data", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/linked-sessions-chart-data", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "intent": "Chart data for the exploratory sessions linked to one test plan — the plan's sessions insight tile", + "shape": "discovered", + "responses": { + "200": { + "description": "`{success, data}` — linked-session chart data for the plan.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes the plan's INTEGER id in the path and nothing else: no query parameters, no paging, no date window.", + "The payload is unwrapped for you — where the upstream reply nests everything under `result`, that inner object is what lands in `data`.", + "A plan with no linked exploratory sessions returns 200 with an empty/zeroed `data`, not a 404. Do not read an empty chart as a missing plan.", + "This route is served by the exploratory-sessions handler rather than the test-plan one, so it is not behind the test-plan view permission the other plan reads use.", + "Chart data only — there is no session list here. Use the exploratory-session listing capabilities to get the sessions themselves." + ], + "returns": [ + "success", + "data" + ] + }, + { + "name": "list_test_plan_test_runs_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page — the page size is fixed at 30." + }, + { + "name": "q", + "type": "string", + "description": "Free-text search over the linked runs. Supplying it also turns off the analytics rollup, so progress fields come back thinner than on an unsearched page." + }, + { + "name": "include_sub_test_plan_runs", + "type": "boolean", + "description": "Also return the runs linked to this plan's sub-plans. Off by default, so a run a user attributes to the plan can be missing simply because it hangs off a sub-plan." + }, + { + "name": "compact", + "type": "boolean", + "description": "Ask upstream for the slim linkage payload (identity fields only, no pagination) used to pre-seed an edit form. Do not combine it with paging expectations." + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Required to see the runs of an ARCHIVED plan — without it an archived plan's run list comes back empty rather than erroring." + } + ], + "intent": "List the test runs linked to a test plan, with each run's state and progress — optionally including the runs that belong to its sub-plans", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListTestPlanTestRunsByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The plan is addressed by INTEGER id; the route only matches digits, so a TP-NNN identifier 404s at routing.", + "Each row's `id` is the TR-NNN string and the integer lives in `uuid`. Take `uuid` for anything that wants a numeric run id — including the plan's own unlink call and the plan update's test_runs list.", + "Sub-plan runs are excluded by default. Pass include_sub_test_plan_runs=true before telling a user a run is not in the plan.", + "An archived plan returns an empty run list unless is_archived=true is passed — empty is not proof the plan has no runs.", + "Page size is fixed at 30 and there is no per_page parameter; page with `p` and stop using the `info` envelope. `count` is {active, closed} across the whole linkage, not the current page.", + "Rows are run summaries, not run detail: `overall_progress_by_status_id` is the useful rollup and `overall_progress` is the legacy one. To see a run's cases, read the run's test cases separately." + ], + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "run_state", + "active_state", + "owner", + "assignee", + "tags", + "is_automation", + "is_dynamic", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "test_plans", + "project_id", + "created_at", + "updated_at" + ], + "max_page_size": 30 + }, + { + "name": "get_test_results_custom_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-results/custom-fields", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. PR-NNN identifiers do not match the route." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control on this endpoint — the page size is fixed at 30." + } + ], + "intent": "List the custom fields a project shows on a test RESULT — the extra fields a tester fills when recording a pass or fail, with the ids and types needed to send values", + "returns": [], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "TestResultCustomFieldsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Result fields only. This endpoint hard-codes entity_type=TestResult, so nothing here overlaps the test-case form fields — a field defined against TestCase never appears, whatever the project.", + "`field_type` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean …), not the canonical vocabulary the admin create/update calls take. A nested_dropdown field has no legacy spelling and comes back as null.", + "Page size is fixed at 30 and cannot be raised: `per_page` is accepted on the wire and discarded, and only `p` is read. Page until `info.next` is null.", + "`optional` and `is_required` are inverses of each other; prefer is_required.", + "Each row's `links.self` is the ready-made path to that field's allowed values scoped to this project — follow it instead of building the path, and note it resolves to the project user list for a `user` field.", + "`option_values` / `field_values` are present only when the upstream row carried them, so an absent key is not proof the field has no options.", + "project_id is the integer id; PR-NNN identifiers do not match the route. An unknown project is rejected by the project lookup before the fields are read." + ] + }, + { + "name": "list_test_results_for_test_case_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "read", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Read the result history for one test case inside one test run — every attempt logged against that case with its status, author, note, attachments, linked defects and custom fields; the read for 'what happened when we executed this case?'", + "returns": [ + "success", + "test-results", + "info", + "unique_issues", + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "custom_fields_count", + "test_run_step_result", + "attachments", + "issues", + "links" + ], + "responses": { + "200": { + "$response": "TestResultListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The list key is `test-results` with a HYPHEN. Reading response['test_results'] finds nothing on a perfectly populated response — this endpoint is the odd one out.", + "Newest first: the first entry is the latest attempt, and it is exactly the one the PATCH-latest capability edits.", + "Page size is FIXED at 30 and is not caller-controllable — `p` is the only paging input, and there is no per_page. Paging metadata is under `info` (count / page / page_size / next / prev).", + "With a configuration matrix you get rows for every configuration mixed together. Narrow with configuration_id, or with mapping_id for one specific run/case row, before concluding anything about 'the' result.", + "`unique_issues` at the top level is the de-duplicated set of defects linked across all the results returned — use it rather than unioning each result's own issues.", + "Custom fields arrive already grouped and typed per result, with `custom_fields_count` beside them; per-step outcomes hang off each result as `test_run_step_result`.", + "An empty list means the case has never been executed in this run — it is not an error and not a permission problem." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Page size is fixed at 30 server-side and cannot be raised." + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Restrict to results logged against one configuration." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Restrict to one specific run/case mapping row." + } + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "list_test_results_for_test_case", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/results", + "mode": "read", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-123", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-101", + "description": "Test case IDENTIFIER (TC- form), resolved by lookup — not the numeric internal id the v1 route takes. An identifier that does not resolve in this project returns 404 \"Test case not found\"." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Note the name is `p`, not `page`; anything else is ignored and page 1 comes back." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Results per page. ONLY 30 or 300 are accepted — any other value is rejected 400 \"Invalid page_size\"." + }, + { + "name": "count", + "type": "integer", + "values": [ + 30, + 300 + ], + "description": "Alias for page_size, and it WINS when both are sent. Same 30-or-300 restriction." + } + ], + "intent": "Read what was recorded for one test case in one test run — every execution logged against it, with status, notes, linked defects, custom fields and configuration", + "guidance": [ + "Returns the case's result history in this run, one entry per recorded execution — not just the latest outcome. Take the newest entry if you only want the current status.", + "Only genuinely logged executions appear here. A case that has been added to the run but never executed has no entry at all, even though the run's case grid shows it with a result id — that id is the run row, not a result.", + "`result_status` on this endpoint is deliberately cut down to `field_value` alone (display case, e.g. 'Passed'). There is no id, internal_name, field_name or colour here, unlike the v1 result reads. Match on the display-case name, or resolve the project's configured statuses separately if you need the id or colour.", + "`custom_fields` is an ARRAY of entries, each carrying a field_name and a value — not an object keyed by field name. It is empty when the result has no custom field values. field_name is the field's system name, and for dropdown / single-select fields the value is the selected option's id rather than its label, so resolve the option separately if you need text.", + "Both ids are identifiers: TR- for the run, TC- for the case. Passing internal numeric ids does not match the route (run) or does not resolve (case).", + "Page size is a closed set: 30 or 300, nothing in between. The page parameter is `p`.", + "`urls.self` is a deep link to the case's row in the run. It is omitted (the key comes back null) when the result cannot be tied to a project, run, case and run row all at once.", + "`step_result` is null for case-level results and only populated for step-level ones; `configuration_id` is null when the run row has no configuration; `dataset` is empty for non-data-driven runs.", + "Results are listed under `test_results` with an underscore here — the v1 list uses `test-results` with a hyphen, so a client written against v1 reads an empty list and reports no results." + ], + "returns": [ + "success", + "test_results", + "info", + "id", + "created_at", + "updated_at", + "created_by", + "description", + "test_case_id", + "test_run_id", + "result_status", + "issues", + "custom_fields", + "step_result", + "configuration_id", + "execution_id", + "dataset", + "urls" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "V2TestResultListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "integer", + "required": true + } + ], + "intent": "Get a test run by INTEGER id (v1) — full detail + its TR-NNN identifier", + "guidance": [ + "The run's id field is the display identifier string, while uuid is the integer. Take uuid whenever you carry this run into another call: the display string is rejected downstream." + ], + "returns": [ + "id", + "identifier", + "name", + "uuid" + ], + "responses": { + "200": { + "description": "Test run detail; data.test_run.identifier is the TR-NNN form.", + "schema": { + "$schema": "V1TestRunDetail" + } + }, + "401": { + "description": "Unauthorized — authentication missing or invalid." + }, + "404": { + "description": "No test run with that integer id (or not visible to this user)." + } + } + }, + { + "name": "get_test_run_detail", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/detail", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Either the TR-NNN identifier or the bare integer — the v1 run routes accept both forms in the path." + } + ], + "intent": "Open a test run's detail view — its header, progress counts, linked defects and the dynamic-filter rule behind it. This does NOT return the run's test cases; list those separately", + "shape": "discovered", + "responses": { + "200": { + "$schema": "TestRunResponse__ListTestRunCases" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the path, this returns NO per-case rows. It is the run header enriched with linked issues and the dynamic-filter rule; the run's cases come from the run's test-cases listing.", + "This is the only read that returns the run's filter rule, and it returns it twice under two names — `dynamic_filter` and `filters` — with identical content. Read either.", + "It also returns `issues` (defects linked to the run) and `test_result_issues` (defects linked to individual results), which the plain run read omits.", + "The run's `id` is the TR-NNN STRING; the integer is in `uuid`. Take `uuid` whenever a numeric run id leaves this read.", + "The run sits under `data.test_run` — with an underscore, inside a `data` envelope. The write endpoints on this entity use `data.testrun` instead, and the v2 run read has no `data` at all.", + "`trtc_folder_progress_enabled` is a group feature flag, and a lookup failure is reported as false — treat false as 'not available', not as a confirmed setting.", + "Needs the test-run view permission, with the automation variant demanded on automation runs." + ], + "returns": [ + "data", + "success", + "test_run", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "attachments", + "metadata", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "closed_at", + "closed_by", + "created_by", + "updated_by", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ] + }, + { + "name": "list_test_run_test_cases", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." + }, + { + "name": "page_size", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page. Defaults to 30 when omitted. Above 300 the request errors ('Page limit exceeds the maximum limit of 300'); with fetch_steps=true the ceiling drops to 30." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "description": "Alias for page_size and read first when both are sent. Same ceilings." + }, + { + "name": "fetch_steps", + "type": "boolean", + "default": false, + "description": "Include each case's steps. Pass the literal string 'true'. Doing so lowers the maximum page size from 300 to 30." + }, + { + "name": "minify", + "type": "boolean", + "default": false, + "description": "Compact row shape. Pass the literal string 'true'." + }, + { + "name": "is_shared", + "type": "boolean", + "description": "Restrict to cases shared in from another project." + }, + { + "name": "source_project_id", + "type": "integer", + "description": "Numeric id of the origin project, to narrow shared cases to one source project." + }, + { + "name": "include_subfolders", + "type": "boolean", + "default": false, + "description": "Make folder_id include all descendant folders." + }, + { + "name": "match", + "type": "object", + "description": "Per-field operator, e.g. match[tags]=all&match[assignee]=none. Multi-value fields (tags) take all|any|none|only; single-value fields take only any|none — anything else is a 400. match[global]=all (default) ANDs the fields, match[global]=any ORs them. `only` needs a group entitlement, else 403." + }, + { + "name": "latest_status", + "type": "string", + "description": "Comma-separated latest execution statuses (untested, passed, failed, retest, blocked, skipped, in_progress, plus any custom group status). Unknown values are a 400 listing what is valid." + }, + { + "name": "assignee", + "type": "string", + "description": "Comma-separated assignee emails. Use match[assignee]=none for unassigned cases. An email that does not resolve in the group is a 400, never a silently ignored filter." + }, + { + "name": "priority", + "type": "string", + "description": "Priority label, resolved to an id inside the group." + }, + { + "name": "tags", + "type": "string", + "description": "Comma-separated tags. Combine with match[tags]=all|any|none|only." + }, + { + "name": "folder_id", + "type": "string", + "description": "Folder id. Pair with include_subfolders=true to include descendants." + }, + { + "name": "configuration", + "type": "string", + "description": "Configuration label, resolved to an id inside the group." + }, + { + "name": "name", + "type": "string", + "description": "Substring match on the test case name." + }, + { + "name": "identifier", + "type": "string", + "description": "Exact test case identifier, e.g. TC-42." + }, + { + "name": "query", + "type": "string", + "description": "Free-text search across name and identifier." + }, + { + "name": "test_case_status", + "type": "string", + "description": "Test case status label, resolved to an id inside the group." + }, + { + "name": "test_case_owner", + "type": "string", + "description": "Comma-separated test case owner emails." + }, + { + "name": "case_type", + "type": "string", + "description": "Category / type label, resolved to an id inside the group." + }, + { + "name": "automation_status", + "type": "string", + "description": "Automation status label, resolved to an id inside the group." + }, + { + "name": "created_by", + "type": "string", + "description": "Comma-separated creator emails." + }, + { + "name": "updated_by", + "type": "string", + "description": "Comma-separated last-updater emails." + }, + { + "name": "template", + "type": "string", + "description": "Template label, resolved to an id inside the group." + }, + { + "name": "created_between", + "type": "string", + "description": "Two comma-separated dates (start,end) bounding creation time." + }, + { + "name": "updated_between", + "type": "string", + "description": "Two comma-separated dates (start,end) bounding last-update time." + } + ], + "intent": "List the test cases inside a run with their latest result, assignee and configuration — the read to answer \"what is still untested / failing in this run?\"", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size` (or `count`). The default page is 30 rows and the hard ceiling is 300; asking for more errors instead of clamping.", + "fetch_steps=true drops the ceiling to 30 rows per page, so a run of any size takes many more calls with steps on. It also adds a `steps` array to each row, which is otherwise absent.", + "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 'request_limit_exceeded'. Filter the run down rather than walking a huge run to the end.", + "Filters fail closed — an unknown status, an unresolvable email or an invalid match mode returns 400 listing the valid values, so a filtered read never quietly degrades into the whole run.", + "Rows are per case-and-configuration mapping: a case mapped to three configurations comes back as three rows, each with its own configuration_id and latest_status. configuration_id is null for cases with no configuration.", + "Rows carry more than the core execution fields: is_shared, source_project_name, execution_id, created_at, last_updated_at, created_by and updated_by arrive on every row, plus a urls object. The created/updated stamps belong to the row's place in the run, not to the underlying test case record.", + "Use latest_result_id from a row when you need to fetch or amend that case's recorded result. It is populated even on rows that have never been executed — the result row is minted when the case is mapped into the run — so a non-null id does NOT mean the case has run. Detect 'never executed' from latest_status being untested instead.", + "The `urls.self` deep link on these rows is currently emitted without the run segment, so it does not resolve. Build run links from the run identifier you already hold rather than following it." + ], + "returns": [ + "success", + "info", + "test_cases", + "identifier", + "name", + "description", + "latest_status", + "latest_result_id", + "assignee", + "priority", + "status", + "case_type", + "configuration_id", + "folder_path", + "dataset", + "project_id", + "source_project_id", + "source_project_name", + "is_shared", + "execution_id", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "TestRunTestCasesV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "query": [ + { + "name": "minify", + "type": "boolean", + "default": false, + "description": "Return the compact run payload (drops the heavier analytics/detail fields). Pass the literal string 'true'." + } + ], + "intent": "Look up one test run — its state, owner, tags, configurations, linked plan and per-status progress counts", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Both ids are prefixed strings here (PR-1 / TR-14). The v2 route only matches PR-\\d+ and TR-\\d+, so the integer ids the v1 run capabilities take give a 404 on this path.", + "This returns the run header only — progress is aggregated counts. To see which cases are in the run, or their latest results, read the run's test cases instead.", + "403 means the caller lacks view permission for this run type (automation runs are gated separately from manual ones).", + "The run is at the top level of the response as `test_run`; there is no `data` envelope here. The v1 run endpoints wrap their payload in `data` — these v2 ones do not.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "test_cases", + "urls" + ], + "responses": { + "200": { + "$schema": "TestRunV2Response__ListTestRun" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_runs_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/form-fields", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "all", + "type": "boolean", + "default": false, + "description": "Include the project's custom result fields as well as the defaults. PRESENCE-checked, not value-checked: `all=false` and `all=0` behave exactly like `all=true`. The only way to get the compact shape is to omit the parameter entirely." + } + ], + "intent": "Discover the fields available when recording a test RESULT in this project — the built-in status options and, on request, the project's custom result fields — so a result can be submitted with valid values", + "returns": [], + "responses": { + "200": { + "$response": "TestRunFormFieldsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These are TEST RESULT fields, not test run fields — despite the path. Use them to build a valid result submission (status id, custom field ids and their allowed option values).", + "The response SHAPE changes with `all`: omit it and you get {success, default_fields} only; send it and `custom_fields` appears alongside. Code that reads custom_fields must send the parameter.", + "`default_fields` is an object keyed by field name (today just `status`), not an array. Each entry lists its allowed values with their numeric ids — those ids, not the display labels, are what result writes take.", + "The import-only pseudo-fields (owner_imported, owner_imported_email) are stripped from custom_fields, so a field visible in an exported CSV can be missing here.", + "The response is flat — {success, default_fields, custom_fields} with no `data` envelope." + ] + }, + { + "name": "list_test_runs_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/selection", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "select_all", + "type": "boolean", + "example": false, + "description": "Select every case in the project. When false, `folders` decides the set.", + "json_path": "/selection/select_all" + }, + { + "name": "unique_test_case_count", + "type": "integer", + "example": 2, + "description": "The caller's own count of the selection. Echoed through; it does not constrain the result.", + "json_path": "/selection/unique_test_case_count" + }, + { + "name": "folders", + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. A folder with test_case_select_all takes the whole folder minus deselected_test_cases; otherwise only selected_test_cases counts.", + "fields": [ + { + "name": "test_case_select_all", + "type": "boolean" + }, + { + "name": "selected_test_cases", + "type": "array" + }, + { + "name": "deselected_test_cases", + "type": "array" + } + ], + "json_path": "/selection/folders" + }, + { + "name": "show", + "type": "string", + "values": [ + "all", + "filtered" + ], + "default": "filtered", + "description": "`filtered` (the default, and what ANY value other than the literal 'all' means) returns only the cases matching both the selection and `q`. `all` returns the whole selection and adds a `selected` array naming which of those rows also match `q`." + }, + { + "name": "q", + "type": "object", + "description": "Test-case filter/search object, intersected with the selection. Omit it to get the selection as-is. Free-text goes in as a search string matched against case name and identifier." + }, + { + "name": "count", + "type": "integer", + "maximum": 20000, + "default": 30, + "description": "PAGE SIZE, despite the name — not a count to return. Defaults to 30 when absent or unparseable, and a value above 20000 is refused with a page-size error." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "shared_selection", + "type": "object", + "description": "Accepted by the parameter filter but NOT read by this action — cases shared in from other projects are not resolved here. Use the count endpoint if you need shared selections accounted for." + } + ], + "intent": "Resolve a folder/case selection into the actual list of test cases it covers — use it to preview or page through what a run create or edit would pull in before committing it", + "returns": [ + "success", + "info", + "test_cases", + "selected", + "id", + "identifier", + "name", + "description", + "type", + "owner", + "folder_id", + "template", + "template_id", + "project_id", + "group_id", + "is_shared", + "is_project_level", + "review_status", + "estimated_duration_seconds", + "automation_status", + "archived_at", + "trashed_at", + "created_at", + "updated_at", + "preconditions", + "expected_result" + ], + "responses": { + "200": { + "$response": "TestRunsSelectionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This returns TEST CASES, not test runs, despite sitting under the test-runs path and despite the older wording of its name. It is the preview step for a run's case selection.", + "`selection` is effectively mandatory: the backing service reads straight into it and a body without it fails rather than defaulting to everything.", + "Page size is the `count` field, not `per_page`, and it caps at 20000; leaving it out gives 30 rows, which is how a selection preview looks far smaller than the run it will create.", + "`selected` only appears when show='all'. In the default mode the rows already ARE the filtered set, so its absence is not a failure.", + "Folder ids are STRING keys in the `folders` map, while the case ids inside are integers. Mixing those up drops the folder silently.", + "shared_selection is ignored here. A selection that pulls cases from other projects will preview short.", + "The response is flat — {success, info, test_cases, selected} with no `data` envelope.", + "Needs the test-run write permission even though it only reads — a viewer-only role is 403'd.", + "The rows here are the RAW case record, not the shared test-case serializer used by every other test-case route. `automation_status` is a plain string, the folder key is `folder_id` (not `test_case_folder_id`), case type arrives as the bare string `type`, and priority, status, case_type, tags, issues, custom_fields and steps are NOT returned at all. Read the case detail route if you need those." + ], + "paginated": true, + "max_page_size": 20000 + }, + { + "name": "list_test_runs_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. There is no page-size parameter: the page is fixed at 30 rows (5000 when filter_mapping is set)." + }, + { + "name": "ignore_run_type", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' to stop the backing service filtering by run type, so manual and automation runs are listed together. Any other value, including boolean true in some encodings, leaves the default filtering in place." + }, + { + "name": "include_test_plan", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' to include each run's linked test plan on the row, so you can tell which runs are already attached to a plan without a second read." + }, + { + "name": "filter_mapping", + "type": "boolean", + "default": false, + "description": "Pass the literal string 'true' for the lightweight picker shape: the page grows to 5000 rows, progress analytics are skipped, and every run is reduced to {id, identifier} ONLY. Every other field disappears — do not use it when you need run state, owner or counts." + } + ], + "intent": "List a project's active test runs with their progress counts, owner and linked plan — the starting point for finding a run to read, execute or report on", + "returns": [ + "success", + "test_runs", + "info", + "count", + "id", + "uuid", + "identifier", + "name", + "description", + "owner", + "assignee", + "run_state", + "active_state", + "is_automation", + "is_dynamic", + "tags", + "configurations", + "test_plans", + "test_cases_count", + "overall_progress", + "overall_progress_by_status_id", + "build_meta", + "assignee_imported", + "environment", + "project_id", + "created_at", + "updated_at", + "observability_url", + "links" + ], + "responses": { + "200": { + "$schema": "TestRunsListResponse__ListTestRunsByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Closed runs are NOT in this list. A run count or an 'all our runs' answer built from this endpoint alone silently omits every finished cycle — read the closed-runs listing too.", + "Page size is fixed at 30 and there is no per_page; only `p` moves. The one exception is filter_mapping=true, which raises it to 5000 and strips every field but id and identifier.", + "Each run's `id` is the TR-NNN STRING, and the integer id is in `uuid`. Take `uuid` whenever a numeric run id leaves this read — passing the string into a test plan's run list reaches the backing service uncoerced and fails there.", + "The three boolean query params are compared against the literal string 'true'; anything else, including 1 or the word yes, is treated as not set.", + "The response is flat — {success, test_runs, info, count} with no `data` envelope. `count` is the project-wide per-state totals, not this page's size; the page's size is in `info`.", + "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent — do not read a zeroed `overall_progress` as 'nothing was executed'.", + "To filter or search runs rather than page through them, use the project entity search with the test-runs entity; this endpoint takes no filter parameters.", + "Needs the test-run view permission, with the automation variant demanded for automation runs." + ], + "paginated": true, + "max_page_size": 30 + }, + { + "name": "import_dataset_csv", + "method": "POST", + "path": "/api/v1/projects/{project_id}/datasets/import_csv", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Parse an uploaded CSV file into column headers and rows — NOT INVOKABLE from this profile: it takes a multipart file upload, and it does not create a dataset", + "responses": { + "200": { + "description": "Parsed CSV contents, wrapped in a `data` envelope (unlike every other dataset endpoint, which returns at the top level). Nothing was written.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "headers": { + "type": "array", + "description": "The CSV header row, in file order — the candidate column names.", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "description": "Data rows as header-keyed objects, not positional arrays — they are NOT in the shape the dataset create call expects.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "400": { + "description": "{error:'No file uploaded'} — what every invocation from this profile returns, because no file part can be sent." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, error:...} — oversized file, non-.csv extension, blank headers, too many rows or columns, or malformed CSV. Also the Team Pro gate, which renders {success:false, message:'Feature not available'}." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "QUARANTINE CANDIDATE — do not attempt to call this. The only input is a multipart/form-data file part; a byte stream has to cross the boundary and there is no way to declare it here, so this record has no parameter for the one input it needs and any invocation fails with 400 {error:'No file uploaded'}.", + "The name is misleading: this does not import anything. It validates and parses the CSV and returns the parsed headers and rows to the caller. Nothing is written — no dataset is created or modified by this call.", + "To get the same result without a file upload: parse the CSV yourself and call the dataset create capability with the header row as `variables` and the data rows as `rows`. That is what the UI does with this endpoint's output.", + "If it is ever reachable, the limits enforced are: .csv extension only, 50 MB max (the published spec's 10 MB is wrong), every header non-blank, and at most 40 columns / 100 rows for a standard group (100 / 200 for groups on the raised-limit flag). Failures come back as 422 {success:false, error:...}.", + "Still behind the Team Pro plan gate and the test-case write permission like the rest of the dataset family." + ], + "disabled": true + }, + { + "name": "list_activity_events", + "method": "GET", + "path": "/api/v1/activities", + "mode": "read", + "entity": "activity", + "query": [ + { + "name": "project_id", + "type": "integer", + "description": "Narrow the group-wide feed to one project. A query filter, not a path segment." + }, + { + "name": "cursor", + "type": "string", + "description": "Opaque cursor from a previous page's pagination.next_cursor. There is no page number." + }, + { + "name": "limit", + "type": "integer", + "description": "Page size, forwarded to the backing service which owns the default and ceiling; the empty-starred short-circuit reports 50. Trust pagination.limit for what you actually got." + }, + { + "name": "actor_id", + "type": "integer", + "description": "Filter to one actor, as a BrowserStack user id (the same id events carry in actor_id)." + }, + { + "name": "starred_only", + "type": "boolean", + "description": "Send the literal string 'true' to restrict to the caller's starred projects. With none starred the response is {events: [], no_starred_projects: true} — that is 'nothing starred', not 'no activity'." + }, + { + "name": "entity_type", + "type": "array", + "description": "Filter by subject entity type. An array parameter — repeat the key rather than sending a comma-joined string." + }, + { + "name": "origin_channel", + "type": "string", + "values": [ + "ui", + "api" + ], + "description": "Restrict to events raised from the UI or from the API. Any other value is silently DROPPED rather than rejected, so a typo quietly widens the result instead of erroring." + } + ], + "intent": "Read the activity feed (audit trail) — CURSOR paged, 15-day window", + "returns": [ + "success", + "events", + "pagination", + "projects", + "no_starred_projects", + "next_cursor", + "has_next_page", + "limit", + "actor_id", + "actor_name" + ], + "responses": { + "200": { + "description": "A cursor page of activity events.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "events": { + "type": "array", + "items": { + "type": "object" + } + }, + "pagination": { + "type": "object", + "properties": { + "next_cursor": { + "type": "string", + "nullable": true + }, + "has_next_page": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + } + }, + "projects": { + "type": "object", + "description": "Project id → metadata, for resolving event project references." + }, + "no_starred_projects": { + "type": "boolean", + "description": "Present and true only when starred_only was set and the user has none starred." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Cursor paging only: follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no page number and no total count, so you cannot state how many events exist or jump to a page.", + "The server clamps every query to the last 15 DAYS regardless of what you ask for. For anything older, say the history is not retrievable through the API and point at the Test Management UI — reporting 'no activity' for an older period is a false negative.", + "Group-wide, not project-scoped: `project_id` is a query filter, not a path segment. The response's `projects` map (project id to metadata) resolves the project each event refers to without a second read.", + "`starred_only` is compared against the literal string 'true'. When the caller has no starred projects the reply is {events: [], no_starred_projects: true} — that means 'nothing is starred', NOT 'no activity'. Check that flag before reporting emptiness.", + "Gated by a group feature flag. A 403 {success:false, error:'Activity feed is not enabled'} means the feed is off for the group, not that the caller lacks permission — say so rather than reporting no activity.", + "Every event is enriched with `actor_name`; an actor who cannot be resolved inside the caller's group renders as 'Unknown User' rather than being dropped, so an unfamiliar name is not necessarily a data gap.", + "`entity_type` is an array filter — repeat the key rather than sending a comma-joined string. `origin_channel` accepts only 'ui' or 'api'; any other value is dropped silently instead of erroring, which widens the result rather than narrowing it.", + "Read-only by design: activity rows are emitted as a side effect of other writes. There is no create, update or delete, so an audit entry cannot be edited or removed on request.", + "Per-project visibility is applied downstream — the feed is already scoped to projects the caller can access, so an empty page can mean 'no accessible projects had events' rather than 'nothing happened'." + ], + "disabled": true + }, + { + "name": "list_archived_test_plans", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans/archived_test_plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. Page size is fixed at 30; there is no per_page." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match on the archived plan's name or identifier." + }, + { + "name": "plan_status", + "type": "string", + "description": "Status filter for the archived view, forwarded upstream as given (a comma-joined list such as 'new,started' is accepted). Unlike the active listing, an unrecognised value is not rejected here — it simply matches nothing." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Forwarded WITHOUT validation on this route (the active listing validates the same parameter), so a typo is passed upstream rather than returning a 400." + } + ], + "intent": "Page through the test plans that have been ARCHIVED in a project — the place to find a plan to restore; archived plans do not appear in the normal plan listing at all", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "V1ArchivedTestPlanListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "string" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a fixed literal route on the plan collection — it takes no plan id. It is a different endpoint from the plan detail read that sits on the same path prefix.", + "Gated on the group archive flag: when it is off the call is 404 {success: false, errors: 'Feature not available'}, which means the feature is unavailable, not that nothing is archived.", + "Needs the test-plan DELETE permission, not view — a user who can list plans normally can still be 403'd here.", + "Archived SUB-plans are not listed; there is no include_sub_plans switch on this route. Only top-level archived plans come back.", + "Rows are serialized exactly like the active listing (same fields, plus record_status: 'archived'), so anything that consumes the normal plan list works unchanged here.", + "Page size is fixed at 30; page with `p` and stop using the `info` envelope. There is no `count` object on this response, unlike the active listing.", + "To put a plan back, pass its integer id to the bulk-retrieve capability." + ], + "returns": [ + "success", + "test_plans", + "info", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "record_status", + "start_date", + "end_date", + "owner", + "assignee", + "tags", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "test_plan_progress_v2", + "project_id", + "created_at", + "updated_at", + "links" + ], + "max_page_size": 30 + }, + { + "name": "list_custom_field_dataset_options", + "method": "GET", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces as a bodyless 404." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Not validated by this handler; a dataset belonging to another field returns the same bodyless 404." + } + ], + "intent": "List the option values a dataset offers, with the numeric option id each one carries — the ids you need to rename, re-default or delete an option", + "shape": "discovered", + "responses": { + "200": { + "$response": "CustomFieldAdminDatasetOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is where option ids come from. The dataset read returns options only when asked and without a stable contract; this call always returns them with their ids.", + "Each row is {id, option_value, is_default, colour, parent_option_id}. When any row carries a parent_option_id the whole list is rebuilt as a tree and children appear under a `children` key on their parent instead of at the top level — a flat scan of `data` then misses them.", + "Exception: if every row in the page has a parent (a single level of a nested_dropdown read on its own), the tree build finds no root and falls back to returning the rows flat.", + "Paging is `p` only; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata. On a nested field the tree is built per page, so a parent and its children can land on different pages.", + "Neither path id is validated here, so a wrong field/dataset pair reaches storage and returns a bare 404 {success:false} with no message." + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control — page size is fixed at 30 and `per_page` is ignored." + } + ], + "paginated": true, + "max_page_size": 30, + "returns": [ + "success", + "data", + "info", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id", + "children" + ] + }, + { + "name": "list_datasets", + "method": "GET", + "path": "/api/v1/projects/{project_id}/datasets", + "mode": "read", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[name]", + "type": "string", + "description": "Partial, case-insensitive match on dataset name. Use this to find a dataset you only know by name." + }, + { + "name": "q[uuid]", + "type": "string", + "description": "Exact match on dataset uuid." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort order, forwarded unchanged to the backing store; the accepted values are not validated or enumerated here, and an unrecognised one is not rejected by this service. Omit it unless you know the store's vocabulary." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Validated globally: a non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}; p=0 is treated as page 1. There is no page-size parameter — see max_page_size." + } + ], + "intent": "Find datasets in a project — browse them or look one up by name or uuid to get the uuid the other dataset calls need", + "returns": [ + "success", + "datasets", + "info", + "uuid", + "name", + "rows_count", + "test_cases_count", + "created_by", + "created_at", + "updated_at" + ], + "paginated": true, + "responses": { + "200": { + "description": "A page of datasets, with paging metadata under `info`.", + "schema": { + "$schema": "DatasetsListResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric page value." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "{success:false, message:'Feature not available'} — the group lacks the Team Pro plan." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are the compact form: name, uuid, counts, tags, metadata, creator and timestamps — but NOT the columns or rows. Read a single dataset by uuid to get its contents.", + "Page size is fixed at 30 and cannot be requested; ask for successive `p` values and stop when `info.next` is null.", + "`test_cases_count` here is the cheapest way to tell whether a dataset is in use before deleting it.", + "A creator no longer in the group resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", + "422 {message:'Feature not available'} means the group lacks the Team Pro plan — every dataset endpoint answers this way, and it is an entitlement answer, not a payload error.", + "`success`, `datasets` and `info` are all top level; there is no `data` envelope and no second nesting under `datasets`." + ], + "max_page_size": 30 + }, + { + "name": "list_duplicates", + "method": "GET", + "path": "/api/v1/projects/{project_id}/ai-duplicates", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `p` is accepted as an alias and `page` WINS if both are sent. Unlike `p`, `page` is NOT validated: a non-numeric value silently becomes 0 and is forwarded as page 0. Send a positive integer." + }, + { + "name": "entity_type", + "type": "string", + "description": "Accepted and IGNORED — the handler never reads it. Every pair links test cases; there is no other entity type. Sending it changes nothing." + }, + { + "name": "entity_id", + "type": "integer", + "description": "Integer test-case id. Restricts the listing to pairs involving that case AND strips that case out of each returned pair, so each row's `test_cases` holds only the OTHER case. Use it to answer 'what is this test case a duplicate of'." + }, + { + "name": "duplicates", + "type": "string", + "values": [ + "exact", + "semantic" + ], + "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim to the backing store and not validated here, so an unrecognised value narrows to nothing rather than returning an error. Omit for both types." + }, + { + "name": "owner", + "type": "string", + "description": "The 'My duplicates' tab. ANY non-empty value scopes the listing to pairs involving the CALLER's own test cases — the value itself is discarded, so this cannot select another user. Omit for all duplicates in the project." + } + ], + "intent": "List the AI-suggested duplicate test-case pairs awaiting review in a project, highest-confidence first — optionally only those involving one test case, or only the caller's own", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "Suggested duplicate pairs. This exact shape with an empty `duplicates` is also what comes back when the group lacks the dedupe feature flag, so an empty list is not evidence the project has none.", + "schema": { + "$schema": "V1DuplicatesListResponse", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "last_scan_time": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the dedupe scan that produced these groups last ran. NOTE: this can be populated while `get_dedupe_status` reports NOT_ENABLED for the same project - the two disagree." + }, + "duplicates": { + "type": "array", + "items": { + "$schema": "DuplicateGroup" + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An empty list is ambiguous. When the group lacks the dedupe feature flag this returns 200 with exactly {success:true, duplicates:[], info:{count:0,total_pages:0}} — indistinguishable from a genuinely clean project. Read the dedupe status capability to tell 'feature off / never run' from 'nothing found'.", + "Only pairs still in `suggested` status appear. Once a pair is merged, archived or discarded it leaves this listing permanently; there is no filter to bring it back.", + "Page size is fixed at 10 and cannot be requested. Pairs are ordered by confidence (ai_score descending, then id descending), NOT by age — the oldest suggestion is not on page 1 and a bigger page cannot be asked for.", + "`info.count` can exceed the rows you actually receive: a pair whose test case cannot be resolved is dropped from the page after the count is taken. Do not derive page counts from row arithmetic; use `info.total_pages` / `info.next`.", + "Carry each row's `id` forward — that integer is the duplicate id the read, resolve and discard calls take.", + "`last_scan_time` at the top level is the project's last dedupe run, not a property of this page; it is fetched separately and can be null when no scan has completed.", + "Duplicate REVIEW is all that is exposed here. There is no way to create a suggestion or trigger a scan through this API." + ], + "returns": [ + "success", + "duplicates", + "info" + ], + "max_page_size": 10 + }, + { + "name": "list_exploratory_session_defects", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. The only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 30." + } + ], + "intent": "List the defects and issues raised during an exploratory session — the external tracker tickets linked to the session and to its individual log entries — to review what the exploration found", + "returns": [ + "success", + "defects", + "info", + "issue_id", + "issue_type", + "jira_id", + "created_at" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionDefectsV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ONLY LOG-LEVEL DEFECTS APPEAR HERE. A defect linked on a session LOG shows up immediately; an issue linked on the SESSION itself (the `issues` array on create/update_exploratory_session) NEVER appears, though it persists and is readable from the session's own detail.", + "The row is FOUR fields - {issue_id, issue_type, jira_id, created_at}. It is NOT the full issue-link record: no id, entity_type, entity_id, entity_uuid, issue_status, priority, record_status, project_id, metadata or updated_at, all of which an earlier version of this contract promised.", + "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected as 400 {success:false, error:'Invalid Params'}.", + "`per_page` is accepted and ignored - the page size is fixed at 30. Walk `p` until a page comes back short.", + "No filters: no status, tracker or date narrowing. Filter client-side.", + "There is no project-wide defect listing in this index. 'What defects exist in this project' means listing sessions and issuing one request per session.", + "Everything is at the top level: defects, info. There is no `data` envelope.", + "An unknown session_id returns a bare {success:false} with 404 and no message." + ] + }, + { + "name": "list_exploratory_session_logs", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. The only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 50, + "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 50." + }, + { + "name": "q[status]", + "type": "string", + "example": "fail,bug", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "description": "Comma-separated list of statuses as a single string (e.g. 'retest,block') — not a repeated array parameter. Every value is allowlisted before the upstream call; anything else, or an empty list, returns 400 'Invalid status value.'. Does not affect status_counts." + } + ], + "intent": "Read the observations recorded during an exploratory session — the timestamped notes, their pass/fail/bug status, elapsed time, linked defects and embedded screenshots — optionally narrowed to particular statuses", + "returns": [ + "success", + "session_logs", + "info", + "status_counts", + "id", + "session_id", + "hashed_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "attachment_relations", + "created_at", + "updated_at" + ], + "max_page_size": 50, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionLogListV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Logs are children of one session: session_id in the path scopes the whole listing, and a log id is only meaningful together with the session it belongs to.", + "Paging uses `p`, NOT `page` — `page` is read by nothing here. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'}.", + "`per_page` is accepted and ignored: the handler overwrites it with the fixed page size of 50. Walk `p` until a page comes back short. Compare against session_log_count on the session to know when you have them all.", + "q[status] is a COMMA-SEPARATED STRING, not an array, and it is allowlisted before the upstream call: any value outside pass, fail, bug, retest, block, note, skipped returns 400 'Invalid status value.'. Note `skipped` — it is a real status that older documentation of this entity omits.", + "status_counts is meant to be the whole-session per-status totals, deliberately unfiltered by q[status]. Do not rely on it today: the typed upstream response object carries no such field, so this key is currently rendered as an empty object on every call.", + "There is no sort parameter on this endpoint and the upstream default is not ascending — the internal session-copy path has to ask for ascending order explicitly. Sort by created_at yourself rather than assuming chronological order.", + "`content` is sanitised HTML, not plain text, and embedded images are TM-hosted attachment URLs. What is stored can differ from what was written: links and images pointing outside the project or group are stripped on write.", + "Each log's `defects` are the issue links on that log. The session-level defects listing is a different, broader set — read that one for everything linked to the session.", + "Everything is at the top level: session_logs, info, status_counts. There is no `data` envelope." + ] + }, + { + "name": "list_exploratory_sessions", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. This is the only page parameter read on this path — `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Accepted but NOT honoured — the handler overwrites it with the fixed page size of 30. There is no way to widen the page from the caller side." + }, + { + "name": "q[session_state]", + "type": "string", + "values": [ + "active", + "closed" + ], + "example": "active", + "default": "active", + "description": "Lifecycle state. Defaults to 'active' when omitted, so closed sessions are invisible unless you ask for them. Not validated locally — an unrecognised value is forwarded upstream rather than rejected." + }, + { + "name": "q[assignee]", + "type": "integer", + "example": 42, + "description": "TCM user id of the session assignee; translated to a BrowserStack user id before the query is sent. Wins over q[owner] when both are given. An id that resolves to no user is sent as null instead of erroring." + }, + { + "name": "q[owner]", + "type": "integer", + "example": 42, + "description": "Alias for q[assignee], used only when q[assignee] is absent. Same TCM user id semantics." + }, + { + "name": "q[created_by]", + "type": "integer", + "example": 10, + "description": "TCM user id of the session creator; translated to a BrowserStack user id. An unresolvable id is sent as null rather than rejected." + }, + { + "name": "q[created_at_from]", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Created on or after this date. Forwarded verbatim — not parsed or validated here." + }, + { + "name": "q[created_at_to]", + "type": "string", + "format": "date", + "example": "2026-01-31", + "description": "Created on or before this date. Forwarded verbatim — not parsed or validated here." + }, + { + "name": "q[tags][]", + "type": "array", + "example": [ + "regression", + "payment" + ], + "description": "Tag names to match. Forwarded verbatim." + }, + { + "name": "q[configuration_ids][]", + "type": "array", + "example": [ + 1, + 2 + ], + "description": "Configuration ids (integers) the session runs against." + }, + { + "name": "q[search]", + "type": "string", + "example": "checkout", + "description": "Free-text match, forwarded upstream as-is." + }, + { + "name": "q[query]", + "type": "string", + "description": "Second free-text key the handler forwards alongside q[search]; both are passed through independently. Undocumented upstream — prefer q[search]." + }, + { + "name": "q[status]", + "type": "string", + "example": "fail", + "description": "Filter by the log statuses recorded inside the session. Forwarded verbatim and not allowlisted here, unlike the same-named filter on the session logs listing." + }, + { + "name": "q[test_plan_id]", + "type": "integer", + "example": 77, + "description": "Only sessions linked to this test plan. Takes the integer test plan id, not a TP-NNN identifier." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "id-asc", + "id-desc", + "title-asc", + "title-desc" + ], + "description": "Combined field+direction token. There is no separate sort_order parameter. Any other value is rejected with 400 'Invalid sort_by value.' before the upstream call." + } + ], + "intent": "Find exploratory testing sessions in a project — filter by state, assignee, creator, tags, configurations, linked test plan, date range or free text — to pick the session whose logs or defects you want to read next", + "returns": [ + "success", + "exploratory_sessions", + "info", + "count", + "id", + "identifier", + "title", + "session_state", + "timebox_duration", + "assignee", + "created_by", + "tags", + "attachments", + "created_at", + "updated_at", + "project_id", + "group_id", + "test_plan_id", + "summary", + "test_plan" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ExploratorySessionListV1Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The default listing hides closed work: when q[session_state] is omitted the handler substitutes 'active'. Pass q[session_state]=closed explicitly to find a finished session, and expect a session to vanish from the default list the moment it is closed.", + "Paging is driven by `p`, NOT `page`. `page` is read by nothing on this path. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'} before the action runs.", + "`per_page` is accepted and ignored — the handler overwrites it with the fixed limit of 30 rows. There is no way to ask for a larger page; walk `p` until a page comes back short.", + "q[assignee] / q[owner] / q[created_by] take TCM user ids (integers) and are translated to BrowserStack user ids before the query is sent. q[owner] is only a fallback for q[assignee] — if both are present, q[assignee] wins. An id that matches no user in this group is not an error: the filter is sent as null and you get back a result set that silently does not mean what you asked for.", + "sort_by is a single combined token ('id-asc', 'id-desc', 'title-asc', 'title-desc') — there is no separate sort_order. Anything else is 400 'Invalid sort_by value.'.", + "`count` is an OBJECT of per-state totals ({active, closed}), not the number of rows matching your filter. The row total lives in info.count. Do not use `count` to size a pagination loop.", + "Rows are NOT put through the custom-field normaliser that the single-session read applies, so custom_fields on a list row is the raw upstream shape (or absent). Re-read the session by id if you need normalised custom fields.", + "assignee / created_by / closed_by come back as user objects resolved from the local user table, and resolve to null when the underlying user is not a TM user in this group — a null assignee here does not prove the session is unassigned.", + "Take `id` (integer) from a row and carry it as session_id into the logs, defects, close, clone, update and delete calls. `identifier` (ES-NNN) is display-only and is not accepted by any route on this entity.", + "Everything is at the top level: exploratory_sessions, info, count. There is no `data` envelope.", + "This listing is a NARROW projection, not the full session. It does not return charter, description, duration, actual_duration, session_log_count, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations — read a single session for those. It is also the one session endpoint that does NOT normalise custom_fields." + ] + }, + { + "name": "list_filters", + "method": "GET", + "path": "/api/v1/projects/{project_id}/filter", + "mode": "read", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "entity", + "required": true, + "type": "string", + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Required — which list's saved views to return, one type per call. Omitting it is a 400 'Invalid entity type'; to see every view, call once per type. An unlisted value is rejected with 422 `{success:false, message:\"Invalid payload\"}` — 422 here, not 400." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. This is the only page parameter the handler reads — a `page` query parameter is ignored and you silently get page 1. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + } + ], + "intent": "List the saved filter views available in a project — yours and the project-wide ones — to find the filter_id to read, apply, update or delete", + "returns": [ + "success", + "data", + "info", + "id", + "name", + "is_project_level" + ], + "paginated": true, + "responses": { + "200": { + "$response": "FilterListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "max_page_size": 30, + "guidance": [ + "Page with `p`. The previously published `page` parameter is never read — sending it returns page 1 and looks like a successful call.", + "Page size is fixed at 30 and cannot be changed; there is no per_page or limit here. Walk pages until `info.next` is null.", + "The list mixes your private views with the project-level ones. Each row carries only `id`, `name` and `is_project_level` — no owner, entity_type or conditions — and `is_project_level` is the only thing that tells private views from project-wide ones.", + "An invalid `entity` returns 422, not the 400 the other filter endpoints use for validation failures.", + "Unlike reading a single filter, this listing does no custom-field validation and never rewrites anything — but it does not return a view's conditions either, only its id, name and scope.", + "Not permission-gated — the filter controller has no RBAC entry." + ] + }, + { + "name": "list_folder_contents", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}", + "mode": "read", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "Open one folder — its immediate subfolders, the folder's own record, and optionally its ancestor breadcrumb — to walk the tree a level at a time or work out where a folder sits", + "returns": [ + "success", + "folders", + "self", + "parent_id", + "ancestors", + "test_cases", + "info", + "id", + "name", + "notes", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "contents", + "links" + ], + "responses": { + "200": { + "$response": "FolderDetailsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number over the SUBFOLDERS. A non-positive or non-numeric value returns 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "detail", + "type": "boolean", + "default": false, + "description": "Send the literal string \"true\" to populate `ancestors` with the folder's breadcrumb up to the root. Without it `ancestors` comes back null. Only the exact string \"true\" enables it." + }, + { + "name": "nested_sub_folders", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" asks for the whole descendant subtree rather than one level. It is additionally gated on a group flag, so it silently does nothing when the group is not enabled. When it does take effect, `self.contents` is stripped from the response." + }, + { + "name": "case_count", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" to include per-folder test-case counts. Read only on the nested_sub_folders path; ignored on a plain one-level listing." + }, + { + "name": "total_count", + "type": "boolean", + "default": false, + "description": "Literal string \"true\" to include the total row count. Read only on the nested_sub_folders path; ignored on a plain one-level listing." + }, + { + "name": "folder_sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "created_at-asc", + "created_at-desc" + ], + "description": "Subfolder ordering. Comma-separated combinations accepted; anything else returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort gains a secondary created_at-asc tiebreak." + } + ], + "paginated": true, + "max_page_size": 300, + "guidance": [ + "Page size is fixed at 300 subfolders and cannot be changed: `per_page` from the caller is discarded and the ceiling is applied unconditionally. Only `p` moves you through the pages. (A group-level flag can lift the limit to unbounded, which you cannot detect from the response.)", + "`test_cases` is ALWAYS null. The key exists but this endpoint never returns test cases — fetch them from the folder's test-case listing instead.", + "`folders` holds the immediate children only. The folder you asked for is at `self`, its parent id is duplicated at the top level as `parent_id`, and `ancestors` is null unless you passed detail=true.", + "Folder notes come back HTML-escaped with signed image URLs injected, at every level of the response — do not treat them as the literal text that was stored.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500. A folder id from another project is a bare 404 {success:false} with no message.", + "This action has no RBAC entry, so it is not permission-gated the way the other folder reads are; it is also the action reachable through a public share link, scoped to the shared folder." + ] + }, + { + "name": "list_folder_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Walk pages until `info.next` is null." + }, + { + "name": "per_page", + "type": "integer", + "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + { + "name": "required[fields]", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." + }, + { + "name": "required[custom_fields]", + "type": "string", + "example": "121,119", + "description": "Comma-separated custom field ids to include on each row." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-ASC", + "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "Send the string `true` to include cases from this folder's subfolders, and add a `folders` block to the response." + }, + { + "name": "shared", + "type": "boolean", + "description": "Scope the listing to cases shared in from other projects." + }, + { + "name": "for_test_run", + "type": "boolean", + "description": "Send the string `true` when building an 'add cases to run' picker: on a project with review gating configured it hides cases whose review status makes them ineligible, so the same folder returns FEWER rows than a plain read." + }, + { + "name": "hasDuplicates", + "type": "boolean", + "description": "When true, fills `duplicates_tc_count` on each row from the dedupe store; otherwise it is null." + } + ], + "intent": "List the test cases in one folder — the repository browser read, with optional extra columns and custom fields.", + "shape": "discovered", + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "$response": "TestCaseFolderListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Folder-scoped and non-recursive by default: pass `include_subfolders_tc=true` to reach cases in child folders. To read the whole project use the project-level listing with all_folders=true instead.", + "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else — so trust `info.page_size`, not your request. Walk `p` until `info.next` is null.", + "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", + "`for_test_run=true` deliberately returns FEWER rows on a review-gated project — do not use it for a plain repository read or you will under-report the folder.", + "A backend 404 is swallowed into an empty list with `success: true`, so an empty result is not proof the folder is empty or that the id is right.", + "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null." + ], + "returns": [ + "success", + "test_cases", + "info", + "folders" + ] + }, + { + "name": "list_projects", + "method": "GET", + "path": "/api/v1/projects", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "q", + "type": "string", + "example": "checkout", + "description": "Free-text match on project name. Must be a flat string — a nested q[...] value returns 400 'Invalid Params: q should be a String'." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive, non-numeric or array values return 400 'Invalid Params'." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Any other value returns 400 echoing the allowed list." + }, + { + "name": "starred", + "type": "boolean", + "description": "Restrict to starred projects. Forwarded to the backing service verbatim, so sending the string 'false' is still a filter, not 'no filter' — omit the key instead." + }, + { + "name": "shared", + "type": "boolean", + "description": "Restrict to projects that have shared test cases. Any present value is treated as true; omit the key to not filter." + }, + { + "name": "is_hidden_projects", + "type": "boolean", + "default": false, + "example": true, + "description": "Include projects hidden from the dashboard. Only the exact string 'true' turns this on." + } + ], + "intent": "List the group's projects with their PR-NNN identifiers, case/run counts and duplicate badge — the listing to use when you need each project's identifier; for a plain name lookup or a count, get_projects_basic is leaner", + "returns": [ + "success", + "projects", + "info", + "id", + "identifier", + "name", + "description", + "created_at", + "import_id", + "links", + "page", + "page_size", + "count", + "prev", + "next" + ], + "paginated": true, + "responses": { + "200": { + "description": "Projects at the top level as `projects` (no `data` envelope), with pagination under `info`. Each row also carries duplicates_tc_count; project_creator is NOT emitted despite being on the shared Project schema.", + "schema": { + "$schema": "ProjectsListResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Page size is fixed at 30 on this route. There is no size parameter — `count` and `page_size` are ignored here (only the basic and minify project listings honour `count`). Walk pages with `p` and stop when `info.next` is null.", + "`q` must be a flat string on this route, not a nested object: `q=checkout`. Sending q[query]=... returns 400 'Invalid Params: q should be a String'. The nested q[query]/q[identifier] form belongs to get_projects_basic.", + "`sort_by` is validated against a closed set; anything else returns 400 listing the allowed values. Omitting it leaves the backing service's default order.", + "Unlike the other project listings, each row carries `identifier` (PR-NNN) — this is the cheapest way to resolve project names to the v2 id form in bulk.", + "`duplicates_tc_count` is a decorative badge and is zero whenever the AI duplicate-detection feature is off for the group; a failure computing it is swallowed and also reads as 0, so never treat 0 as 'no duplicates'.", + "`project_creator` is documented on the shared Project schema but is NOT emitted by this endpoint — it is commented out in the serializer. Do not branch on it.", + "`starred` and `shared` are forwarded to the backing service verbatim, including the string 'false', so send them only when you mean to filter.", + "Requires the project_view permission under RBAC." + ], + "max_page_size": 30 + }, + { + "name": "list_schedules", + "method": "GET", + "path": "/api/v1/projects/{project_id}/reports/schedules", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q[query]", + "type": "string", + "description": "Case-insensitive substring search over report type, title and owner full name. Also enables the `owner_id` and `created_between` filters, which are ignored without it." + }, + { + "name": "q[scheduled]", + "type": "string", + "values": [ + "true", + "false" + ], + "description": "Only the exact string `true` filters (to scheduled reports). `false` and every other value are ignored and return all reports." + }, + { + "name": "owner_id", + "type": "integer", + "description": "Restrict to one owner. Only honoured alongside `q[query]`; supplying it also drops the owner name from the search match." + }, + { + "name": "created_between", + "type": "array", + "description": "Two timestamps, [from, to], bounding created_at. Only honoured alongside `q[query]`, and only when exactly two entries are given — otherwise ignored." + }, + { + "name": "per_page", + "type": "integer", + "description": "Accepted and then IGNORED — the page size is fixed at 30 on both code paths." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, 30 per page. A page past the last one returns 400 `page_out_of_bounds`, not an empty list." + } + ], + "intent": "List a project's reports — scheduled and unscheduled alike, with type, window, cadence and owner — the way to find a report_id", + "returns": [ + "data", + "scheduled_report_count", + "total_report_count", + "info" + ], + "paginated": true, + "responses": { + "200": { + "description": "A successful response containing a list of schedules.", + "schema": { + "$schema": "ReportScheduleGetResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the discovery call for the report entity: every other report capability needs an integer `report_id`, and this is where it comes from (`data[].id`; `identifier` is the SC-NNN display string and is not accepted as an id anywhere).", + "It lists ALL of the project's reports, not only scheduled ones — the route name is misleading. `scheduled_report_count` and `total_report_count` are counts over the whole project, not over the returned page.", + "`q[scheduled]` only acts on the exact string `true`. `q[scheduled]=false` is silently ignored and returns everything, so you cannot filter for unscheduled reports — filter client-side on `frequency == null` instead.", + "Page size is a hard-coded 30 and cannot be changed: `per_page` is read from the request and then never applied. Page with `p`; requesting a page past the end returns 400 `page_out_of_bounds` rather than an empty list.", + "Ordering is always newest first by id. An `order_by` parameter is parsed and then ignored.", + "`q[query]` is a case-insensitive substring match over report type, title, and (unless `owner_id` is also given) the owner's full name. Supplying it switches to a different code path that additionally honours `owner_id` and `created_between` — those two do nothing without `q[query]`.", + "Requires the report_view permission on the project (403 otherwise)." + ], + "max_page_size": 30 + }, + { + "name": "list_templates", + "method": "GET", + "path": "/api/v2/templates", + "mode": "read", + "entity": "template", + "query": [ + { + "name": "name", + "type": "string", + "description": "Narrow the list to templates whose name contains this value. It is a substring filter, not an exact lookup, so it can return several templates or none." + }, + { + "name": "p", + "type": "integer", + "description": "Page number. This is the page parameter for this endpoint — `page` is ignored. A value that is not a positive integer is rejected with a 400 before the search runs.", + "minimum": 1, + "default": 1 + }, + { + "name": "count", + "type": "integer", + "description": "Page size. This is the size parameter for this endpoint — `per_page` is ignored. Above 300 the request is REJECTED with a 400, it is not clamped. A non-numeric value silently falls back to 30.", + "maximum": 300, + "default": 30 + } + ], + "intent": "List the workspace's test-case templates — start here to find a template's id before creating a test case from it or before editing the template itself.", + "guidance": [ + "Asking for more than 300 per page fails the whole call with a 400 rather than returning a clamped page; page through at 300 or below instead.", + "List items carry only template metadata and counts — no field detail. Read a single template when you need its description or property fields.", + "Only test-case templates are returned. The entity type is fixed server-side and cannot be widened from the request, so result/plan/exploratory-session templates are not reachable here.", + "project_count and the field counts can come back null: on some workspaces the expensive per-project count is skipped server-side. Treat a null count as 'not computed', not as zero." + ], + "returns": [ + "success", + "templates", + "info", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at" + ], + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "$response": "TemplateListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/tags-v2", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. IGNORED whenever `q` is also sent: the search branch drops the pagination options entirely and always returns the first page. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + }, + { + "name": "q", + "type": "string", + "description": "Free-text match on tag name. Trimmed; an all-whitespace value is treated as absent. Sending it silently disables paging (see `p`)." + } + ], + "intent": "List a project's test-case tag vocabulary with usage counts — the set of tag names to pick from when tagging a case or filtering a list by tag", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "TagsSearchResponse" + }, + "401": { + "description": "Unauthorized." + } + }, + "guidance": [ + "`tags` is a plain array of tag NAMES; `tag_objects` carries the same tags with `created_at` and `usage_count`. Tags are referenced by name everywhere else in this product, so `tags` is usually the field you want and `usage_count` is what tells you which are actually in use.", + "Combining `q` with `p` does not work: when `q` is present the handler forwards only the query and never the page, so you always get page 1 and a second page request returns the same rows. To walk the whole vocabulary, page with `p` and no `q`; to search, accept the first page and narrow the query.", + "Page size is fixed at 30 and there is no per_page parameter.", + "This is the TEST-CASE tag vocabulary only. Run, plan and shared-step tags are separate vocabularies with separate listings, and a name existing here says nothing about the others.", + "No tag ids are returned here. If you need an id (the tag merge takes integer ids), use the group-level tag search.", + "Not permission-gated — the tags controller has no RBAC entry." + ], + "returns": [ + "tags", + "tag_objects", + "info", + "name", + "created_at", + "usage_count" + ], + "max_page_size": 30 + }, + { + "name": "list_test_case_templates", + "method": "GET", + "path": "/api/v1/admin-v2/settings/templates", + "mode": "read", + "entity": "template", + "query": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "TestCase", + "TestRunResult" + ], + "example": "TestCase", + "description": "Which kind of template to list. Effectively required — omitting it errors rather than defaulting. Only these two values are accepted; TestPlan and ExploratorySession templates are not listable here." + }, + { + "name": "project", + "type": "integer", + "description": "Integer project id — restricts the list to templates linked to that project." + }, + { + "name": "name", + "type": "string", + "description": "Fuzzy match on template name (not an exact match)." + }, + { + "name": "paginated", + "type": "boolean", + "default": false, + "description": "Must be the literal string 'true' to turn paging on. Without it `p` is ignored, every matching template is returned in one array, and `info` is absent from the response." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Only read when paginated=true is also sent; page size is then fixed at 10." + } + ], + "intent": "List the workspace's test-case (or test-result) templates with their ids — the lookup to run before creating a case from a template or editing a template's field layout", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "`{success, templates: [{id, name, entity, enabled, step_type, is_default, is_system, future_projects_applicable, field_count, linked_to_all_projects, project_count, created_at, updated_at}]}`. `info` is present only when paginated=true was sent.", + "schema": { + "type": "object" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`entity_type` is effectively REQUIRED even though it reads as optional: it is forwarded straight to the backing client, which refuses a missing value outright rather than defaulting to TestCase. Always send it.", + "Only two values are accepted: `TestCase` and `TestRunResult`. `TestResult` is not the accepted spelling, and TestPlan / ExploratorySession templates cannot be listed from here at all — anything outside the pair errors rather than returning an empty list.", + "Paging is opt-in and easy to miss: `p` is IGNORED unless `paginated=true` is sent alongside it. With paging on, page size is fixed at 10 and `info` appears in the response; with it off you get every matching template in one array and no `info`.", + "`project` narrows to templates linked to that integer project id; `name` is a fuzzy match on the template name, not an exact one.", + "Rows are the summary shape — id, name, entity, step_type, is_system, is_default, enabled, project_count, field_count, linked_to_all_projects. The field layout itself is not here; read a single template for that.", + "Templates belong to the workspace, not to a project: the same id is valid in every linked project, so do not look for a per-project id.", + "Responses are served from a short-lived per-group cache, so a template created or edited through a different surface can take a moment to show up here.", + "A validation failure from the backing service is passed through raw at its original status rather than being reshaped into the usual {success:false} envelope." + ], + "max_page_size": 10, + "returns": [ + "success", + "templates", + "info", + "id", + "name", + "entity", + "enabled", + "step_type", + "is_default", + "is_system", + "future_projects_applicable", + "field_count", + "linked_to_all_projects", + "project_count", + "created_at", + "updated_at" + ] + }, + { + "name": "list_test_plans_by_integer_id", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page — the page size is fixed at 30." + }, + { + "name": "include_sub_plans", + "type": "boolean", + "default": false, + "description": "Include sub-plans in the page. Off by default, so a sub-plan the user named looks missing until this is set. Pass the literal string 'true'." + }, + { + "name": "status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "Plan status filter. Anything outside the three values returns HTTP 200 carrying {success: false, error: 'Check query params for test plan status'} and NO plans — it does not 400." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name-asc", + "name-desc", + "identifier-asc", + "identifier-desc" + ], + "description": "Sort order. Validated: anything else is 400 with the allowed list in the message. Must be a string — an array form is 400 'Invalid Params: sort_by should be a String'." + }, + { + "name": "minify", + "type": "boolean", + "description": "Ask for the slim plan rows. A group-level flag can force this on regardless of what you send, so rows may be slimmer than requested." + }, + { + "name": "q", + "type": "object", + "description": "Filter object (must be a nested object, not a string, or the call is 400). Recognised keys include query (free text), tags, ids, parent_plan_id, owner, team, created_at / created_between, days_left, overdue_days, tp_custom_fields, issue_type + issue_ids (both needed together), and match. Keys outside that set are ignored silently.", + "fields": [ + { + "name": "query", + "type": "string" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "ids", + "type": "array" + }, + { + "name": "parent_plan_id", + "type": "integer" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "team", + "type": "array" + }, + { + "name": "created_at", + "type": "string" + }, + { + "name": "created_between", + "type": "array" + }, + { + "name": "days_left", + "type": "string" + }, + { + "name": "overdue_days", + "type": "string" + }, + { + "name": "tp_custom_fields", + "type": "object" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "issue_ids", + "type": "array" + }, + { + "name": "match", + "type": "object" + } + ] + }, + { + "name": "ignore_type", + "type": "boolean", + "description": "Drop the implicit plan_status narrowing that the filter path otherwise applies (active plans unless status=completed). Pass the literal string 'true'." + }, + { + "name": "filter_details", + "type": "boolean", + "description": "Add a filter_details object resolving the owner and custom-field filters you sent in `q` back into displayable values. Only populated for q.owner and q.tp_custom_fields." + }, + { + "name": "filter_mapping", + "type": "boolean", + "description": "Id-picker mode: raises the page size to 5000 but returns ONLY {id, identifier} per plan — no name, no status, no dates. Do not use it to read plan detail." + } + ], + "intent": "Page through a project's test plans — filter by status, search and sort, and optionally include sub-plans; this is how you resolve a plan name a user mentioned to the integer id every other v1 plan route needs", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "V1TestPlanListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Sub-plans are hidden by default. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", + "An invalid `status` is a 200 trap: the response is HTTP 200 with {success: false, error: 'Check query params for test plan status'} and no plans. Branch on `success`, not on the status code.", + "Page size is fixed at 30 and there is no per_page parameter. Page with `p` and stop using the `info` envelope. filter_mapping=true is the only way to get more rows and it strips every row down to {id, identifier}.", + "`count` is {active, completed} for the whole project (active = every plan not completed), not for the current page — do not use it as a page total.", + "Each row carries both ids: `id` is the integer every v1 plan route takes, `identifier` is the TP-NNN / STP-NNN for display. Carry the integer forward.", + "Rows are full plan objects, same serializer as the single-plan read, unless a minify/skip-analytics path trims them — a group-level flag can turn that on without you asking, so check for the fields you need rather than assuming.", + "`q` must be sent as a nested object; a scalar q is 400 'Invalid Params: q should be a ActionController::Parameters'. Custom-field filters inside q.tp_custom_fields are validated and a bad field id fails the whole request.", + "Archived plans are NOT in this listing under any filter — they have their own archived listing.", + "Needs the test-plan VIEW permission." + ], + "returns": [ + "success", + "test_plans", + "info", + "count", + "filter_details", + "id", + "identifier", + "name", + "description", + "plan_status", + "active_state", + "record_status", + "start_date", + "end_date", + "owner", + "assignee", + "created_by", + "tags", + "issues", + "custom_fields", + "parent_plan_id", + "sub_plans_count", + "test_runs_count", + "total_test_runs_count", + "test_plan_progress_v2", + "project_id", + "created_at", + "updated_at", + "links" + ], + "max_page_size": 30 + }, + { + "name": "list_workspace_fields", + "method": "GET", + "path": "/api/v1/admin-v2/settings/fields", + "mode": "read", + "entity": "custom_field", + "query": [ + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Which entity's fields to list. Any other value is a 400 'Invalid Params'. TestCaseStep returns an empty list when the group's step-custom-fields flag is off." + }, + { + "name": "field_source", + "type": "string", + "values": [ + "system", + "custom", + "all" + ], + "default": "all", + "description": "Case-insensitive. `system` returns every system field on a single page (total_pages 1) and no custom fields; `custom` skips the system fields entirely. Anything else is a 400 naming the valid values." + }, + { + "name": "field_type", + "type": "string", + "description": "Filter custom fields by data type, using the canonical vocabulary (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown). System fields all report as `dropdown`, so any other value silently excludes every one of them." + }, + { + "name": "q", + "type": "string", + "description": "Case-insensitive substring match on the field name. Applied to system fields in this layer and forwarded as a search for custom fields." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. The only pagination control — page size is fixed at 10 and cannot be raised. Must be a scalar; `p[]=1` is a 400." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "name", + "field_name", + "created_at", + "id" + ], + "description": "Sort column. Also accepts a combined `-` form, e.g. `name-desc`, in which case sort_order is ignored. `name` is an alias for field_name. Any unrecognised column falls back to sorting by id." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Direction, used only when sort_by does not already carry one. Compared case-insensitively; anything that is not `desc` sorts ascending." + }, + { + "name": "selected_ids", + "type": "array", + "description": "Custom field ids to EXCLUDE from the custom-field half of the result. Has no effect on the system fields injected on page 1." + } + ], + "intent": "Browse or search every field configured for the workspace — built-in system fields and user-defined custom fields together — to find a field's numeric id, type and how many projects it reaches", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "$response": "WorkspaceFieldsListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Start here. This is the only listing that returns both system and custom fields, and the numeric `id` it gives is what every other custom-field call takes in its path.", + "The page size is fixed at 10 and there is no way to raise it: only `p` is read. Expect to page. `info` carries {count, page, page_size, total_pages}.", + "System fields are injected on page 1 ONLY, ahead of the custom fields, and they eat into that page's 10 slots. Page 2 onward is custom fields only. Row order is therefore not stable across pages unless you also send sort_by.", + "Every system field is reported as field_type `dropdown`, so a field_type filter with any other value silently drops all of them. Set field_source=custom when you only want user-defined fields, or field_source=system to get them all on one page.", + "An unsupported field_source is a 400 naming the three valid values; an unsupported entity_type is a 400 'Invalid Params'. entity_type=TestCaseStep returns an empty list (not an error) when the group lacks the step-custom-fields flag.", + "sort_by also accepts a combined form like `name-desc`; `name` is an alias for field_name. Sorting is applied in this layer AFTER the system fields are merged in, so it reorders only the rows on the page you asked for.", + "`q` matches on field name only, case-insensitively, and is applied to both system and custom fields.", + "Rows carry `dataset_count` and a derived `linked_to_all_projects`, but no option values and no project list — read the field, then its datasets, for those." + ], + "max_page_size": 10, + "returns": [ + "success", + "data", + "info", + "id", + "field_name", + "field_type", + "entity_type", + "system_name", + "place_holder_text", + "linked_projects_count", + "dataset_count", + "is_system_field", + "type", + "field_values", + "linked_to_all_projects", + "count", + "page", + "page_size", + "total_pages" + ] + }, + { + "name": "merge_tags", + "method": "POST", + "path": "/api/v1/projects/{project_id}/tags/merge", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Needed to match the route, and nothing else — it is never sent to the backing service. The merge is NOT scoped to this project; it applies across the whole workspace." + } + ], + "body": [ + { + "name": "source_id", + "type": "integer", + "required": true, + "description": "The tag that LOSES. It is absorbed into the target and ceases to exist; every entity carrying it is re-pointed at the target tag. Integer id — the project tag listings return names only, so resolve the id from the group tag search first." + }, + { + "name": "target_id", + "type": "integer", + "required": true, + "description": "The tag that SURVIVES. It keeps its own name and picks up all of the source tag's usages. Nothing about the target's name or metadata changes." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run", + "test_plan", + "shared_step" + ], + "description": "Which tag vocabulary the two ids belong to. NOT validated here — only checked for presence — so an unknown or mismatched value is forwarded and comes back as an opaque 400 from the backing service." + } + ], + "intent": "Merge one tag into another — everything tagged with the source tag ends up carrying the target tag instead, and the source tag stops existing", + "responses": { + "200": { + "description": "The backing service's merge result, passed through unchanged. No `success` flag and no envelope are added by this API; the exact keys are owned by the tag service.", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Missing source_id/target_id/entity_type, or any failure reported by the backing service (including an unknown tag id), as `{error: \"\"}`." + }, + "401": { + "description": "Unauthorized." + }, + "403": { + "description": "Production only: caller is not an owner or admin — `{error: \"Only admins can manage tags\"}`." + }, + "500": { + "description": "Unexpected failure — `{error: \"Internal server error\"}`." + } + }, + "guidance": [ + "CURRENTLY IMPOSSIBLE TO COMPLETE, and not because of the field names here. The server accepts `source_id` exactly as declared, then forwards it to the backing service, which rejects the request asking for a differently-named plural field. So a correctly-formed call is refused downstream and no input from this surface succeeds. Do not work around it by renaming the field — the server's own check would then reject the call first. This needs a product fix, not a contract change.", + "Which tag loses: `source_id`. After the merge the source tag no longer exists as a tag, and every test case, run, plan or shared step that carried it carries the target's tag instead. `target_id` is unchanged apart from gaining those usages. Getting the two the wrong way round destroys the tag you meant to keep.", + "There is no unmerge. The response does not list what was re-tagged, so nothing here tells you which entities to fix afterwards — capture the source tag's usage list (search by that tag) BEFORE calling, or the mapping is unrecoverable.", + "Not project-scoped despite the path: `project_id` is used for routing only and never reaches the backing service, so the merge applies across the entire workspace and can touch projects you were not looking at.", + "Both ids are integers. Every project-level tag read in this profile returns names rather than ids — resolve the ids through the group-level tag search, and never send a tag name here.", + "`entity_type` must match the vocabulary both tags live in. It is not validated locally, so a mismatch is not caught until the backing service rejects it.", + "Tag management is restricted to account owners and admins. Check who is asking before promising a merge, and expect a refusal for anyone else.", + "Every downstream failure — unknown tag id, mismatched entity_type, permission problem — is flattened to 400 `{error: \"\"}`. There is no 404 and no `success` flag; the message text is the only signal.", + "On success the body is the backing service's raw merge result passed straight through, with no envelope added by this API." + ], + "disabled": true + }, + { + "name": "move_folder_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mv", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "new_base_folder_id", + "type": "integer", + "example": 123, + "description": "Destination parent folder. NOT required despite what the name suggests: omit it or send null to move the folder to the project ROOT. It must exist in the destination project." + }, + { + "name": "new_base_project_id", + "type": "integer", + "example": 456, + "description": "Destination project; omit for a same-project move. Send it as a JSON NUMBER — the cross-project test is a raw inequality against the integer project id, so the string \"456\" against project 456 is misread as a cross-project move and takes the slow async path with all its extra restrictions." + }, + { + "name": "auto_rename", + "type": "boolean", + "default": true, + "description": "Defaults to TRUE here, unlike create and rename. A name clash in the destination is silently renamed rather than rejected; send false explicitly if you would rather the move fail." + }, + { + "name": "prev", + "type": "integer", + "description": "Optional: after the move, place the folder immediately after this sibling id. Must be an integer; sending prev or next triggers a second reorder call whose failure is not reported in this response." + }, + { + "name": "next", + "type": "integer", + "description": "Optional: after the move, place the folder immediately before this sibling id. Must be an integer." + } + ], + "intent": "Move a folder — with everything under it — to a different parent, to the project root, or into another project; same-project moves complete immediately, cross-project ones run in the background", + "returns": [ + "data", + "success", + "async", + "unique_id", + "folder", + "source_path_folders", + "destination_path_folders", + "parent_id", + "links" + ], + "responses": { + "200": { + "description": "Folder moved successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder__MoveFolderByIntegerId" + }, + "source_path_folders": { + "type": "array", + "nullable": true, + "items": { + "$schema": "Folder" + } + }, + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "PathFolder__MoveFolderByIntegerId" + } + }, + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean" + }, + "unique_id": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or move not allowed", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Two completely different responses come out of this one route. A same-project move is synchronous and returns {data:{folder, source_path_folders, destination_path_folders, success:true, async:false}}. A cross-project move returns only {data:{success:true, async:true, unique_id}} and has done nothing yet — branch on `data.async` before reading `data.folder`.", + "For the async path there is no HTTP status endpoint. `unique_id` is the X-Request-ID echoed back and progress goes over a websocket the API does not expose; confirm the move by re-reading the destination folder.", + "Omit new_base_folder_id to move to the project root. That is the only way to promote a nested folder to root level.", + "Moving a folder into one of its own descendants is NOT blocked. The only self-move check compares the folder id to the destination id, so a move into a child is accepted here (the copy endpoint does check for this — the move endpoint does not).", + "Cross-project moves carry two hard limits: the subtree may contain at most 50 folders counting all nesting, and the folder must contain no shared test cases at all. Either one fails the request with 422 {success:false, error:'...'} before anything moves.", + "The error shape varies by stage: local validation failures are 422 {success:false, error}, upstream rejections are 400 {success:false, error, message} (both keys, same text), and anything unexpected is 500 {success:false, error:'An unexpected error occurred. Please try again.'}.", + "`user_action` appears nowhere in the codebase — it is accepted and ignored. Do not build anything on it.", + "This action has no RBAC entry, so it is not permission-gated the way rename and reorder are.", + "A move is reversible by moving the folder back, but the async cross-project path recreates folders rather than relocating them, so ids on the far side are new." + ] + }, + { + "name": "rename_folder_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rename", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/folder/name", + "description": "New folder name. Send it flat — the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves. Not strictly required: a notes-only edit is accepted, but the `folder` object itself must be present and non-empty." + }, + { + "name": "notes", + "type": "string", + "json_path": "/folder/notes", + "description": "New description, HTML allowed; unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. The key is `notes` — `description` is silently dropped by the permit list on this v1 route." + }, + { + "name": "auto_rename", + "type": "string", + "values": [ + "true" + ], + "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "fields": [ + { + "name": "blob_id", + "type": "integer" + } + ], + "description": "Replaces the folder's attachment set with these blob ids. Sending the key at all switches the handler into attachment mode and re-reads the folder's relations; an invalid set returns 422 with the bare string \"attachment is invalid\"." + } + ], + "intent": "Rename a folder or rewrite its notes — despite the name this is the general folder-edit call, and it changes whichever of the two fields you send", + "returns": [ + "data", + "success", + "folder", + "links" + ], + "responses": { + "200": { + "$schema": "FolderWriteResponse__RenameFolderByIntegerId" + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send `name` flat. The `folder` wrapper is added for you from the field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key. Sending no fields at all fails on the server for an empty wrapper.", + "Sibling-name uniqueness is always checked. A clash relays the entity store's own message and status verbatim rather than the generic masked text most folder endpoints return — read `message` for the real reason. auto_rename:\"true\" turns the rejection into a silent rename.", + "There is no undo. Folders keep no version history, so an overwritten name or notes value is gone and the only way back is to send the old value again — capture it with a read first if it matters.", + "The updated folder is at `data.folder`. The `cases_count` and `sub_folders_count` in that echo are copied from the read taken BEFORE the update, not re-derived — do not treat them as fresh counts.", + "This endpoint does NOT return a request_trace_id; the only keys are `data.folder` and `data.success`.", + "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500.", + "Requires the folder-edit permission." + ] + }, + { + "name": "reorder_folders", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folders/reorder", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "current", + "type": "array", + "required": true, + "example": [ + 21 + ], + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "description": "The folder ids being repositioned. Must be a JSON array of NUMBERS — the check is a strict integer type test, so [\"21\"] or a form-encoded current[]=21 fails with 422 {success:false, message:'test_case_folders must be an array of integers.'}." + }, + { + "name": "prev", + "type": "integer", + "example": 101, + "description": "Id of the sibling the moved folders should land AFTER. Integer or null only — a string is 422. Null means 'place at the top', but see next: prev and next cannot both be omitted." + }, + { + "name": "next", + "type": "integer", + "example": 103, + "description": "Id of the sibling the moved folders should land BEFORE. Integer or null only. At least one of prev/next must be a real id." + } + ], + "intent": "Change the display order of sibling folders by dropping one or more of them between two named neighbours — ordering only, it never changes nesting", + "returns": [ + "data", + "success", + "message" + ], + "responses": { + "200": { + "description": "Folders reordered successfully", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Folders reordered successfully" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`current` alone is not enough, whatever the required flags say. Sending neither prev nor next fails with 422 {success:false, message:'Both top_folder_id and bottom_folder_id cannot be nil.'} — you must anchor the move against at least one neighbour.", + "Every id is type-checked as a Ruby Integer before anything is sent upstream. Numeric strings are rejected, so send a JSON body with real numbers rather than form-encoded parameters.", + "All the validation failures come back as 422, not 400, even though they are plainly bad-request conditions — the status the code intended is not the status that is sent.", + "The response is only {data:{success:true, message:'Folders reordered successfully'}}. It does not echo the new order and it does NOT contain a request_trace_id. Re-read the folder listing to confirm the result.", + "Nothing about the ids is validated locally — existence, project ownership and sibling-ness are all the upstream service's problem, so a wrong-but-well-typed id can return success-shaped output without moving what you meant.", + "This reorders siblings within their existing parent. To change a folder's parent, use the move call instead.", + "Requires the folder-edit permission." + ] + }, + { + "name": "reorder_test_cases_by_folder", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "top_test_case", + "type": "string", + "description": "Integer id of the case the moved block should sit BELOW. Omit it to move the block to the top of the page.", + "json_path": "/re_order/top_test_case" + }, + { + "name": "bottom_test_case", + "type": "string", + "description": "Integer id of the case the moved block should sit ABOVE. Omit it to move the block to the bottom of the page.", + "json_path": "/re_order/bottom_test_case" + }, + { + "name": "page", + "type": "integer", + "description": "Page of the folder listing the move happens on. Ordering is resolved against that page's window, so a neighbour that is not on this page cannot anchor the move.", + "json_path": "/re_order/page" + }, + { + "name": "test_cases", + "type": "array", + "required": true, + "description": "Integer ids of the cases to move, in the order they should end up in. These are moved as one block between `top_test_case` and `bottom_test_case`.", + "json_path": "/re_order/test_cases" + } + ], + "intent": "Change the manual order of test cases within a folder by dropping a block of them between two neighbours.", + "returns": [ + "success", + "test_cases", + "info" + ], + "paginated": true, + "responses": { + "200": { + "description": "Test cases reordered successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__ReorderTestCasesByFolder" + } + }, + "info": { + "type": "object", + "description": "Additional metadata about the reorder operation." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The wrapper applied for you here is `re_order`, NOT `test_case` like every other test-case write on this surface — that difference is handled by the json_paths, so you do not need to act on it, but it is why a hand-built `test_case` wrapper fails here in a different way than elsewhere.", + "Position is expressed by NEIGHBOURS, not by index: `top_test_case` and `bottom_test_case` are the case ids the moved block lands between. Omit `top_test_case` for the top of the page, `bottom_test_case` for the bottom.", + "Everything is integer case ids — the anchors and the moved list alike. TC-NNN identifiers are not accepted here.", + "Ordering is resolved within the page named by `page`, so both anchors must be on that page of the folder listing for the move to mean what you intend.", + "A rejection comes back as 400 `{errors: \"\"}` — not the usual `{success:false}` — and covers 'ids not in this folder' as well as malformed input.", + "The 200 body is the folder's listing in its NEW order, so you can confirm the result without a second read. Ordering is not versioned: the only way back is another reorder." + ] + }, + { + "name": "replace_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' — it is not skipped." + }, + { + "name": "run_state", + "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it.", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "json_path": "/test_run/run_state" + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings; the list you send replaces the run's tags." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read. Duplicates are collapsed." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^$|^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^$|^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "The run's COMPLETE intended case list, not a list to add. The partial-update call on this same path REPLACES membership rather than unioning it, and this full-replace call is no gentler: assume a case missing from the list is removed with its results and assignee. Send the run's existing case identifiers plus the new ones — or, to add cases, use bulk_update_test_run_test_cases with add_test_cases, which is truly additive." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders" + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list — cases you list elsewhere are still included, with a null-configuration row." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "json_path": "/test_run/filter_scope", + "description": "Accepted by the parameter filter but NOT honoured on either update path — the selection is always built in global mode here. Only create reads it." + } + ], + "intent": "Rewrite a test run from a complete payload — every metadata field you leave out is reset, so the run ends up as whatever this one body says. Use the partial update instead when you only mean to change some fields.", + "guidance": [ + "Two different operations share this path. This POST rebuilds the whole run payload; the PATCH applies only the keys you send. If you are renaming a run, closing it, or linking a plan, pick the PATCH.", + "Omission is destructive here: no run_state resets the run to new_run, no test_plan_id/sub_test_plan_id unlinks the plan it was under, and no issues clears its linked defects. Read the run first and re-send its current values for anything you do not mean to change.", + "An invalid run_state is not rejected on this call — it is silently coerced to new_run. The partial-update call on the same run returns 400 instead, so use that one if you want the error.", + "shared_test_cases and filter_scope are accepted by the parameter filter and then ignored on this path. Cross-project cases only land through the PATCH.", + "Case membership is REPLACED, not unioned: whatever test_cases / folder_ids / include_all resolve to should be treated as the run's whole case list. Always send the existing cases plus the new ones; a case left out loses its results.", + "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "400 'invalid data' also covers an assignee email that cannot be resolved in this group — the run is left untouched in that case.", + "There is no undo: test runs keep no version history, so a field this call clears cannot be restored.", + "The updated run comes back at the top level as `testrun` — one word, no underscore, and no `data` envelope.", + "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", + "`urls.self` is a link to the run in the web UI, not an API path — it is built from the frontend base URL, so calling it returns HTML.", + "`assignee` comes back as a plain email string — or null when the owner cannot be resolved — and `configurations` as a list of configuration ids. Neither is a list of objects.", + "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", + "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", + "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", + "The run's case list is not on this reply — `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", + "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write. Re-read the run when the exact stored values matter.", + "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunWriteV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "resolve_duplicate", + "method": "POST", + "path": "/api/v1/projects/{project_id}/ai-duplicates", + "mode": "write", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "duplicate_id", + "type": "integer", + "required": true, + "description": "The duplicate to resolve, from the `id` of a listing row. Passed in the BODY, not the path — this endpoint posts to the collection path, unlike discard which puts to the item path." + }, + { + "name": "merge_action", + "type": "string", + "required": true, + "values": [ + "merge", + "archive" + ], + "description": "Exactly `merge` or `archive`. There is no default and no fallback: any other value, including a missing one, is rejected with 400 {error:'Invalid action'} and nothing happens." + }, + { + "name": "source_testcase_id", + "type": "integer", + "description": "Required when merge_action is `merge`. The integer id of the case that will be archived and folded into the other. Must be one of the pair's two test cases." + }, + { + "name": "target_testcase_id", + "type": "integer", + "description": "Required when merge_action is `merge`. The integer id of the case that survives and receives the merge comment. Must be one of the pair's two test cases." + }, + { + "name": "archive_testcase_id", + "type": "integer", + "description": "Required when merge_action is `archive`, and ignored otherwise. The integer id of the single case to archive; must be one of the pair's two test cases. Without it the archive path fails with 'Test case not found in duplicates'." + } + ], + "intent": "Act on a duplicate suggestion — merge one test case into the other (archiving the source and commenting on the survivor), or archive one of the pair outright", + "responses": { + "200": { + "description": "Duplicate resolved. Body is {success:true} only — nothing about the affected test cases is returned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "400": { + "description": "{error:'Invalid action'} when merge_action is neither `merge` nor `archive`, or {errors:'Invalid Input'} when the backing store rejects the merge/archive." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "The caller lacks the test-case delete permission (tc_delete)." + }, + "404": { + "description": "{errors:'Duplicate not found.'} — no such pair in this project, or it is no longer in `suggested` status." + }, + "409": { + "description": "{errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} — a dedupe data migration is in progress. Nothing was changed; retry later." + }, + "422": { + "description": "A test case id that is not part of this pair. Body is {errors:'Test case not found in duplicates'} on the merge path and {success:false, message:'Test case not found in duplicates'} on the archive path." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Two different operations behind one field. `merge` archives source_testcase_id, tags it 'Archived as Duplicate', posts a comment on target_testcase_id recording the merge, stores a snapshot of the source, and marks the pair merged. `archive` archives archive_testcase_id only — no comment, no snapshot — and still marks the pair merged.", + "Ids are not interchangeable: the SOURCE is the case that disappears. Getting source and target the wrong way round archives the case you meant to keep, and the only route back is unarchiving it manually.", + "Archived is not deleted — a merged-away or archived case is recoverable from the project's archived cases, findable by the 'Archived as Duplicate' tag on the merge path. Nothing here is a hard delete, but the merge comment, the snapshot and the pair's status change are not undone by unarchiving.", + "Resolving requires the test-case DELETE permission (tc_delete), not write. A caller who can edit test cases may still be refused here with 403.", + "The pair must still be `suggested`. An already-resolved pair is 404 {errors:'Duplicate not found.'}, which usually means someone else handled it, not that the id is wrong.", + "Undeclared until now, and retryable: while a dedupe data migration is in progress the call is refused with 409 {errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. It is deliberately not a 200 — nothing was merged. Retry later.", + "A test case id that is not part of the pair is rejected AFTER the pair lookup and reports 422, not 404, with two different bodies depending on the action: merge gives {errors:'Test case not found in duplicates'} and archive gives {success:false, message:'Test case not found in duplicates'}.", + "Success is just {success:true} — the affected test cases are not echoed back. Re-read the surviving case if you need its post-merge state, and note the pair leaves every duplicates listing at this point.", + "After a merge the source case's pre-merge contents are readable only through the merged pair's source-snapshot call. Keep the duplicate id if you might need it; it is no longer discoverable from any listing." + ], + "returns": [ + "success" + ], + "disabled": true + }, + { + "name": "restore_history", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}/restore", + "mode": "write", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + }, + { + "name": "history_id", + "type": "integer", + "required": true + } + ], + "intent": "Roll a test case back to an earlier revision — reapplies that snapshot's name, description, preconditions, steps, status, priority, owner, tags, defects, custom fields, attachments and reviewers to the live case", + "responses": { + "200": { + "description": "Restore applied. Body is only {success: true} — read the test case back to see the restored content.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type is not restorable ({success:false, message:'Not supported'} / 'Not supported for build')." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is changed.", + "`history_id` is the numeric revision id from the listing, not the V1/V2 position label.", + "It is a full OVERWRITE of the snapshotted fields, not a merge. A custom field that has a value today but was empty in the snapshot is CLEARED. Steps are rewritten from the snapshot, so steps added since are removed, and steps whose ids no longer exist come back with new ids. The attachment set is rewritten too, unlinking files added after that revision.", + "There is a way back, but only one: the restore is applied as an ordinary edit of the live case, so the pre-restore state remains in the revision trail and can itself be restored. There is no undo call.", + "Say what will be lost before calling. The caller cannot see the delta from this endpoint — read the diff (or the two revisions) first and tell the user which fields will change.", + "Only test cases are restorable on this route; the same shape against another entity type answers 422 'Not supported'.", + "Success is only {success: true}. Read the case back to confirm what the restore actually produced — in particular, custom fields whose definition no longer exists in the project are not recreated and stay absent." + ], + "returns": [ + "success" + ] + }, + { + "name": "search_duplicates", + "method": "POST", + "path": "/api/v1/projects/{project_id}/ai-duplicates/search-v2", + "mode": "read", + "entity": "duplicate", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "q", + "type": "object", + "description": "Test-case filter object, the same grammar the test-case search takes; a pair matches when its test cases match. Sharp edges here: `owner`/`reviewers` take TM user ids and are translated server-side (an id resolving to nobody filters to nothing, never to everything); date filters must use `date_range` or the relative `created_at`/`updated_at` tokens, and `all_time` is dropped rather than applied.", + "fields": [ + { + "name": "query", + "type": "string", + "description": "Free-text match on test case title / identifier." + }, + { + "name": "folder_ids", + "type": "string", + "description": "Folder ids, comma-separated string or array." + }, + { + "name": "owner", + "type": "string", + "description": "TM user ids, comma-separated. Translated to backing-store ids before the search." + }, + { + "name": "reviewers", + "type": "string", + "description": "TM user ids, comma-separated. Translated like owner." + }, + { + "name": "tags", + "type": "string", + "description": "Tag names, comma-separated string or array." + }, + { + "name": "priority", + "type": "string", + "description": "Priority option ids, not display labels." + }, + { + "name": "status", + "type": "string", + "description": "Status option ids, not display labels." + }, + { + "name": "case_type", + "type": "string", + "description": "Case-type option ids." + }, + { + "name": "automation_state", + "type": "string", + "description": "Option ids, or the literals `automated` / `manual` which the server resolves." + }, + { + "name": "issue_ids", + "type": "string", + "description": "Linked issue ids, comma-separated." + }, + { + "name": "custom_fields", + "type": "object", + "description": "Keyed by custom-field id, e.g. {\"179720\": [\"Yes\"]}." + }, + { + "name": "match", + "type": "object", + "description": "Per-field operator map (all / any / none). Values stay beside it under their own key — a value placed inside `match` applies no filter at all." + }, + { + "name": "empty", + "type": "object", + "description": "Is-empty filter: {system_fields:[...], custom_fields:[...]}." + }, + { + "name": "date_range", + "type": "string", + "description": "Absolute created-at range; takes precedence over created_at when both are sent." + }, + { + "name": "created_at", + "type": "string", + "description": "Relative token such as `7_day`. `all_time` is dropped, applying no bound." + }, + { + "name": "updated_at", + "type": "string", + "description": "Relative token, same form as created_at. `all_time` is dropped." + } + ] + }, + { + "name": "match_scope", + "type": "string", + "values": [ + "any", + "all" + ], + "default": "any", + "description": "Whether ONE test case of a pair matching `q` is enough (`any`) or BOTH must match (`all`). Defaults to `any` — with `all` a filter that matches only one side returns nothing." + }, + { + "name": "duplicates", + "type": "string", + "values": [ + "exact", + "semantic" + ], + "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim and not validated here; an unrecognised value narrows to nothing." + }, + { + "name": "owner", + "type": "string", + "description": "The 'My duplicates' tab. ANY non-empty value scopes results to the CALLER's own test cases; the value is discarded, so it cannot select another user. This is separate from the `owner` filter inside `q`, which does take user ids." + }, + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `p` is an accepted alias and only `p` is validated (a bad `p` is 400 {success:false, error:'Invalid Params'}); `page` wins when both are sent and a non-numeric `page` silently becomes 0." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Send the literal string 'true' (with a non-empty `q`) to get a `filter_details` object back that resolves the filter's ids to display labels. Any other value, or a missing `q`, omits the key entirely." + } + ], + "intent": "Search the AI-suggested duplicate pairs with the full test-case filter grammar — narrow the review queue by folder, owner, tags, priority, status, custom fields or free text", + "shape": "discovered", + "paginated": true, + "responses": { + "200": { + "description": "Matching suggested duplicates, plus `filter_details` when it was requested. The same shape with an empty `duplicates` is returned when the dedupe feature flag is off.", + "schema": { + "$schema": "V1DuplicatesListResponse" + } + }, + "400": { + "description": "{success:false, error:'Invalid Params'} — a non-positive or non-numeric `p`." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "description": "{success:false, message:...} — the backing store refused the filter combination (usually because it resolves to too many test cases). This is NOT 'no results'." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A POST, but a read — it changes nothing. It exists because the filter object is too long for a query string.", + "Same feature gate as the plain listing: with the dedupe flag off this returns 200 {success:true, duplicates:[], info:{count:0,total_pages:0}} and never reaches the filter. An empty result is not evidence that nothing matched.", + "'Nothing matched' and 'the filter could not be evaluated' are different answers. A filter the backing store refuses (typically one resolving to too many test case ids) comes back 422 {success:false, message:}, not an empty list — do not treat that as zero results.", + "Page size is fixed at 10, ordering is by confidence (ai_score descending), and `info.count` can exceed the rows on the page because pairs with an unresolvable test case are dropped after counting.", + "Send only the filter keys you mean. `q` is forwarded to the search engine, which discards keys that would take it off the duplicate-pair scope, so an unexpected key fails silently rather than erroring.", + "Carry each row's `id` forward as the duplicate id for the read, resolve and discard calls.", + "Response adds two keys the plain listing has: `filter_details` (only when requested) and, as there, `last_scan_time` for the project's last dedupe run." + ], + "returns": [ + "success", + "duplicates", + "info" + ], + "max_page_size": 10 + }, + { + "name": "search_group_tags", + "method": "GET", + "path": "/api/v1/group/{entity_type}/tags", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run" + ], + "example": "test_case", + "description": "Which tag vocabulary to search. Hyphens are normalised to underscores, so `test-case` and `test_case` both work. Not validated against a list here — an unrecognised value is forwarded to the backing service rather than rejected." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search, trimmed. Omit it to get the first page of the whole vocabulary." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." + } + ], + "intent": "Search the workspace-wide tag vocabulary for one entity type and get each tag's id — the only tag read here that returns ids, so it is where you resolve the source_id and target_id a tag merge needs", + "returns": [ + "tags", + "tag_objects", + "info", + "name" + ], + "paginated": true, + "responses": { + "200": { + "$schema": "TagsSearchResponse__SearchGroupTags" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Group-scoped: no project is involved and none can be supplied, so tags from every project in the workspace come back together. A name you find here may not be in use in the project you care about.", + "This read returns the tag `id` alongside the name — that is what makes it the resolution step for a tag merge, which takes integer ids. The project-level tag reads return names only.", + "Page size is fixed at 30 and there is no per_page parameter; page with `p`.", + "`created_at` and `usage_count`, which the project-scoped tag search returns, are not guaranteed on this group-scoped path — treat `id` and `name` as the fields you can rely on.", + "Not permission-gated — the search-filter controller has no RBAC entry, and this route deliberately skips project resolution.", + "Neither `info.count` nor `info.next` can be trusted to size a fetch. Confirmed live: a listing that returns 30 rows on page 1 and 30 more distinct rows on page 2 reports a count of 31 on both, and `next` is correct on page 1 and then null on page 2 with 60 rows already returned. Page until a page comes back empty rather than until the count is satisfied or `next` is null, and never report the count to a user as a total." + ], + "max_page_size": 30 + }, + { + "name": "search_project_entities", + "method": "GET", + "path": "/api/v1/projects/{project_id}/{entity}/search", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "test-runs", + "trtc", + "test-plans" + ], + "example": "test-cases", + "description": "What to search. 'trtc' means the test cases inside one test run and needs test_run_id. 'test-plans' matches the route but has no handler branch and always returns an empty list. 'reports' is NOT a valid value — it fails the route constraint with a 404." + } + ], + "query": [ + { + "name": "q[tags][]", + "type": "array", + "example": [ + "2FA", + "delivery" + ], + "description": "Tag filter. Send it as a REPEATED BRACKETED parameter - `q[tags][]=2FA&q[tags][]=delivery` - one entry per tag name. Multiple tags are OR'd: a case matching any of them is returned. DO NOT use `q[test_case_tags]`: it is accepted, returns 200, and is SILENTLY IGNORED - the full unfiltered set comes back looking like a successful search. A bare `q[tags]` carrying a JSON string matches nothing (0 rows), and `q[tags]` with some other shapes 500s." + }, + { + "name": "q[query]", + "type": "string", + "example": "login", + "description": "Free-text match on title/identifier. Special characters are stripped before the match." + }, + { + "name": "q[folder_ids]", + "type": "string", + "example": "3306878,3669741", + "description": "Comma-separated folder ids to restrict to (test-cases). Add include_subfolders_tc=true to descend into sub-folders." + }, + { + "name": "q[folder_id]", + "type": "integer", + "example": 29529465, + "description": "Single folder id; read on the trtc path only." + }, + { + "name": "q[status]", + "type": "string", + "example": "9319,9321", + "description": "Comma-separated numeric status IDs, not display names — resolve them first. Renamed to status_id internally." + }, + { + "name": "q[priority]", + "type": "string", + "example": "550376,9261", + "description": "Comma-separated numeric priority IDs, not display names. Renamed to priority_id internally." + }, + { + "name": "q[case_type]", + "type": "string", + "example": "9379,9375", + "description": "Comma-separated numeric case-type IDs, not display names. Renamed to category_id internally." + }, + { + "name": "q[automation_state]", + "type": "string", + "example": "automated", + "description": "Either the literal word 'automated' / 'manual' (resolved to ids for this project) or comma-separated numeric ids. The only filter here that accepts words." + }, + { + "name": "q[owner]", + "type": "string", + "example": "3700,3701", + "description": "Comma-separated TM user ids (test-cases and test-runs). Pass use_bstack_id=1 if you are supplying BrowserStack ids instead." + }, + { + "name": "q[assignee]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids; the trtc equivalent of owner (mapped to owner internally)." + }, + { + "name": "q[reviewers]", + "type": "string", + "example": "3700", + "description": "Comma-separated TM user ids; translated to BrowserStack ids server-side because reviews are stored against the BrowserStack id." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "3_month", + "description": "Relative window as _[_gte], or 'all_time' for no bound. Converted to an absolute range server-side." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "3_month", + "description": "Same token format, on last-updated date." + }, + { + "name": "q[review_status]", + "type": "string", + "example": "in_review", + "description": "Comma-separated review-status tokens (approved, rejected, in_review, pending, changes_requested), test-cases only." + }, + { + "name": "q[configuration_id]", + "type": "string", + "example": "101,102", + "description": "Comma-separated configuration ids; trtc path." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "description": "Rows per page — this is the size parameter, NOT page_size or count. Above 300 on test-cases the call silently switches to id-only compact rows; on test-runs it is overridden to 30 regardless." + }, + { + "name": "required[fields]", + "type": "string", + "example": "count", + "description": "Comma-separated list of extra columns to hydrate. The single value 'count' switches the endpoint into count-only mode: the response becomes {success, count} with no rows." + }, + { + "name": "sort_by", + "type": "string", + "example": "name-asc", + "description": "Column and direction as -. For test-cases the column must also appear in required[fields] when that is used, or the request fails." + }, + { + "name": "use_bstack_id", + "type": "string", + "values": [ + "0", + "1" + ], + "description": "Set to '1' when the user ids in q[owner]/q[assignee]/q[reviewers]/q[last_executed_by] are BrowserStack ids; they are then translated to TM ids. Without it BrowserStack ids simply match nothing." + }, + { + "name": "test_run_id", + "type": "integer", + "example": 45123, + "description": "Required when entity=trtc — the run whose test cases you are searching. Omitting it yields an empty page, not an error." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "default": false, + "description": "Descend into sub-folders of q[folder_ids] (test-cases). Only the exact string 'true' enables it." + }, + { + "name": "include_test_plan", + "type": "boolean", + "default": false, + "description": "Decorate each run with its linked test plans (test-runs only). Only the exact string 'true' enables it." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true" + ], + "description": "Also return the filters you sent, resolved into display objects, under `filter_details`. Ignored when q is empty." + }, + { + "name": "compact", + "type": "boolean", + "default": false, + "description": "Force compact mode for test-cases: id-only stub rows grouped by folder, empty filter_details. This is the same mode per_page>300 triggers implicitly." + } + ], + "intent": "Search and filter a project's test cases, test runs, or the test cases inside one run — the general-purpose finder: free text plus status / priority / type / owner / folder / tag / date filters, with an id-only count mode", + "shape": "discovered", + "max_page_size": 300, + "paginated": true, + "responses": { + "200": { + "description": "Top-level result; the collection key depends on entity (test_cases for test-cases and trtc, test_runs for test-runs) and there is no `data` envelope. Compact mode and count-only mode return reduced shapes — see guidance.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "description": "Present for entity=test-cases and entity=trtc. In compact mode the rows degrade to {id, test_case_folder_id, project_id} stubs.", + "items": { + "$schema": "V1TestCaseSearchRow__SearchProjectEntities" + } + }, + "test_runs": { + "type": "array", + "description": "Present for entity=test-runs.", + "items": { + "$schema": "V1TestRunListRow" + } + }, + "folders": { + "type": "object", + "description": "KEYED BY FOLDER ID — {\"764093\": [{id, name, parent_id, ui_position}, …]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." + }, + "count": { + "type": "integer", + "description": "Total matches. Always present for entity=test-runs; the sole payload (with success) in count-only mode." + }, + "filter_details": { + "type": "object", + "description": "The filters you sent, resolved to display objects. Empty {} unless filter_details=true and q was non-empty, and always empty in compact mode." + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "TAG FILTERING USES `q[tags][]`, REPEATED ONCE PER TAG. The previously documented `q[test_case_tags]` does not filter at all - it returns 200 and the complete unfiltered list, which reads as 'no tag filtering available'.", + "Four entity values route here: test-cases, test-runs, trtc (the test cases inside one test run) and test-plans. Anything else — 'reports' included — does not match the route constraint and is a 404 before the API is reached.", + "entity=test-plans is accepted by the route and by the controller but has no implementation behind it: it always renders {test_plans: [], info: null} with a 200. It is a dead branch, not an empty result — use the test-plan listing capabilities instead.", + "entity=trtc additionally requires a `test_run_id` query param. Without it the run filter is built from 0 and you get an empty page rather than an error.", + "The page-size parameter is `per_page`, not `page_size` or `count`; `page_size` is ignored and you silently get the 30-row default.", + "Ceiling behaviour differs per entity and this is the sharpest edge here. For test-cases, asking for more than 300 rows (or passing compact=true) flips the call into compact mode: the rows degrade to id-only stubs {id, test_case_folder_id, project_id}, filter_details comes back empty, and the page size jumps to 20000. For test-runs, per_page is overridden to 30 no matter what you ask for. Only trtc forwards per_page unchanged.", + "Count-only mode: send required[fields]=count (exactly that one field) and the response collapses to {success, count} with no rows — far cheaper than paging for a total.", + "Filter keys are renamed on the way through: q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id. They take numeric IDs, not display labels — resolve them with the system/custom field value capabilities first. q[automation_state] is the exception: it accepts the literal words 'automated' or 'manual' as well as ids.", + "Any key under q that the backing allow-list does not recognise is dropped silently. A misspelled filter therefore widens the result set to everything instead of erroring — always sanity-check the count against an unfiltered call.", + "q[owner] / q[assignee] / q[reviewers] take TM user ids by default. Pass use_bstack_id=1 if your ids are BrowserStack ids and they will be translated first; without it, BrowserStack ids match nothing and the response is simply empty.", + "Response shape depends on the entity: test-cases returns {success, test_cases, folders, info, filter_details}; trtc returns {success, info, test_cases, filter_details}; test-runs returns {success, test_runs, info, count} plus filter_details only when filter_details=true and q is non-empty. There is no `data` envelope on any of them.", + "For test-cases, `sort_by` must name a column that is also present in required[fields] when required[fields] is used, or the call fails with 'Sort by column X is not present in required fields'.", + "Binned (recycle-bin) and archived test cases are excluded from this search; they have their own listing capabilities." + ], + "returns": [ + "success", + "test_cases", + "test_runs", + "folders", + "info", + "filter_details", + "count", + "page", + "page_size", + "prev", + "next" + ] + }, + { + "name": "search_project_entities_by_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/{entity}/search-v2", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "test-cases", + "trtc", + "test-runs", + "test-plans" + ] + } + ], + "body": [ + { + "name": "query", + "type": "string", + "example": "tc", + "description": "Search query string", + "json_path": "/q/query" + }, + { + "name": "owner", + "type": "string", + "example": "14", + "json_path": "/q/owner" + }, + { + "name": "reviewers", + "type": "string", + "example": "14,15", + "description": "Filter by reviewer, same form as `owner` — comma-separated TM user ids as a string, `$none` for none.", + "json_path": "/q/reviewers" + }, + { + "name": "last_executed_by", + "type": "string", + "example": "14", + "description": "Filter by who last executed the case, same form as `owner`. A non-string value raises server-side rather than returning empty.", + "json_path": "/q/last_executed_by" + }, + { + "name": "folder_id", + "type": "string", + "example": 29529465, + "description": "Filter by folder ID (single value or array)", + "json_path": "/q/folder_id" + }, + { + "name": "folder_ids", + "type": "string", + "example": [ + 125382, + 125385, + 125386 + ], + "description": "Filter by multiple folder IDs (comma-separated string or array)", + "json_path": "/q/folder_ids" + }, + { + "name": "priority", + "type": "string", + "example": [ + 1268, + 1270, + 1269, + 1267 + ], + "description": "Filter by priority IDs (comma-separated string or array)", + "json_path": "/q/priority" + }, + { + "name": "status", + "type": "string", + "description": "Filter by status IDs (comma-separated string or array)", + "json_path": "/q/status" + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Filter by issue type (e.g., jira, github)", + "json_path": "/q/issue_type" + }, + { + "name": "issue_ids", + "type": "string", + "description": "Filter by issue IDs (comma-separated string or array)", + "json_path": "/q/issue_ids" + }, + { + "name": "tags", + "type": "string", + "description": "Filter by tags (comma-separated string or array)", + "json_path": "/q/tags" + }, + { + "name": "case_type", + "type": "string", + "description": "Filter by case-type option ids (comma-separated string or array).", + "json_path": "/q/case_type" + }, + { + "name": "automation_state", + "type": "string", + "description": "Option ids, or the literals `automated` / `manual` which the server resolves.", + "json_path": "/q/automation_state" + }, + { + "name": "automation_status", + "type": "string", + "description": "Filter by automation status.", + "json_path": "/q/automation_status" + }, + { + "name": "review_status", + "type": "string", + "description": "Filter by review status (only meaningful where review/approval is configured).", + "json_path": "/q/review_status" + }, + { + "name": "created_at", + "type": "string", + "example": "7_day", + "description": "Relative token `_` with a SINGULAR unit (`day`, `hour`, `week`, `month`,\n`year`) — e.g. `7_day`, `24_hour`; `_gte` inverts it (`7_day_gte` = older than).\nAlso accepts `all_time` or an absolute `\"YYYY-MM-DD YYYY-MM-DD\"` range. A PLURAL\nunit such as `7_days` is an unhandled server error (500), not a 400.", + "json_path": "/q/created_at" + }, + { + "name": "updated_at", + "type": "string", + "example": "1_month", + "description": "Same form as `created_at`.", + "json_path": "/q/updated_at" + }, + { + "name": "date_range", + "type": "string", + "description": "Absolute created-at range as epoch milliseconds: `\",\"`.", + "json_path": "/q/date_range" + }, + { + "name": "ids", + "type": "string", + "description": "Fetch specific cases by integer id (comma-separated string or array).", + "json_path": "/q/ids" + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Restrict to archived (or non-archived) cases.", + "json_path": "/q/is_archived" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "179720": [ + "Yes" + ] + }, + "json_path": "/q/custom_fields" + }, + { + "name": "match", + "type": "object", + "example": { + "tags": "all", + "custom_fields": { + "179720": "none" + } + }, + "description": "Per-field **operator** map — how to combine a field's values, NEVER the values\nthemselves. The value stays beside `match` under its own key; `match` only says\nhow to read it. A value placed inside `match` sets a meaningless operator and\napplies NO filter, which is the silent no-op described on `q`.\n\nAny filterable field may appear here, plus `custom_fields` keyed by field id.\nModes: `all`, `any`, ", + "json_path": "/q/match" + }, + { + "name": "empty", + "type": "object", + "example": { + "system_fields": [ + "priority" + ], + "custom_fields": [ + "179720" + ] + }, + "description": "Is-empty / is-unset filter. `system_fields` accepts the payload names `status`,\n`priority`, `case_type`, `automation_state`; `custom_fields` takes field ids.", + "fields": [ + { + "name": "system_fields", + "type": "array" + }, + { + "name": "custom_fields", + "type": "array" + } + ], + "json_path": "/q/empty" + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page number", + "minimum": 1, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "example": 5, + "description": "Results per page. Rows are large and vary with the data, so probe rather than assume: send p=1 with a moderate per_page and take the rows that actually come back as your ceiling (for entity=test-cases a row is ~40 keys / 5-9 KB, so start around 5). Then keep per_page FIXED for the whole walk — the offset is (p-1)*per_page, so changing it part-way shifts the window and SKIPS rows (page 1 at 5 then ", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + { + "name": "fields", + "type": "string", + "example": "owner,priority", + "description": "Comma-separated JOINED columns to hydrate (owner, tags, issues, reviewers, priority, status, case_type, automation_state, defects; unlisted names pass through). It selects joins ONLY — it can never remove the base fields (name, description, preconditions, steps, test_case_steps, custom_fields, attachments), so naming fewer changes how many rows fit per page, NOT the order of magnitude, and it cann", + "json_path": "/required/fields" + }, + { + "name": "custom_fields", + "type": "string", + "example": "121,119", + "json_path": "/required/custom_fields" + }, + { + "name": "result_custom_fields", + "type": "string", + "description": "Same idea for test-RESULT custom fields, on the result-bearing listings.", + "json_path": "/required/result_custom_fields" + } + ], + "intent": "Search for entities within a project (v2 - POST with body)", + "guidance": [ + "Tag and status filtering WORK here. Send `tags` (and `status`) as ordinary body fields - their json_path maps them under `q`, which is the shape the server wants.", + "The general way to find cases by attribute. Ask for a large page when collecting ids to feed into another call, so the follow-up gets the whole set rather than the first page." + ], + "shape": "discovered", + "max_page_size": 100, + "paginated": true, + "responses": { + "200": { + "description": "Search results. Flat body, no `data` envelope: {success, test_cases, folders, info, filter_details}. The rows are the same shared test-case record the v1 search returns, including `attachments` and the per-value `valueDetails` bindings.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "description": "Matching rows for entity=test-cases.", + "items": { + "$schema": "V1TestCaseSearchRow__SearchProjectEntitiesByFilter" + } + }, + "folders": { + "type": "object", + "description": "KEYED BY FOLDER ID — {\"764093\": [{id, name, parent_id, ui_position}, …]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." + }, + "info": { + "$schema": "PageInfo" + }, + "filter_details": { + "type": "object", + "description": "The filters you sent, resolved to display objects. `{}` unless filter_details was requested and q was non-empty." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "returns": [ + "success", + "test_cases", + "folders", + "info", + "filter_details" + ] + }, + { + "name": "search_project_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users/search", + "mode": "read", + "entity": "user", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Matches FULL NAME only, case-insensitive contains. Email is not searched — a query that looks like an address returns nothing. Omit it to list everyone in the workspace." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Paging overflows to the last page rather than returning an empty one." + }, + { + "name": "sort", + "type": "string", + "values": [ + "created_at", + "id", + "identifier" + ], + "default": "created_at", + "description": "Sort column. The allowlist is shared across entities — `identifier` is not a user column and has no effect. An unlisted value is ignored rather than rejected, leaving the default order." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "default": "desc", + "description": "Lower-case only; an unlisted value silently falls back to the default ordering." + } + ], + "intent": "Find people by name and read their role, permissions and product access — the search behind owner and assignee pickers; note it searches the whole workspace, not just this project's members", + "returns": [ + "users", + "info", + "id", + "browserstack_user_id", + "full_name", + "role", + "productRole", + "permissions", + "has_access", + "onboarded", + "group_id", + "reports_and_notification_enabled", + "customisable_dashboard_accessible", + "webhook_accessible", + "is_build_listing_enabled", + "is_delighted_visible", + "is_product_intro_visible", + "is_onboarding_project_header_visible", + "is_lcnc_explore_dismissed", + "is_new_project_settings_visible", + "is_jira_app_project_panel_visible" + ], + "max_page_size": 30, + "paginated": true, + "responses": { + "200": { + "$response": "ProjectUsersSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This is NOT project-scoped, despite the path and the name. The query filters on the caller's group only — `project_id` resolves the route and is never used in the lookup — so every user in the workspace comes back, including people with no access to this project.", + "`has_access` on each row is the user's product-access flag, not project membership. Do not read it as 'is on this project'; there is no project-membership field in this response at all.", + "`q` matches full_name only, case-insensitive contains. Searching by email returns an empty list, which looks identical to 'no such person'.", + "`id` is the Test Management user id — the one owner and assignee fields take. `browserstack_user_id` is the BrowserStack account id for the same person and is a different number.", + "Page size is fixed at 30: the `page_size` parameter that was published here is not read, and asking for 100 still returns 30.", + "Paging overflows to the LAST page — a `p` beyond the end returns the final page again rather than an empty list. Stop on `info.next == null`, never on 'the page came back empty', or you will loop forever on the last page.", + "Default order is creation date descending, so the newest members appear first; it is not alphabetical.", + "`permissions` is computed per request from the user's product role and this workspace's feature flags, so the same person can show a different permission list in a different workspace, and the list is not a stored attribute.", + "Not permission-gated — the search-filter controller has no RBAC entry." + ] + }, + { + "name": "search_test_case_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-case/tags/search", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Name search, trimmed; an all-whitespace value is treated as absent and you get the backing service's default first page. This is the ONLY query parameter the handler reads." + } + ], + "intent": "Check whether a test-case tag name exists in a project, or complete a partial one, before tagging a case or building a tag filter", + "returns": [ + "tags", + "tag_objects", + "info", + "name", + "created_at", + "usage_count" + ], + "max_page_size": 100, + "paginated": false, + "responses": { + "200": { + "$response": "TagsSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This search cannot be paged. `p` and `page_size` are accepted by the URL and never read — no page parameter reaches the backing service, so every call returns its first page and a request for page 2 silently returns the same rows. If a project has more tags than one page, narrow with `q` rather than paging.", + "The response still carries an `info` block; it describes the one page you were given, not a page you can navigate away from.", + "`tags` is an array of names, `tag_objects` the same tags with `created_at` and `usage_count`. No tag ids here — use the group-level tag search when you need an id.", + "If the project cannot be resolved the handler returns 200 with `{tags: []}`. An empty vocabulary and an unknown/inaccessible project are indistinguishable, so do not read an empty result as 'this project has no tags'.", + "Test-case tags only. Run tags are a separate vocabulary on a separate path, and a name present in one says nothing about the other.", + "Not permission-gated — the search-filter controller has no RBAC entry." + ] + }, + { + "name": "search_test_cases_by_folder", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/search", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "id", + "type": "integer", + "description": "Exact integer test case id. Blank values are skipped." + }, + { + "name": "name", + "type": "string", + "description": "EXACT case title match — not a substring search. Blank values are skipped." + }, + { + "name": "identifier", + "type": "string", + "description": "Exact identifier match. Blank values are skipped." + } + ], + "intent": "Legacy id/name/identifier lookup against the deprecated local test-case table — do NOT use it to find test cases; use the project search or the folder listing instead.", + "returns": [ + "id", + "name", + "identifier" + ], + "responses": { + "200": { + "$response": "LegacyTestCaseSearchResponse" + }, + "400": { + "$response": "BadRequest" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DEPRECATED AND EFFECTIVELY DEAD. It queries the legacy local test-case table, which is no longer the system of record — for any project whose data lives in the current backend it returns an empty list with 200, whatever you search for. An empty result here is not evidence that a case does not exist.", + "It does NOT accept a free-text `query` parameter. The only filters read are `id`, `name` and `identifier`, each matched for EXACT equality, and a blank value is skipped.", + "The response is a BARE JSON ARRAY of legacy rows — no `success`, no `info`, no `test_cases` wrapper, and the rows are the old table's columns, not the serialised test case the rest of this surface returns.", + "The folder id in the path is not used to scope the query; only the project and group are.", + "Use the project-wide entity search for finding cases by text, or the folder listing to enumerate a folder." + ], + "disabled": true + }, + { + "name": "send_report_email_now", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/send_email_now", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "emails", + "type": "array", + "required": true, + "example": [ + "qa-leads@company.com" + ], + "description": "Recipient addresses. MUST be an array, even for one address. 1-10 entries, each matching a standard address syntax — invalid entries fail the whole call 400 and are listed in the message. Any address is accepted; recipients need not be TM users." + }, + { + "name": "file_type", + "type": "array", + "values": [ + "csv", + "pdf", + "xlsx" + ], + "example": [ + "pdf" + ], + "default": [ + "pdf" + ], + "description": "Format to attach. MUST be an array with EXACTLY ONE entry — two entries, or an explicit empty array, crash the allow-list check and return 500. Omit the field entirely to default to [\"pdf\"]." + } + ], + "intent": "Email an existing report to specific people right now, as a PDF/CSV/XLSX attachment, without changing its schedule", + "responses": { + "200": { + "description": "Generation job enqueued; the mail is sent asynchronously.", + "schema": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Report generation started. Email will be sent shortly." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the only way to get a report's file out of the product through the API — the download capability hands back a channel id you cannot resolve. Mail it to yourself to obtain the file.", + "Send a SINGLE entry in `file_type`. The server checks the allow-list with a splat, so an array of two formats (or an explicit empty array) raises an ArgumentError and comes back 500, not 400. Call the endpoint once per format you need.", + "Both fields must be JSON arrays even for one value — a bare string is 400 `emails should be an array` / `file_type should be an array`.", + "`emails` is effectively required: it defaults to an empty array which then fails the count check with 400 `Please provide between 1 and 10 email addresses.` Between 1 and 10 addresses, each syntactically valid; invalid ones are named in the 400 rather than skipped.", + "Recipients are arbitrary addresses — they need not be TM users, and the mail goes out immediately with no further confirmation. Treat this as an irreversible external side effect.", + "The 200 only means the job was enqueued (`Report generation started. Email will be sent shortly.`). Generation or delivery can still fail afterwards and there is no job id and no status endpoint — you cannot confirm the mail was sent.", + "Unlike every other report call, this one is NOT permission-gated: any user with access to the project can share a report. An unknown report id is 404 `Report not found`.", + "The report's own `mail_to` recipients are untouched: this does not add them, and it does not affect the schedule." + ], + "returns": [ + "success", + "message" + ] + }, + { + "name": "delete_folder_and_subtree", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm", + "mode": "destructive", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "DOES NOT restore a deleted folder — this route currently deletes the folder and its entire subtree, identically to the delete call; there is no folder-restore API", + "returns": [ + "data", + "success", + "folder", + "path_folders", + "id", + "name", + "parent_id", + "project_id", + "group_id", + "is_automation", + "cases_count", + "total_cases_count", + "sub_folders_count", + "links" + ], + "responses": { + "200": { + "description": "Folder successfully restored", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "$schema": "Folder" + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Do not call this to undo a deletion. The handler is a one-line alias that forwards to the delete action, and the two arguments it passes to signal 'restore' are never read by that action — the request that reaches the backing service is exactly the same DELETE the remove endpoint sends.", + "The effect is therefore identical to deleting the folder: the folder, every subfolder beneath it and every test case in the subtree are removed, with test cases landing in the recycle bin only if the group's Move to Bin flag is on.", + "It is also the cheaper way to cause that damage: this route is gated on the folder-EDIT permission while the real delete route requires folder-DELETE, so an edit-only caller can destroy a folder through it.", + "There is no folder-restore capability anywhere in this API. Binned TEST CASES can be listed and restored from the project bin; folders cannot be brought back at all. If you need the structure again, recreate it.", + "The response is the delete response: {data:{folder, path_folders, success}}, with the echoed folder's counts zeroed." + ], + "disabled": true + }, + { + "name": "unlink_test_runs_from_test_plan", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/unlink", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "test_runs", + "type": "array", + "required": true, + "description": "Run ids to unlink, as the INTEGER `uuid` from a run read — NOT the TR-NNN string that run endpoints return in `id`. Sent at the TOP LEVEL of the body: this call has no `test_plan` wrapper, unlike plan create/update." + } + ], + "intent": "Detach test runs from a test plan without deleting them — the runs survive and become plan-less, ready to be linked somewhere else", + "responses": { + "200": { + "description": "`{\"success\": true}` on an unlink. Also returned with `{\"success\": false, \"error\": \"Please check test runs\"}` when `test_runs` is missing or not an array — a 200 that did nothing.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is FLAT here — {\"test_runs\": [...]} at the top level. Wrapping it in `test_plan` the way create and update require means the handler sees nothing and the call silently no-ops.", + "A missing test_runs, or one that is not an array, returns HTTP 200 with {success: false, error: 'Please check test runs'} and changes nothing. This is the main failure mode and it does not look like a failure.", + "Use each run's integer `uuid`. Run endpoints put the TR-NNN string in `id`, and passing that fails upstream rather than being rejected here.", + "The 200 is returned without checking what actually happened: ids that were never linked to this plan, or that do not exist, are absorbed silently. Read the plan's run list before and after if you need to report what changed.", + "This only breaks the plan↔run link. The runs themselves, and their test cases and results, are untouched — they just stop belonging to any plan.", + "To re-link runs, send them in the plan update's test_runs list (which REPLACES the whole link set) rather than looking for a link endpoint here." + ], + "returns": [ + "success", + "error" + ], + "disabled": true + }, + { + "name": "update_custom_field_dataset_option", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Not validated by this handler." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id holding the option. Not validated by this handler." + }, + { + "name": "option_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric option id, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." + } + ], + "body": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The option label. REQUIRED even when unchanged — a missing or null value is a 400. Must stay unique within the dataset." + }, + { + "name": "is_default", + "type": "boolean", + "required": true, + "description": "REQUIRED even when unchanged — a missing or null value is a 400. Setting it true does not clear any other option's default." + } + ], + "intent": "Rename one dropdown choice, or make it the default — the option id stays the same, so cases already holding this value follow the new label", + "responses": { + "200": { + "$response": "CustomFieldAdminOptionResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A full replace of the option's label and default flag: both option_value and is_default must be sent, even when only one is changing. A missing or null either way is a 400 {success:false, error:'Invalid Params'}.", + "`parent_option_id` cannot be changed here — the handler does not forward it, so an option cannot be re-parented in a nested_dropdown. Delete and recreate instead, which drops the stored values.", + "The option id is stable across a rename, and stored values reference the id, so renaming does not lose data and can be undone by renaming back.", + "The rename lands in every project the dataset is linked to, with no per-project variant.", + "Making an option the default does not clear the previous default; on a single-select dropdown clear the old one yourself. On a multi_dropdown every option should stay is_default false.", + "Neither path id nor the option id is validated in this layer — a wrong triple surfaces as a bare 404 {success:false} with no message. A duplicate label is relayed verbatim from upstream; every other failure is masked as 400 'The API request is invalid or improperly formed'." + ], + "returns": [ + "success", + "data", + "id", + "option_value", + "is_default", + "colour", + "parent_option_id" + ] + }, + { + "name": "update_custom_field_dataset_project_mapping", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/project-mappings/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric custom field id. Validated only on the synchronous path; an async (>300 project) request does not check it at all." + }, + { + "name": "dataset_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Numeric dataset id. Validated only on the synchronous path; an async request does not check it at all." + } + ], + "body": [ + { + "name": "link_projects", + "type": "array", + "description": "Integer project ids to ADD. Note the spelling — `linked_projects` is silently dropped here. Send [] rather than omitting it: unless select_all is present, a missing array makes the request 500." + }, + { + "name": "unlink_projects", + "type": "array", + "description": "Integer project ids to REMOVE. Destroys those projects' stored values for this field, irreversibly. Send [] rather than omitting it — unless select_all is present, a missing array makes the request 500." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Auto-link projects created after this call. Forwarded as-is; omitting it sends null." + }, + { + "name": "select_all", + "type": "boolean", + "description": "true = link every project in the workspace except unlink_projects; false = UNLINK every project except link_projects, deleting their stored values. Only literal true/false are recognised — any other value falls back to the two explicit lists. Over 300 projects the call goes async." + } + ], + "intent": "Change which projects a custom field's dataset applies to — link projects so they see the field, or unlink them, which deletes the values those projects already stored", + "responses": { + "200": { + "$response": "CustomFieldDatasetProjectMappingUpdateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send BOTH `link_projects` and `unlink_projects`, even when one is empty. Unless `select_all` is present, the handler adds their two lengths together, and a missing array raises an unhandled error — the call comes back 500, not 400.", + "The spelling is link_projects / unlink_projects here. The create calls use linked_projects / linked_to_all_projects; those names are silently dropped on this path, so the call returns 200 with nothing changed.", + "Unlinking is destructive to data: the values every case in those projects stored for this field are removed, and there is no undo. Name and count the projects before proceeding.", + "`select_all: true` links every project in the workspace except those in unlink_projects. `select_all: false` is the inverse and UNLINKS every project except those in link_projects — a whole-workspace data deletion from a single boolean. Only the literal strings/values true and false are recognised.", + "Above 300 affected projects the call switches to a background job and returns {success: true, async: true, unique_id: \"\"} with NO dataset body. The unique_id is a job handle only; there is no endpoint here to poll it, so verify by re-reading the dataset.", + "In that async case the path ids are never validated, so a bad field or dataset id is accepted with a 200 and the work fails silently in the worker.", + "A project may belong to only one dataset per field. Linking a project already held by a sibling dataset fails with the upstream message relayed verbatim (e.g. 'Projects already linked to a different dataset for this custom field...', 'invalid project mapping', 'Dataset id is invalid').", + "On the synchronous path the response is the re-read dataset under `data`, with its options included." + ], + "returns": [ + "success", + "data", + "async", + "unique_id", + "id", + "link_to_future_projects", + "linked_project_count", + "linked_projects", + "linked_to_all_projects", + "options" + ] + }, + { + "name": "update_custom_field_definition", + "method": "POST", + "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/update", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "custom_fields_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "field_name", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Display label. REQUIRED even when unchanged. Stripped before comparison and storage." + }, + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "REQUIRED for validation. Send the field's CURRENT type — this surface does not compare it to what is stored, it forwards it." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "description": "REQUIRED, literal JSON boolean. Omitting it, or sending the string \"true\", is a 400." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase", + "TestResult", + "TestPlan", + "ExploratorySession", + "TestCaseStep" + ], + "default": "TestCase", + "description": "Used only to select the valid field_type set and the reserved-name list; never sent upstream, so it cannot move the field to a different entity. Send the field's real entity_type." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "Hint text. Omitting it sends null and CLEARS the stored value — resend it to keep it." + }, + { + "name": "default_value", + "type": "boolean", + "description": "Applied only when field_type is `boolean`. For any other type the handler sends null regardless of what you pass, clearing any stored default." + } + ], + "intent": "Rename a custom field, or change whether it is mandatory, its placeholder or its boolean default — a full replace of the field's attributes, not a partial edit", + "responses": { + "200": { + "$response": "CustomFieldAdminWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a REPLACE, not a patch. field_name, field_type and is_required must all be present or the call is 400 'Invalid Params' — read the field first and resend the whole set.", + "Omitting place_holder_text sends null and clears the stored hint text. Likewise, for any non-boolean field the handler always sends default_value as null, so a stored default on a string/int/date field does not survive this call. Resend every value you want to keep.", + "field_type must be sent and must be valid for the entity_type, but this surface does NOT compare it against the stored type — it is forwarded upstream. Send the field's CURRENT type. (Its sibling on the v2 custom-fields surface rejects a changed type outright with 400 'Field type cannot be changed'.)", + "entity_type is used only to pick the valid field_type list and the reserved-name list; it is never sent upstream, so a field cannot be moved between TestCase / TestResult / TestPlan. Send the field's real entity_type or the validation applies the wrong rules.", + "Datasets, options and project links are NOT touched here — this call carries no `datasets` key upstream. Change option values through the dataset option calls and project scope through the dataset project-mapping call.", + "Renaming onto a reserved name (Title, Description, Priority, State, Owner, Tags, …) is a 400, except when the field already carries that name — an import-created field keeps its name and stays editable.", + "There is no version history for a field definition: an overwritten label, placeholder or default cannot be restored. A rename is workspace-wide and lands in every project the field's datasets link to.", + "Upstream duplicate-name errors come back verbatim; every other upstream failure is masked as 400 {success:false, message:'The API request is invalid or improperly formed'}, and a field id that does not exist comes back as a bare 404 {success:false} with no message." + ], + "returns": [ + "success", + "data", + "id", + "field_name", + "field_type", + "is_required", + "default_value", + "place_holder_text" + ] + }, + { + "name": "update_dataset", + "method": "PUT", + "path": "/api/v1/projects/{project_id}/datasets/{uuid}", + "mode": "write", + "entity": "dataset", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "uuid", + "type": "string", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "json_path": "/dataset/name", + "description": "Dataset name. REQUIRED on update despite reading like a partial edit — update shares the create request schema. A name already used by another dataset in the project returns 400 {success:false, message:'Dataset name already exists.'}." + }, + { + "name": "variables", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 40, + "json_path": "/dataset/variables", + "description": "The complete new column list — this REPLACES the existing columns, it does not merge. Required. Send each column's existing `uuid` (from a read) for the columns you are keeping; omit it only for genuinely new columns. Blank-named or entirely-empty columns are silently dropped. 40 is the default cap, 100 for groups with the raised-limit flag.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", + "description": "Letters, digits and underscores only, must start with a letter. Duplicate names (case-insensitive) fail the call." + }, + { + "name": "uuid", + "type": "string", + "description": "The existing column's uuid, taken from a read. Forwarded to the store so the column keeps its identity; leave it out and you are creating a new column." + } + ] + }, + { + "name": "rows", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 100, + "json_path": "/dataset/rows", + "description": "The complete new row set — REPLACES the existing rows. Required. Same normalisation as create: blank rows dropped, cells trimmed, duplicate rows collapsed, row_number reassigned 1..N. 100 is the default cap, 200 for groups with the raised-limit flag.", + "fields": [ + { + "name": "row_number", + "type": "integer", + "required": true, + "description": "Required by the schema, then overwritten with the row's final position." + }, + { + "name": "row_data", + "type": "array", + "required": true, + "minItems": 1, + "description": "Strings only, positional against `variables`; length must match the surviving column count after empty-column pruning." + }, + { + "name": "uuid", + "type": "string", + "description": "The existing row's uuid from a read, for rows you are keeping." + } + ] + }, + { + "name": "tags", + "type": "array", + "maxItems": 20, + "json_path": "/dataset/tags", + "description": "Replaces the dataset's tags when the key is present; omit the key entirely to leave existing tags alone. Sending [] clears them. Each tag is a string of at most 50 characters, at most 20 tags.", + "fields": [ + { + "name": "tag", + "type": "string", + "maxLength": 50 + } + ] + } + ], + "intent": "Replace a dataset's contents — rename it, or write a new set of columns and rows over the existing table", + "returns": [ + "success", + "dataset", + "uuid", + "name", + "variables", + "rows", + "rows_count", + "created_at", + "created_by" + ], + "responses": { + "200": { + "description": "Dataset updated; the full stored dataset is returned at the top level as `dataset`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "dataset": { + "$schema": "Dataset__UpdateDataset" + } + } + } + }, + "400": { + "description": "Request-schema rejection ({error:'Invalid JSON data', details:[...]}) — including a body missing name/variables/rows — or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" + }, + "422": { + "description": "{success:false, message:...}. 'Feature not available' = no Team Pro entitlement; any other message is a dataset-shape validation failure." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a full replace, not a patch, and `name`, `variables` and `rows` are ALL required — the update reuses the create request schema verbatim. A body with only `name` is rejected with 400 {error:'Invalid JSON data'}; the spec marking these optional is wrong.", + "To rename without losing data: read the dataset first and send its existing `variables` and `rows` (uuids included) back alongside the new name.", + "There is no undo. Datasets keep no version history and there is no restore endpoint, so an overwritten column or row set is gone. Read the current contents before writing.", + "`metadata` passes validation here and is then silently discarded — the handler forwards only name, columns, rows and tags. Sending metadata returns 200 with metadata unchanged. It can only be set at creation.", + "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key. Nothing beyond the declared keys is permitted anywhere in the body (additionalProperties:false at every level) — an extra or misspelled key fails the whole call with 400.", + "The `uuid` in the path is the dataset's uuid string, resolved by looking it up in the project's dataset list; an unknown one is 404 {success:false, message:'Dataset not found.'}.", + "Rows and columns come back normalised (blank rows and empty columns removed, cells trimmed, duplicate rows collapsed, row_number renumbered) — trust the response, not your payload.", + "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad body; any other 422 message is a real validation failure.", + "Needs the test-case write permission (tc_write)." + ] + }, + { + "name": "update_exploratory_session", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + } + ], + "body": [ + { + "name": "title", + "type": "string", + "example": "Updated checkout exploration", + "description": "New title. `name` is accepted by the request schema and silently dropped, so use this field.", + "json_path": "/exploratory_session/title" + }, + { + "name": "description", + "type": "string", + "description": "Replacement description.", + "json_path": "/exploratory_session/description" + }, + { + "name": "charter", + "type": "string", + "description": "Replacement testing charter.", + "json_path": "/exploratory_session/charter" + }, + { + "name": "timebox_duration", + "type": "integer", + "example": 90, + "description": "Planned duration. Rendered as minutes by TM's own exports but documented upstream as seconds; passed through unconverted.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/timebox_duration" + }, + { + "name": "duration", + "type": "integer", + "example": 45, + "description": "Tracked duration. Same unit ambiguity as timebox_duration.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/duration" + }, + { + "name": "actual_duration", + "type": "integer", + "example": 50, + "description": "Final actual duration. This is the ONLY place it can be set — the close call ignores any duration you send it — so write it here before closing the session.", + "minimum": 0, + "maximum": 2147483647, + "json_path": "/exploratory_session/actual_duration" + }, + { + "name": "folder_id", + "type": "integer", + "example": 789, + "description": "Move the session to a different folder; a numeric string is also accepted.", + "json_path": "/exploratory_session/folder_id" + }, + { + "name": "test_plan_id", + "type": "integer", + "example": 77, + "description": "Integer id of the test plan to link (not a TP-NNN identifier). Only reachable through the wrapped body.", + "json_path": "/exploratory_session/test_plan_id" + }, + { + "name": "owner", + "type": "integer", + "example": 55, + "description": "TCM user id of the new owner; translated to the upstream user id. Wins over `assignee` when both are sent. An unresolvable id clears the assignee instead of failing.", + "json_path": "/exploratory_session/owner" + }, + { + "name": "assignee", + "type": "integer", + "example": 55, + "description": "Genuine alias for `owner` on this endpoint — both are translated here. (On create only `owner` is, so prefer `owner` in code shared between the two.)", + "json_path": "/exploratory_session/assignee" + }, + { + "name": "tags", + "type": "array", + "example": [ + "smoke", + "payment" + ], + "description": "REPLACES the session's tags with this list; an empty array clears them. Non-string entries are rejected by the request schema.", + "json_path": "/exploratory_session/tags" + }, + { + "name": "configurations", + "type": "array", + "example": [ + 2, + 4 + ], + "description": "REPLACES the session's configuration ids (integers or numeric strings). An empty array clears them.", + "json_path": "/exploratory_session/configurations" + }, + { + "name": "attachments", + "type": "array", + "description": "REPLACES the attached blob ids. Every id you leave out is detached and its blob freed for reuse. Ids must be integer blob ids from a prior generic attachment upload; an invalid set returns 422 with the bare string body \"attachment is invalid\".", + "json_path": "/exploratory_session/attachments" + }, + { + "name": "issues", + "type": "array", + "description": "REPLACES the linked issues. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the whole list to empty. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. A per-entry metadata object is accepted. Applied asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/exploratory_session/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "101": "Regression" + }, + "description": "Custom field values as a map of {custom_field_id: value} for entity type ExploratorySession, converted server-side. Not the array shape returned on read. Only sent when non-empty — an empty object is ignored rather than clearing values.", + "json_path": "/exploratory_session/custom_fields" + } + ], + "intent": "Change fields on an existing exploratory session — retitle it, revise the charter, record the time spent, reassign it, move it to another folder, or replace its tags, configurations, attachments and linked issues", + "returns": [ + "success", + "exploratory_session", + "id", + "identifier", + "title", + "description", + "charter", + "session_state", + "timebox_duration", + "duration", + "actual_duration", + "session_log_count", + "test_plan_id", + "assignee", + "created_by", + "tags", + "configurations", + "issues", + "custom_fields", + "attachments", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "description": "Exploratory session updated successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key.", + "Scalar fields are patched by presence: only title, description, charter, timebox_duration, duration, actual_duration, folder_id and test_plan_id keys actually present in the body are sent. Omitted keys are left alone.", + "The collection fields are NOT patched, they are replaced. Sending `tags`, `configurations`, `issues` or `attachments` overwrites the whole list; sending an empty array clears it. Omitting the key is the only way to leave a collection untouched.", + "Replacing `attachments` detaches every blob you left out and frees it for reuse. An invalid set is rejected with 422 whose body is the bare JSON string \"attachment is invalid\" — not the usual {success:false, message} envelope, so do not parse it like one.", + "`owner` and `assignee` are both translated to the upstream user id here (unlike create, where only `owner` is). `owner` wins when both are sent. An id that resolves to no user clears the assignee rather than failing.", + "There is no way to change `session_state` through this call, and no reopen endpoint exists — closing a session is one-way.", + "`name` is accepted by the request schema and then dropped by the parameter filter; retitle with `title`.", + "Exploratory sessions keep no version history and there is no undo: an overwritten title, description, charter or tag list cannot be restored.", + "Issue linking is applied asynchronously after the 200, so the session echoed back may not yet reflect the new links.", + "Upstream validation failures all arrive as 400 {success:false, message:'The API request is invalid or improperly formed'} — the same text for a bad configuration id, a folder in another project or a malformed value." + ] + }, + { + "name": "update_exploratory_session_log", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", + "mode": "write", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "session_id", + "type": "integer", + "required": true, + "example": 123 + }, + { + "name": "log_id", + "type": "integer", + "required": true, + "example": 789 + } + ], + "body": [ + { + "name": "content", + "type": "string", + "example": "

Confirmed the spinner issue on iOS 17 as well.

", + "description": "Replacement rich-text HTML. Sanitised on write. Re-sending it re-derives the log's image attachments from the blob ids in the HTML, so an image removed from the HTML is detached. A null is stripped rather than clearing the field — send an empty string to blank it.", + "json_path": "/session_log/content" + }, + { + "name": "status", + "type": "string", + "values": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note", + "skipped" + ], + "example": "bug", + "description": "Replacement outcome. `skipped` is valid; anything outside the seven is rejected by the request schema. A null is stripped rather than clearing the status.", + "json_path": "/session_log/status" + }, + { + "name": "elapsed_time", + "type": "integer", + "example": 180, + "description": "Replacement time spent, in seconds. Must be an integer. A null is stripped rather than clearing the value.", + "json_path": "/session_log/elapsed_time" + }, + { + "name": "defects", + "type": "array", + "description": "REPLACES the log's defect links when non-empty; omit the key to leave them untouched. An empty array is treated as 'not supplied' and does NOT clear the links. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the list to empty; a bad jira id fails the whole call. Applied asynchronously after the response.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "required": true + }, + { + "name": "issue_type", + "type": "string", + "required": true + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/session_log/defects" + } + ], + "intent": "Revise an observation already recorded in an exploratory session — correct the note, change its pass/fail/bug outcome, adjust the time spent or restate which defects it raised", + "returns": [ + "success", + "session_log", + "id", + "session_id", + "content", + "status", + "elapsed_time", + "created_by", + "defects", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "description": "Session log updated successfully.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_log": { + "$schema": "ExploratorySessionLog__UpdateExploratorySessionLog" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Both ids matter and both are integers: log_id identifies the entry, session_id in the path identifies its parent session. A log_id that belongs to a different session is not addressable here.", + "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. Only content, status, elapsed_time and defects are read, and only the keys present are forwarded — everything else is left as it is.", + "You cannot blank a field out: nulls are stripped from the outgoing payload, so sending content:null or status:null leaves the existing value in place. Send an empty string for content instead, and expect status and elapsed_time to be un-clearable.", + "`defects`, when non-empty, REPLACES the log's defect links; omit the key to leave them alone. The same coercion applies as on create — bare id strings become jira issues, malformed entries silently reduce the list to empty, and a bad jira id fails the whole call. Linking is applied asynchronously after the 200.", + "You cannot unlink every defect from a log: sending defects:[] is treated as 'not supplied' and the existing links survive. This differs from the session update, where an empty tags/configurations/issues array does clear the list.", + "Re-sending `content` re-derives the log's image attachments from the blob ids in the HTML: an image you drop from the HTML loses its attachment link, and if any referenced blob id is not visible to your group or project then ALL of the call's attachment links are silently dropped while the update still succeeds.", + "`content` is sanitised on write, so the stored HTML can differ from what you sent.", + "Session logs keep no version history and there is no undo — an overwritten note cannot be recovered.", + "`status` must be one of pass, fail, bug, retest, block, note, skipped; anything else is a 400 from the request schema." + ] + }, + { + "name": "update_filter", + "method": "POST", + "path": "/api/v1/projects/{project_id}/filter/{filter_id}", + "mode": "write", + "entity": "filter", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "filter_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "description": "Required on every update, even when you are only changing the conditions. Read the filter first and resend its current name rather than inventing one — the value you send is what it is renamed to." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "testcase", + "testplan", + "testrun", + "exploratory_session", + "test_plan_test_case" + ], + "description": "Required on every update, but not applied: a saved view's list type cannot be changed, and a different value returns success while changing nothing. Resend the filter's existing entity_type. An unlisted value returns 400 'Invalid JSON data'." + }, + { + "name": "is_project_level", + "type": "boolean", + "description": "Forwarded whenever the key is present, including `false` — this is how you demote a project-wide view back to private. Omit the key to leave the current scope alone." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement condition set, in the same UI filter-state shape as create_filter's `filters` (systemFields option objects, extraSystemFields.issue_type, matchAllMatchAny) — send the whole set, not just the change. Omit to leave the stored conditions untouched. `{}` is treated as absent, so conditions cannot be cleared to empty through this call." + } + ], + "intent": "Rename a saved filter view, replace its conditions, or switch it between private and project-wide — resending name and entity on every call", + "returns": [ + "success", + "data", + "id", + "name", + "entity_type", + "is_project_level", + "filters", + "owner", + "project_id", + "created_at", + "updated_at" + ], + "responses": { + "200": { + "$response": "FilterUpdateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is a POST, not a PATCH or PUT, and it is only half a partial update: `name` and `entity` are required by the request-body schema on every call, while `is_project_level` and `filters` are forwarded only when you send them. Read the filter first and resend name + entity_type unchanged.", + "The body is flat and closed — an extra key returns 400 'Invalid JSON data' before the handler runs.", + "`filters: {}` is dropped as blank, so there is no way to empty a view's conditions here; delete and recreate instead.", + "Saved filter views keep no version history, so an overwritten name or condition set cannot be restored. Read the filter before you overwrite it if you may need the old value.", + "Changing is_project_level to true publishes a previously private view to the whole project immediately.", + "An unknown filter_id comes back as 400 `{success:false, message:...}`, not 404 — this action only translates the backing service's 400, and every other failure from it becomes a 500.", + "Not permission-gated: the filter controller has no RBAC entry, so a project-level view can be edited by anyone with product access to the project, not only its owner." + ] + }, + { + "name": "update_latest_test_result_for_test_case", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", + "mode": "write", + "entity": "result", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$" + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "configuration_id", + "type": "string" + }, + { + "name": "mapping_id", + "type": "string" + } + ], + "body": [ + { + "name": "status_id", + "type": "integer", + "description": "Result status id (resolve via the statuses-and-states concept — this path takes ids, not names; a `status` name is accepted by the filter and then discarded).", + "json_path": "/test_result/status_id" + }, + { + "name": "description", + "type": "string", + "description": "Rich text note on the result. Links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent.", + "json_path": "/test_result/description" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom field values keyed by custom field id (not by field name — that is the v2 contract).", + "json_path": "/test_result/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked defects. Each entry is an OBJECT — a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_result/issues" + }, + { + "name": "attachments", + "type": "array", + "example": [ + 10288829 + ], + "description": "Integer blob ids from a prior generic attachment upload — not filenames and not file bytes.", + "json_path": "/test_result/attachments" + }, + { + "name": "elapsed_time", + "type": "integer", + "description": "Accepted by the parameter filter and then DROPPED before the write on this path — setting it here does nothing and reports no error.", + "json_path": "/test_result/elapsed_time" + }, + { + "name": "configuration_id", + "type": "integer", + "description": "Which configuration's latest result to patch. TOP level — the server does not read it from inside `test_result`." + }, + { + "name": "mapping_id", + "type": "integer", + "description": "Target a specific run/case mapping. Top level." + } + ], + "intent": "Amend the most recent result logged for a test case in a run — correct its status, note, linked defects or custom fields in place, without adding another attempt to the case's history", + "returns": [ + "id", + "status", + "result_status", + "description", + "author", + "created_at", + "updated_at", + "configuration_id", + "custom_fields", + "custom_fields_count", + "test_run_step_result", + "attachments", + "issues", + "links" + ], + "responses": { + "200": { + "description": "Test result successfully updated", + "schema": { + "$schema": "TestResult" + } + }, + "400": { + "description": "Bad request - Test run is closed or invalid parameters", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "status": { + "type": "integer", + "example": 400 + }, + "error": { + "type": "string", + "example": "Cannot Update Closed Test Run" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No test results found", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "status": { + "type": "integer", + "example": 404 + }, + "message": { + "type": "string", + "example": "No test results found" + } + } + } + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This EDITS the latest existing result; it never creates one. With nothing logged yet it answers 404 {success:false, message:'No test results found'} — log a result first, then amend it.", + "A closed test run is refused with 400 {success:false, error:'Cannot Update Closed Test Run'} before any field is written. A closed run cannot be reopened, so its results are final — record new results in a clone of the run instead.", + "Send the fields flat. The `test_result` wrapper is added for you from each field's json_path, and sending your own `test_result` object is rejected before the call as an unknown body key. Sending no fields at all is legal and re-saves the current values unchanged.", + "Only description, status_id, custom_fields, attachments and issues survive to the write. `status` (the name) and `elapsed_time` are accepted by the parameter filter and then dropped on this path — set the status by id here, and use the v2 result surface if you only have a status name.", + "Keys you omit keep their current values (the handler re-reads the result and fills them in), so this is a genuine partial update.", + "configuration_id and mapping_id are read at the TOP level — query string or body root, never from inside `test_result`. Without one of them 'latest' means latest across all configurations, which on a configuration matrix is usually not the row you meant.", + "Unlike the id-addressed result update, this path applies NO authorship check — only the standard project permission gate runs, so it will overwrite a result logged by someone else. The edit is in place and the previous values are not recoverable; log a new result instead if you want the earlier outcome preserved.", + "The 200 body is the bare updated result object at the TOP level — no `data` envelope and no `success` key, unlike the create-result endpoint on the same path." + ], + "disabled": true + }, + { + "name": "update_project_settings", + "method": "POST", + "path": "/api/v1/projects/{project_id}/settings", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Turn a project's configuration flags on or off — review/approval gating, the custom test-case id prefix, test-case sharing, the manual-execution timer, Part 11 re-authentication, Jira/Azure linking and test-plan review", + "responses": { + "200": { + "description": "Written settings echoed back as one object per setting. A body containing no recognised setting name also returns 200, with success:false and an empty array — nothing was written.", + "schema": { + "type": "object", + "required": [ + "success", + "project_settings" + ], + "properties": { + "success": { + "type": "boolean", + "description": "False (with HTTP 200) when nothing was written — including when the body contained no recognised setting name." + }, + "project_settings": { + "type": "array", + "description": "One single-key object per setting written, {settingName: value}. Note this differs from the read, which returns a single flattened object at index 0.", + "items": { + "type": "object" + } + } + } + } + }, + "400": { + "description": "Invalid test_case_id_prefix (format, reserved, or already used elsewhere) or an unknown review-status token." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "description": "Timer settings written without the project-settings write permission." + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "New project settings not enabled for this group/user, or a plan gate (Team Pro / Enterprise) not met." + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Settings are sent as FLAT top-level body keys, one per setting — there is no wrapper object and no generic {key, value} pair. Only the names listed below are read; everything else is ignored.", + "The silent no-op to watch for: if the body contains none of the known setting names, the request still returns HTTP 200 with {success:false, project_settings:[]} and nothing is written. A 200 here is not confirmation — check `success` and check that the setting you sent comes back in project_settings.", + "This is a partial update: only the keys you send are touched, and the others keep their stored values. It does NOT reset omitted settings.", + "There is no undo and no history — the previous value is gone the moment the write lands. Re-read get_project_settings first if you need to be able to restore it.", + "Feature-gated for the whole endpoint: without the new-project-settings flag for the group or user the call fails 422 'Feature not available' before any key is examined.", + "Per-setting gates, all raised before the write: review_approve and the two eligible-review-status settings need a Team Pro plan (422); part_11_reauth and part_11_reauth_send_for_review need an Enterprise plan (422); the four timer settings need the project-settings write permission (403 'Only admins can update Time Tracking settings').", + "test_case_id_prefix is validated twice — locally against 2-10 uppercase alphanumerics starting with a letter and not equal to 'TC', then against the backing service for reserved prefixes and cross-project collisions. Either failure returns 400 with the reason, and it is checked before anything else in the body is written.", + "The two eligible-review-status settings take a comma-separated list drawn from approved / rejected / in_review / pending / changes_requested. An unknown token fails the whole request with 400; an empty string is valid and means gating is OFF.", + "Values are stored as strings in an EAV table, so send booleans as the strings \"true\"/\"false\" and numbers as numeric strings; that is also how they read back.", + "The response echoes one object PER setting written ([{name: value}, ...]) — a different shape from the read, which flattens everything into a single object at project_settings[0].", + "Removing a setting entirely is a different operation (a delete on the same path with the attribute name) and is not exposed as a capability here; writing an empty string is not the same as unsetting it." + ], + "body": [ + { + "name": "review_approve", + "type": "string", + "description": "Enable the test-case review/approval workflow. Requires a Team Pro plan — otherwise 422 'Feature not available'. Send \"true\"/\"false\" as strings." + }, + { + "name": "test_run_eligible_review_statuses", + "type": "string", + "description": "Comma-separated review statuses a test case must be in before it can be added to a run. An unknown token fails the whole request with 400. Empty string = no gating. Requires a Team Pro plan." + }, + { + "name": "test_execution_eligible_review_statuses", + "type": "string", + "description": "Comma-separated review statuses a test case must be in before results can be logged against it. Same validation and plan gate as the run variant." + }, + { + "name": "default_test_case_approval_state", + "type": "string", + "description": "The review state newly created test cases start in." + }, + { + "name": "test_case_id_prefix", + "type": "string", + "description": "Custom identifier prefix for this project's test cases. 2-10 characters, uppercase letters and digits, must start with a letter, and cannot be \"TC\". Also checked against reserved prefixes and other projects' prefixes; either check failing returns 400 with the reason." + }, + { + "name": "tc_sharing", + "type": "string", + "description": "Allow this project's test cases to be shared into other projects." + }, + { + "name": "part_11_reauth", + "type": "string", + "description": "Require re-authentication for regulated (21 CFR Part 11) actions. Requires an Enterprise plan — otherwise 422." + }, + { + "name": "part_11_reauth_send_for_review", + "type": "string", + "description": "Require re-authentication specifically when sending for review. Requires an Enterprise plan — otherwise 422." + }, + { + "name": "preferred_jira_host", + "type": "string", + "description": "Default Jira site URL used when linking issues from this project." + }, + { + "name": "preferred_azure_org", + "type": "string", + "description": "Default Azure DevOps organisation used when linking work items from this project." + }, + { + "name": "sync_jira_defect_attachments", + "type": "string", + "description": "Copy attachments to the linked Jira defect when one is raised." + }, + { + "name": "link_jira_requirements", + "type": "string", + "description": "Enable linking Jira requirements from this project." + }, + { + "name": "link_jira_test_case_requirement", + "type": "string", + "description": "Enable linking a Jira requirement to a test case." + }, + { + "name": "link_jira_test_run_requirement", + "type": "string", + "description": "Enable linking a Jira requirement to a test run." + }, + { + "name": "jira_create_allowed_hosts", + "type": "string", + "description": "Allow-list of Jira site URLs that may create or update requirements, as a JSON-ENCODED ARRAY STRING (e.g. the JSON text of an array of site URLs). Omitting the key means all hosts are allowed; the string \"[]\" means none." + }, + { + "name": "time_tracking_enabled", + "type": "string", + "description": "Per-project master switch for the manual-execution timer; the feature is on only when this is exactly \"true\". Requires the project-settings write permission — 403 otherwise." + }, + { + "name": "timer_auto_start", + "type": "string", + "description": "Start the execution timer automatically when a tester opens a case. Requires the project-settings write permission." + }, + { + "name": "timer_elapsed_required", + "type": "string", + "description": "Require an elapsed time before a result can be submitted. Requires the project-settings write permission." + }, + { + "name": "timer_min_threshold_seconds", + "type": "string", + "description": "Minimum elapsed seconds before a result is accepted, as a numeric string. Requires the project-settings write permission." + }, + { + "name": "test_plan_review_enabled", + "type": "string", + "description": "Enable the review workflow for test plans." + }, + { + "name": "test_plan_review_sub_plan_mode", + "type": "string", + "description": "How sub-plans participate in test-plan review." + }, + { + "name": "test_plan_review_default_state", + "type": "string", + "description": "The review state newly created test plans start in." + } + ], + "returns": [ + "success", + "project_settings" + ] + }, + { + "name": "update_report", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/reports/{report_id}", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "example": 10000, + "description": "Integer project id the rewritten filter rows are filed under. Not enforced by the server: omit it and rows are written against project 0 and become invisible, at 200. Send the report's own project id." + }, + { + "name": "project_ids", + "type": "array", + "example": [ + 10000, + 10001 + ], + "description": "Integer project ids for a cross-project report. Only applied when present — omitting it leaves the stored set alone. Max 60 (5 for User Workload Report), and multi-project only for the seven supported types; the cap is checked against the report_type in THIS body." + }, + { + "name": "report_type", + "type": "string", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary", + "description": "Report type as its DISPLAY NAME. Required, and it also selects which filter-rewrite branch runs — omit it and the report_type column is nulled AND the filters are silently left untouched. `Defects Summary` / `Defects Detailed Report` have no data branch and no filter branch." + }, + { + "name": "title", + "type": "string", + "example": "Updated Title", + "description": "Written unconditionally — omit it and the title is set to null." + }, + { + "name": "description", + "type": "string", + "example": "", + "description": "Written unconditionally — omit it and the description is set to null." + }, + { + "name": "report_timeframe", + "type": "string", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week", + "description": "The window the report covers. `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500); any other value NULLS the stored custom_date_range. The five entity timeframes carry no dates and each reads one matching `report_filters` key." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "example": "custom_report", + "description": "Written unconditionally. Unlike create, `system_generated` here does NOT re-fetch requirements for a traceability report — send `report_filters.requirements` yourself." + }, + { + "name": "is_dynamic", + "type": "boolean", + "description": "Written unconditionally from the body — omit it and the report's dynamic flag is set to null (unlike create, it is not inferred from the filters)." + }, + { + "name": "grain", + "type": "string", + "values": [ + "test_case", + "test_plan" + ], + "description": "Immutable after creation. Send the report's persisted value or omit it; anything different is 400 'grain cannot be changed after report creation'." + }, + { + "name": "test_run", + "type": "object", + "fields": [ + { + "name": "created_at", + "type": "string", + "required": true + }, + { + "name": "status", + "type": "array", + "required": true + }, + { + "name": "owner", + "type": "array", + "required": true + }, + { + "name": "automation_status", + "type": "array", + "required": true + }, + { + "name": "type", + "type": "array", + "required": true + } + ], + "json_path": "/dynamic_filters/test_run", + "description": "Dynamic run selection, accepted under either `dynamic_filters` or `filters`. Only rewritten for a Requirement Traceability Report; on other types it is read but the JQL config is not updated." + }, + { + "name": "status", + "type": "array", + "json_path": "/report_filters/status" + }, + { + "name": "priority", + "type": "array", + "json_path": "/report_filters/priority" + }, + { + "name": "case_type", + "type": "array", + "json_path": "/report_filters/case_type" + }, + { + "name": "assignee", + "type": "array", + "json_path": "/report_filters/assignee" + }, + { + "name": "automation_status", + "type": "array", + "json_path": "/report_filters/automation_status" + }, + { + "name": "automation_state", + "type": "array", + "json_path": "/report_filters/automation_state" + }, + { + "name": "test_runs", + "type": "array", + "description": "Run ids as INTEGERS — a quoted id or TR-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored run selection.", + "json_path": "/report_filters/test_runs" + }, + { + "name": "test_plans", + "type": "array", + "description": "Plan ids as INTEGERS — a quoted id or TP-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored plan selection.", + "json_path": "/report_filters/test_plans" + }, + { + "name": "test_cases", + "type": "object", + "description": "Case selection — must be an OBJECT (an array is 400), folder-keyed like SelectionData: folder ids are STRING keys, case ids INTEGERS. Read the current value from get_selected_report_testcases and send it back; omitting the key deletes the saved selection.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "required": true + }, + { + "name": "folders", + "type": "object", + "required": true + }, + { + "name": "unique_test_case_count", + "type": "integer", + "required": true + } + ], + "json_path": "/report_filters/test_cases" + }, + { + "name": "shared_selection", + "type": "object", + "description": "Cross-project case selection: { \"\": } for non-primary projects. A project id outside the report's project set is dropped silently.", + "json_path": "/report_filters/shared_selection" + }, + { + "name": "session_ids", + "type": "array", + "description": "Exploratory session ids — pairs with report_timeframe=specific_sessions.", + "json_path": "/report_filters/session_ids" + }, + { + "name": "requirements", + "type": "object", + "description": "{ issue_type: [issue_id, ...] } — must be an object, not an array. Pairs with report_timeframe=requirements and is NOT auto-populated on edit even for system_generated reports.", + "json_path": "/report_filters/requirements" + }, + { + "name": "test_plan_selection", + "type": "object", + "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. A malformed entry is discarded silently rather than rejected.", + "json_path": "/report_filters/test_plan_selection" + }, + { + "name": "test_runs_by_project", + "type": "object", + "description": "Cross-project run selection: { \"\": [run_id, ...] }. Numeric-string keys and numeric-id arrays only, else 400. Replaces the flat `test_runs` key when present.", + "json_path": "/report_filters/test_runs_by_project" + }, + { + "name": "test_plans_by_project", + "type": "object", + "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Replaces the flat `test_plans` and `test_plan_selection` keys when present.", + "json_path": "/report_filters/test_plans_by_project" + }, + { + "name": "builds", + "type": "array", + "json_path": "/report_filters/builds", + "description": "Internal build ids paired with `test_runs`. The report read returns them as `build_ids`; send them back unchanged or the paired rows are wiped on the next write." + }, + { + "name": "projects", + "type": "array", + "json_path": "/report_filters/projects" + }, + { + "name": "folder", + "type": "array", + "json_path": "/report_filters/folder" + }, + { + "name": "test_case_tags", + "type": "array", + "json_path": "/report_filters/test_case_tags" + }, + { + "name": "tc_custom_fields", + "type": "object", + "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity`; elsewhere it is dropped silently. Persisted and read back under the name `custom_fields`.", + "json_path": "/report_filters/tc_custom_fields" + }, + { + "name": "custom_date_range", + "type": "string", + "example": "2025-04-16 2025-04-24", + "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED when report_timeframe is `custom` (omitting it is a 500); NULLED on the record for every other timeframe." + }, + { + "name": "users", + "type": "array", + "description": "TM user ids to mail the report to. Every id must be in your group or the request is 400. Only applied when the report is already scheduled.", + "json_path": "/mail_to/users" + }, + { + "name": "external_mails", + "type": "array", + "description": "Email addresses outside the group. Only applied when the report is already scheduled.", + "json_path": "/mail_to/external_mails" + }, + { + "name": "frequency", + "type": "string", + "values": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly", + "description": "Send with `frequency_details` to keep or change the schedule. Omitting EITHER unschedules the report — both columns are nulled and it stops emailing. `once` is not a value: it passes validation and then raises a 500." + }, + { + "name": "time", + "type": "string", + "example": "08:00", + "description": "24-hour HH:MM, UTC. Required whenever frequency is sent.", + "json_path": "/frequency_details/time" + }, + { + "name": "day", + "type": "string", + "example": "monday", + "description": "Required for weekly (lowercase English day name) and monthly (integer 1-28). Omit for daily.", + "json_path": "/frequency_details/day" + } + ], + "intent": "Rewrite a saved report — its type, window, filters, schedule or recipients — replacing the whole definition in one call", + "returns": [ + "data", + "report_data", + "report_filters", + "dynamic_filters" + ], + "responses": { + "200": { + "$response": "UpdateReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This PATCH is a FULL REPLACE, not a merge. `title`, `description`, `report_type`, `report_timeframe`, `report_creation_mode` and `is_dynamic` are written from the body unconditionally, so any one you omit is set to NULL. Read the report first and send the complete object back with your change applied.", + "Omitting `frequency` / `frequency_details` UNSCHEDULES the report: both columns are nulled and it stops emailing. Resend the persisted values to keep the schedule. Likewise `custom_date_range` is nulled whenever `report_timeframe` is not `custom`.", + "`projectId` in the body is effectively required even though it is not marked so: it is the project every rewritten filter row is filed under. Omit it and the rows are written against project 0, which no read ever finds — the call still returns 200 with a report whose filters have silently vanished.", + "`report_filters` is authoritative: a key you omit has its stored rows deleted. Send the full filter set, not a delta. Use the report read and get_selected_report_testcases to recover the current values first.", + "Filters are only rewritten for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Plan Detailed Report, Test Case Activity, User Workload Report, Requirement Traceability Report and Exploratory Session Summary. On `Defects Summary` / `Defects Detailed Report`, or when `report_type` is omitted, the filter update is skipped entirely and `report_filters` comes back null — at 200.", + "There is no undo and no version history. The previous definition and its filter rows are gone once this returns.", + "`grain` cannot be changed after creation — a differing value is 400. Resending the persisted value is accepted.", + "`mail_to` is only applied when the report is already scheduled AND `mail_to` is present in the body; on an unscheduled report recipients are ignored.", + "The response is NOT the create envelope: there is no `success` key. The report is at `data`, the computed figures at `report_data`, the resolved filters at `report_filters`. `report_data` is computed inline here, so a large report makes this call slow.", + "The report is resolved by id within your group — the project in the path does not scope the lookup, so a report belonging to a sibling project still resolves (404 `Schedule not found` only when no such report exists in the group). Requires the report_update permission.", + "The same cross-project caps as create apply, and they are re-checked against the incoming `report_type`: 60 projects overall, 5 for User Workload Report, and multi-project only for the seven supported types." + ] + }, + { + "name": "update_shared_step", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", + "mode": "write", + "entity": "shared_step", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "shared_step_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Required on every call even when you are only changing steps. Read the shared step first and resend its current title; whatever you send becomes the new title." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Shared-field folder to move the block into. Forwarded only when the key is present, so omitting it leaves the block where it is; send null to move it to the root (Unassigned)." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "description": "Plain tag name strings; the list you send replaces the block's tags. The key is always forwarded, so omitting it sends a null tags value rather than preserving the current tags — resend the existing tags if you want to keep them." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The FULL replacement step list, not a patch. Rows you leave out are removed from the block. Required — omitting the key fails with a flat 400 'Validation Error'.", + "fields": [ + { + "name": "id", + "type": "integer", + "description": "Existing step-row id, from a prior read. Rows sent with an id are updated in place and keep their identity; rows sent without one are created fresh, which breaks the shared_step_detail_id that referencing test-case steps already hold." + }, + { + "name": "order", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Required here too, not optional: local validation demands order >= 1 on every row, so a row sent without an order fails the whole update." + }, + { + "name": "step", + "type": "string", + "description": "Step body, HTML. Each row must carry at least one of step or result." + }, + { + "name": "result", + "type": "string", + "description": "Expected result, HTML. Each row must carry at least one of step or result." + }, + { + "name": "test_data", + "type": "string" + } + ] + } + ], + "intent": "Replace a shared step's title, folder and its whole ordered step list — the edit lands at once in every test case that embeds the block", + "returns": [ + "success", + "data", + "id", + "title", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$schema": "SharedStepsIDResponse__UpdateSharedStep" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Blast radius first: a shared step is embedded by reference, so this edit changes the steps rendered by every test case that embeds the block — immediately, without touching or versioning those cases. Read the block (or find it in the listing) and check `test_case_count` before you write: that is how many test cases you are about to change.", + "shared_step_details is a full replacement, not a merge. Any existing row you omit is deleted, and deleting a row orphans the `shared_step_detail_id` that referencing test-case steps point at. Read the current rows, edit that array, send it back whole.", + "Rows carrying their existing `id` are updated in place; rows without one are created new. Dropping the ids and resending the same text rebuilds every row with fresh ids and breaks every reference into the block.", + "It is a PATCH but not a partial one: title and shared_step_details are both required on every call, and an omitted `tags` is sent as null rather than left alone.", + "Shared steps keep no version history — only test cases, test plans and builds do — so there is no undo for an overwritten title or step list, and no restore-previous-version call to fall back on.", + "The handler reads the block before writing, so a wrong or deleted shared_step_id returns 404 `{data:null, success:false}` before anything changes.", + "Every local validation failure is the same opaque 400 `{data:null, success:false, message:\"Validation Error\"}` — no field name. The usual causes are a missing shared_step_details, a row with no order (or order 0), or a row with neither step nor result.", + "Requires tc_write." + ] + }, + { + "name": "update_template", + "method": "PATCH", + "path": "/api/v2/templates/{id}", + "mode": "write", + "entity": "template", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "Template id — a plain integer with no prefix, discovered from the template list.", + "pattern": "^[0-9]+$" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "description": "New template name. Only applied when the key is present; omit it to leave the name alone.", + "minLength": 1 + }, + { + "name": "enabled", + "type": "boolean", + "description": "true re-activates the template, false disables it. Read as truthiness: send a real JSON boolean, because the STRING \"false\" is truthy and ENABLES the template. Disabling is reversible and is the alternative to deleting." + }, + { + "name": "is_default", + "type": "boolean", + "description": "Make this the workspace default template for its entity type. Setting a new default displaces the current one; the system default template cannot be disabled or deleted." + }, + { + "name": "description_fields", + "type": "array", + "description": "REPLACES the description section wholesale — it is not merged. Anything you leave out is removed from the template. Read the template first and resend every field you want to keep.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + }, + { + "name": "properties_fields", + "type": "array", + "description": "REPLACES the properties section wholesale. Sending this key also silently re-adds the Review & Approve system fields (reviewers, review status), so the stored set is a superset of what you sent and those two cannot be removed by omission.", + "fields": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The system field id when is_system is true, otherwise the custom field id." + }, + { + "name": "is_system", + "type": "boolean" + }, + { + "name": "is_mandatory", + "type": "boolean" + } + ] + } + ], + "intent": "Rename a template, enable or disable it, make it the workspace default, or replace its description and property field layout.", + "guidance": [ + "A name-only PATCH works and returns a bare {success:true} - unlike the create, the update does NOT echo the template back, so re-read it if you need the result.", + "Sparse update: only the keys actually present in the body are touched, everything else keeps its current value. Sending an empty body is accepted and changes nothing.", + "Field arrays REPLACE, they do not merge. Templates have no version history in this product, so the previous layout cannot be restored — read the template, edit the returned arrays, and send them back whole.", + "Editing the field layout changes the fields shown on every test case using this template, in every linked project. Establish how many projects are affected before writing.", + "Sending properties_fields re-injects the Review & Approve system fields, and reads filter those same fields out, so a read-modify-write round-trip neither loses them nor can remove them. Do not report them as deleted.", + "enabled is evaluated for truthiness, so the string \"false\" enables rather than disables. Use a JSON boolean.", + "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is changed — check the flag, not the status code.", + "Success returns only success:true with no template body. Re-read the template to confirm the new layout.", + "Project links are not changed here; linking and unlinking projects is a separate operation on the template." + ], + "returns": [ + "success" + ], + "responses": { + "200": { + "$response": "TemplateWriteAckResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_test_plan_by_integer_id", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/update", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "test_plan_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/test_plan/name", + "description": "Plan name. EFFECTIVELY REQUIRED on any content edit: the server's edit branch is gated on name being present, so omitting it rejects the whole call with 400 'Invalid Update Params' however valid the rest is. Read the plan first and echo the current name back unchanged." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text; rewritten server-side, so the stored value can differ from what you sent. Dropped entirely when the same request also changes plan_status." + }, + { + "name": "start_date", + "type": "string", + "description": "ISO-8601 date. Only settable while the plan is still `new` — once started, the stored start date is used and this field is ignored. REQUIRED in the same request when moving plan_status to 'started'.", + "format": "date", + "json_path": "/test_plan/start_date" + }, + { + "name": "end_date", + "type": "string", + "description": "ISO-8601 date. Must not be earlier than the start date (400 'Invalid end_date, should be greater than start_date'). REQUIRED in the same request when moving plan_status to 'completed'.", + "format": "date", + "json_path": "/test_plan/end_date" + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "description": "Target status. Only moves forward one step: new -> started -> completed. Sending a value that differs from the current one switches the call into transition mode, in which EVERY other content field in the body is discarded.", + "json_path": "/test_plan/plan_status" + }, + { + "name": "owner", + "type": "integer", + "description": "Owner as the internal TM user id (the `id` from the project users read — not a BrowserStack user id or email). An id that does not resolve inside the caller's group clears the owner instead of erroring. Only applied on a content edit, never alongside a status transition.", + "json_path": "/test_plan/owner" + }, + { + "name": "parent_plan_id", + "type": "integer", + "description": "Re-parent a SUB-plan under a different parent plan (integer id). It CANNOT convert a top-level plan into a sub-plan (400 'Cannot update a parent test plan to a sub plan') nor promote a sub-plan to top level (400 'Cannot update a sub test plan to a parent plan'), and the move is only applied while the plan is still `new`.", + "json_path": "/test_plan/parent_plan_id" + }, + { + "name": "tags", + "type": "array", + "description": "Plain tag strings; the list you send REPLACES the plan's tags. Discarded when the same request changes plan_status.", + "json_path": "/test_plan/tags" + }, + { + "name": "reviewers", + "type": "array", + "description": "Reviewer user ids; the list REPLACES the current reviewers. Omit the key to leave reviewers untouched, send [] to clear them. A reviewers-only body is the one edit that does not need `name`.", + "json_path": "/test_plan/reviewers" + }, + { + "name": "attachments", + "type": "array", + "description": "Attachment ids already uploaded via the attachments surface. An attachment that does not verify returns 422 with the verifier's own body rather than the usual envelope.", + "json_path": "/test_plan/attachments" + }, + { + "name": "custom_fields", + "type": "object", + "description": "Custom-field values keyed as the test-plan form-fields read returns them. Unlike the other content fields, these ARE applied even alongside a plan_status transition. A bad key or value is 400 {success: false, error: }.", + "json_path": "/test_plan/custom_fields" + }, + { + "name": "issues", + "type": "array", + "description": "Linked tracker issues. Structured — NOT a flat array of keys. Only url and ticket_id survive inside metadata. Linking is processed on a background job, so the plan echoed back may not show them yet.", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + }, + { + "name": "metadata", + "type": "object" + } + ], + "json_path": "/test_plan/issues" + }, + { + "name": "test_runs", + "type": "array", + "description": "The plan's linked test runs, as run INTEGER `uuid`s. This REPLACES the whole link set rather than appending; [] unlinks everything; omitting the key leaves the linkage alone. Applied even alongside a status transition.", + "json_path": "/test_plan/test_runs" + } + ], + "intent": "Edit a test plan or sub-plan — rename it, change its dates, owner, tags, custom fields or reviewers, move its status forward, or replace the set of test runs it groups", + "responses": { + "200": { + "description": "The updated plan, re-read and returned under a top-level `test_plan` key (no `data` wrapper) — same shape as the plan detail read.\n", + "schema": { + "$schema": "V1TestPlanDetail__UpdateTestPlanByIntegerId" + } + }, + "400": { + "description": "A local rule violation: `{success: false, error: ...}` where error is one of 'Invalid Update Params', 'Cannot update completed test plan', 'Cannot revert started test plan', 'Cannot directly complete test plan', 'Cannot update a parent test plan to a sub plan', 'Cannot update a sub test plan to a parent plan', 'Invalid start_date', 'Invalid end_date', 'Invalid end_date, should be greater than start_date', 'Invalid field plan_status', 'Invalid Test Plans Params'. Also a rejected custom field.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Upstream rejected the update (for example the review gate on completing an unapproved plan): `{success: false, error_code, error}`. Attachment verification failures also land on 422 with the verifier's own body.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error_code": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Everything goes inside a `test_plan` object. A flat body is rejected before any field is read.", + "name IS REQUIRED on every content edit, including one that only touches test_runs or tags. Omit it and the call is 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt.", + "A status transition and a content edit are mutually exclusive branches, not one merged write. When plan_status differs from the plan's current status, ONLY the dates and the new status are applied — name, description, tags, issues, owner and parent_plan_id in the same body are silently discarded, with a 200 and no warning. Rename first, then transition.", + "Moving to 'started' requires a valid start_date in the SAME request (400 'Invalid start_date'); moving to 'completed' requires end_date (400 'Invalid end_date'). The plan's stored dates are not used as a fallback.", + "Status only moves forward one step: new -> started -> completed. new -> completed is 400 'Cannot directly complete test plan' and started -> new is 400 'Cannot revert started test plan'.", + "A completed plan is FROZEN — even a rename is refused with 400 'Cannot update completed test plan'. Complete it last.", + "Run ids in test_runs are the INTEGER `uuid` from a run read, NOT the TR-NNN string that every run endpoint puts in `id`. And linking MOVES a run: a run belongs to at most one plan, so linking silently unlinks it from whichever plan held it, with nothing in the 200 to say so. Read the runs' current membership first.", + "test_runs REPLACES the link set — send the full list you want, not just the additions. An empty array unlinks every run.", + "parent_plan_id cannot promote or demote: a top-level plan can never become a sub-plan and a sub-plan can never become top-level. Moving a sub-plan between parents only takes effect while the plan is still `new`.", + "Plans keep no version history. An overwritten name, description, date or tag list is gone for good — there is no prior revision to restore.", + "Upstream rejections (for example the review gate that blocks completing an unapproved plan) come back as 422 {success: false, error_code, error}, while the local rule violations above are 400 {success: false, error}.", + "The success body is the freshly re-read plan under a top-level `test_plan` key — the same shape as the plan detail read, with no `data` wrapper." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "assignee", + "created_by", + "test_runs_count", + "total_test_runs_count", + "links" + ] + }, + { + "name": "update_test_run", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^TR-\\d+$", + "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ — unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' — it is not skipped." + }, + { + "name": "run_state", + "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it. The partial update: on a closed run it answers 404 'Test Run ID is invalid' rather than a clear refusal.", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "json_path": "/test_run/run_state" + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings; the list you send replaces the run's tags." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read. Duplicates are collapsed." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^$|^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^$|^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "The run's COMPLETE intended case list — NOT a list to add. Any case already in the run and missing from this list is removed, with its run row, assignee and results; the run's own fields (owner, tags, description) are kept. To ADD cases, use bulk_update_test_run_test_cases with add_test_cases instead — it is the only truly additive call. If you must use this field, send the run's current case identifiers (list_test_run_test_cases) plus the new ones: retained cases then keep their run row id and assignee, and only the new cases are inserted." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders" + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list — cases you list elsewhere are still included, with a null-configuration row." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "json_path": "/test_run/filter_scope", + "description": "Accepted by the parameter filter but NOT honoured on either update path — the selection is always built in global mode here. Only create reads it." + }, + { + "name": "shared_test_cases", + "type": "array", + "fields": [ + { + "name": "project_id", + "type": "integer" + }, + { + "name": "test_case_ids", + "type": "array" + } + ], + "json_path": "/test_run/shared_test_cases", + "description": "Cases shared in from other projects, as [{project_id, test_case_ids: [...]}]. Only this partial-update call resolves them; the full-replace call on the same run accepts the key and ignores it." + }, + { + "name": "is_automation", + "type": "boolean", + "json_path": "/test_run/is_automation", + "description": "Only honoured on this partial-update call (and only for the literal values true/false); the full-replace call on the same run ignores it. It turns an existing manual run into an automation run — this is the only way to get one, since both run creates ignore the flag. The reply carries no is_automation, so confirm with the v1 run read." + } + ], + "intent": "Change just the fields you name on a test run — rename it, move its state on, link or unlink a test plan, attach configurations or a dynamic filter — leaving everything you omit untouched", + "guidance": [ + "Two different operations share this path. This PATCH applies only the keys present in `test_run`; the POST rebuilds the whole payload and resets what you omit. This is the safe default for a targeted edit.", + "Unlinking is explicit: send test_plan_id (or sub_test_plan_id) as null or \"\" to detach the plan. Omitting the key keeps the current link.", + "run_state is validated here: an unknown value returns 400 echoing it. Both 'new_run' and 'new' are accepted. Omit the key to leave the state alone.", + "is_dynamic lives inside filter_test_cases and nowhere else. true needs at least one other criterion in the same object or the call 400s; false flips a dynamic run back to static; omitting it leaves the flag as it is.", + "Composition keys interact: sending only configurations re-reads the run's existing case selection so the case list survives, and sending selection keys without configurations re-reads the existing configuration list. Send neither and the run's composition is left completely untouched.", + "Case membership is REPLACED, not unioned: the selection you send becomes the run's whole case list, and any case missing from it is removed with its run row, assignee and results — with a 200. Only the run's own fields follow the partial rule (omitted owner, tags and description are kept). To add cases use bulk_update_test_run_test_cases (add_test_cases); if you use this call anyway, read the current cases and send existing + new, then re-read to confirm the count.", + "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", + "There is no undo: test runs keep no version history, so an overwritten name, description or tag list cannot be restored.", + "The updated run comes back at the top level as `testrun` — one word, no underscore, and no `data` envelope. This differs from the read endpoints, which use `test_run`.", + "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", + "`urls.self` is a link to the run in the web UI, not an API path — it is built from the frontend base URL, so calling it returns HTML.", + "`assignee` comes back as a plain email string — or null when the owner cannot be resolved — and `configurations` as a list of configuration ids. Neither is a list of objects.", + "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", + "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", + "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", + "The run's case list is not on this reply — `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", + "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write, and `tags` can come back [] while the stored tags are unchanged. Re-read the run when the exact stored values matter.", + "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunWriteV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "upload_ai_attachments", + "method": "POST", + "path": "/api/v1/projects/{project_id}/generic/attachments/ai_uploads", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Upload a document as AI context for a project — the same store as a generic upload but with the restricted type and size rules that the test-case generation flow requires", + "returns": [ + "id", + "name", + "content_type", + "download_url", + "size", + "url" + ], + "responses": { + "200": { + "$response": "GenericAttachmentsResponse" + }, + "400": { + "description": "A file failed the AI type allowlist or its per-type size cap. All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "NOT INVOCABLE through this interface. Identical handler to the generic upload: it reads `attachments` as an array of uploaded FILES from a multipart form, so the one input it needs cannot be expressed as a JSON argument and this record carries no body parameter.", + "With no files it returns 200 and `generic_attachment: []` rather than an error — a call from here looks successful and uploads nothing.", + "Only the path differs from the generic upload, and the difference is validation. Allowed types: pdf, txt, csv, json, rtf, doc, docx, xls, xlsx, pptx, jpeg, png. Anything else is rejected with 'Invalid file type for AI uploads'.", + "Size caps are PER TYPE, not the generic 50 MB: 500 KB for csv/txt/json/xls/xlsx, 5 MB for jpeg/png, 10 MB for pptx, 25 MB for pdf/doc/docx/rtf. Passing linked_jira_data=true replaces all of them with a flat 3 MB cap and files under a separate storage prefix.", + "Any text/* file that is byte-sniffed as something outside the allowlist (markdown read as text/html, for example) is silently normalised to text/plain and accepted.", + "On a real multipart upload the status is 200, not the 201 the spec claims. `id` is the blob id the test-case creation flow consumes; `url`/`download_url` are pre-signed for 30 minutes and `size` is a human-readable string." + ], + "disabled": true + }, + { + "name": "upload_generic_attachments", + "method": "POST", + "path": "/api/v1/projects/{project_id}/generic/attachments", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "intent": "Upload a file to a project and get back its blob id plus pre-signed view/download URLs — the first half of the attach flow, needed before a file can be linked to a test case, result or rich-text field", + "returns": [ + "id", + "name", + "content_type", + "download_url", + "size", + "url" + ], + "responses": { + "200": { + "$response": "GenericAttachmentsResponse" + }, + "400": { + "description": "At least one file failed validation (size). All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "NOT INVOCABLE through this interface. The handler reads `attachments` as an array of uploaded FILES from a multipart form and streams their bytes to storage; a file cannot be expressed as a JSON argument, which is why this record carries no body parameter at all.", + "Worse than an error: with no files the handler takes the empty path and answers 200 with `generic_attachment: []`. A call from here looks like it succeeded and uploads nothing — do not report a successful upload on that response.", + "If you need a blob id, the file has to be uploaded out of band (the product UI, or a direct multipart request built by the caller). Once it exists, the blob id is what the test-case, test-result and step attach capabilities take.", + "On a real multipart upload the status is 200, not the 201 the spec claims, and the body is {generic_attachment: [...]}: `id` is the blob id to carry forward, `url` and `download_url` are pre-signed and expire after 30 minutes, and `size` is a HUMAN-READABLE string ('300 KB') rather than a byte count.", + "No type allowlist is enforced on this generic path; the only limit is 50 MB per file. SVG/HTML/XML uploads are served with a forced-download disposition rather than inline.", + "A single invalid file fails the whole request: every blob already written is deleted, and the 400 body reports only the LAST failing file's errors, under errors.message." + ], + "disabled": true + }, + { + "name": "link_attachments_to_test_case", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", + "mode": "write", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + }, + { + "name": "test_case_id", + "type": "integer", + "required": true + } + ], + "intent": "Link already-uploaded files to a test case by their blob ids — the second half of the two-step attach flow; it does not carry file content", + "returns": [ + "success", + "attachments", + "id", + "filename", + "content_type", + "byte_size" + ], + "responses": { + "200": { + "description": "Attachments uploaded successfully", + "schema": { + "type": "object", + "properties": { + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentResponse" + } + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "DOES NOT WORK. A well-formed request returns a bare 500. The handler calls an attach method that the object it holds does not have, so the call raises before anything is stored. There is no request that makes it succeed — do not retry with a different body.", + "Despite the name and the published spec this is NOT a multipart file upload. The body is JSON and `attachments` is a list of INTEGER blob ids returned by a prior generic attachment upload. No file bytes cross this boundary.", + "Send the list flat. The `test_case` wrapper is added for you from the field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", + "Each blob must belong to the caller's group or to this project, and must not already be attached to anything. Reusing a blob that is attached elsewhere fails the WHOLE call with 422 'the attachment has been referenced elsewhere' — upload a fresh copy for each attachment point.", + "An id that resolves to no blob gives 422 'attachment not found'. An EMPTY array is accepted, does nothing, and still answers 200 with the case's existing attachments — it does not clear them.", + "The write is additive: the ids you list are added to whatever the case already has. Removal is the delete-attachment capability, one id at a time.", + "`folder_id` and `test_case_id` must both be integers or the route does not match; folder_id itself is not used to locate the case.", + "Success returns the case's attachments (id, filename, content_type, byte_size) at the top level alongside `success` — no `data` envelope." + ], + "body": [ + { + "name": "attachments", + "type": "array", + "required": true, + "json_path": "/test_case/attachments", + "example": [ + 10288829, + 10288830 + ], + "description": "Integer blob ids from a prior generic attachment upload — NOT filenames and NOT file content. Each blob must be unused; one already attached elsewhere fails the whole request with 422. An empty array is a silent no-op, not a clear." + } + ], + "disabled": true + }, + { + "name": "get_user_workload_drilldown", + "method": "POST", + "path": "/api/v1/projects/{project_id}/reports/{report_id}/drilldown", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "report_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "user_id", + "type": "integer", + "required": true, + "example": 12345, + "description": "BrowserStack user id to break down — the id carried by the report's `user_table` rows, NOT the TM user id. Missing or <= 0 is 422. A Member-role caller may only pass their own id (403 otherwise)." + } + ], + "intent": "Break one person's workload out of a User Workload Report — the test cases they executed, the runs they took part in, and their day-by-day activity", + "paginated": false, + "responses": { + "200": { + "$response": "UserWorkloadDrilldownResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only works on a report whose stored `report_type` is `User Workload Report`. Any other report is 400 `Drill-down is only available for User Workload Reports`; an id not in your group is 404.", + "`user_id` is the BrowserStack user id, the same id the report's `user_table` rows carry — not the TM-internal user id. A missing or non-positive value is 422 (not 400), with `user_id is required for drill-down`.", + "A caller whose role is Member may only drill into themselves: any other `user_id` is 403 `Forbidden: cannot drill into another user`. Admins and above can drill into anyone.", + "It is a POST but it changes nothing — safe to call repeatedly. The window and run scope are taken from the REPORT's saved timeframe and filters; you cannot override them from the body.", + "Response is the analytics payload verbatim — there is no `success` flag and no envelope. Expect `tcs` (cases executed), `trs` (runs participated in) and `days` (daily timeline). An upstream failure surfaces as 422 `User Workload drill-down failed: ...`, so do not read an empty body as 'this person did nothing'.", + "NOT paginated: page and per-page values in the body are ignored — the full breakdown comes back in one response. Do not loop pages.", + "A report saved on a relative or custom timeframe that resolves to no dates falls back to a five-year window when it has specific runs selected, and is 422 otherwise.", + "Requires the report_view permission." + ], + "returns": [ + "tcs", + "trs", + "days" + ], + "disabled": true + }, + { + "name": "update_test_case", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id the v1 test-case endpoints take does NOT resolve here and comes back 404 'Test case not found'." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Checking the login functionality", + "json_path": "/test_case/name", + "description": "Case title. Omit to leave it alone; sending an empty or whitespace-only string is rejected 422 'Test case name cannot be empty' rather than clearing it. Note the response calls this field `title`, not `name`." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_case/description", + "description": "Rich-text field. Links are stripped server-side, so the stored value can differ from what you sent." + }, + { + "name": "preconditions", + "type": "string", + "json_path": "/test_case/preconditions", + "description": "Rich-text field, same server-side link stripping as description." + }, + { + "name": "test_case_folder_id", + "type": "integer", + "example": 21, + "json_path": "/test_case/test_case_folder_id", + "description": "Move the case to this folder as part of the edit. This is the name the parameter filter accepts — `folder_id`, the name the response and the published schema use, is DROPPED here and the case silently stays put. Unlike the v1 edit path, a value equal to the current folder is not stripped before the write." + }, + { + "name": "priority", + "type": "string", + "example": "Medium", + "json_path": "/test_case/priority", + "description": "The project's display value for priority (e.g. 'Low', 'Medium', 'High') or that value's numeric field id. Matched on the display value first, then on the id; anything else fails the WHOLE call with 400 'Invalid default field value priority'. Resolve the allowed set from the project's system field values first — it is per project, not a fixed list." + }, + { + "name": "status", + "type": "string", + "example": "Active", + "json_path": "/test_case/status", + "description": "The project's display value for test-case status, or its numeric field id. Same matching and same 400 'Invalid default field value status' as priority. Not the run/result status — this is the case's own lifecycle field." + }, + { + "name": "case_type", + "type": "string", + "example": "Smoke & Sanity", + "json_path": "/test_case/case_type", + "description": "The project's display value for case type, or its numeric field id. Stored internally as the category, and a wrong value fails the whole call with 400 'Invalid default field value case_type'." + }, + { + "name": "automation_status", + "type": "string", + "values": [ + "not_automated", + "automated", + "automation_not_required", + "cannot_be_automated", + "obsolete" + ], + "example": "automated", + "json_path": "/test_case/automation_status", + "description": "Internal name, not a display label. Validated against this closed list before the request leaves the API with 400 'Invalid automation status', and then matched against the project's own automation-state options." + }, + { + "name": "owner", + "type": "string", + "example": "jane@acme.com", + "json_path": "/test_case/owner", + "description": "Owner as an email address, or a TM user id. DANGER: an address that does not resolve to a user in this group is NOT an error — it is written as null, clearing the owner, and the call still returns 200. Verify the user before sending." + }, + { + "name": "tags", + "type": "array", + "example": [ + "regression", + "smoke" + ], + "json_path": "/test_case/tags", + "description": "Plain tag-name strings. The list you send REPLACES the case's tags, so read the current tags and send the union if you mean to add one; `[]` clears them all; omit the key to leave them untouched." + }, + { + "name": "issues", + "type": "array", + "example": [ + "TES-204" + ], + "json_path": "/test_case/issues", + "description": "Linked issue/defect ids as plain STRINGS on this path — the parameter filter here permits only scalars, so the {issue_id, issue_type} objects the v1 edit body takes are dropped and the links never happen. The list replaces the case's linked issues." + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "Sprint": "24", + "Component": [ + "auth", + "billing" + ] + }, + "json_path": "/test_case/custom_fields", + "description": "Keyed by the custom field's NAME here — not by its numeric id, which is what the v1 edit and bulk paths use. A key the project does not define is skipped SILENTLY and the call still returns 200; a defined field with an unusable value fails the whole call with 400 'Custom field validation failed for '. Dropdown values are the option label; a user-type field takes an email." + }, + { + "name": "test_case_steps", + "type": "array", + "example": [ + { + "step": "Open the login page", + "result": "The login form is shown" + } + ], + "fields": [ + { + "name": "step", + "type": "string" + }, + { + "name": "result", + "type": "string" + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer" + }, + { + "name": "order", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps", + "description": "The case's ordered step list. `order` is overwritten by array position, so the array order is what counts. Omit the key to keep the existing steps. Rejected with 422 for a BDD-template case — send feature/background/scenario instead. This is the only surface that writes steps: the bulk edit refuses them outright." + }, + { + "name": "template", + "type": "string", + "example": "test_case_steps", + "json_path": "/test_case/template", + "description": "Template NAME, not an id. Omit it and the case's current template is kept — the action fills it in from the stored case before the write." + }, + { + "name": "template_id", + "type": "integer", + "example": 42, + "json_path": "/test_case/template_id", + "description": "Custom-template id. Must be a TestCase-entity template visible to the caller's group; an unknown or cross-group id is rejected 400 rather than quietly falling back to the system default. Explicit null is a no-op, not a clear." + }, + { + "name": "feature", + "type": "string", + "json_path": "/test_case/feature", + "description": "BDD-template cases only; 422 on a non-BDD case. Omit to keep the stored value — sending an EMPTY string is rejected 400 'Cannot clear feature and scenario for BDD test cases.'" + }, + { + "name": "background", + "type": "string", + "json_path": "/test_case/background", + "description": "BDD-template cases only; 422 on a non-BDD case. Omitted or blank keeps the stored background." + }, + { + "name": "scenario", + "type": "string", + "json_path": "/test_case/scenario", + "description": "BDD-template cases only; 422 on a non-BDD case. Like feature, an empty string is rejected 400 rather than clearing it." + }, + { + "name": "reviewers", + "type": "array", + "example": [ + "jane@acme.com" + ], + "json_path": "/test_case/reviewers", + "description": "Reviewer EMAILS. An array replaces the whole reviewer set ([] unassigns everyone). An object form {value: [...], operation: replace|add|remove} is also accepted for an additive or subtractive change. A bare string or null is dropped by the parameter filter and can never become a reviewer write. Revoked reviewers are emailed. Naming the case owner is rejected 422." + }, + { + "name": "send_for_review", + "type": "boolean", + "json_path": "/test_case/send_for_review", + "description": "Moves the case into review and mails the reviewers, independently of `reviewers`. String booleans ('true'/'false'/'1'/'0'/'on'/'off') are cast; an ambiguous string such as 'yes' is rejected 422 rather than guessed." + }, + { + "name": "review_status", + "type": "string", + "values": [ + "pending", + "in_review" + ], + "json_path": "/test_case/review_status", + "description": "Assignment states only — a verdict (approved / rejected / changes_requested) is one reviewer's decision and is rejected 422. OMIT this on a metadata-only edit: sending `pending` on an approved case invalidates the approval and writes an audit row. The pending/in_review narrowing only applies once the reviewer feature flag is on; with it off the value is passed through unvalidated." + }, + { + "name": "identifier", + "type": "string", + "json_path": "/test_case/identifier", + "description": "External id for the case, used only when the workspace is configured for project-level ids; it is stored as the case's third-party identifier. On any other workspace it is ignored. `third_party_identifier` is accepted as a synonym." + }, + { + "name": "expected_result", + "type": "string", + "json_path": "/test_case/expected_result", + "description": "ACCEPTED BUT NOT WRITTEN on this path — the parameter filter permits it and the request builder never copies it, so it is dropped before the write and you still get 200. The v1 edit paths do persist it. Never report an expected result as saved from here." + }, + { + "name": "estimate", + "type": "string", + "json_path": "/test_case/estimate", + "description": "ACCEPTED BUT NOT WRITTEN — passes validation and is then dropped before the write, exactly as on the v1 create/edit paths. Never report an estimate as saved." + }, + { + "name": "test_case_dataset", + "type": "object", + "json_path": "/test_case/test_case_dataset", + "description": "ACCEPTED BUT NEVER APPLIED on this path: the builder only regenerates dataset mappings when the modified-dataset flag is set, and that flag is not accepted by this action, so the existing mappings are always kept. Returns 200 with the dataset unchanged." + }, + { + "name": "old_id", + "type": "string", + "required": false, + "description": "Legacy id carried over from an import; free text, not validated against anything.", + "json_path": "/test_case/old_id" + }, + { + "name": "expectation", + "type": "string", + "required": false, + "description": "Expected outcome, the counterpart to `preconditions`. Distinct from `expected_result`, which belongs to a step.", + "json_path": "/test_case/expectation" + }, + { + "name": "attachment_id", + "type": "integer", + "required": false, + "description": "Id of a previously stored attachment to associate with the case.", + "json_path": "/test_case/attachment_id" + }, + { + "name": "ids", + "type": "array", + "required": false, + "description": "Ids of other cases this write applies to, for the shared-write path.", + "json_path": "/test_case/ids" + }, + { + "name": "issue_tracker", + "type": "object", + "required": false, + "description": "Tracker context for `issues` — which external system the ids belong to. Validated together with `issues`.", + "json_path": "/test_case/issue_tracker" + }, + { + "name": "created_at", + "type": "string", + "required": false, + "description": "Explicit creation timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", + "json_path": "/test_case/created_at" + }, + { + "name": "updated_at", + "type": "string", + "required": false, + "description": "Explicit update timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", + "json_path": "/test_case/updated_at" + } + ], + "intent": "Change one existing test case — rename it, rewrite its steps, preconditions or description, set priority/status/type/owner, retag it, relink issues or put it into review — sending only the fields you want changed", + "guidance": [ + "This is the SINGLE-case update, addressed by one identifier. The bulk edits take a selection (ids or a filter) instead, refuse step lists, and key custom fields by numeric id — so when you are changing one case, and always when you are changing its steps, this is the one to use.", + "Both ids in the path are prefixed strings (PR-1 / TC-42). The case id is resolved by an identifier lookup inside the project, so the internal integer id that the v1 edit endpoints take returns 404 'Test case not found' here.", + "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key. Send at least one field: a 400 saying 'Invalid request format: test_case parameter is missing or malformed' means the body reached the server empty, not that you must nest it.", + "Only the keys you send are written — omitted fields keep their stored values. There is no undo, but the case keeps version history, so a bad edit can be recovered by restoring the previous version.", + "Accepted and then silently discarded on this path, each returning 200: expected_result, estimate, is_shared, the dataset payload, and `folder_id` (use test_case_folder_id to move). Re-read the case before telling anyone these landed.", + "priority, status and case_type take the PROJECT's display value or that value's numeric id; one bad value fails the whole call with 400 'Invalid default field value '. Resolve the allowed values from the project's system field values first rather than guessing 'P1' or 'Open'.", + "custom_fields is keyed by field NAME here (the v1 paths key by numeric id). An unknown name is skipped without error, so a typo means a 200 that changed nothing — confirm by reading the case back.", + "An owner that does not resolve to a user in this group is written as null and CLEARS the owner instead of erroring.", + "BDD and step-based cases are mutually exclusive: steps on a BDD case are 422, feature/background/scenario on a non-BDD case are 422, and clearing feature or scenario to an empty string is 400.", + "reviewers, send_for_review and review_status run a gate ladder (Team-Pro plan, the project's Review & Approve setting, 21 CFR Part 11) and are applied AFTER the case update has committed. A 502 with error_code reviewer_write_failed and test_case_updated true means the edit landed but the reviewer change did not — retry with only the reviewer field, never with the whole body.", + "Changing anything here can move the case out of an approved review state, and reviewer removals send email immediately.", + "Sharing cannot be changed here. `is_shared` is checked before the write — sending it on a project without test-case sharing enabled returns 400 — and is then discarded, so the value never takes effect. Use the bulk edit to change sharing.", + "Four fields are entitlement-gated and are dropped without error when the workspace lacks the entitlement: `created_at` and `updated_at` need external-API timestamps, and `identifier` applies only to project-level-id workspaces. Read the case back if you need to confirm one landed.", + "Parts of the echoed case are conditional, so test for the key rather than for an empty value: `feature`, `background` and `scenario` come back only on a Gherkin case (where `steps` is then an empty array), and `reviewers` and `review_status` appear only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps`, `custom_fields` and `attachments` come back as empty arrays when nothing is set — note that `attachments` is empty here, not missing, unlike the create response, and that editing a case never adds one: files are attached through the attachment upload capability." + ], + "returns": [ + "success", + "test_case", + "identifier", + "title", + "folder_id", + "priority", + "status", + "case_type", + "automation_status", + "owner", + "tags", + "template", + "description", + "preconditions", + "steps", + "custom_fields", + "issues", + "attachments", + "is_shared", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "responses": { + "200": { + "description": "The updated test case, in the v2 public shape (title, not name; no integer id).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "V2PublicTestCase" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "The test case WAS updated but the reviewer change could not be applied (error_code reviewer_write_failed, test_case_updated true). Also returned with error_code review_approve_unknown, in which case nothing was written at all." + } + } + }, + { + "name": "move_test_case", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/move", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API. It is also the only project involved — the move cannot leave it." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id returns 404 'Test case not found'." + } + ], + "body": [ + { + "name": "destination_folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 5, + "description": "Target folder, as a plain positive INTEGER folder id — folders are not identified by a prefixed string. Sent at the TOP LEVEL of the body, not inside a `test_case` object (the bulk move is the opposite and nests it). Missing, non-numeric, zero or negative is refused before anything is read. A numeric string is accepted and coerced." + } + ], + "intent": "File one test case in a different folder — use this to reorganise the tree, move a case out of an inbox/triage folder, or put a case where a run or plan expects to find it", + "guidance": [ + "Within ONE project only: there is no destination-project field, and the case is looked up in the same project it is moved inside. A cross-project move is not available here.", + "One case per call. To move many, use the bulk move, which takes a selection under a `test_case` object and runs asynchronously past ~30 cases; this one is always synchronous and is the cleaner choice for a single case.", + "The destination is a plain integer folder id and is NOT guessable — resolve it from the project's folder tree first. Nothing in this layer checks that the folder belongs to this project, so a stray id from another project is only caught (or not) downstream.", + "Moving a case into the folder it is already in is accepted and returns 200 with the case unchanged. Unlike the v1 edit path, the same-folder value is not stripped before the write, so the upstream write still happens.", + "Capture the case's current folder before you call if you may want to undo: the response reports only the NEW folder, and moving back means calling this again with the old id.", + "Nothing but the folder changes — the identifier, steps, tags, owner and review state are untouched — and the response carries the whole case in the v2 public shape.", + "The same folder change can be made as part of a single-case update by sending test_case_folder_id in its body; use this endpoint when the move is all you want, because it needs no other field and cannot accidentally rewrite one." + ], + "returns": [ + "success", + "test_case", + "message", + "identifier", + "title", + "folder_id", + "priority", + "status", + "case_type", + "automation_status", + "owner", + "tags", + "template", + "description", + "preconditions", + "steps", + "custom_fields", + "issues", + "created_at", + "last_updated_at", + "created_by", + "updated_by", + "urls" + ], + "responses": { + "200": { + "description": "The moved test case in the v2 public shape, with folder_id now the destination, plus a top-level message 'Test case moved successfully'.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "V2PublicTestCase" + } + } + }, + "message": { + "type": "string" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case_histories", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/history", + "mode": "read", + "entity": "version", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Test case identifier: TC-NNN, or the third-party identifier set on the case. The route deliberately leaves this segment unconstrained, but it is resolved through identifier lookup before anything is read — a bare internal numeric case id is not a match and comes back 404 'Test case not found'." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "example": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read — you silently get page 1 again. Page size is fixed at 30 and is not caller-controllable here: `per_page` and `page_size` are accepted by the URL but ignored. A `p` that is not a positive integer (blank, zero, negative, non-numeric, repeated) is refused with 400 'Invalid Params'." + } + ], + "intent": "See what changed on a test case, when, and who changed it — the revision trail, newest first, naming the fields touched in each edit; the read to answer 'what did this case look like before that edit?' or 'which revision should we roll back to?'", + "guidance": [ + "Rows come newest first. Each row's `version_name` is a positional label — V1 is the oldest revision still visible, and the highest number is the newest edit on page 1.", + "`version_name` is the only handle a row gives you: the v2 rows carry no history record id. Strip the leading V and pass the number as the version number to the single-version read to pull that revision's full test case snapshot.", + "Only the newest row on each page is enriched with the per-field old/new `modified` map — the rest carry `modified_fields` (the field names only), so a per-field before/after for an older edit needs the single-version read for that version. The creating revision never carries `modified` at all, even when it is the only row on the page, because a create has nothing to diff against — so a single-revision history is expected to come back with `modified_fields` null and no `modified` map.", + "Without an Enterprise or Team Pro plan the caller sees only the last 30 days of history. Older revisions are simply absent and nothing in the response flags that the window was applied, so a short list is not proof that the case was rarely edited. Version numbers count only what is visible, so they renumber as old revisions age out.", + "Listing degrades gracefully across plans, but comparing two versions and restoring one are paid-plan only — fall back to listing plus single-version reads when those are refused.", + "Each record carries `source` ('create' on the first revision, 'update' for an edit, 'restore' after a rollback), `created_at`, `modified_fields`, and `updated_by` — the editor's email, null when that user cannot be resolved in this group. The raw `user_id` is the BrowserStack user id, not a Test Management one.", + "Page with `p`; the page size is fixed at 30, so a long-lived test case is walked 30 revisions at a time and the total is in `info.count`.", + "Every row also carries `origin_channel`, recording how that edit arrived: ui, api, jira_app, azure, import, automation or ai. Anything written through this API is recorded as api, so the field only discriminates once a case has also been touched in the web app, through an issue-tracker app, or by an import." + ], + "returns": [ + "success", + "history", + "info", + "version_id", + "version_name", + "source", + "modified_fields", + "modified", + "comment", + "created_at", + "updated_by", + "user_id", + "testcase_id", + "origin_channel" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "V2HistoryListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_runs", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-runs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (50, 100, 301) is refused with a 400 'parameter_error' rather than clamped, so ask for 300 directly when you want a large project in few calls." + }, + { + "name": "include_closed", + "type": "boolean", + "default": false, + "description": "Include closed runs. Closed runs are EXCLUDED from this listing by default, so counts and 'all runs' answers are silently short without this. Pass the literal string 'true'." + }, + { + "name": "run_state", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "example": "in_progress", + "description": "Filter by lifecycle state. Supplying it also lifts the default closed-run exclusion on its own, so run_state=closed works without include_closed." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com,lead@acme.com", + "description": "Comma-separated owner emails, resolved against this group. This filter FAILS OPEN: an address that matches no user in the group is silently dropped from the filter instead of erroring, so a typo widens the result set and still returns 200. Verify the emails resolve before trusting a filtered count." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^TP-\\d+$", + "example": "TP-1", + "description": "Restrict to runs linked to this test plan. An identifier that matches no plan in the project is a 404 ('Test Plan ID is invalid'), not an empty list." + }, + { + "name": "created_after", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Lower bound on creation date, YYYY-MM-DD. May be sent on its own. A value in a different format is a 400; a date later than tomorrow is a 400." + }, + { + "name": "created_before", + "type": "string", + "format": "date", + "example": "2026-03-31", + "description": "Upper bound on creation date, YYYY-MM-DD. May be sent on its own. Sending a created_after later than this is a 400 naming the start date." + }, + { + "name": "closed_after", + "type": "string", + "format": "date", + "example": "2026-01-01", + "description": "Lower bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." + }, + { + "name": "closed_before", + "type": "string", + "format": "date", + "example": "2026-03-31", + "description": "Upper bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." + } + ], + "intent": "Find test runs in a project — filter by owner, lifecycle state, linked plan or date to locate a run, count how many there are, or collect the run ids you need before linking runs to a test plan", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 — any other number is a 400 'parameter_error', not a clamp — so pass 300 when you want the project in as few calls as possible.", + "Closed runs are excluded by default. Without include_closed=true (or a run_state filter, which lifts the exclusion by itself) a 'how many runs do we have?' answer quietly omits every closed run and still comes back 200.", + "Rows carry the run's `identifier` (TR-14) and nothing numeric — this listing's serializer emits no `id` and no `uuid` field at all. TR-NNN is exactly what the newer single-run and run-test-case capabilities want in their paths, so for reading a run onward this is all you need.", + "Linking runs to a test plan is the exception: a plan's linked-run array takes the INTEGER run id. The TR-NNN string reaches the backing service uncoerced and 502s there, and this listing does not give you the integer. Resolve it first — the v1 project run listing (list_test_runs_by_integer_id) carries the integer as `uuid` on each run — and send that, never the TR-NNN string.", + "Rows are a summary projection, not the single-run read. They never carry `issues` or `issue_tracker`, however many issues a run actually has linked — read the run itself when you need those.", + "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none', and read the run itself to see them.", + "`test_plan` appears only on runs that are linked to a plan, and the key is omitted entirely rather than sent as null when there is no link. `overall_progress` behaves the same way — omitted, not zeroed, for a run with nothing executed yet.", + "The assignee filter fails open while the plan and date filters fail closed: an unresolvable email is dropped silently, but an unknown TP-NNN is a 404 and a malformed or out-of-order date is a 400. Only the assignee filter can quietly widen your result.", + "closed_after / closed_before are not just a date window — either one restricts the whole result to closed runs.", + "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 with error_code 'request_limit_exceeded'. Narrow with a filter rather than walking a large project to the end.", + "Rows are run headers only — progress is aggregated counts. To see which cases are in a run, read that run's test cases." + ], + "returns": [ + "success", + "test_runs", + "info", + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "is_dynamic", + "overall_progress", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "TestRunsListV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "429": { + "description": "Deep-paging guard. Returned when page x page_size exceeds the account's row ceiling (30,000 by default).", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Page count exceeds limit. Requests with more than 1000 pages are temporarily disabled. Please reduce the page count and try again." + }, + "error_code": { + "type": "string", + "example": "request_limit_exceeded" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-runs", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Regression - Sprint 24", + "json_path": "/test_run/name", + "description": "Run name. Optional in practice despite the published schema marking it required: omitting it names the run 'Test Run DD/MM/YYYY' from today's date rather than failing." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_run/description", + "description": "HTML is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent." + }, + { + "name": "assignee", + "type": "string", + "example": "qa@acme.com", + "json_path": "/test_run/assignee", + "description": "Run owner, as an email resolved inside this group. Unlike the run edit paths, an address that does not resolve is NOT an error here — it is dropped and the run is created owned by the calling user, so a typo produces a wrongly-owned run with a 200." + }, + { + "name": "run_state", + "type": "string", + "values": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "default": "new_run", + "example": "new_run", + "json_path": "/test_run/run_state", + "description": "Lifecycle state, checked against this fixed list before any case selection is built. On create an absent or unrecognised value is not rejected — it is silently stored as new_run — so send the state you want explicitly rather than relying on a validation error." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "sprint-24" + ], + "json_path": "/test_run/tags", + "description": "Plain tag strings." + }, + { + "name": "configurations", + "type": "array", + "fields": [ + { + "name": "configuration_id", + "type": "integer" + } + ], + "example": [ + 101, + 102 + ], + "json_path": "/test_run/configurations", + "description": "Configuration IDs (integers) the run executes against — not the configuration objects returned on read, and not the display names the published schema's example shows. Duplicates are collapsed. Overwritten entirely when configuration_map is also sent." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + }, + { + "name": "issue_type", + "type": "string" + } + ], + "json_path": "/test_run/issues", + "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." + }, + { + "name": "test_plan_id", + "type": "string", + "pattern": "^TP-\\d+$", + "example": "TP-1", + "json_path": "/test_run/test_plan_id", + "description": "Parent test plan to link at creation. Resolved by identifier — a TP-NNN that matches no plan in this project fails the whole create with 404, the run is not created. Mutually exclusive with sub_test_plan_id; when both are sent test_plan_id wins." + }, + { + "name": "sub_test_plan_id", + "type": "string", + "pattern": "^STP-\\d+$", + "example": "STP-1", + "json_path": "/test_run/sub_test_plan_id", + "description": "Sub test plan to link at creation. Mutually exclusive with test_plan_id." + }, + { + "name": "test_cases", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + } + ], + "example": [ + "TC-1", + "TC-2" + ], + "json_path": "/test_run/test_cases", + "description": "Test case identifiers to put in the run. This, shared_test_cases, folder_ids and include_all are the ONLY inputs that actually place cases in a run — filter_test_cases alone does not." + }, + { + "name": "shared_test_cases", + "type": "array", + "fields": [ + { + "name": "project_id", + "type": "integer" + }, + { + "name": "test_case_ids", + "type": "array" + } + ], + "json_path": "/test_run/shared_test_cases", + "description": "Cases shared in from other projects: a list of {project_id, test_case_ids: [...]} objects. The flat/scalar form is stripped by the parameter filter and silently yields zero mapped cases, so always send the object shape. Not present in the published schema." + }, + { + "name": "include_all", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_all", + "description": "Put every test case in the project into the run." + }, + { + "name": "folder_ids", + "type": "array", + "fields": [ + { + "name": "folder_id", + "type": "integer" + } + ], + "example": [ + 1, + 2 + ], + "json_path": "/test_run/folder_ids", + "description": "Folder IDs (integers) whose cases go into the run." + }, + { + "name": "include_sub_folders", + "type": "boolean", + "default": false, + "json_path": "/test_run/include_sub_folders", + "description": "Descend into sub-folders of folder_ids. Sending this key with folder_ids absent or empty is a hard 400 ('Cannot process nested folders of empty folder array'), so only send it alongside a non-empty folder_ids." + }, + { + "name": "filter_test_cases", + "type": "object", + "fields": [ + { + "name": "is_dynamic", + "type": "boolean" + }, + { + "name": "priority", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "case_type", + "type": "array" + }, + { + "name": "owner", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "custom_fields", + "type": "object" + }, + { + "name": "match", + "type": "object" + } + ], + "json_path": "/test_run/filter_test_cases", + "description": "Forward-matching rule for the run, and the only place is_dynamic is read. Sending it ALONE does not select cases: the run is created empty and marked dynamic. Labels ('High', 'Draft') are resolved to ids; an owner email that does not resolve in this group fails the create with 400. is_dynamic:true needs at least one other key here or it is a 400." + }, + { + "name": "filter_scope", + "type": "string", + "values": [ + "global", + "within_folders" + ], + "default": "global", + "json_path": "/test_run/filter_scope", + "description": "How filter_test_cases combines with folder_ids: 'global' unions them across the project, 'within_folders' intersects the filter with the listed folders. Honoured on create only — the run update paths accept it and ignore it." + }, + { + "name": "configuration_map", + "type": "array", + "fields": [ + { + "name": "test_case_id", + "type": "string" + }, + { + "name": "configuration_ids", + "type": "array" + } + ], + "json_path": "/test_run/configuration_map", + "description": "Per-case configuration assignment: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Sending it REPLACES the top-level configurations list with the ids used here. It is an overlay, not the case list — cases selected elsewhere still come in, with a null-configuration row." + }, + { + "name": "test_case_assignee", + "type": "string", + "example": "tester@acme.com", + "json_path": "/test_run/test_case_assignee", + "description": "Default assignee applied to the cases placed in the run, as an email. Applied by the asynchronous population step, not at create time. Not present in the published schema." + }, + { + "name": "is_automation", + "type": "boolean", + "default": false, + "json_path": "/test_run/is_automation", + "description": "Accepted and then DROPPED — it does not make the run an automation run. It is still read to decide which write permission is demanded, so sending true can 403 a caller who could have created a manual run. To get an automation run, create it without this flag and then call update_test_run with is_automation: true on the returned identifier; confirm with the v1 run read." + } + ], + "intent": "Create a test run in a project and load it with the test cases to be executed — the starting point for recording results, and what you make before assigning testers or linking the run to a plan", + "guidance": [ + "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload — the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", + "Every field goes inside a `test_run` object. A flat body is rejected before a single field is read.", + "Only test_cases, shared_test_cases, folder_ids or include_all actually put cases in the run. filter_test_cases is a forward-sync rule, NOT a selection: a create carrying only filter_test_cases returns 200 with an EMPTY run and silently marks it dynamic even though you did not ask. If the user named cases, list them.", + "Create runs STATIC unless the user explicitly asked for one that keeps picking up new cases. A dynamic run matches on nine fields only — priority, status, category, owner, automation_status, automation_state, folder, tags, issues — so a 'cases created in the last 7 days' filter is stored, echoed back on read, and never matched, and the run over-collects forever.", + "is_dynamic belongs inside filter_test_cases and nowhere else; a top-level is_dynamic is stripped by the parameter filter and has no effect. is_dynamic:true with no other key inside filter_test_cases is a 400 saying filter criteria are required.", + "A bare 400 'invalid data' is about the run's own fields — it is raised while converting the run header, before any case selection is built — most often an owner or reviewer inside filter_test_cases that resolves to no user in this group. It is never a complaint about the shape of your selection.", + "Populating the run is asynchronous. A 200 means the run exists, not that its cases are attached yet; re-read the run's test cases to confirm. A 202 with build_pending:true means the run was created but is not readable back yet — poll for it, never retry the create, which would make a second run.", + "include_sub_folders is only meaningful next to a non-empty folder_ids; on its own it is a hard 400.", + "A run created here keeps no version history. If the wrong cases land in it, adding is easy but removing is a separate operation, and the run's own metadata cannot be rolled back — only overwritten or the run deleted.", + "The new run comes back at the top level as `test_run`, not under `data` — unlike the v1 create, which wraps its payload in `data`.", + "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only — if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", + "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer — it is a browser link for a human, not an API path, so do not call it.", + "`configurations` comes back empty on the older run backend whether or not the run has any — that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." + ], + "returns": [ + "identifier", + "name", + "description", + "assignee", + "run_state", + "active_state", + "tags", + "configurations", + "filter_test_cases", + "test_plan", + "overall_progress", + "issues", + "issue_tracker", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestRunV2Response" + }, + "202": { + "description": "The run was created but is not yet readable back — its build is still being provisioned and case population is in progress. Poll for the run rather than retrying the create.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "build_pending": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Test run was created; its build is still being created and population is in progress." + }, + "test_run": { + "$schema": "TestRun" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_plans", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." + }, + { + "name": "count", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page; read first when both this and page_size are sent. Defaults to 30. Above 300 the call is refused with 400 'Page limit exceeds the maximum limit of 300' rather than clamped." + }, + { + "name": "page_size", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Alias for count, read only when count is absent. Same 300 ceiling and the same 400 above it." + } + ], + "intent": "List the test plans in a project — the read that turns a plan name the user said into the TP-NNN id every other plan call needs, and answers 'what plans do we have?'", + "guidance": [ + "Page with `p` (NOT `page`) and size with `count` (or `page_size`). Default 30, hard ceiling 300 — asking for more errors instead of clamping, so ask for 300 in one go rather than walking 30 at a time.", + "Deep paging is capped on rows, not pages: once p x page_size passes the account's limit (30,000 by default) the call returns 429 'request_limit_exceeded'.", + "There is NO filtering on this route — no name search, status, owner, tag or sort parameter is read here. Page through and match client-side, or use the v1 plan listing, which does take status/sort/include_sub_plans.", + "Whether sub-plans (STP-NNN) appear among these rows is decided by the backing service and is not steerable from here. Read each row's parent_plan_id rather than assuming, and list a parent's children through the sub-plans listing.", + "Rows are the same plan shape the single-plan read returns, minus the embedded runs. Carry `identifier` (TP-NNN / STP-NNN) forward — it is what the v2 plan paths take, not the numeric id the v1 plan capabilities use.", + "Page against the `info` envelope (page, page_size, count, next, prev) instead of probing for an empty page." + ], + "returns": [ + "success", + "test_plans", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls", + "info" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "TestPlanV2ListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Release 2.0 plan", + "json_path": "/test_plan/name", + "description": "Plan name, and the only required field. Blank or missing fails the whole call with 400 'Name field cannot be blank' before anything is created." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/test_plan/start_date", + "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. An unparseable value is dropped silently rather than refused." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-01-31", + "json_path": "/test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "release-2.0" + ], + "json_path": "/test_plan/tags", + "description": "Plain tag strings. A non-string entry makes the whole list fall back to empty — no error, just an untagged plan." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123", + "JIRA-456" + ], + "json_path": "/test_plan/issues", + "description": "Plain issue keys, not objects: the v1 structured {issue_id, issue_type, metadata} form is filtered out here. Each key is stored as a jira-type link unless issue_tracker says otherwise. Linking is queued asynchronously, so the issues may not yet appear on an immediate re-read." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker, adding ticket metadata. Needs BOTH name ('jira') and host; a host that matches no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'. Omit it and issues are stored as bare jira references." + }, + { + "name": "created_at", + "type": "string", + "format": "date-time", + "json_path": "/created_at", + "description": "Backdated creation time, for migrating plans in from another tool. Sits at the TOP level of the body, not inside test_plan, and is only honoured for groups with the external-API timestamp feature enabled — otherwise it is ignored silently. Unparseable is 400." + }, + { + "name": "updated_at", + "type": "string", + "format": "date-time", + "json_path": "/updated_at", + "description": "Backdated last-update time. Top level like created_at, same feature gate. Must be >= created_at (or >= now when created_at is absent), else 400." + } + ], + "intent": "Create a test plan to group the test runs for a release, sprint or milestone — the TP-NNN it returns is what runs and sub-plans then hang off", + "guidance": [ + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own wrapper — `test_plan`, `plan` or `sub_test_plan` — is rejected before the call as an unknown body key.", + "The plan is always created in the `new` state: a plan_status in the create body is not read at all. Move it to started/completed afterwards with the update capability.", + "Owner is always the calling user — there is no owner field on this route, unlike the v1 create.", + "This route always creates a TOP-LEVEL plan: a parent_plan_id you send is filtered out before the plan is built, so the created plan has no parent and the response never carries a parent_plan_id key at all - not even as null. To create a child, use the sub-plan create under its parent; parentage cannot be changed afterwards on v2.", + "Reviewers, attachments and custom fields are v1-only on plans; sending them here changes nothing and is not reported.", + "Recoverable: a plan created by mistake can be archived or deleted afterwards, so nothing here is one-way.", + "The response is the created plan under `test_plan`; take `identifier` (TP-NNN) from it. There is no numeric id field, but the numeric plan id and project id are both embedded in the web-app link under urls.self - that is where to recover the numeric id the v1 plan capabilities take." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$schema": "TestPlanV2Response__CreateTestPlan" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_plan", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan identifier in TP-NNN form, or STP-NNN for a sub-plan — both match this route. The integer plan id the v1 plan capabilities take does NOT match here and gives a routing 404." + } + ], + "intent": "Read one test plan or sub-plan — dates, status, owner-facing description, tags, linked issues, how many runs and sub-plans it holds, and a first glimpse of the runs inside it", + "guidance": [ + "Pass an STP-NNN to read a sub-plan: the same route serves both, and the plan still comes back under the `test_plan` key (never `sub_test_plan`).", + "The embedded `test_runs` array is a preview, not the membership list: only identifier and name, and only the first 30 runs of the plan. Use the plan's test-runs read to page through them or to see run state and progress.", + "If the upstream run fetch fails, the call still returns 200 with the `test_runs` and `links` keys ABSENT. Absent is not the same as an empty plan — check test_runs_count before concluding a plan has no runs.", + "test_runs_count splits active/closed and counts this plan's own runs only. parent_plan_id appears ONLY when the plan you read is a sub-plan, and carries the parent's TP-NNN, not its numeric id; on a top-level plan the key is omitted from the payload entirely rather than returned as null, so test for the key's presence, not for a null value. sub_plans_count comes back on both - it is the number of children, and reads 0 on a sub-plan.", + "urls.self is a web-app deep link built from numeric ids, and is the only place those numeric ids appear; urls.sub_test_plans, the address of this plan's children listing, is added only when the plan has no parent. links.self and links.test_runs are relative API paths and come and go with the test_runs preview.", + "active_state is derived: 'completed' once the plan is completed, otherwise 'active'. It does not distinguish a new plan from a started one.", + "A wrong, mistyped or other-project id is a 404 'Test Plan ID is invalid. Please enter valid Test plan ID and try again' — the same message for every not-found case.", + "Whether an archived plan is readable here is not settled by this route; treat a 404 on a plan the user insists exists as a possible archive and check the archived listing." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "test_runs", + "sub_plans_count", + "parent_plan_id", + "start_date", + "end_date", + "created_at", + "project_id", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "TestPlanV2DetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_sub_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/sub-test-plans", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "The PARENT plan, as TP-NNN. This is where the new child hangs; it is not the id of the plan being created. An unknown parent is a 404 before anything is created." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Payments — phase 1", + "json_path": "/sub_test_plan/name", + "description": "Sub-plan name, and the only required field. Blank or missing fails the call with 400 'Name field cannot be blank'." + }, + { + "name": "description", + "type": "string", + "json_path": "/sub_test_plan/description", + "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/sub_test_plan/start_date", + "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. Not validated against the parent plan's dates — a child may sit entirely outside its parent's window." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-01-15", + "json_path": "/sub_test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression" + ], + "json_path": "/sub_test_plan/tags", + "description": "Plain tag strings. Tags are not inherited from the parent plan. A non-string entry makes the whole list fall back to empty, with no error." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123" + ], + "json_path": "/sub_test_plan/issues", + "description": "Plain issue keys, not objects. Stored as jira-type links unless issue_tracker says otherwise, and queued asynchronously so they may lag an immediate re-read." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/sub_test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." + } + ], + "intent": "Break a test plan into a child plan — use this when a release plan needs per-team, per-phase or per-platform slices that each hold their own runs", + "guidance": [ + "Send the fields flat. The wrapper applied for you here is `sub_test_plan`, NOT `test_plan`; that difference is handled by the json_paths, and sending either wrapper yourself is rejected before the call as an unknown body key.", + "The parent comes from the path; there is no parent_plan_id field, and a sub-plan cannot be re-parented or promoted afterwards on v2.", + "Every response from this route carries parent_plan_id - the parent's TP-NNN, not its numeric id - because the route only ever creates children. It also carries sub_plans_count, which is 0 on a plan that was just created; that field is not suppressed for sub-plans. urls.self is a web-app deep link and the only place the new plan's numeric id appears; the sub_test_plans link is added to top-level plans only, so a sub-plan does not get one.", + "The created plan comes back under `sub_test_plan` (not test_plan) and its identifier is STP-NNN. That STP id is accepted wherever a plan id is taken on these v2 plan routes — read it, edit it, list its runs.", + "Runs are not moved here: a new sub-plan starts empty, and a run is attached by setting its plan on the run itself, which unlinks it from whichever plan held it before.", + "Recoverable: an unwanted sub-plan can be deleted or archived afterwards.", + "Completed sub-plans are hidden from the sub-plan listing, so a child you create and later complete stops appearing there even though it still exists." + ], + "returns": [ + "success", + "sub_test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$response": "SubTestPlanV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_plan_test_runs", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/test-runs", + "mode": "read", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan identifier in TP-NNN form, or STP-NNN to list a sub-plan's own runs. The integer plan id the v1 capabilities take does not match this route." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. This is the ONLY paging control here — the page size is fixed." + } + ], + "intent": "List the test runs that belong to a test plan, with each run's state, assignee and pass/fail progress — the read behind 'how is this release going?' and 'which runs are in this plan?'", + "guidance": [ + "Page with `p` only. The page size is FIXED at 30 and cannot be raised: `count` and `page_size` are documented on this route but not read, so asking for 100 still returns 30. Page against the `info` envelope.", + "Rows carry `identifier` (TR-NNN) but NO numeric run id — the integer uuid some plan-linking calls need is not in this payload, so read the run itself when you need it.", + "Rows here omit `test_plan` entirely — the plan is already the scope you asked for. Do NOT read its absence as the run being unlinked; the project-wide run listing does carry the plan link for the same run.", + "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none'.", + "Each row also carries a `urls` object whose `self` is a deep link to the run in the web app, alongside the `links` object of API references.", + "There is no include-sub-plan-runs switch here (v1 has one). To cover a parent and its children, list each sub-plan's runs separately.", + "When the upstream run service fails, the body STILL says success: true with test_runs null while the HTTP status carries the real failure. Check that test_runs is an array before reading 'no runs' as an empty plan.", + "Progress arrives as overall_progress, a map of status name to count for the run's cases; a run with nothing executed may omit the key entirely rather than report zeros.", + "This lists runs, not results or cases — drill into a run's test cases for case-level state." + ], + "returns": [ + "success", + "test_runs", + "info", + "identifier", + "name", + "description", + "run_state", + "active_state", + "assignee", + "tags", + "configurations", + "filter_test_cases", + "is_dynamic", + "overall_progress", + "project_id", + "created_at", + "updated_at", + "links", + "urls" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestRunsListV2Response" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_test_plan", + "method": "POST", + "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/update", + "mode": "write", + "entity": "test_plan", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "test_plan_id", + "type": "string", + "required": true, + "example": "TP-12", + "pattern": "^(TP|STP)-\\d+$", + "description": "Plan to edit, as TP-NNN — or STP-NNN to edit a sub-plan through this same route. The integer plan id the v1 capabilities take does not match here." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "example": "Release 2.0 plan", + "json_path": "/test_plan/name", + "description": "New name. Optional here, unlike the v1 plan edit which rejects any body without it. An empty string is ignored and the current name is kept — the name cannot be cleared." + }, + { + "name": "description", + "type": "string", + "json_path": "/test_plan/description", + "description": "New description. An empty string is ignored and the stored description is kept, so a description cannot be cleared here. Sanitised server-side, so the stored value can differ from what you sent." + }, + { + "name": "start_date", + "type": "string", + "format": "date", + "example": "2026-01-01", + "json_path": "/test_plan/start_date", + "description": "Plain date (YYYY-MM-DD). Applied only when non-empty; omitted leaves the current date. Once the plan is started this is fixed and further values for it are ignored." + }, + { + "name": "end_date", + "type": "string", + "format": "date", + "example": "2026-02-28", + "json_path": "/test_plan/end_date", + "description": "Plain date (YYYY-MM-DD). Applied only when non-empty. Earlier than the plan's start date is refused with 400 'Invalid end_date, should be greater than start_date' and nothing is saved." + }, + { + "name": "plan_status", + "type": "string", + "values": [ + "new", + "started", + "completed" + ], + "example": "started", + "json_path": "/test_plan/plan_status", + "description": "Lifecycle move. Only forward, one step at a time: new -> started -> completed. 'started' stamps start_date (today unless you send one) and 'completed' stamps end_date the same way." + }, + { + "name": "tags", + "type": "array", + "fields": [ + { + "name": "tag", + "type": "string" + } + ], + "example": [ + "regression", + "release-2.0" + ], + "json_path": "/test_plan/tags", + "description": "REPLACES the plan's tags with this list. An empty array is treated as 'not sent' and leaves the existing tags in place, so tags cannot be cleared through this call." + }, + { + "name": "issues", + "type": "array", + "fields": [ + { + "name": "issue_id", + "type": "string" + } + ], + "example": [ + "JIRA-123" + ], + "json_path": "/test_plan/issues", + "description": "Plain issue keys, not the v1 structured objects. Sent non-empty they replace the linked set; an empty array is ignored, so links cannot be removed here. Processing is queued, so an immediate re-read may lag." + }, + { + "name": "issue_tracker", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "host", + "type": "string" + } + ], + "example": { + "name": "jira", + "host": "https://example.com/jira" + }, + "json_path": "/test_plan/issue_tracker", + "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." + } + ], + "intent": "Change an existing test plan or sub-plan — rename it, move its dates, retag it, relink issues, or start/complete it; note that attaching or detaching a test run is NOT done from the plan on v2, it is set on the run", + "guidance": [ + "Merge, not replace: only the fields you send change and anything omitted keeps its current value, so a one-field edit is safe. (The v2 run 'update' on the same path shape behaves the opposite way — do not carry that habit here.)", + "Linking runs is the trap. A `test_runs` list in this body is SILENTLY DISCARDED — 200, plan unchanged, nothing linked, no warning. It is a v1-only field. On v2 you set the run's plan from the run side (its test_plan_id / sub_test_plan_id), and that MOVES the run: a run belongs to at most one plan, so the plan that held it loses it with nothing in the response to say so. Check the runs' current plans first and tell the user which plans will lose runs.", + "Unlike the v1 plan edit, name is NOT required — an update carrying only dates, tags or a status move is accepted.", + "Lifecycle is one-way and one step at a time: new -> completed is 400 'Cannot directly complete test plan', started -> new is 400 'Cannot revert started test plan', and once completed the plan is FROZEN — every later edit, including a rename, is 400 'Cannot update completed test plan'. Complete a plan last.", + "No undo: plans keep no version history, so a name, description or date this call overwrites cannot be restored and there is nothing to roll back to. Read the plan first if the old value matters.", + "Clearing is not possible through this call: empty strings and empty arrays are read as 'not sent' for name, description, tags and issues.", + "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path — including when the target is a sub-plan (STP-NNN), where `sub_test_plan` is NOT the key this route reads. Sending your own wrapper is rejected before the call as an unknown body key.", + "parent_plan_id comes back only when the plan you edited is a sub-plan, carrying the parent's TP-NNN; a top-level plan's response omits the key entirely rather than returning null. This call cannot change parentage, so the field never appears or disappears as a result of the edit. sub_plans_count is always present, and test_runs and links are not - they belong to the single-plan read.", + "Owner, reviewers, attachments, custom fields and re-parenting are v1-only on plans; sending them here changes nothing and is not reported.", + "A rejected edit returns 400 with {success: false, error: ...} and saves nothing — the plan is left exactly as it was." + ], + "returns": [ + "success", + "test_plan", + "identifier", + "name", + "description", + "active_state", + "tags", + "issues", + "test_runs_count", + "sub_plans_count", + "start_date", + "end_date", + "created_at", + "project_id", + "urls" + ], + "responses": { + "200": { + "$schema": "TestPlanV2Response__UpdateTestPlan" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_custom_fields", + "method": "GET", + "path": "/api/v2/custom-fields", + "mode": "read", + "entity": "custom_field", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page` is not read on this listing: send it and you silently get page 1 again. A zero, negative, non-numeric or repeated value is refused with 400 'Invalid Params' rather than coerced." + }, + { + "name": "count", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Rows per page. Defaults to 30 and the real ceiling is 300 — a larger value is REFUSED with 400 'Page limit exceeds the maximum limit of 300', not clamped, so ask for 300 directly on a large workspace. The published spec claims a maximum of 100; the code enforces 300." + } + ], + "intent": "Discover the test-case custom fields defined in this workspace — list each field with its name, type, required flag and linked-project count, and pick up the numeric field id needed before reading, editing or filling in a field", + "guidance": [ + "Page with `p` and `count` — not `page` / `per_page`, which this listing ignores. `count` above 300 is a 400 naming the limit, so request 300 when you want the workspace in as few calls as possible.", + "Only TestCase fields are listed. The entity type is fixed server-side, so test-result, test-plan and exploratory-session fields never appear here whatever you send — the workspace field listing on the admin-settings surface is the one that covers those.", + "Rows are field metadata only: no dropdown options, no dataset and no list of linked projects, just a linked_projects_count. Read the field's datasets when you need the option values or the actual project links.", + "This is the newer public custom-fields surface. The admin-settings custom-field capabilities read the same stored definitions but answer in a different envelope and cover every entity type — prefer this one for test-case fields, and fall back to those for result/plan fields and for dataset or option editing.", + "Carry the numeric `id` from each row forward: that is exactly what the single-field read and the field edit take in their path.", + "Needs the field-view permission when role-based access is on. Without it the answer is 403 'This action is not permitted for the user', not an empty list." + ], + "returns": [ + "success", + "custom_fields", + "info", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "placeholder", + "system_name", + "is_bulk_editable", + "is_filterable", + "linked_projects_count", + "group_id" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "CustomFieldsV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_custom_field", + "method": "POST", + "path": "/api/v2/custom-fields", + "mode": "write", + "entity": "custom_field", + "body": [ + { + "name": "field_type", + "type": "string", + "required": true, + "values": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "example": "dropdown", + "description": "The kind of field. Mandatory in practice even though the body schema marks nothing required — anything outside this list is a flat 400 'Invalid Params'. It is fixed for the life of the field: the edit capability will not change it.", + "minLength": 1 + }, + { + "name": "field_name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 256, + "example": "Test Environment", + "description": "Label shown on the test case. Mandatory; trimmed before storage, so ' Priority' is stored as 'Priority'. Over 256 characters or blank is a 400 'Invalid Params'. Names that collide with a system field are refused separately (see guidance)." + }, + { + "name": "is_required", + "type": "boolean", + "required": true, + "example": false, + "description": "Whether a test case must fill this field. Mandatory, and it must be a real JSON boolean: the string \"true\" fails the parameter check with a flat 400 'Invalid Params' that names no field." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "example": "Select an environment", + "description": "Hint text shown in the empty field. Trimmed before storage; over 256 characters is a 400 'Invalid Params'." + }, + { + "name": "default_value", + "type": "string", + "description": "Accepted by the schema but NOT settable here: for every non-boolean type it is discarded before the write, and for boolean the checks contradict each other (a JSON boolean fails the body schema, the string 'true' fails the parameter check). Set it afterwards with the field edit capability." + }, + { + "name": "entity_type", + "type": "string", + "values": [ + "TestCase" + ], + "default": "TestCase", + "description": "What the field attaches to. Only TestCase exists on this route — any other value is 400 'Only TestCase entity type is supported'. Result, plan and exploratory-session fields are created on the admin-settings surface instead." + }, + { + "name": "options", + "type": "array", + "description": "Choices for dropdown / multi_dropdown. Persisted ONLY when the request also carries applies_to_all_projects or assigned_projects — sent alone they are silently dropped while still being echoed in the response. For multi_dropdown every is_default is forced to false; for dropdown only the first is_default:true survives.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The choice as the user sees it. Must be non-empty.", + "minLength": 1 + }, + { + "name": "is_default", + "type": "boolean", + "description": "Pre-selected choice. At most one per dropdown; ignored for multi_dropdown." + } + ] + }, + { + "name": "applies_to_all_projects", + "type": "boolean", + "default": false, + "description": "Link the new field to every project that exists today. Heavy: the server enumerates the whole workspace (stopping around 3000 projects) and links each one. NOTE this is not the same flag as on the edit capability, where it only toggles future-project linking." + }, + { + "name": "assigned_projects", + "type": "array", + "example": [ + "PR-12345", + "PR-67890" + ], + "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ], + "items": { + "type": "string" + } + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "default": false, + "description": "Automatically offer this field in projects created later. Only meaningful alongside applies_to_all_projects or assigned_projects, because it is stored on the dataset those build." + }, + { + "name": "levels", + "type": "array", + "minItems": 2, + "description": "Hierarchy tiers, REQUIRED for field_type nested_dropdown and ignored for every other type. At least two entries, each with name and level_type; the schema refuses a nested_dropdown create without them.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Tier label, e.g. COUNTRY." + }, + { + "name": "level_type", + "type": "string", + "required": true, + "description": "Widget for the tier, e.g. dropdown." + }, + { + "name": "index", + "type": "integer", + "description": "Position of the tier, top tier first." + } + ] + }, + { + "name": "parent_custom_field_id", + "type": "integer", + "minimum": 1, + "description": "For nested_dropdown the KEY must be present even when the value is null — the upstream nested-create path is chosen on the key's presence. Set to an existing nested field's id to add one more tier to it, in which case only field_name is validated and the rest is copied from the parent." + }, + { + "name": "datasets", + "type": "array", + "description": "Advanced, forwarded upstream as sent. Lets one call define option values and project links together, including a recursive children tree for nested_dropdown. Prefer options + assigned_projects / applies_to_all_projects; reach for this only when you need nested option trees or explicit unlinks.", + "fields": [ + { + "name": "linked_to_all_projects", + "type": "boolean", + "description": "Expand to every project in the workspace before sending." + }, + { + "name": "linked_projects", + "type": "array", + "description": "Numeric project ids to link on this dataset (this path takes ids, not PR-NNN identifiers)." + }, + { + "name": "unlinked_projects", + "type": "array", + "description": "Numeric project ids to unlink on this dataset." + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Link projects created later." + }, + { + "name": "options", + "type": "array", + "description": "Option rows; each may carry a children array one tier down for nested_dropdown." + } + ] + } + ], + "intent": "Define a new test-case custom field — choose its type, name it, say whether filling it is mandatory, and optionally give a dropdown its choices and link it to the projects that should offer it", + "guidance": [ + "field_type, field_name and is_required are all mandatory although the body schema marks nothing required, and is_required must be a real JSON boolean. Every failure of that validator comes back as the same 400 'Invalid Params' with no field named, so check all three before retrying.", + "options are only stored when the same request also carries applies_to_all_projects or assigned_projects — the choices live on a dataset, and no dataset is built without one of those. Sent alone they are dropped, and the response still echoes them, so a dropdown can look configured while holding no values. Re-read the field's datasets to confirm.", + "The returned custom_field is partly an echo of the request: options, assigned_projects, applies_to_all_projects and link_to_future_projects are replayed from what you sent (or defaulted to false / empty), not read back from storage. Only id, field_name, field_type, entity_type and is_required are authoritative.", + "assigned_projects takes PR-NNN identifiers, not numeric project ids, and one unresolvable identifier fails the whole create with 400 — resolve the projects first. applies_to_all_projects:true instead walks every project in the workspace (up to about 3000) and links them all, which is a slow call on a large account.", + "default_value cannot be set at create time for any type. Create the field, then set it through the field edit capability, where a boolean default is sent as the string 'true' or 'false'.", + "These names are reserved and rejected with 400 'Field with same name already exists. Use a unique name.': Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status.", + "Choose the type carefully — it can never be edited. Changing a field's type in practice means deleting it and creating it again, which destroys every value already stored on test cases.", + "nested_dropdown needs levels with at least two entries (name + level_type each) and the parent_custom_field_id key present even when null. To append one more tier to an existing nested field, send parent_custom_field_id plus field_name and nothing else.", + "Same stored fields as the admin-settings create capability, different contract: this route is TestCase-only, takes PR-NNN project identifiers rather than numeric ids, and answers with a serialized custom_field object instead of the raw upstream record.", + "Needs the field-write permission; 403 otherwise. The new field can be removed again, but any values captured on test cases in the meantime go with it.", + "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null — and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "options", + "applies_to_all_projects", + "assigned_projects", + "link_to_future_projects", + "system_name", + "is_bulk_editable", + "is_filterable", + "placeholder", + "group_id", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2DetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_custom_field", + "method": "GET", + "path": "/api/v2/custom-fields/{id}", + "mode": "read", + "entity": "custom_field", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The field's numeric id, as the custom-fields listing returns it. It is validated as a positive integer before anything else runs: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer'. The published spec showing a prefixed 'cf-123' string is wrong." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page of the datasets fetched behind this field, reflected in dataset_ids / dataset_urls. `p` is accepted as a legacy alias for the same thing. Zero or non-numeric falls back to 1 instead of erroring." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 20, + "description": "Datasets per page behind this field. Defaults to 20 here (not 30) and is clamped at 300 rather than refused. Most fields have a single dataset, so this rarely matters." + } + ], + "intent": "Read one custom field by its numeric id — confirm its name, type, placeholder, default and whether it is mandatory, and pick up the ids of the datasets that hold its option values", + "guidance": [ + "applies_to_all_projects and link_to_future_projects come back hardcoded false on this read. They are shape fillers, not the field's real project configuration — never report project scope from this response; read the field's datasets for that.", + "No option values are included. What you get is dataset_ids plus ready-made dataset_urls and datasets_url; follow one of those to see the choices a dropdown offers and which projects it is linked to.", + "An id that exists nowhere is 404 'Custom field not found', while an id that is not a positive integer is 400 — the two are distinguishable, so do not treat a 400 as 'no such field'.", + "The `project` query parameter documented in the spec is not read by this route. Filtering a field's option values by project is done on the dataset listing, not here.", + "Needs the field-view permission when role-based access is on; 403 otherwise.", + "Same stored definition as the admin-settings single-field read, but a different body: that one returns the field with its datasets and options inline, this one returns metadata plus dataset references.", + "Built by the same serializer as the create and update replies, which omits any field without a value — so several keys are absent rather than null, and `is_bulk_editable` and `is_filterable` are omitted when FALSE as well as when unset, which makes those two indistinguishable from unset in the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "placeholder", + "system_name", + "is_bulk_editable", + "is_filterable", + "group_id", + "applies_to_all_projects", + "link_to_future_projects", + "dataset_ids", + "datasets_url", + "dataset_urls", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2WithDatasetsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_custom_field", + "method": "PATCH", + "path": "/api/v2/custom-fields/{id}", + "mode": "write", + "entity": "custom_field", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The field's numeric id. Validated as a positive integer before the field is loaded: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer', and an id with no field behind it is 404 'Custom field not found'." + } + ], + "body": [ + { + "name": "field_name", + "type": "string", + "minLength": 1, + "maxLength": 256, + "example": "Test Environment", + "description": "New label. Omit it to keep the current one — this is a partial update. An empty string does NOT clear the name, it leaves the stored value untouched. Renaming onto a reserved system-field name is refused with 400." + }, + { + "name": "is_required", + "type": "boolean", + "description": "Whether the field must be filled. Omit to keep the current setting; false is honoured (unlike the text attributes, this one is applied even when falsy). Must be a real JSON boolean." + }, + { + "name": "place_holder_text", + "type": "string", + "maxLength": 256, + "description": "New hint text, trimmed before storage. Omit to keep the current one. An empty string does not clear it — the stored value survives." + }, + { + "name": "default_value", + "type": "string", + "description": "New default. Unlike the create call this one is applied, for every field type. On a boolean field send the STRING \"true\" or \"false\" — it is coerced to \"1\"/\"0\", a real JSON boolean is rejected by the body schema, and an empty or falsy value leaves the stored default in place." + }, + { + "name": "options", + "type": "array", + "description": "Replaces the dropdown's option list WHOLE on the field's existing dataset — options you leave out are removed and the values test cases hold for them are gone, in every linked project, with no undo. Send the full intended list. Ignored on non-dropdown field types.", + "fields": [ + { + "name": "option_value", + "type": "string", + "required": true, + "description": "The choice as the user sees it.", + "minLength": 1 + }, + { + "name": "is_default", + "type": "boolean", + "description": "Pre-selected choice." + } + ] + }, + { + "name": "assigned_projects", + "type": "array", + "example": [ + "PR-12345", + "PR-67890" + ], + "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ], + "items": { + "type": "string" + } + }, + { + "name": "linked_projects", + "type": "array", + "example": [ + "PR-12345" + ], + "description": "Projects to ADD, as PR-NNN identifiers. Sending this (or unlinked_projects) switches off the assigned_projects diff entirely, so existing links you do not mention are left alone. An identifier that resolves to no project fails the call with 400.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ] + }, + { + "name": "unlinked_projects", + "type": "array", + "example": [ + "PR-99999" + ], + "description": "Projects to REMOVE, as PR-NNN identifiers. Unlinking discards the values those projects' test cases hold for this field and cannot be undone. An unresolvable identifier fails the call with 400.", + "fields": [ + { + "name": "identifier", + "type": "string", + "description": "A project identifier such as PR-12345.", + "pattern": "^PR-\\d+$" + } + ] + }, + { + "name": "link_to_future_projects", + "type": "boolean", + "description": "Whether projects created later automatically offer this field. Applied to the field's dataset; sending it is itself enough to trigger the dataset write." + }, + { + "name": "applies_to_all_projects", + "type": "boolean", + "description": "Legacy alias that here ONLY sets the future-projects flag — it does not link the projects that already exist, unlike the same flag at create time. An explicit link_to_future_projects wins over it. Use linked_projects to reach existing projects." + }, + { + "name": "levels", + "type": "array", + "description": "Nested-dropdown tiers, forwarded upstream but currently ignored there for renames. Rename or retype a tier by editing that tier's own field id instead.", + "fields": [ + { + "name": "id", + "type": "integer", + "description": "Existing tier field id." + }, + { + "name": "name", + "type": "string", + "description": "Tier label." + }, + { + "name": "level_type", + "type": "string", + "description": "Widget for the tier." + }, + { + "name": "index", + "type": "integer", + "description": "Position of the tier." + } + ] + } + ], + "intent": "Change an existing test-case custom field — rename it, make it mandatory or optional, adjust its placeholder or default value, replace a dropdown's choices, or change which projects offer it", + "guidance": [ + "This is a PARTIAL update: every attribute you leave out keeps its stored value, so sending field_name alone is a valid rename. It differs from the admin-settings update capability, which is a full replace demanding field_name, field_type and is_required together.", + "None of it can be undone. Option lists and project links are rewritten in place, there is no bin and no prior revision: an option dropped from `options` is removed from the dataset and every value test cases hold for it disappears across all linked projects.", + "assigned_projects is read as the complete desired set, not an addition — any currently linked project you omit is unlinked, and the values stored there go with it. Count and name the projects you are about to drop before sending, or use linked_projects / unlinked_projects to change links one at a time.", + "field_type is not editable here. A field_type in the body is ignored, the stored type is kept and the call still answers 200, so never report a type change as done. A guard meant to refuse it with 400 exists but cannot fire, because the stored field is loaded after that check runs.", + "Empty strings do not clear anything: field_name, place_holder_text and default_value are applied only when non-empty, so the stored value survives an attempt to blank it. is_required is the exception — false is written.", + "Dataset-side changes (options, assigned_projects, linked_projects, unlinked_projects, link_to_future_projects, applies_to_all_projects) only take effect on dropdown, multi_dropdown and nested_dropdown fields. On any other type they are accepted, answered 200 and quietly ignored. If the field has no dataset yet, one is created.", + "applies_to_all_projects means something different here than at create time: it only toggles the future-projects flag and never links existing projects.", + "The two halves are not atomic. Field attributes are written first, the dataset second, so a failure on the dataset call leaves the rename or required-flag change already applied. Re-read the field before retrying.", + "The response replays options, assigned_projects, applies_to_all_projects and link_to_future_projects from your request rather than from storage — only the field attributes in it are read back. Confirm project links and option values by reading the field's datasets.", + "Needs the field-write permission; 403 otherwise. Unknown id is 404, a non-integer id is 400.", + "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null — and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." + ], + "returns": [ + "success", + "custom_field", + "id", + "field_type", + "field_name", + "entity_type", + "is_required", + "default_value", + "place_holder_text", + "options", + "applies_to_all_projects", + "assigned_projects", + "link_to_future_projects", + "system_name", + "is_bulk_editable", + "is_filterable", + "placeholder", + "group_id", + "linked_projects_count" + ], + "responses": { + "200": { + "$response": "CustomFieldV2DetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_reports", + "method": "GET", + "path": "/api/v2/projects/{project_id}/reports", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "example": 2, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again with a 200. Rows come back newest-created first." + }, + { + "name": "page_size", + "type": "integer", + "values": [ + 30, + 300 + ], + "default": 30, + "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (5, 50, 100, 301) is refused with a 400 naming the two allowed values rather than clamped, so ask for 300 directly when you want a whole project in few calls." + }, + { + "name": "is_scheduled", + "type": "boolean", + "example": true, + "description": "Narrows to recurring (scheduled) reports only, and it is a one-way switch: the filter is applied only when the value is exactly the string 'true'. is_scheduled=false applies NO filter and returns both kinds, so it cannot be used to find one-off reports — read the is_scheduled flag on each row for that." + }, + { + "name": "report_type", + "type": "string", + "example": "Test Run Summary", + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Plan Detailed Report", + "Test Case Activity", + "Requirement Traceability Report", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "description": "Exact-match filter on the stored report type DISPLAY NAME, including spaces and capitals. A near-miss ('test run summary', 'Test Runs Summary') is not an error — it returns 200 with an empty list, which reads exactly like 'this project has no such reports'." + }, + { + "name": "q[query]", + "type": "string", + "example": "regression", + "description": "Case-insensitive substring search across report title AND report type, matched together. The parameter is nested and must go on the wire as q[query]=term. A flat q=term is not an error and not a search: the filter is skipped and you get the full unfiltered list with a 200." + } + ], + "intent": "Find a project's reports — search saved and scheduled reports by title or type, see which ones run on a recurring schedule and when each next fires, or pick up the SC-NNN identifier of a report you then want to read or download", + "guidance": [ + "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 — any other number is a 400, not a clamp — so pass 300 when you want the project in as few calls as possible.", + "Both one-off and scheduled reports are listed by default; is_scheduled=true narrows to scheduled ones, and nothing narrows to one-off ones.", + "scheduled_report_count and total_report_count describe the WHOLE filtered result set, not the page you are holding — use them to answer 'how many?' without paging, but remember any report_type / q / is_scheduled filter you sent is already baked into both numbers.", + "Each row's `id` is the SC-NNN identifier, and that is what the v2 single-report read and the v2 download take. It is NOT the integer report id the v1 report capabilities (report detail, report sections, drilldown, send-email-now) require: SC-NNN is a per-group sequence number, so stripping the prefix gives the wrong id. Get the integer from the v1 schedules listing, which returns `id` and `identifier` side by side on the same records.", + "Rows carry the report's CONFIGURATION only — type, timeframe, filters, schedule, recipients. No figures, no report data. Reading the numbers a report contains is a separate call.", + "`latest_attachment` is derived from the newest generated file in the whole PROJECT, not from this report — a row can advertise an attachment produced by a different report. Treat it as 'something was generated here recently', not as this report's output.", + "`dynamic_filters` is always null on every row, even for reports created with dynamic filters; only `report_filters` echoes back what was configured.", + "Cross-project reports appear under every project they cover, so the same SC-NNN can show up in more than one project's listing." + ], + "returns": [ + "success", + "reports", + "info", + "scheduled_report_count", + "total_report_count", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "V2ReportListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_report", + "method": "POST", + "path": "/api/v2/projects/{project_id}/reports", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project the report is filed under, in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. For a multi-project report this project is still the owner; the full set goes in project_ids." + } + ], + "body": [ + { + "name": "report_type", + "required": true, + "values": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Test Case Activity", + "Requirement Traceability Report", + "Exploratory Session Summary" + ], + "description": "What the report reports on, as a DISPLAY NAME - the set is closed and matched exactly, spaces and capitals included. Only these six can be created here. Other report types exist in the product and appear on reports created elsewhere - Test Plan Detailed Report, User Workload Report, Defects Summary, Defects Detailed Report - but every one of them is refused by this create. Never substitute a neighbour: an activity question is 'Test Case Activity', not 'Test Run Summary'." + }, + { + "name": "report_timeframe", + "required": true, + "values": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_sessions" + ], + "description": "What the report covers: either a rolling window, or a mode that says the scope comes from report_filters (specific_test_runs / specific_test_plans / specific_sessions). 'custom' pairs with custom_date_range. Two further values exist in the stored data and can be seen on reports created elsewhere - a requirements scope and a specific-test-cases scope - but neither is accepted by this create, and both are refused by body validation before any report-specific message is reached." + }, + { + "name": "report_filters", + "required": true, + "description": "Required - a create without this key is refused by body validation before anything else is checked, even when the report needs no scoping. Send an empty object for a rolling-window report. When you do scope it, this is a STRICT ALLOW-LIST of exactly four keys - test_runs, test_plans, session_ids, projects. Any other key, including a typo or an advanced-filter name such as status, priority, assignee, requirements, folder or per-case custom fields, is refused by name. Nothing is silently dropped." + }, + { + "name": "mail_to", + "required": true, + "description": "Required - a create without this key is refused by body validation, even for a report nobody will be emailed. Send both inner lists empty for that case. Recipients are wired up only when the report is genuinely recurring; on a one-off the value is accepted and then ignored, so nobody is emailed." + }, + { + "name": "frequency", + "required": true, + "values": [ + "once", + "daily", + "weekly", + "monthly" + ], + "description": "Required - a create without this key is refused by body validation. 'once' means a one-off, unscheduled report and is what you send when nothing should recur. A recurring value only takes effect when frequency_details is sent with it - frequency alone is silently ignored and you get a one-off back, created successfully." + }, + { + "name": "title", + "description": "Display name. Genuinely optional: omit it and the server fills in the report type followed by today's date, which is easy to mistake later for a report someone else made. Titles are not unique, and this is the field the list search matches on." + }, + { + "name": "report_creation_mode", + "type": "string", + "required": true, + "values": [ + "custom_report", + "system_generated" + ], + "description": "Whether the report is one the caller defines or one the product generates. REQUIRED — the request schema rejects a body without it, so a create that omits it fails before anything is stored. It was missing from this contract entirely, which made the capability impossible to call successfully from here." + } + ], + "intent": "Set up a new report over a project's test data — either a one-off you will download, or a recurring daily/weekly/monthly schedule that emails chosen people; this saves the report's definition (type, window, scope, recipients) and hands back its SC-NNN identifier, it does not produce any figures", + "guidance": [ + "`report_creation_mode` is required and takes `custom_report` or `system_generated`. Sending `system_generated` with a traceability report type is treated specially by the server, so use `custom_report` unless you specifically want the generated form.", + "This returns a report DEFINITION, not report content. The created body is the saved configuration echoed back - identifier, type, timeframe, filters, schedule, recipients - and contains no counts, rows or figures at all. Never present it as the report's findings.", + "Getting the content afterwards is a separate, ASYNCHRONOUS step: request a download for the new report identifier and the server answers with a job id and a status URL, having queued the generation; then poll the matching download-status read until status flips from 'processing' to 'ready' and a file URL appears (pdf or csv only). Nothing in this create waits for that.", + "The alternative route to content is the v1 report reads (report detail and its section/widget reads), but those take the INTEGER report id and this API never exposes it - the public identifier is a per-group sequence, so you cannot derive it. Resolve it through the v1 schedules listing, which carries both ids on the same record.", + "Six fields are required, not three: report_type, report_timeframe and report_creation_mode, plus report_filters, mail_to and frequency. The last three are required even when they carry nothing meaningful, and omitting any of them is refused up front by body validation with a message naming the missing property. An empty object for report_filters, empty lists for both parts of mail_to, and 'once' for frequency are all accepted and are exactly what an unscheduled, unmailed report needs.", + "Scheduling needs frequency AND frequency_details together, with frequency one of daily/weekly/monthly. Send frequency alone, or frequency 'once', and you get an unscheduled one-off back with no warning - check the scheduled flag and the next-run time in the response before telling anyone a schedule exists.", + "Reading a report back always shows a frequency of 'once' when no schedule is stored - that is a stand-in for an empty value, not evidence that a one-time run was scheduled. The scheduled flag and the next-run time are the fields that actually distinguish the two.", + "Recipients are wired up only for a recurring report: recipients on a one-off are accepted and then ignored, so nobody is emailed. When you do send them, include BOTH the in-group users and the external addresses (empty lists are fine, and there is a cap of 50 external addresses) - a missing inner key fails after the record is written, and the server deletes the half-built report and answers with an unprocessable-entity error.", + "Validation happens in two stages with two different error shapes. A missing required key, a value outside a published set, or a wrong type is caught first by body validation, which answers with a generic 'invalid JSON data' message plus a details list naming each problem. Everything after that - unknown emails, unresolvable identifiers, unsupported filter keys - is answered as a single message string prefixed 'Validation error.' Branch on the status, not on the body's shape.", + "Every identifier in the body is resolved before anything is written: an unknown email or an unknown run, plan or project identifier comes back as a 400 with field-level errors, and nothing is persisted.", + "Cross-project: more than one project is allowed only for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Case Activity and Requirement Traceability Report, is capped at 60 projects, and for every one of those except Test Case Activity also needs the cross-project feature enabled for the group - without it the answer is a forbidden error. The product's own multi-project list also names two report types this create will not accept at all, so ignore those here.", + "The only way back from this call is deleting the report, which is itself irreversible - there is no archive or trash for reports. Re-running a create with the same title does not replace anything: titles are not unique and you get a second report.", + "The attachment summary on the response is not this report's: it is the newest generated file anywhere in the project, and its ready flag is set whenever any such file exists. A brand-new report can therefore appear to already have output. Do not use it to decide whether this report has been generated." + ], + "returns": [ + "success", + "data", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "responses": { + "201": { + "$response": "V2ReportDetailResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report", + "method": "GET", + "path": "/api/v2/projects/{project_id}/reports/{report_id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. It is authoritative: the report must belong to this project or the read is a 404, even inside the same group." + }, + { + "name": "report_id", + "type": "string", + "required": true, + "example": "SC-42", + "pattern": "^SC-\\d+$", + "description": "Report identifier in SC-NNN form — the `id` field of the v2 report listing. The route is constrained to SC-\\d+, so the integer report id used by the v1 report capabilities gives a routing 404 here, and SC-NNN is a per-group sequence number that cannot be converted to that integer by stripping the prefix." + } + ], + "intent": "Read one report's setup by its SC-NNN identifier — what it covers (type, time window, the runs/plans/sessions it is scoped to), whether it is on a schedule and when it next runs, who receives it, and whether a generated file exists; configuration only, no figures", + "guidance": [ + "This returns the report's CONFIGURATION, never its data — there is no report_data, no counts and no rows in the response. For the numbers inside a report, either fetch the generated file (request a download for this SC-NNN, then poll the download status until it is ready) or use the v1 report detail / section reads, which take the INTEGER report id this API never exposes; the v1 schedules listing is where SC-NNN and that integer appear together.", + "Every miss is the same 404 'Report not found': an identifier that does not exist, one from another group, and one that exists but belongs to a different project. A 404 therefore does not mean the report is gone — retry under the project that actually owns it, and for a cross-project report any of its projects will serve it.", + "`latest_attachment` is NOT this report's attachment. It is the newest generated file anywhere in the project, so it can belong to a different report, and it reports ready:true whenever it is present. Its file_type is a MIME type such as application/pdf, not the 'pdf'/'csv' the spec advertises. Only a download job you started yourself tells you about this report's file.", + "`dynamic_filters` is hard-coded to null on this read even when the report was created with them; only report_filters reflects configured scope.", + "report_filters echoes public identifiers rather than the internal ids stored (TR-NNN, TP-NNN, assignee emails). Two caveats: a sub-plan filter comes back as TP-NNN because the sub-plan marker is not kept, and if the upstream identifier lookup fails the values degrade to prefix+raw-id which may not be a real identifier.", + "`frequency` reads 'once' for an unscheduled report — that is a substitution for a stored null, not something the report was created with. Trust `is_scheduled` and `next_run_at` for whether anything will actually fire.", + "`project_ids` lists every project a cross-project report covers, and `project_id` is only the owning one." + ], + "returns": [ + "success", + "data", + "id", + "project_id", + "project_ids", + "report_type", + "title", + "description", + "report_timeframe", + "report_creation_mode", + "custom_date_range", + "frequency", + "frequency_details", + "next_run_at", + "is_scheduled", + "created_at", + "owner", + "mail_to", + "report_filters", + "dynamic_filters", + "latest_attachment" + ], + "responses": { + "200": { + "$response": "V2ReportDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_entity_attachments", + "method": "GET", + "path": "/api/v2/projects/{project_id}/{entity_type}/{entity_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "example": "PR-1", + "pattern": "^PR-\\d+$", + "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "example": "test-results", + "values": [ + "test-cases", + "test-results", + "test-plans" + ], + "description": "Which kind of parent item to read attachments from. A CLOSED set of exactly these three hyphenated plurals, enforced in the route. Singular or snake_case spellings (test-case, test_case, testcase, test_results) do not match the route and come back as a routing 404 with no JSON body, not a validation error naming the field. Test runs and exploratory sessions are not attachable here." + }, + { + "name": "entity_id", + "type": "string", + "required": true, + "example": "TC-42", + "description": "Form depends on entity_type: TC-NNN identifier for test-cases (resolved by identifier lookup; the internal integer case id gives 404 'Test case not found'), the plain INTEGER result id for test-results (no TR- prefix - that is a run), TP-NNN or STP-NNN for test-plans. The route leaves this segment unconstrained, so a wrong form is a 404 from the resolver rather than a 400." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "minimum": 1, + "maximum": 300, + "description": "Rows per page. Unlike the v2 run and result listings this is a real range, not an allow-list of 30/300 - any value up to 300 is honoured. Above 300 the call is refused with 400 'parameter_error' ('Page limit exceeds the maximum limit of 300'). A non-numeric or absent value is NOT an error: it silently falls back to 30, so a typo quietly shortens your page." + }, + { + "name": "file_type", + "type": "string", + "example": "image", + "description": "Filter by the attachment's content type, matched as a prefix - `image` keeps every image/* row, `application/pdf` only PDFs. Applied upstream; an unmatched value returns an empty list rather than an error." + }, + { + "name": "uploaded_by", + "type": "integer", + "example": 1234, + "description": "Filter by uploader, as the INTERNAL NUMERIC user id - not an email and not a username. The value is coerced with to_i, so a non-numeric value such as an email address becomes 0 and matches nothing while still returning 200. Resolve the user id from a project users read before filtering." + } + ], + "intent": "List the files attached to one test case, test result or test plan - use this to see what screenshots, logs or evidence are on the item and to get the URL for reading each file", + "guidance": [ + "One route serves all three parent types; entity_type is a closed set of exactly `test-cases`, `test-results` and `test-plans`, spelled hyphenated and plural. Anything else, including the singular or snake_case form, fails to match the route and returns a bare 404 rather than telling you the value was wrong.", + "entity_id is the trap: it is an IDENTIFIER for two of the three types and an integer for the other. test-cases wants TC-NNN (the internal integer id 404s 'Test case not found'), test-plans wants TP-NNN or STP-NNN, and test-results wants the plain integer result id - the one a v2 test-results listing returns as `id`. There is no route constraint on the segment, so the wrong form always surfaces as a 404 from the resolver, never a 400.", + "A test-result id that belongs to another project is rejected with 404 'Test result not found' rather than an empty list, so a 404 here means wrong id or wrong project, not an item with no attachments.", + "Page with `p` (NOT `page`) and `per_page`. per_page defaults to 30 and the real ceiling is 300; ask for 300 directly instead of walking pages of 30. Over 300 is a 400 'parameter_error'; a non-numeric value is silently treated as 30.", + "Rows are deliberately thin: id, name, size in bytes, created_at and url. Despite what the reference spec shows, this listing carries no content_type, byte_size, checksum or download_url. Read the file straight from `url` - there is no separate download capability for these attachments.", + "Inline attachments are always included. Images embedded in a description through the rich-text editor come back alongside real file attachments and carry an extra `data_id`, so this count can exceed what the item's attachment panel shows.", + "Prefer this over the v1 test-case attachments read whenever you hold a TC-/TP- identifier, need a test result's or plan's files, or want paging and file-type / uploader filtering. The v1 read handles test cases only, needs the integer project, folder and case ids, and returns every row in one unpaginated response.", + "`info` carries page, page_size, count, prev and next - use `count` to decide whether another page is worth fetching instead of paging until you get an empty array.", + "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments." + ], + "returns": [ + "success", + "attachments", + "info", + "id", + "name", + "size", + "created_at", + "url", + "data_id" + ], + "paginated": true, + "max_page_size": 300, + "responses": { + "200": { + "$response": "AttachmentListV2Response" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_results", + "method": "GET", + "path": "/api/v1/test-results", + "mode": "read", + "entity": "result", + "query": [ + { + "name": "issue_id", + "type": "string", + "example": "BUG-123", + "description": "The tracker issue whose linked results you want — the issue KEY as the tracker shows it (Jira 'BUG-123', not a numeric id). Only filters when issue_type is sent with it; on its own it is ignored and you get the unfiltered account-wide list back. Forwarded with no validation, so a wrong or unknown key comes back as an empty list with success:true, never an error. This is the filter the Jira/ADO issue panel uses." + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Which tracker issue_id belongs to. REQUIRED for the issue filter to apply at all — the search only builds the issue predicate when both issue_id and issue_type are present, so sending issue_id alone silently returns every recent result in the account rather than that issue's results. Trackers the product stores links for: jira, azure, asana, linear, clickup, confluence, figma, devrev, gitlab, github, trello, onedrive, google-drive, sharepoint, zoho, custom. Not validated." + }, + { + "name": "priority", + "type": "string", + "example": "high", + "description": "ACCEPTED AND IGNORED. It is forwarded to the search service but is not a filterable field for results, and the underlying query never joins the test case, so there is nothing to match a case priority against. Sending it changes nothing: you get the same rows you would have got without it, not an empty list. Filter by priority yourself from the test_case object you get with compact=false." + }, + { + "name": "compact", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "true", + "example": "false", + "description": "Shape switch, compared as a literal STRING against 'false'. Only compact=false expands each row with its test_run and test_case objects; every other value (including omitting it, and including boolean false serialised oddly) gives the lean shape with issues and configuration only. Rows always carry issues and configuration whatever you send." + }, + { + "name": "include_relations", + "type": "string", + "description": "Read and then thrown away: the action overwrites it unconditionally from the compact switch before the search runs, on BOTH branches. Asking for a relation here has no effect at all and no error. Use compact=false to get test_run and test_case; there is no way to request any other relation on this endpoint." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 2, + "description": "PAGE NUMBER, and the parameter is named `p` — `page=2` is not read and silently returns page 1. A value that is not a strictly positive integer (0, -1, 'abc', or a repeated/array form) is rejected before the action with HTTP 400 {success:false, error:'Invalid Params'}." + }, + { + "name": "per_page", + "type": "integer", + "description": "NOT a page size — a presence flag, and its value is discarded. Sending per_page at ALL (per_page=100, per_page=1, anything non-blank) SHRINKS the page to 10 rows; omitting it entirely gives 30 rows, the maximum this endpoint can return. Omit it whenever you want the fewest round trips." + }, + { + "name": "q", + "type": "object", + "description": "Optional wrapper for the same filters, e.g. q[issue_id]=BUG-123&q[issue_type]=jira. When q is present the flat top-level filters are IGNORED and only q's contents are read, and they reach the search service in a different (query_object) shape. It must be a nested param hash — a JSON-encoded STRING here raises and 500s. p and per_page are still read from the top level. Prefer the flat form.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "example": "BUG-123", + "description": "Same as the top-level issue_id, and it still needs issue_type beside it to filter anything." + }, + { + "name": "issue_type", + "type": "string", + "example": "jira", + "description": "Same as the top-level issue_type, and equally required for the issue filter to apply." + }, + { + "name": "priority", + "type": "string", + "example": "high", + "description": "Same as the top-level priority: accepted and ignored." + }, + { + "name": "compact", + "type": "string", + "example": "false", + "description": "Same string-literal 'false' switch as the top-level compact." + }, + { + "name": "include_relations", + "type": "string", + "description": "Overwritten from compact exactly as at the top level; it does nothing." + } + ] + } + ], + "intent": "Find test results anywhere in the account without knowing the project, run or case — above all, list every result linked to a tracker issue (a Jira/ADO ticket key) to answer 'what testing covers this bug?', optionally narrowed by case priority", + "guidance": [ + "This is NOT a project read and it takes no project. The action replaces whatever project context arrived with a placeholder before calling the search service, so the search spans every project in the authenticated user's group (account) and is scoped by nothing but that group and your filters. That placeholder also skips the per-project access check, so rows from projects you would not otherwise be able to open can appear here. Passing a project_id query param cannot narrow it — a numeric one you lack access to only makes the call 404 before the search runs, and a PR-NNN one is ignored outright.", + "The ONLY filter that works is issue_id AND issue_type sent together. Either one alone is dropped, and `priority` is accepted but never applied — none of them produce an error, they just widen the call back to an account-wide dump of recent results. If a filtered call returns suspiciously many rows spanning many projects, that is what happened.", + "Use this when the only handle you have is an issue key, or when you need results across projects and runs. When you already know project + run + case and want that case's result history, use the per-case reads (list_test_results_for_test_case_by_integer_id / list_test_results_for_test_case) instead — they are scoped, richer, and cannot silently span the account.", + "Failures are swallowed. An upstream search error is logged and the request STILL answers HTTP 200 with success:true, an empty test_results array and info:null. An empty list is therefore not evidence that nothing matched — treat info:null as 'the search did not run' and retry rather than reporting zero results.", + "Page with `p`, never `page`, and do NOT send per_page: its value is discarded and its mere presence cuts the page from 30 rows to 10. 30 is the hard ceiling here; there is no way to ask for more, so a large issue's results must be walked page by page.", + "Row shape changes with compact. With compact=false you get test_run and test_case objects, and `test_run_id` is then overwritten with the run's TR-NNN identifier string. Without it there are no test_run/test_case objects and test_run_id / test_case_id come from the result mapping as integers — so the same field name carries a different type depending on one query flag. Never assume test_run_id is numeric.", + "Read the outcome from result_status.field_value, which is display case ('Passed'/'Failed'/'Blocked'). The flat `status` beside it is the lowercase internal token and is null on almost every row — it is the more obvious field name and the wrong one to reason over.", + "Several declared fields are structurally empty on this endpoint because the account-wide search does not load them: attachments is ALWAYS null (never an array - guard before iterating), updated_by is ALWAYS null, and started_at, finished_at, time_elapsed and created_by_imported are always null. The per-case and single-result reads do populate them.", + "Rows carry no custom fields and no step results, and `links.self` appears only for rows that came back with a project_id. To read a result's custom fields, follow up with the project-scoped single-result reads using the row's project_id and id.", + "Rows here are NOT the same shape as the project-scoped result reads — a different shape entirely, not a subset. This one carries author, backtrace, links and a full result_status object, plus updated_by and attachments keys that are always null here; the v2 per-case read carries created_by, custom_fields, dataset, step_result, urls and a result_status cut down to field_value alone. Do not write one parser for both.", + "issue_id and issue_type are always forwarded even when absent, so calling with no filters at all is a legal account-wide dump of recent results — expensive and rarely what you want." + ], + "returns": [ + "success", + "test_results", + "info", + "id", + "author", + "updated_by", + "status", + "result_status", + "description", + "error_description", + "backtrace", + "failure_type", + "log_type", + "expanded", + "started_at", + "finished_at", + "time_elapsed", + "created_at", + "updated_at", + "created_by_imported", + "attachments", + "issues", + "configuration", + "test_case", + "test_run", + "test_case_id", + "test_run_id", + "test_run_results_mapping_id", + "project_id", + "links" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestResultSearchResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_test_case_comments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", + "mode": "read", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "pattern": "^(TR-)?[0-9]+$", + "example": "TR-14", + "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The test case's NUMERIC id, not its TC-NNN identifier — the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." + } + ], + "query": [ + { + "name": "mappingId", + "type": "integer", + "minimum": 1, + "example": 90731, + "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "example": 1, + "description": "PAGE NUMBER — the parameter really is named `p`; `page=2` is ignored and you get page 1 again. Page size is fixed at 30 and cannot be changed. A page past the end does NOT error or come back empty: the server serves the LAST page again, so stop when info.next is null rather than when a page is empty. 0 or a non-number is a 400." + } + ], + "intent": "List the comments left on a test case inside a specific test run — use this to read the discussion or review feedback on that execution, find who said what and when, or discover a comment's id", + "guidance": [ + "Needs the test-case VIEW permission (a 403 'This action is not permitted for the user' otherwise) AND a paid plan: on a free plan the call still returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} — check `success` in the body, not the status code.", + "Three ids in the path, two conventions: project_id and test_case_id are plain INTEGERS (a TC-NNN in the case slot never matches the route), while test_run_id takes the TR-NNN identifier or the bare integer. Get the numeric case id from the `id` field of a v1 test case read or of the v1 run test-case listing.", + "A run/case pair that does not resolve is a 400 with message 'TRTC not found for given testrun_id:…' — not a 404 — and an automation (Automate/Observability) run is a 400 'Can't comment on automation test'. A test case that does not exist in your group is a bare {success:false} 404.", + "Comments are threaded per run/case/configuration cell. If the case is mapped to more than one configuration in the run, pass mappingId to pick the cell; without it the server resolves a cell itself and you may read a different thread than the UI shows.", + "Newest first (by id), 30 per page, no page-size control. `info.count` is the total across pages; walk `p` until `info.next` is null — asking for a page beyond the end silently returns the last page again, so an 'empty page' loop never terminates.", + "Each row's `comment` is HTML with @mentions already rendered to display names; `user_id` is an OBJECT {id, full_name, email} whose `id` is the internal TM user id (null for imported comments, which show the original author's name). `edited` is true when the comment was changed after posting.", + "This is the only way to discover comment ids. Reading comments still works on an archived test case; posting does not.", + "Comments on the same test case outside a run are a separate thread with its own capability - nothing posted there appears in this listing, and nothing posted here appears there. Test-plan comments are a further separate surface.", + "Rows also carry group_id, project_id, parent_id, entity_id and entity_type. On this surface entity_type is always TRTC and entity_id identifies the run/case mapping cell, not the test case and not the run; parent_id is always null because replies are not threaded. review_status never appears on this surface - only comments recorded as part of a review verdict carry it, and nothing writes one against a run." + ], + "returns": [ + "comments", + "success", + "info", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "comment", + "edited", + "is_editable" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "CommentsListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_run_test_case_comment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", + "mode": "write", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "pattern": "^(TR-)?[0-9]+$", + "example": "TR-14", + "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 4821, + "description": "The test case's NUMERIC id, not its TC-NNN identifier — the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." + } + ], + "query": [ + { + "name": "mappingId", + "type": "integer", + "minimum": 1, + "example": 90731, + "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." + } + ], + "body": [ + { + "name": "comment", + "type": "string", + "required": true, + "json_path": "/comment/comment", + "example": "

Failed on Safari 17 only — retest after the fix for TM-1234 lands.

", + "description": "Comment body as HTML (rich text), nested as {\"comment\": {\"comment\": \"...\"}}. Whitespace-only or tag-only content is a 400 'Please enter valid comment' (an counts). Any other key inside the `comment` object is a 400 'Invalid JSON data'. To @mention: Display Name with the BrowserStack user id — all three attributes, as the product's editor writes them; without data-mention the post succeeds but the name shows as plain text, not a tag. Someone outside the group is a 403." + } + ], + "intent": "Post a comment on a test case within a test run — use this to leave review feedback on an execution, ask the run owner a question, record why a result was marked as it was, or @mention a colleague", + "guidance": [ + "Send the body as application/json with the nested shape {\"comment\": {\"comment\": \"

text

\"}} — the field is named comment inside an object also named comment. Without a JSON content type the schema check is skipped and the call fails later with a less helpful 400.", + "Needs the test-case VIEW permission (not a write permission) and a paid plan. Below a paid plan the call returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} and nothing is posted — check `success` in the body.", + "SIDE EFFECT: the run owner is notified of every new comment, and every @mentioned user gets a mention alert, immediately and outside this API. The author never alerts themselves. Confirm wording with the user before posting on their behalf.", + "Cannot post on an automation run (400 'Can't comment on automation test') or on an ARCHIVED test case (bare {success:false} 400 — reading still works). A run/case pair that does not resolve is a 400 'TRTC not found for given testrun_id:…', not a 404.", + "If the case is mapped to several configurations in the run, pass mappingId so the comment lands on the intended cell; each cell has its own thread and the listing for a different mappingId will not show it.", + "@mentions must be the span markup carrying data-lexical-mention, data-mention (the display name) and data-id (the person's BrowserStack user id). Leaving out data-mention is NOT refused: the post returns 200 and the span is stored, but it shows as plain text and no tag appears. A mention with no id is a 403 'Missing User Id in comment', one for a user not in this group is a 403 'User does not belong to this project', and either fails the whole post. Plain '@name' text mentions nobody.", + "Link markup in the HTML is normalised server-side and mention spans are rewritten, so the stored `comment` returned in the response can differ from what you sent; use the returned `id` for any follow-up.", + "Success is HTTP 200 (not 201) with the new comment under data.comment. Recoverable: the author can edit or delete the comment afterwards in the product (delete hides it rather than destroying it) — those operations are not part of this pass.", + "A comment posted here attaches to the run/case cell, not to the test case itself: entity_type comes back as TRTC and entity_id is the mapping cell's id, never the test case id or the run id. It is visible only in the in-run comment listing and never in the test case's own comment thread, which is a separate capability. Test-plan comments are a further separate surface.", + "The returned comment never carries review_status on this surface - nothing writes a review verdict onto a comment attached to a run. On a freshly created comment edited is always false, is_editable is always true, and parent_id is always null because replies are not threaded." + ], + "returns": [ + "data", + "success", + "comment", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "edited", + "is_editable" + ], + "responses": { + "200": { + "$response": "CommentResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "export_test_run_csv", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/download", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." + } + ], + "body": [ + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv" + ], + "example": "csv", + "description": "Export format. Only 'csv' is accepted; any other value is a 422 'Only CSV file type is supported.' before anything is queued." + }, + { + "name": "required", + "type": "object", + "description": "Optional column selection keyed by entity: {test_run_test_case:{fields:[..]}, test_run:{fields:[..]}, test_result:{fields:[..]}}. Omit to export EVERY supported column. `fields` may be an array or comma-separated string; 'all' expands to every supported column. Unknown column names are NOT rejected: the call still returns 200 and the export fails later on the realtime channel.", + "fields": [ + { + "name": "test_run_test_case", + "type": "object", + "description": "{fields:[...]} — supported: id, title, priority, test_case_status, owner, folder_id, folder_path, case_type, automation_status, description, preconditions, template, steps, issues, tags, created_at, created_by, updated_at, updated_by, attachments, result_status, assignee, configurations, dataset_values, project, estimated_duration_seconds." + }, + { + "name": "test_run", + "type": "object", + "description": "{fields:[...]} — supported: id, title, created_at, created_by, state, tags, owner, type, updated_by, updated_at, requirements." + }, + { + "name": "test_result", + "type": "object", + "description": "{fields:[...]} — supported: attachments, defects, updated_at, updated_by, description, time_elapsed." + } + ] + } + ], + "intent": "Start a CSV export of a test run's cases and their latest results — use this when the user wants the run's contents as a downloadable spreadsheet file rather than data returned inline", + "guidance": [ + "The response carries no file and no download link. It only confirms that a background job was queued and returns a `channel` uuid. The CSV is built asynchronously, stored, and its time-limited download link is pushed to the requesting user's web session over a realtime channel; that push carries the same uuid as `id` so the UI can match it. Nothing in this HTTP API returns the link afterwards, so tell the user the file will appear in their Test Management browser session (download notification) rather than promising to hand it back.", + "A 200 means 'queued', not 'exported'. Failures inside the job (bad column names, upstream errors) surface only as a failure message on that realtime channel, never as an HTTP error, so this call cannot be polled for completion.", + "The whole run is exported: no filter or search criteria are honoured, and the export is capped at 20,000 rows fetched 300 at a time — a larger run is silently truncated at 20,000 rows.", + "Column selection defaults to ALL supported columns when `required` is omitted. Each test-case step after the first becomes its own extra row under the case, so row counts in the file exceed the case count.", + "This is a write only in the sense that it creates a stored export artifact; it does not change the run. It carries no run-specific permission check in this service (the action is absent from the permissions map), so any user who can reach the project can trigger it; plan or product-access gates still apply as a 403.", + "Works for automation runs as well as manual ones (the run lookup deliberately ignores run type). Closed runs export too.", + "Unlike the newer run endpoints, project_id must be the plain integer; the run may be given as TR-14 or its integer id." + ], + "returns": [ + "channel", + "success" + ], + "responses": { + "200": { + "$response": "DownloadReportResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Project not found, or the test run identifier resolves to no run in this project ('Test Run ID is invalid. Please enter valid Test Run ID and try again' / 'Test run not found.').", + "schema": { + "$schema": "ErrorResponse" + } + }, + "422": { + "description": "file_type was not 'csv': {success:false, message:'Only CSV file type is supported.'}", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "get_test_run_progress", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/progress", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 1, + "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." + }, + { + "name": "test_run_id", + "type": "string", + "required": true, + "example": "TR-14", + "pattern": "^(TR-)?[0-9]+$", + "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." + } + ], + "query": [ + { + "name": "configuration_id", + "type": "integer", + "minimum": 1, + "example": 101, + "description": "Narrow to ONE configuration of the run. Changes the response shape: with it you get a single `overall_progress_by_status_id` breakdown (an empty object when there is no data); without it you get a paginated `configurations` array, each row carrying its own breakdown. Configuration ids come from the run detail's `configurations` list." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number for the configurations list, and the parameter really is named `p` (`page` is ignored). Only meaningful without configuration_id. 0, negative or non-numeric is a 400 'Invalid Params'. Page size is fixed at 30 and cannot be changed — there is no per-page parameter on this endpoint." + } + ], + "intent": "Get pass/fail/untested progress of a test run broken down by result status — per configuration (browser/device) across the whole run, or for one configuration — to answer 'how far along is this run on each configuration?'", + "guidance": [ + "Two response shapes from one endpoint, chosen by configuration_id. Without it: {success, configurations:[{id, name, ..., overall_progress_by_status_id}], info}. With it: {success, overall_progress_by_status_id}. Read the shape you asked for.", + "overall_progress_by_status_id is a map keyed by result-status id (as a string), each value {count, result_status:{id, field_value, internal_name, field_name, colour}}. Status ids are project-specific — use result_status.internal_name (e.g. 'passed') to compare across projects, not the numeric key.", + "A configuration row without any executed results may carry no overall_progress_by_status_id key at all, and the single-configuration form returns {} when there is no breakdown. Treat a missing or empty breakdown as 'nothing recorded', not as an error.", + "Paging: pass `p`; the page is always 30 configurations and there is no way to ask for more per page. When `p` is omitted no paging hints are sent upstream and the default page size is decided by the backing service, so send p=1 explicitly when you intend to walk pages. `info` is the backing service's paging block, passed through unchanged.", + "For a run-wide total (not per configuration) use the single-run detail instead — its overall_progress already aggregates across configurations.", + "Any upstream failure inside the calculation is reported as a 500 {success:false, message:'Failed to calculate configuration progress: ...'}, not as a 404 or 400. An unknown run identifier is still a 404 because the run is resolved before the calculation.", + "No run-specific permission is checked in this service (the action is absent from the permissions map); it is also one of the endpoints reachable with a public share-link token for a run." + ], + "returns": [ + "success", + "configurations", + "info", + "overall_progress_by_status_id" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "TestRunConfigurationProgressResponse" + }, + "400": { + "description": "`p` was 0, negative or non-numeric: {success:false, error:'Invalid Params'}", + "schema": { + "$schema": "ErrorResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "Project not found, or the test run identifier resolves to no run in this project.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "description": "Progress calculation failed (including upstream errors): {success:false, message:'Failed to calculate configuration progress: '}", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "generate_test_case_automation", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/lcnc", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer test case id (not TC-NNN). The case is loaded from the entity store first, so an unknown id is a 404 before anything is submitted." + } + ], + "body": [ + { + "name": "version", + "type": "string", + "values": [ + "v1", + "v2" + ], + "default": "v1", + "description": "Payload flavour forwarded to the automation service. Anything other than 'v2' takes the v1 path and forwards `steps`; 'v2' forwards `test_case_details` + `tags` (+ preTestConfig) instead and REQUIRES test_case_details — with version=v2 and no test_case_details the request 500s." + }, + { + "name": "test_name", + "type": "string", + "example": "Login with valid credentials", + "description": "Display name for the generated automation test. Forwarded verbatim; not validated here." + }, + { + "name": "base_url", + "type": "string", + "example": "https://staging.example.com", + "description": "URL the generated automation starts from. Forwarded verbatim (also lifted into batch defaults on the bulk-pipeline path); not validated here." + }, + { + "name": "credentials", + "type": "string", + "description": "Login credentials the automation may use, forwarded verbatim to the automation service. Not stored in Test Management." + }, + { + "name": "local_enabled", + "type": "boolean", + "default": false, + "description": "Run against a BrowserStack Local tunnel. Defaults to false when omitted." + }, + { + "name": "lcnc_link", + "type": "string", + "description": "Existing automation build link (the case's metadata.lcnc_build_map link) when RE-automating. Its mere presence switches the submission from mode 'new' to 'reautomate'; omit it for a first-time automation." + }, + { + "name": "steps", + "type": "array", + "description": "v1 flow only: the test steps as plain strings. Ignored when version=v2." + }, + { + "name": "test_case_details", + "type": "object", + "fields": [ + { + "name": "name", + "type": "string" + }, + { + "name": "identifier", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "preconditions", + "type": "string" + }, + { + "name": "test_case_steps", + "type": "array" + } + ], + "description": "v2 flow only: {name, identifier, description, preconditions, test_case_steps[]} of the case, echoed to the automation service (it does not re-read the case). Required when version=v2 (missing → 500); ignored otherwise." + }, + { + "name": "feedback", + "type": "string", + "description": "v2 flow only: free-text feedback on a previous generation, forwarded inside test_case_details." + }, + { + "name": "score", + "type": "number", + "description": "v2 flow only: rating of a previous generation, forwarded inside test_case_details." + }, + { + "name": "tags", + "type": "array", + "description": "v2 flow only: tags forwarded with the payload. Elements are strings." + }, + { + "name": "preTestConfig", + "type": "object", + "description": "v2 flow only: pre-test configuration object (setup steps/profile) forwarded verbatim when present." + }, + { + "name": "test_case_folder_id", + "type": "integer", + "description": "Folder id of the case. Only read on the bulk-pipeline submission path (environment flag); harmless otherwise." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder id of the case; when sent the folder is loaded for an ownership check and passed to the case lookup. Optional; an unknown folder fails the call." + } + ], + "intent": "Kick off no-code (LCNC / Low Code Automation) generation of an automated test for ONE test case — use this when the user wants a manual case automated or re-automated; it queues work in the automation service and returns immediately", + "guidance": [ + "Submission is handed to a separate automation service, and that service's own failures are not translated before they reach you: they arrive as 422 with the service's raw message copied into the `message` field as text. A 422 here does not mean your request was invalid, and the text in it is not addressed to you — do not retry against it field by field.", + "422 is the status used for that relayed failure regardless of what actually went wrong upstream, so it carries no information about whether the problem is transient. Treat it as 'submission did not happen' and check the case before retrying.", + "Nothing is recorded when submission fails: the case's automation status and its timestamps are untouched, so a failed attempt leaves the case immediately resubmittable rather than locked. Re-reading the case is how you tell a failed submission from a queued one.", + "A queued submission cannot be followed to completion through this index. Re-reading the case exposes its automation status, but no capability here exposes the resulting build link, so completion can only be confirmed in the product." + ], + "returns": [ + "status", + "body" + ], + "responses": { + "422": { + "description": "The automation service refused or failed the submission and its response was relayed as-is: `{\"success\": false, \"message\": }`. The string is frequently itself structured text rather than a sentence. This status is used for every upstream failure, not only for invalid input, and it is not a validation result for the body you sent. Nothing is queued and the case is unchanged.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Always false." + }, + "message": { + "type": "string", + "description": "The automation service's own response, relayed verbatim as text. Not a caller-facing message; do not parse or display it." + } + } + } + } + }, + "disabled": true + }, + { + "name": "list_binned_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/bin", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number. `page` is accepted as an alias and WINS if both are sent. Only `p` is validated (non-positive / non-numeric → 400 'Invalid Params'); a bad `page` is forwarded as-is." + }, + { + "name": "limit", + "type": "integer", + "minimum": 1, + "description": "Rows per page, forwarded unchanged to the backing service, which owns the default and the ceiling (its client docs say default 25, max 100; not enforced in this codebase — see open questions). Ask for what you need and trust metadata.page_size for what you got." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match on test case title or identifier." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Only cases that lived in this folder when they were binned." + }, + { + "name": "moved_to_bin_by", + "type": "string", + "description": "Filter by who moved the case to the bin, as BrowserStack user id(s), comma-separated. `updated_by` is an accepted alias (moved_to_bin_by wins). These are browserstack_user_ids, NOT the internal user ids the review endpoints take." + }, + { + "name": "owner", + "type": "string", + "description": "Filter by case owner as BrowserStack user id(s), comma-separated." + }, + { + "name": "date_from", + "type": "string", + "example": "2026-09-01", + "description": "Binned on or after this ISO 8601 date." + }, + { + "name": "date_to", + "type": "string", + "example": "2026-09-16", + "description": "Binned on or before this ISO 8601 date." + }, + { + "name": "sort_by", + "type": "string", + "values": [ + "trashed_at", + "updated_at", + "name", + "identifier" + ], + "default": "trashed_at", + "description": "Sort column; default is when the case was binned." + }, + { + "name": "sort_order", + "type": "string", + "values": [ + "ASC", + "DESC" + ], + "default": "DESC", + "description": "Sort direction; upper-case only." + }, + { + "name": "status", + "type": "string", + "description": "Filter by status as numeric status ID(s), comma-separated — despite the UI-style name this takes ids, not display values (resolve them via get_system_field_values). `status_id` is an accepted alias (status wins)." + }, + { + "name": "priority", + "type": "string", + "description": "Filter by priority as numeric priority ID(s), comma-separated, not display values. `priority_id` is an accepted alias (priority wins)." + }, + { + "name": "type", + "type": "string", + "description": "Filter by case type as numeric category ID(s), comma-separated, not display values. `category_id` is an accepted alias (type wins)." + }, + { + "name": "automation_state", + "type": "string", + "description": "Filter by automation state as numeric ID(s), comma-separated. `automation_state_id` is an accepted alias (automation_state wins)." + }, + { + "name": "tags", + "type": "string", + "description": "Filter by tag name(s), comma-separated." + } + ], + "intent": "List the test cases sitting in a project's recycle bin (moved to bin / soft-deleted, awaiting purge) with who binned them and when — use this to find a case to restore or inspect; for just 'how many are in the bin' use count_binned_test_cases instead", + "guidance": [ + "Rows here are the WIDE v1 case shape, not the trimmed v2 one: `priority` and `status` arrive as objects carrying `field_value`, `colour`, `field_name` and `value_category` rather than as plain strings. Code written against a v2 case listing will read them as objects and compare wrongly.", + "Feature-gated: when the project/user lacks the Move to Bin or New Project Settings flags the call is 403 {success:false, error:'... is not enabled for this user'} — that is a feature gap, not a permission problem. Needs the tc_view permission under RBAC.", + "Paging metadata is under `metadata` {total, page, page_size, total_pages}, NOT the `info` {count, next, prev} envelope other v1 listings use — there is no `next`; stop when page >= total_pages.", + "Rows carry the integer `id`, the TC-NNN `identifier`, `trashed_at`, `days_remaining` until automatic purge, and `folder_name`/`folder_path` of where the case was. `updated_by` and `owner` are raw BrowserStack user ids; the resolved people are in `updator_details` (who binned it) and `owner_details` {id, browserstack_user_id, full_name, email}.", + "priority and status come back as small objects {id, field_value, internal_name} (status also has colour), not strings.", + "Only the filters listed exist. Binned cases are excluded from every other test case listing and search, so this is the only way to see them.", + "A backing-service failure is rendered as {success:false} with that service's status and no message." + ], + "returns": [ + "success", + "test_cases", + "metadata", + "id", + "identifier", + "name", + "priority", + "status", + "owner", + "owner_details", + "tags", + "issues", + "folder_name", + "folder_path", + "updated_by", + "updator_details", + "trashed_at", + "days_remaining", + "total", + "page", + "page_size", + "total_pages", + "template", + "created_by", + "creator_details" + ], + "paginated": true, + "responses": { + "200": { + "$response": "BinnedTestCasesListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "count_binned_test_cases", + "method": "GET", + "path": "/api/v1/projects/{project_id}/test-cases/bin-count", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "intent": "Return just the number of test cases currently in a project's recycle bin — the cheap way to answer 'how many binned/deleted cases are there?' without pulling any rows into context (use list_binned_test_cases when you need the cases themselves)", + "guidance": [ + "Same feature gate as the bin listing: 403 {success:false, error:'... is not enabled for this user'} when Move to Bin / New Project Settings are off for this user. Needs tc_view under RBAC.", + "Counts the whole bin; it takes no filters. For a filtered count, call the listing with the filter and read metadata.total.", + "A backing-service failure is {success:false} with that service's status and no count." + ], + "returns": [ + "success", + "count" + ], + "responses": { + "200": { + "description": "Number of test cases in the bin.", + "schema": { + "type": "object", + "required": [ + "success", + "count" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "count": { + "type": "integer", + "example": 12 + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "submit_test_cases_for_review", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-review", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the test cases to send for review. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", + "type": "object", + "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." + } + ], + "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." + }, + { + "name": "q", + "type": "object", + "description": "SCOPE for select_all — the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project — verify the count before sending." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." + }, + { + "name": "reviewers", + "type": "array", + "json_path": "/test_case/reviewers", + "example": [ + 4821 + ], + "description": "INTERNAL Test Management user ids of the reviewers (the `id` from search_project_users / list_project_users — not browserstack_user_id, not email). Replaces the current reviewer set. Each id must belong to the caller's account (else 422 reviewer_not_found) and, when the project keeps an allowed-reviewer list, be on it (else 422). Omit to change status only. Elements are integers." + }, + { + "name": "review_status", + "type": "string", + "json_path": "/test_case/review_status", + "values": [ + "in_review" + ], + "example": "in_review", + "description": "The review state to move the selected cases into. Send 'in_review' to submit for review; it is forwarded to the backing store as a replace. Verdicts (approved/rejected/changes_requested) are NOT applied here — use apply_test_case_review_verdict." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Note to the reviewers. It is only carried in the reviewer notification when reviewers are given — it is NOT stored as a comment on the cases." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "format": "uuid", + "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use — a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." + } + ], + "intent": "Send one or many test cases for review — set them to 'in review' and assign the reviewers who must approve them; use it for a single case (ids with one element) or a whole selection/folder at once", + "guidance": [ + "Needs the project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 {success:false, message:'Feature not available'}). No RBAC permission entry exists for this action, so any authenticated project member with those two passes.", + "Part 11 e-signature: when the project setting part_11_reauth_send_for_review is 'true', the caller must first re-authenticate (email + password at the reauth endpoint, not in this index) and pass the returned reauth_token; missing/expired/reused → 401 {success:false, error_code:'reauth_required'} and the block is written to the audit log.", + "Selections over 100 cases run asynchronously: the response is {success:true, async:true, unique_id} with NO testCases — the change lands in the background and you re-read the cases to confirm. Under the threshold the updated cases are returned inline.", + "Reviewer ids are the internal Test Management user id; the same people are addressed by browserstack_user_id in the bin listing filters, so do not carry ids across. A reviewer who is not on the project's allowed-reviewer list is refused (422) for the whole call — nothing is partially applied.", + "Cases locked by another bulk operation are refused with 400 {success:false, message:'TestCases in progress with another operation'}; retry after it finishes.", + "Every other failure collapses to 400 {errors:'An error occurred during bulk review'} — including backing-service errors — so a 400 is not necessarily a bad request.", + "Recoverable: review state is a tracked status; a reviewer verdict or a later edit moves the case on, and each case keeps version history.", + "Sending reviewers notifies them (in-app alert / email) immediately." + ], + "returns": [ + "success", + "testCases", + "async", + "unique_id", + "id", + "identifier", + "name", + "review_status", + "reviewers", + "review_sent_date", + "owner", + "status", + "priority", + "test_case_folder_id" + ], + "responses": { + "200": { + "$response": "TestCaseReviewOutcomeResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "ReauthRequired" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "apply_test_case_review_verdict", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/bulk-approve", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the test cases to give a verdict on. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", + "type": "object", + "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." + } + ], + "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." + }, + { + "name": "q", + "type": "object", + "description": "SCOPE for select_all — the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project — verify the count before sending." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." + }, + { + "name": "review_status", + "type": "string", + "required": true, + "json_path": "/test_case/review_status", + "values": [ + "approved", + "rejected", + "changes_requested" + ], + "example": "approved", + "description": "The verdict: 'approved' passes the case, 'rejected' fails it, 'changes_requested' sends it back to the author for edits. Applied to every selected case; there is no per-case verdict in one call." + }, + { + "name": "comment", + "type": "string", + "json_path": "/test_case/comment", + "description": "Review comment. Unlike the submit-for-review comment this one IS stored: one comment is created on each selected case, tagged with the verdict, and echoed back under `comment`. Strongly recommended for rejected/changes_requested." + }, + { + "name": "notify", + "type": "boolean", + "json_path": "/test_case/notify", + "default": false, + "description": "Compared as the string 'true'. Also email the case owners the verdict (in-app personal alerts go out regardless)." + }, + { + "name": "reauth_token", + "type": "string", + "json_path": "/test_case/reauth_token", + "format": "uuid", + "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use — a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." + } + ], + "intent": "Record a reviewer's verdict on test cases that are under review — approve them, reject them, or request changes — for one case or a whole selection in a single call", + "guidance": [ + "Every selected case must currently be 'in_review'. If any is not (the author edited it and it dropped back to pending, or it was already decided) the WHOLE call is refused with 422 {success:false, error_code:'invalid_review_state'} and nothing is applied — re-read the cases and resubmit only the ones still under review.", + "Needs project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 'Feature not available'). No RBAC permission entry exists for this action beyond project access.", + "Part 11 e-signature: when project setting part_11_reauth is 'true', every verdict value requires a fresh reauth_token from the re-authentication step (not in this index); missing/expired/reused → 401 {success:false, error_code:'reauth_required'}.", + "Selections over 100 cases are applied asynchronously: {success:true, async:true, unique_id} with no testCases; re-read to confirm. Below that the updated cases come back inline, plus `comment` (the created comment objects) when a comment was sent.", + "Cases locked by another bulk operation → 400 {success:false, message:'TestCases in progress with another operation'}.", + "When the backing store rejects the verdict (e.g. the audit-log write fails) its status is passed through with {success:false, error_code, errors}; the verdict was not applied. Any other failure is a generic 400 {success:false, errors:'An error occurred during bulk approval'}.", + "Recoverable: a verdict is a tracked review status — the author can edit and resubmit, a reviewer can decide again, and every case keeps version history. Nothing is deleted.", + "Side effects outside the data: owners get an in-app alert for every verdict, an email too when notify is 'true', and Part 11 projects write an audit-log row." + ], + "returns": [ + "success", + "testCases", + "comment", + "async", + "unique_id", + "id", + "identifier", + "name", + "review_status", + "reviewers", + "owner", + "status", + "priority", + "test_case_folder_id" + ], + "responses": { + "200": { + "$response": "TestCaseReviewOutcomeResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "ReauthRequired" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "Verdict refused: a selected case is no longer under review (error_code invalid_review_state), or the account plan lacks the feature.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error_code": { + "type": "string", + "example": "invalid_review_state" + }, + "error": { + "type": "string", + "example": "Test case in Pending state cannot be approved. It was modified and moved back to Pending — refresh to review the latest changes." + }, + "errors": { + "type": "string" + }, + "message": { + "type": "string", + "description": "Present instead of error/errors on the plan gate ('Feature not available')." + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "validate_bdd_export_selection", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-cases/exports/bdd/validate", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "minimum": 1, + "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "json_path": "/test_case/ids", + "example": [ + 1201, + 1202 + ], + "description": "Integer ids of the cases you intend to export. Non-BDD cases among them are silently skipped by the validator. Send ONE of ids, select_all or folders." + }, + { + "name": "select_all", + "type": "boolean", + "json_path": "/test_case/select_all", + "default": false, + "description": "Validate every BDD case in scope (q + folder_id, or the whole project) minus de_selected_ids. Compared as the string 'true'." + }, + { + "name": "de_selected_ids", + "type": "array", + "json_path": "/test_case/de_selected_ids", + "description": "Integer ids to exclude from a select_all scope." + }, + { + "name": "folders", + "type": "object", + "json_path": "/test_case/folders", + "fields": [ + { + "name": "", + "type": "object", + "description": "{select_all: bool, ids: [int], de_selected_ids: [int]} for that folder." + } + ], + "description": "Cross-folder selection keyed by folder id; the union is validated. Takes precedence over ids/select_all. An empty map validates nothing (400 'No BDD Test Case(s) selected.')." + }, + { + "name": "q", + "type": "object", + "description": "Filter scope for select_all — same filter object as the test-case search, sent as a sibling of test_case. Only read when select_all is true; an unknown key is silently dropped (scope widens)." + }, + { + "name": "folder_id", + "type": "integer", + "description": "Folder to scope a select_all validation to. Ignored for explicit ids." + } + ], + "intent": "Check, before exporting test cases to a BDD feature file, that every selected BDD case shares the same Feature and Background text so they can be merged into one file — returns a pass/fail verdict and message, nothing is exported or changed", + "guidance": [ + "Pure check despite the POST: it only reads the selected cases' steps and compares them; no export job, file or record is created. Run it before the real export when the user wants a single merged feature file.", + "Pass and fail share ONE body shape {success, message}: HTTP 200 + success:true on pass, HTTP 400 + success:false on any failure. Read `message` to tell the failures apart — 'No BDD Test Case(s) selected.' means the selection contained no BDD-template cases at all.", + "Hard cap: a selection resolving to more than 300 BDD cases is refused with 400, but with the MISLEADING message 'Validation failed. Selected test cases belong to different features or backgrounds.' — the same text as a genuine mismatch. Check the selection size yourself before trusting that message.", + "Only BDD-template cases are considered: text/step cases in the selection are filtered out silently and a pass says nothing about them.", + "Comparison is exact after trimming whitespace, on the Feature and Background of each case's FIRST step only.", + "No RBAC permission is attached to this action; project access and authentication suffice." + ], + "returns": [ + "success", + "message" + ], + "responses": { + "200": { + "$response": "BddExportValidationResponse" + }, + "400": { + "$response": "BddExportValidationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "create_test_case_comment", + "method": "POST", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", + "mode": "write", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." + } + ], + "body": [ + { + "name": "comment", + "type": "string", + "required": true, + "json_path": "/comment/comment", + "example": "Checked against the latest build - step 3 still fails on Safari.", + "description": "The comment text, as rich text (HTML); plain text is accepted and kept as sent. Content that is only whitespace or empty tags is rejected as invalid, though an image on its own counts as content. To mention a colleague, send the span exactly as the product's own editor writes it: Display Name, with data-id set to their BrowserStack user id. All three attributes are needed: without data-mention the post still succeeds and the span is stored, but it shows as plain text rather than a tag. Mentioning someone outside the account is refused." + } + ], + "intent": "Post a comment on a test case.", + "guidance": [ + "The comment is attributed to the calling user and the case owner is notified the moment it lands; anyone mentioned in the text is notified separately. Those messages leave the product and cannot be recalled, so confirm the wording before sending.", + "The author can edit or delete their own comment afterwards - each is a separate operation - which is what makes this recoverable; an edit replaces the earlier text and keeps no previous version, and a delete leaves no self-serve way back.", + "Only the author can change a comment, so posting on someone's behalf leaves them unable to correct it.", + "Commenting on an archived test case is refused; restore the case first.", + "This is a paid-plan feature. On a plan without it the call still succeeds at the transport level and reports in the payload that the feature is unavailable, so check the success flag rather than assuming the comment landed.", + "Comments are flat: every one is posted against the case itself, and there is no way to reply beneath an existing comment.", + "Success is HTTP 200, not 201, and the new comment comes back one level down, inside a data envelope that holds a success flag alongside the comment object. Take the comment's id from there for any later edit or delete.", + "The returned comment carries id, user_id, group_id, project_id, parent_id, entity_id, entity_type, created_at, comment, edited and is_editable. user_id is the AUTHOR as an object with id, full_name and email rather than a bare id, and that id is the internal user id, not the BrowserStack one. parent_id is always null because replies are not threaded.", + "On this surface entity_type is always TestCase and entity_id is the test case's own id, so this thread is distinct from the one on the same case inside a test run: a comment posted here never appears in the in-run listing, and one posted there never appears here.", + "Two fields are fixed on a freshly created comment: edited is always false, and is_editable is always true. review_status is never present on a comment posted this way - it only ever appears on comments recorded as part of a review verdict.", + "Link markup in the body is normalised and mention markup is rewritten before the comment is stored, so the text returned can differ from the text sent; treat the returned text as authoritative." + ], + "returns": [ + "data", + "success", + "comment", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "edited", + "is_editable" + ], + "responses": { + "200": { + "$response": "CommentCreateResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_folder", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "description": "Only the literal value true has any effect. When true, a name that clashes with a sibling (case-insensitive, trimmed) is created as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/folder/name", + "example": "Checkout", + "description": "Folder name. Trimmed; must not be empty after trimming and must be unique among siblings ignoring case, otherwise 400 'Folder with same name exists in this location' (unless auto_rename)." + }, + { + "name": "description", + "type": "string", + "json_path": "/folder/description", + "example": "Payment and cart flows", + "description": "Free-text notes shown on the folder. Use this key, not `notes` (the internal-API key): `notes` and any other unknown key inside `folder` are rejected with 400 'Invalid JSON data'. HTML is sanitised on save." + }, + { + "name": "parent_id", + "type": "integer", + "json_path": "/folder/parent_id", + "example": 12, + "description": "Integer id of the existing folder to nest under. Omit it to create a root folder (null cannot be sent through this transport). An id not in this project returns 404. 0 is not an alias for root." + }, + { + "name": "attachments", + "type": "array", + "json_path": "/folder/attachments", + "description": "Array of integer ids of already-uploaded attachment blobs to attach to the folder. Any invalid id fails the whole create with 422." + } + ], + "intent": "Create a folder, or a sub folder under a parent.", + "guidance": [ + "Resolve the project's PR- identifier first via the projects list; the numeric project id used by v1 capabilities is not accepted here.", + "Send the description as `description`. The `notes` key used by the internal API is rejected, and so is any key this capability does not declare: the body is schema-validated with unknown keys forbidden and the request fails with 400 'Invalid JSON data'.", + "Sibling names must be unique ignoring case and surrounding whitespace; a clash returns 400. auto_rename=true creates ' (1)' with the name lowercased instead, so read the returned name rather than assuming the one you sent.", + "parent_id 0 is not root: omit the field for a root folder. An unknown parent_id returns 404.", + "The response carries the new folder's integer id — carry it forward as parent_id for sub folders, as folder_id when creating test cases, or as the move destination.", + "cases_count and sub_folders_count come back null on creation rather than 0, even though a folder is empty the moment it is made. Read them as 0; every other folder capability reports the same brand-new folder as 0, and the rename and move capabilities return real integers.", + "A folder whose name already exists in the same parent is refused with a 400 and `{success: false, message: ...}` naming the collision — the only body on that path, and the reason a repeated create with a fixed name fails after the first. Vary the name or read the parent's children first." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_test_case", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}/test-cases", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-12", + "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 105, + "description": "Integer id of the folder the case is created in. This is authoritative: a different folder id in the body is ignored. Discover it from the folder listing." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "example": "Login with valid credentials", + "description": "Title of the test case. The only always-required field; comes back as `title` in the response.", + "json_path": "/test_case/name" + }, + { + "name": "description", + "type": "string", + "example": "Verifies the happy-path login flow.", + "description": "Rich-text body. Send plain text or HTML, not a JSON document.", + "json_path": "/test_case/description" + }, + { + "name": "preconditions", + "type": "string", + "example": "User account exists and is verified.", + "json_path": "/test_case/preconditions" + }, + { + "name": "template", + "type": "string", + "values": [ + "test_case_steps", + "test_case_bdd" + ], + "default": "test_case_steps", + "example": "test_case_steps", + "description": "Step style. `test_case_steps` takes `test_case_steps` rows; `test_case_bdd` takes Gherkin in `feature` / `background` / `scenario` and refuses step rows (422). Mixing the two is refused with 422.", + "json_path": "/test_case/template" + }, + { + "name": "template_id", + "type": "integer", + "example": 42, + "description": "Id of a test-case template from list_templates. Omit or send null for the project's default template. An unknown id, or one from another workspace, is refused with 400 rather than falling back to the default. Choose one whose step_type matches `template`.", + "json_path": "/test_case/template_id" + }, + { + "name": "test_case_steps", + "type": "array", + "description": "Ordered step rows for a `test_case_steps` case. `order` is assigned by array position, so any `order` you send is overwritten. Not allowed for `test_case_bdd` (422). A differently named key such as `steps` is dropped and the case is created with NO steps, so verify against `steps` in the response.", + "fields": [ + { + "name": "step", + "type": "string", + "description": "Action the tester performs." + }, + { + "name": "result", + "type": "string", + "description": "Expected result of this step." + }, + { + "name": "test_data", + "type": "string" + }, + { + "name": "shared_step_id", + "type": "integer", + "description": "Reuse a shared step instead of inline text." + }, + { + "name": "shared_step_detail_id", + "type": "integer" + } + ], + "json_path": "/test_case/test_case_steps" + }, + { + "name": "feature", + "type": "string", + "description": "Gherkin Feature text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", + "json_path": "/test_case/feature" + }, + { + "name": "background", + "type": "string", + "description": "Gherkin Background text; optional, BDD cases only (422 otherwise).", + "json_path": "/test_case/background" + }, + { + "name": "scenario", + "type": "string", + "description": "Gherkin Scenario text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", + "json_path": "/test_case/scenario" + }, + { + "name": "owner", + "type": "string", + "example": "jane@acme.com", + "description": "Owner as a workspace user's email (a user id is also accepted). An owner that cannot be resolved silently falls back to the caller — check `owner` in the response.", + "json_path": "/test_case/owner" + }, + { + "name": "status", + "type": "string", + "example": "Active", + "description": "Matched against the project's configured status display values (defaults: Active, Draft, In Review, Rejected, Outdated; a numeric option id also matches). Unknown value → 400 'Invalid default field value'. Omit for the project default. Resolve the live set with get_system_field_values.", + "json_path": "/test_case/status" + }, + { + "name": "priority", + "type": "string", + "example": "High", + "description": "Matched against the project's configured priority display values (defaults: Low, Medium, High, Critical; a numeric option id also matches). Unknown value → 400. Omit for the project default.", + "json_path": "/test_case/priority" + }, + { + "name": "case_type", + "type": "string", + "example": "Functional", + "description": "Matched against the project's configured type display values (defaults include Functional, Regression, Smoke & Sanity, Acceptance, Security, Performance, Usability, Accessibility, Compatibility, Destructive, Automated, Manual, Other). Unknown value → 400. Omit for the project default.", + "json_path": "/test_case/case_type" + }, + { + "name": "automation_status", + "type": "string", + "default": "not_automated", + "example": "not_automated", + "description": "Built-in values: not_automated, automated, automation_not_required, cannot_be_automated, obsolete (matched case-insensitively by internal name or display label). Workspaces that manage their own automation statuses accept their custom labels too. Unknown value → 400.", + "json_path": "/test_case/automation_status" + }, + { + "name": "tags", + "type": "array", + "example": [ + "login", + "smoke" + ], + "description": "Free-text tag names; created if new.", + "json_path": "/test_case/tags" + }, + { + "name": "issues", + "type": "array", + "example": [ + "TES-204" + ], + "description": "Linked issues. Either bare issue keys (treated as Jira) or objects {issue_id, issue_type}. Linking is processed asynchronously after the create returns.", + "fields": [ + { + "name": "issue_id", + "type": "string", + "description": "Issue key, e.g. TES-204." + }, + { + "name": "issue_type", + "type": "string", + "description": "Tracker name, e.g. jira." + } + ], + "json_path": "/test_case/issues" + }, + { + "name": "custom_fields", + "type": "object", + "example": { + "Component": "Checkout", + "Regression candidate": true + }, + "description": "Map of custom-field DISPLAY NAME → value. Dropdown: option label; multi-dropdown: array of labels; user: email; boolean: true/false; date, int, url, text as typed. A name that is not a custom field on this project is SILENTLY SKIPPED (200, value absent); a reserved system-field name (e.g. 'Automation Status') is refused; a value failing its type check fails the whole call with 400.", + "json_path": "/test_case/custom_fields" + }, + { + "name": "identifier", + "type": "string", + "example": "LOGIN-001", + "description": "Custom identifier for the case. Honoured ONLY when the workspace uses project-level custom ids; otherwise ignored. A duplicate is refused with 400 'Test Case ID already exists'.", + "json_path": "/test_case/identifier" + }, + { + "name": "review_status", + "type": "string", + "description": "Review assignment state. Reviewer write: gated on a Team Pro plan and the project's Review & Approve setting when the reviewer API is enabled for the workspace. Omit unless the user asked to put the case in review. Known states include pending and in_review; the accepted set is not enforced here, so an unknown value is refused by the server.", + "json_path": "/test_case/review_status" + }, + { + "name": "reviewers", + "type": "array", + "example": [ + "jane@acme.com" + ], + "description": "Reviewer emails. Requires Team Pro plan and Review & Approve enabled on the project (422/400 otherwise). The owner cannot be a reviewer (422). A non-array value is silently ignored.", + "json_path": "/test_case/reviewers" + }, + { + "name": "send_for_review", + "type": "boolean", + "description": "Moves the new case into review and emails reviewers. Same plan/project gates as `reviewers`. Ambiguous strings like 'yes'/'no' are refused with 422.", + "json_path": "/test_case/send_for_review" + }, + { + "name": "created_at", + "type": "string", + "format": "date-time", + "description": "Backdated creation timestamp for migrations. Honoured only when the workspace has the external-API timestamp feature enabled; otherwise dropped silently.", + "json_path": "/test_case/created_at" + }, + { + "name": "updated_at", + "type": "string", + "format": "date-time", + "description": "Backdated update timestamp for migrations. Same feature gate as `created_at`; must not be earlier than `created_at` (400).", + "json_path": "/test_case/updated_at" + } + ], + "intent": "Create a test case in a folder.", + "guidance": [ + "Resolve the project's PR-NNN identifier and the destination folder's integer id first; the folder in the path is authoritative and a folder id in the body is ignored.", + "Decide the step style before writing: a Gherkin case needs template=test_case_bdd plus feature and scenario and must carry no test_case_steps; a classic case must carry no feature/background/scenario. Either mix is refused with 422, and a template_id whose step_type disagrees is the usual cause.", + "status, priority and case_type take the project's DISPLAY values (e.g. 'Active', 'High', 'Functional'), which a workspace can rename or extend — resolve them with get_system_field_values rather than guessing; unknown values are refused with 400.", + "custom_fields is keyed by display name and an unknown name is dropped without error, so confirm each field exists on the project (get_project_form_fields) and verify the response's custom_fields before reporting them saved.", + "The response echoes the case with `title` (not name), `steps` as [{step, result}] and `owner` as an email, and carries NO integer id — carry data.test_case.identifier forward for later reads, updates and moves.", + "Recoverable: the new case can be deleted or archived afterwards, so this is an ordinary write.", + "The echoed case omits keys rather than returning them empty. A freshly created case has no `attachments` key at all — not an empty array — because nothing can be attached yet; `feature`, `background` and `scenario` are present only on a Gherkin case; `reviewers` and `review_status` only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps` and `custom_fields` are always present, empty when nothing is set, so only those three are safe to iterate unguarded." + ], + "returns": [ + "identifier", + "title", + "description", + "preconditions", + "template", + "folder_id", + "status", + "priority", + "case_type", + "automation_status", + "owner", + "tags", + "issues", + "steps", + "feature", + "background", + "scenario", + "custom_fields", + "attachments", + "is_shared", + "review_status", + "reviewers", + "created_at", + "created_by", + "last_updated_at", + "updated_by", + "urls" + ], + "responses": { + "200": { + "$response": "V2TestCaseResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "error_code=review_approve_unknown — the project's Review & Approve setting could not be read, so nothing was written. Retry.", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "edit_project", + "method": "POST", + "path": "/api/v1/projects/{project_id}/edit", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "example": 11046, + "description": "Integer project id (the numeric `id`, not the PR-NNN identifier). Discover it through the projects list. A project the caller cannot see returns 404, not 403." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/project/name", + "example": "Checkout Regression", + "description": "New project name. Leading/trailing spaces are trimmed. Must be unique within the account and must not contain | ; > < ` + ? % \" / or backslash; violations return 400 with the reason in `error`. Omit to keep the current name." + }, + { + "name": "description", + "type": "string", + "json_path": "/project/description", + "example": "Owns all checkout-flow test cases", + "description": "New description. HTML is escaped on the way in and anything past 16 KB is silently truncated. Send an empty string to clear it; omit to keep the current one." + }, + { + "name": "access_type", + "type": "string", + "json_path": "/project/access_type", + "values": [ + "private", + "restricted", + "org_wide" + ], + "example": "org_wide", + "description": "Project visibility: `private` (owner only), `restricted` (owner plus named users), `org_wide` (everyone in the account). Other values return 400 'Invalid access type'. Honoured only on accounts with project-level access control; elsewhere silently ignored and the response still says success. Server may adjust it: private with named users becomes restricted, restricted with none becomes private." + }, + { + "name": "users", + "type": "array", + "json_path": "/project/users", + "fields": [ + { + "name": "user", + "type": "string", + "required": true, + "description": "Email address of a user in the same account." + }, + { + "name": "role", + "type": "string", + "required": true, + "description": "Product role name to grant on this project (account-configured, e.g. Product Admin, Tester, Viewer)." + } + ], + "example": [ + { + "user": "qa.lead@example.com", + "role": "Tester" + } + ], + "description": "Per-user access grants. Processed only when `access_type` is also sent, otherwise ignored. Each entry is added or has its role changed; users not listed keep their existing access, so this does not replace the list. Entries whose email is not in the account or whose role is not recognised are skipped silently, not rejected." + } + ], + "intent": "Rename a project, or change its description or visibility.", + "guidance": [ + "There is no undo. The previous name and description are overwritten; the change is logged in the project's activity feed and version history, so the old value can be looked up and re-entered by hand, but nothing restores it.", + "Send at least one of name, description or access_type inside the `project` object; an empty `project` object is rejected 400 before anything is looked up.", + "Only account owners/admins or the project owner may edit; anyone else gets 403 with the reason in `error`. A project the caller cannot see comes back 404, not 403.", + "Visibility is silently ignored on accounts without project-level access control: the call returns 200 and the unchanged value. Read `access_type` back from the response to confirm the change took.", + "The `users` list is only read alongside `access_type`. To change one user's role without touching visibility, resend the current access_type with the users entry.", + "Name conflicts and blocked characters are enforced by the backend and surface as 400 with a human-readable `error`; validation runs after the permission check, so a 403 means permissions, not the name." + ], + "returns": [ + "id", + "thProjectId", + "identifier", + "name", + "description", + "created_at", + "import_id", + "test_cases_count", + "test_runs_count", + "test_plans_count", + "links", + "normalisedName", + "starred", + "jira_mapped", + "projectVisibilityBanner" + ], + "responses": { + "200": { + "description": "Project edited. Envelope is data.success and data.project, same as the single-project read; project also carries access_type, normalisedName and projectVisibilityBanner, and the counts are carried over from before the edit rather than recomputed.", + "schema": { + "$schema": "V1ProjectDetail" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_folder_attachments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/attachments", + "mode": "read", + "entity": "attachment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "folder_id", + "type": "integer", + "required": true + } + ], + "intent": "List the files attached to a folder itself.", + "guidance": [ + "This is the folder's own shelf of files. Files attached to the test cases in the folder are a separate list and none of them appear here.", + "It does not look inside subfolders, so walk the folder tree and ask for each folder you care about.", + "Each entry carries links to the stored file; read the file from there, as there is no separate download step.", + "A folder with nothing attached comes back as an ordinary success with an empty list, so an empty list is an answer and not a failure.", + "Alongside the exact byte count each entry also carries a ready-formatted size for display; use the byte count when you need to compare or add sizes up.", + "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments.", + "This lists attachments held on the FOLDER itself, which is a different collection from the attachments on the cases inside it — attaching a file to a case never makes a row appear here." + ], + "returns": [ + "success", + "attachments", + "id", + "name", + "content_type", + "byte_size", + "key", + "checksum", + "url", + "download_url", + "product_id" + ], + "responses": { + "200": { + "$response": "FolderAttachmentsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_exploratory_session_form_fields", + "method": "GET", + "path": "/api/v1/projects/{project_id}/exploratory-sessions/form-fields", + "mode": "read", + "entity": "exploratory_session", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "values", + "type": "boolean", + "default": false, + "description": "Set to true to get each field's configured choices alongside its definition. Left off, the reply is a compact list of the field definitions with the choice lists omitted, which is enough to learn the field names but not the values they accept.", + "example": true + } + ], + "intent": "List the form fields for authoring an exploratory session.", + "guidance": [ + "Asking for the configured choices does not currently work: any truthy value for the choices flag is answered with a not-found, on every project, while leaving it off succeeds. Call it without that flag and expect field names and types but no choice lists, and resolve a dropdown's accepted values another way.", + "The choices flag is matched against the exact lowercase word true; anything else, including a capitalised spelling, is treated as off.", + "Only the fields your team added are listed. The built-in session details such as name, charter and status are never described here - the built-in list is always returned empty - so an empty reply means nothing extra was configured, not that the session has no fields.", + "Both lists can be empty on a perfectly valid project. Do not read that as a failure.", + "Configuration is per project, so fetch it again for each project - a field set up in one project need not exist in another.", + "Each field carries both an internal name and the display name your team sees; when a person names a field in plain words, match it against the display name.", + "A field describing a dependent choice points at the field it hangs off, so resolve the parent's value first when one is present.", + "Errors here do not carry the enveloped error object described elsewhere in this registry: a not-found comes back as nothing more than a success flag set to false, with no code, message or details to branch on." + ], + "returns": [ + "success", + "custom_fields", + "default_fields", + "id", + "field_name", + "field_user_name", + "field_type", + "entity_type", + "is_required", + "optional", + "option_values", + "field_values", + "default_value", + "placeholder", + "is_bulk_editable", + "is_filterable", + "parent_custom_field_id", + "links" + ], + "responses": { + "200": { + "$response": "ExploratorySessionFormFieldsResponse" + }, + "404": { + "description": "Returned for a project that cannot be read, and - currently - for every request that asks for the configured choices. The body carries only a false success flag: no error object, no code, no message.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + } + } + } + } + } + }, + { + "name": "get_lcnc_user_test_config", + "method": "GET", + "path": "/api/v1/projects/{project_id}/lcnc/user-test-config", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project the setup is read for." + } + ], + "intent": "Get the caller's saved low-code test setup for a project.", + "guidance": [ + "The setup itself belongs to BrowserStack's low-code automation product and is handed back exactly as that product produced it. Test Management does not define, validate or reshape those fields, so they are not guaranteed here and may change without a change in Test Management - read them each time rather than relying on a remembered shape.", + "The status reported inside the payload is the one the other product returned, echoed as the transport status as well, so its failure surfaces as that same code here with the detail in the payload.", + "The setup is per person: it is the calling user's own default, so two people on the same project can correctly get different answers, and it is not a project-wide setting you can read on someone else's behalf." + ], + "returns": [ + "status", + "body" + ], + "shape": "discovered", + "responses": { + "200": { + "description": "The low-code automation service's own response, passed through unchanged. `status` echoes that service's status code and `body` is its parsed payload, whose fields are owned by that service and are not guaranteed by Test Management.", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "description": "Status code echoed from the upstream service." + }, + "body": { + "type": "object", + "description": "Upstream payload, passed through unchanged. Field set may vary; see the `discovered` shape." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_shared_components", + "method": "GET", + "path": "/api/v1/projects/{project_id}/shared-components", + "mode": "read", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project whose shared fields are listed." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, starting at 1. The page size is fixed by the service and cannot be raised from here; read the returned page details to see the total and whether another page exists." + }, + { + "name": "q[query]", + "type": "string", + "description": "Text matched against the shared field's title only - its step content is not searched. Send it in this bracketed form; a bare q value is ignored and the unfiltered list comes back looking like a successful search." + }, + { + "name": "q[tags]", + "type": "string", + "example": "smoke,regression", + "description": "Comma-separated tag names. Only shared fields carrying at least one of them are listed." + }, + { + "name": "q[empty][system_fields][]", + "type": "array", + "values": [ + "tags" + ], + "example": [ + "tags" + ], + "fields": [ + { + "name": "system_field", + "type": "string" + } + ], + "description": "Lists shared fields that have nothing set for a system field. Only tags is understood; any other name is dropped without an error, which silently widens the result. Combines with a tag filter as an either/or rather than narrowing it." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Restrict the listing to one kind of shared field. Omit to list every kind." + }, + { + "name": "folder_id", + "type": "string", + "example": "null", + "description": "Collection to list from: the collection's numeric id, or the literal four-character text null to list the Unassigned root where uncollected fields sit. That is the word null as a value, not an absent one. Omit the parameter entirely to list the project's shared fields regardless of collection." + }, + { + "name": "include_sub_collections", + "type": "boolean", + "default": false, + "description": "Set true to include the chosen collection's sub-collections as well as the collection itself. Only the exact value true turns it on, it does nothing without a collection, and it never applies to the Unassigned root, which has no children." + }, + { + "name": "sort_by", + "type": "string", + "description": "Sort order for the listing, passed straight through to the service that stores shared fields. Omit it for the default order; an unrecognised value is not reported as an error here." + }, + { + "name": "pre_fetch", + "type": "boolean", + "default": false, + "description": "When true the response also carries the steps of the first shared field on the page, saving a follow-up call. Honoured only on page 1 and only when the page has at least one row." + } + ], + "intent": "List or search a project's shared fields.", + "guidance": [ + "Shared fields and shared steps are different objects with separate listings and separate id sets; an id taken from one will not resolve in the other, so confirm which the user means before acting.", + "The listing carries each field's title, type and usage count but not its step content - fetch a single shared field when the actual steps are needed.", + "The usage count is what tells you how wide the blast radius of an edit would be; read it before suggesting a change to a field.", + "Filtering by collection is the only way to reproduce what the user sees in a collection view - an unfiltered list spans the whole project and will look like the filter was ignored." + ], + "returns": [ + "data", + "id", + "title", + "field_type", + "step_count", + "test_case_count", + "tags", + "created_by", + "updated_by", + "info", + "pre_fetched_shared_step", + "success" + ], + "paginated": true, + "responses": { + "200": { + "$response": "SharedComponentsGetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_case_comments", + "method": "GET", + "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", + "mode": "read", + "entity": "comment", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, starting at 1. Each page holds at most 30 comments and the size cannot be raised; the response reports the total and the next page number." + } + ], + "intent": "Read the comments on a test case.", + "guidance": [ + "The newest comment is first on page 1, so a 'what was said last' question is answered without paging; page on only when the reported total exceeds what you already hold.", + "Comments deleted by their author are gone from every page and from the total, so a thread can be shorter than a screenshot or an email suggests.", + "Bodies are rich text (HTML). A mention of a colleague arrives as markup whose visible text is that person's display name, so strip tags before showing the text plainly.", + "An archived test case still returns its comments, even though nothing new can be added to it.", + "Needs the test-case view permission and a paid plan. Below a paid plan the call still comes back as HTTP 200 with a false success flag and a message saying the feature is for paid customers, so read the success flag in the body rather than trusting the status code.", + "The page parameter is named p, and page size is fixed at 30 with no way to raise it. Asking for a page past the end neither errors nor returns an empty list - it serves the last page again - so stop when the reported next page is null, never when a page comes back empty.", + "Each row's user_id is the AUTHOR as an object with id, full_name and email rather than a bare id; that id is the internal user id and is null for comments brought in by an import, where full_name carries the original author's name instead. Rows also carry group_id, project_id, parent_id, entity_id and entity_type, and parent_id is always null.", + "On this surface entity_type is always TestCase and entity_id is the test case's own id. Comments left on the same case inside a test run are a separate thread with its own capability - nothing posted there shows up here.", + "review_status appears on a row only when that comment was recorded as part of a review verdict, which is how the bulk review flow on test cases writes it; a comment posted through the ordinary comment endpoint never carries it. Treat the field as optional and absent by default.", + "This listing is the only way to discover a comment's id, which every later edit or delete needs." + ], + "returns": [ + "comments", + "success", + "info", + "id", + "user_id", + "group_id", + "project_id", + "parent_id", + "entity_id", + "entity_type", + "created_at", + "comment", + "edited", + "is_editable" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case", + "method": "GET", + "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", + "mode": "read", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-12", + "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." + }, + { + "name": "test_case_id", + "type": "string", + "required": true, + "example": "TC-1024", + "description": "The case's TC-NNN identifier, or its customer-defined identifier when the workspace uses project-level custom ids; matched exactly within the project. A bare integer id is NOT accepted here (404) — use get_test_case_by_integer_id for integer ids." + } + ], + "intent": "Get one test case in full by its TC-NNN identifier.", + "guidance": [ + "Returns `title` rather than name, `steps` as [{step, result}], custom_fields as [{name, value|values}] and owner as an email; there is no integer id in the body.", + "feature/background/scenario are present only for a Gherkin (test_case_bdd) case and `steps` is then empty; reviewers/review_status appear only when the project has Review & Approve enabled.", + "A 404 means the identifier does not resolve in THIS project; a case moved to another project keeps its identifier but must be read there." + ], + "returns": [ + "identifier", + "title", + "description", + "preconditions", + "template", + "folder_id", + "status", + "priority", + "case_type", + "automation_status", + "owner", + "tags", + "issues", + "steps", + "feature", + "background", + "scenario", + "custom_fields", + "attachments", + "is_shared", + "review_status", + "reviewers", + "created_at", + "created_by", + "last_updated_at", + "updated_by", + "urls" + ], + "responses": { + "200": { + "$response": "V2TestCaseResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + }, + "502": { + "description": "Upstream read failed with an unknown status; retry.", + "schema": { + "$schema": "ErrorResponse" + } + } + } + }, + { + "name": "search_all_projects", + "method": "GET", + "path": "/api/v1/global/search", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "entity", + "type": "string", + "required": true, + "example": "test_case,test_run,project", + "description": "Comma-separated list of the entity types to search. Allowed tokens: test_case, test_run, test_plan, project, shared_step, report. They are snake_case and SINGULAR — the hyphenated plurals used by the project-scoped search (test-cases, test-runs) are rejected here with a 400 naming the invalid token. Omitting this is a 400, not a search of everything." + }, + { + "name": "q[query]", + "type": "string", + "required": true, + "example": "login", + "description": "The text to search for. Required — a blank or missing value is a 400. %, _ and \\ are literal characters here and are not escaped or treated as wildcards." + }, + { + "name": "q[project_ids]", + "type": "string", + "example": "101,102", + "description": "Comma-separated NUMERIC project ids to restrict the search to. These are integer ids, not PR-NNN identifiers. Omit to search every project the caller can see." + }, + { + "name": "q[owner]", + "type": "string", + "example": "14,15", + "description": "Comma-separated Test Management user ids; the server converts them to the underlying user ids. Applied to test cases, runs and plans, and to reports. Deliberately NOT applied to project or shared_step hits, whose stored user is a creator rather than an owner, so an owner filter does not narrow those two at all." + }, + { + "name": "q[tags]", + "type": "string", + "example": "regression,smoke", + "description": "Comma-separated tag ids applied to every entity type being searched. A per-entity tag filter overrides it for that entity." + }, + { + "name": "q[issue_ids]", + "type": "string", + "example": "JIRA-1,JIRA-2", + "description": "Comma-separated linked-issue ids. Applied to test cases only at the shared level; use the per-entity form for runs." + }, + { + "name": "q[created_at]", + "type": "string", + "example": "7_day", + "description": "Relative token `_` with a SINGULAR unit (day, hour, week, month, year) — e.g. 7_day, 24_hour; a `_gte` suffix inverts it (7_day_gte = older than). Also accepts `all_time` (resolved as one year) or an absolute \"YYYY-MM-DD YYYY-MM-DD\" range. A plural unit such as 7_days raises server-side rather than returning a 400." + }, + { + "name": "q[updated_at]", + "type": "string", + "example": "1_month", + "description": "Same form as q[created_at]." + }, + { + "name": "q[created_between]", + "type": "array", + "example": [ + "2026-04-05T00:00:00.000Z", + "2026-05-05T23:59:59.999Z" + ], + "minItems": 2, + "maxItems": 2, + "fields": [ + { + "name": "0", + "type": "string" + }, + { + "name": "1", + "type": "string" + } + ], + "description": "Absolute creation range as a positional 2-element array: index 0 is the start, index 1 the end, ISO 8601. Takes precedence over q[created_at] when both are sent." + }, + { + "name": "q[updated_between]", + "type": "array", + "example": [ + "2026-04-05T00:00:00.000Z", + "2026-05-05T23:59:59.999Z" + ], + "minItems": 2, + "maxItems": 2, + "fields": [ + { + "name": "0", + "type": "string" + }, + { + "name": "1", + "type": "string" + } + ], + "description": "Absolute last-updated range, same positional 2-element form as q[created_between]. Takes precedence over q[updated_at]." + }, + { + "name": "q[test_case][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, test cases only. Overrides the shared q[tags] for this entity." + }, + { + "name": "q[test_case][priority_id]", + "type": "string", + "example": "1268,1270", + "description": "Comma-separated priority option ids. Ids, not labels — resolve them from the project's system field values first." + }, + { + "name": "q[test_case][status_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated status option ids." + }, + { + "name": "q[test_case][automation_state_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated automation-state option ids." + }, + { + "name": "q[test_case][category_id]", + "type": "string", + "example": "1,2", + "description": "Comma-separated case-type (category) option ids." + }, + { + "name": "q[test_case][issue_ids]", + "type": "string", + "example": "JIRA-1,JIRA-2", + "description": "Comma-separated linked-issue ids, test cases only." + }, + { + "name": "q[test_run][tags]", + "type": "string", + "example": "sprint-1", + "description": "Comma-separated tag ids, test runs only." + }, + { + "name": "q[test_run][issue_ids]", + "type": "string", + "example": "JIRA-3", + "description": "Comma-separated linked-issue ids, test runs only." + }, + { + "name": "q[test_plan][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, test plans only." + }, + { + "name": "q[project][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, project hits only. Projects and shared steps accept tags and the date ranges only — no owner, priority or status filter reaches them." + }, + { + "name": "q[shared_step][tags]", + "type": "string", + "example": "12,13", + "description": "Comma-separated tag ids, shared-step hits only." + }, + { + "name": "q[match][tags]", + "type": "string", + "example": "all", + "description": "How to combine the tag values supplied elsewhere — an OPERATOR, never the values themselves: all (has every tag), any, none. A value placed here instead of beside it sets a meaningless operator and applies NO filter, silently widening the result. The operator is broadcast to every entity being searched." + }, + { + "name": "combine", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "true merges all entity types into one list sorted newest-first with an entity_type on each row. Case-insensitive. It forces page 1 and caps the merged list at 5 rows while info.count still reports the real total, so it is a preview, not a way to read results." + }, + { + "name": "typeahead", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "true takes a fast path returning only entity_type, id, identifier, name, project_id and owner per hit, with no folder path, status or description enrichment. It also DROPS reports from the search entirely even when they are listed in `entity`. Case-insensitive." + }, + { + "name": "filter_details", + "type": "string", + "values": [ + "true", + "false" + ], + "default": "false", + "description": "Exactly the lowercase string `true` adds a filter_details block resolving the filter ids you sent back to names. Any other spelling, including True, is ignored. If resolution fails the block is simply absent — the search itself still succeeds." + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number, applied to every entity type at once. A value below 1 becomes 1. `page` is accepted as an alias. Ignored when combine is true." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 30, + "default": 30, + "example": 30, + "description": "Rows per entity type per page, ceiling 30. A larger value is silently reduced to 30 rather than refused. `count` is accepted as an alias." + } + ], + "intent": "Search every entity type across the account at once.", + "guidance": [ + "Account-wide by default. There is no project path segment; narrow it with the numeric project-ids filter when the user did name a project, otherwise every project the caller can see is searched.", + "Entity tokens here are snake_case and singular (test_case, test_run, test_plan, project, shared_step, report). This is a different vocabulary from the project-scoped searches, which take hyphenated plurals - passing those gets a 400 that names the invalid token.", + "Both the entity list and the query text are mandatory, and each missing one comes back as HTTP 400 whose body is a single bare error string and nothing else - no success flag, and no code, message or details sub-fields. Detect the failure from the status, not from the body's shape.", + "Pagination is per entity type, not overall: page 2 advances every type together, so a type with 3 hits contributes nothing to page 2 while another is still producing rows. Read each type's own info block to know when that type is exhausted.", + "Reports are the exception to the page-size setting: that bucket is fixed at 30 rows per page no matter what you ask for, and its own info block reports 30 back. Page through reports rather than trying to widen the page.", + "Combining results is a preview only - it pins you to page 1 and returns at most 5 rows across all types while still reporting the true total in the count, so a count of 200 beside 5 rows is expected and not a bug. Leave it off and read the per-type results when you actually need the matches.", + "Combining also replaces the reply: the per-entity buckets are gone and you get one merged list with a single info block, plus the project lookup and the test-case folder lookup when there is anything in them. That info block's page size is the per-type page sizes added together, so a three-type search reports 90 - it is not a row count and not a limit you can use.", + "Priority, status, automation-state and case-type filters take option ids, not the labels a user says. Resolve them first; a wrong-form value matches nothing and still returns a successful empty result.", + "Projects and shared steps only honour tags and the date ranges. An owner filter does not narrow them at all, so a query scoped by owner still returns every matching project.", + "Freshly created records are not findable here immediately - indexing lags creation by minutes. If you have just made something and need to confirm it exists, read it back directly instead of searching for it." + ], + "returns": [ + "test_case", + "test_run", + "test_plan", + "project", + "shared_step", + "report", + "project_details", + "data", + "info", + "test_case_folders", + "filter_details" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "GlobalSearchResponse" + }, + "400": { + "description": "Missing entity list, missing query text, or an unrecognised entity token. The body is a single bare error string - there is no success flag and no nested error object.", + "schema": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "description": "The reason, as plain text.", + "example": "No entities provided for search" + } + } + } + } + } + }, + { + "name": "initiate_export", + "method": "POST", + "path": "/api/v1/projects/{project_id}/{entity_type}/exports/{file_type}", + "mode": "write", + "entity": "test_case", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric project id, not the PR-NNN identifier." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-cases" + ], + "example": "test-cases", + "description": "Only test cases can be exported through this capability. Dashboard analytics has its own export capability; asking for it here is accepted, records an export and returns an id, then the background job fails." + }, + { + "name": "file_type", + "type": "string", + "required": true, + "values": [ + "csv", + "bdd" + ], + "example": "csv", + "description": "csv produces a spreadsheet of every field, custom field and step. bdd produces Gherkin .feature output and silently restricts the export to BDD-template cases only." + } + ], + "body": [ + { + "name": "ids", + "type": "array", + "example": [ + 101, + 102, + 103 + ], + "fields": [ + { + "name": "id", + "type": "integer" + } + ], + "json_path": "/test_case/ids", + "description": "The exact test cases to export, by NUMERIC id. A TC-NNN identifier or a customer-defined id is not matched here. Ignored when select_all is true or when a folder selection map is sent. Omit the whole test_case object at your peril: with no selection form at all the export job is started against an empty id set." + }, + { + "name": "select_all", + "type": "boolean", + "example": true, + "json_path": "/test_case/select_all", + "description": "true exports everything matching the q filters within folder_id instead of an explicit id list, minus de_selected_ids. Sent as false (or omitted) the explicit id list is used instead." + }, + { + "name": "de_selected_ids", + "type": "array", + "example": [ + 104 + ], + "fields": [ + { + "name": "id", + "type": "integer" + } + ], + "json_path": "/test_case/de_selected_ids", + "description": "Numeric ids to exclude from a select_all export. Has no effect unless select_all is true." + }, + { + "name": "folders", + "type": "object", + "example": { + "125382": { + "select_all": true + }, + "125385": { + "ids": [ + 9001 + ] + } + }, + "json_path": "/test_case/folders", + "description": "Cross-folder selection: an object KEYED BY FOLDER ID, each value carrying that folder's own select_all / ids / de_selected_ids choice; the server resolves it to a union of case ids. Present, it WINS over both select_all and ids at the top level. An empty map resolves to no cases, never to the whole project.", + "fields": [ + { + "name": "select_all", + "type": "boolean" + }, + { + "name": "ids", + "type": "array" + }, + { + "name": "de_selected_ids", + "type": "array" + } + ] + }, + { + "name": "folder_id", + "type": "integer", + "example": 29529465, + "description": "Folder to scope a select_all export to. Only the folder itself unless subfolders are opted into." + }, + { + "name": "include_subfolders_tc", + "type": "boolean", + "example": true, + "description": "true also pulls cases from the folder's descendants. Off by default, so a folder export silently returns only the cases sitting directly in that folder." + }, + { + "name": "q", + "type": "object", + "example": { + "priority": "1268", + "tags": "12,13" + }, + "fields": [ + { + "name": "priority", + "type": "string" + }, + { + "name": "status", + "type": "string" + }, + { + "name": "tags", + "type": "string" + }, + { + "name": "owner", + "type": "string" + }, + { + "name": "custom_fields", + "type": "object" + } + ], + "description": "Filters selecting which cases a select_all export covers. Same vocabulary and same id-based values as the project test-case search, so resolve option ids first. An unrecognised key is dropped rather than refused, which widens the export instead of erroring." + }, + { + "name": "merge_into_single_file", + "type": "boolean", + "example": true, + "json_path": "/metadata/merge_into_single_file", + "description": "BDD exports only: merge every selected case into one .feature file instead of one file per case. The merge needs a single shared feature and background across the selection." + }, + { + "name": "common_feature", + "type": "string", + "json_path": "/metadata/common_feature", + "description": "BDD merge only: the Feature block to write at the top of the merged file. Omit it and the first exported case's own feature is used." + }, + { + "name": "common_background", + "type": "string", + "json_path": "/metadata/common_background", + "description": "BDD merge only: the Background block for the merged file. Omit it and the first exported case's background is used." + } + ], + "intent": "Start a background export of test cases to CSV or Gherkin.", + "guidance": [ + "THE FILE NEVER COMES BACK THROUGH HERE. This only queues the job and hands you an export id. Downloading the finished file is deliberately not part of this catalogue — a file download cannot be served over this transport — and there is no status or polling capability either. So after a successful call, tell the user plainly that the export is being prepared and that they must collect it from the exports list in the Test Management UI. Do not promise to fetch, attach or summarise the file.", + "A 200 with an export id means the job was accepted, not that it succeeded. The work runs afterwards and can still fail, in which case the id stays valid and no file ever appears — which the caller cannot observe from here.", + "Selection has three forms and the server picks one in a fixed order: a folder-keyed selection map beats everything; otherwise select_all exports everything matching the filters within the folder minus the de-selected ids; otherwise only the listed ids. Send exactly one form — the losing ones are silently ignored, not merged.", + "Explicit ids are matched on the numeric primary key only. Feeding TC-NNN identifiers there exports nothing and still returns a successful export id.", + "Choosing BDD restricts the selection to BDD-template cases behind your back, so exporting a folder of step-template cases as BDD produces an empty file with no error. Validate a BDD selection for a shared feature and background before merging into one file.", + "This creates an export record against the caller's account each time it is invoked; repeating it produces a new id and a new job rather than reusing the previous one." + ], + "returns": [ + "export_id" + ], + "responses": { + "200": { + "$response": "DashboardExportInitiatedResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "link_entities_to_jira_issue", + "method": "POST", + "path": "/api/v1/integration/jira-app/jira-ticket/link-entities", + "mode": "write", + "entity": "issue", + "body": [ + { + "name": "issue_id", + "type": "string", + "required": true, + "example": "TES-121", + "description": "The external tracker's own ticket key, e.g. the Jira key TES-121. It is not a Test Management id and is not minted here — take it from the tracker. A missing value is refused with 400 'Missing issue_id'; a key that does not exist in the tracker is NOT validated and the link is stored anyway." + }, + { + "name": "project_id", + "type": "string", + "required": true, + "example": "10001", + "description": "Integer id of the Test Management project that owns the entities being linked, sent as a string. A missing value is refused with 400 'Missing project_id'. This endpoint does not check it is numeric, so a non-numeric value fails deeper as a 500 rather than a clean 400." + }, + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test-case", + "test-run" + ], + "example": "test-case", + "description": "What is being linked. Matched case-insensitively. Anything outside the list is refused with 400 'Unsupported entity_type'. This also decides which other body field is required: 'test-case' requires `selection`, 'test-run' requires `entity_ids`." + }, + { + "name": "entity_ids", + "type": "array", + "example": [ + 27912, + 27399, + 27398 + ], + "description": "Integer ids of the test runs to link. Required ONLY when entity_type is 'test-run' (400 'Missing entity_ids for testrun' otherwise). For 'test-case' this field is read but never used — cases are resolved from `selection`, so ids sent here are silently discarded." + }, + { + "name": "selection", + "type": "object", + "example": { + "select_all": false, + "unique_test_case_count": 12, + "folders": {} + }, + "description": "Which test cases to link, in the same folder/filter selection shape the selection preview returns. Required ONLY when entity_type is 'test-case' (400 'Missing selection for testcase' otherwise). Ignored for 'test-run'.", + "fields": [ + { + "name": "select_all", + "type": "boolean", + "description": "Select every case matching the selection rather than an explicit list." + }, + { + "name": "unique_test_case_count", + "type": "integer", + "description": "Caller-supplied count of cases this selection resolves to. The server trusts it: it is the only input to the >200 async decision and is never recomputed against the selection." + }, + { + "name": "folders", + "type": "object", + "description": "Folder-keyed selection map, taken verbatim from the selection preview." + } + ] + }, + { + "name": "shared_selection", + "type": "object", + "description": "Additional selection covering test cases shared in from other projects. Only read on the synchronous test-case path; the queued path above 200 cases drops it, so shared cases are not linked in an async batch." + }, + { + "name": "build_ids", + "type": "array", + "description": "Build ids matching `entity_ids` one-for-one, for accounts on build-based run ingestion. Omit the field entirely to let the server resolve runs the legacy way. If you do send it, it must hold exactly one positive integer per entry in `entity_ids` with no duplicates, or the call is refused with 400." + } + ], + "intent": "Link test cases or runs to a tracker ticket.", + "guidance": [ + "Which body field carries the entities depends on what you are linking: cases are selected by a selection object, runs by an explicit list of ids. Sending ids alongside a case selection links nothing extra.", + "Above 200 entities the work is queued to a background worker and the response comes back immediately with async true and no per-entity outcome. Nothing has been linked yet at that point, and a worker failure is never reported back — re-read the ticket's linked test cases to confirm.", + "The 200 threshold for a case selection is read from the count you supplied, not from the selection itself. Understate it and the call takes the synchronous path, which resolves at most 200 cases and links only those — reporting success for the whole selection.", + "Build the case selection from the selection preview for the same ticket and project rather than assembling it by hand; the preview returns exactly the object this field expects.", + "The ticket key is not verified against the tracker. A typo produces a link to a ticket that does not exist, with a 200 and a success message.", + "Linking is additive and idempotent per entity; relinking an already-linked entity does not duplicate it.", + "Every failure on the argument-validation path comes back as 400 with a plain message, including cases that are really 'not found'." + ], + "returns": [ + "success", + "message", + "async" + ], + "responses": { + "200": { + "description": "The linking was accepted. Nothing about WHICH entities were linked comes back - no ids, no per-entity results, no failure list - so the only way to know what the ticket now holds is to read its linked entities back.\n", + "schema": { + "type": "object", + "required": [ + "success", + "message", + "async" + ], + "properties": { + "success": { + "type": "boolean", + "description": "True on this response. It reports that the request was accepted, not that every entity was linked - when `async` is true nothing has been linked yet. A refusal comes back as a 4xx or 5xx with success false and the reason in `message`.", + "example": true + }, + "message": { + "type": "string", + "description": "Human-readable outcome: 's linked successfully' when the work was done in-request, 's successfully queued for linking' when it was handed to a background worker. The entity type is echoed from your request verbatim, so it is your own wording plus an 's' - do not parse it, read `async` instead.", + "example": "test-runs linked successfully" + }, + "async": { + "type": "boolean", + "description": "False means the entities were linked before this response was sent, and reading the ticket's linked entities now will show them. True means NOTHING has been linked yet: the batch was over 200 entities and was handed to a background worker, and this response carries no job id and no way to poll it. Treat true as 'accepted, not done' - an empty read straight afterwards is expected and is NOT a failure. Do not resend the same request on the strength of an empty read: there is no de-duplication and the resend simply queues the same linking work again. Wait and re-read instead. Note also that for a test-case batch the 200-entity threshold is measured from the count YOU supplied inside the selection, not from a count the server recomputes, so a wrong count there decides this flag." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_linked_test_case_selection", + "method": "GET", + "path": "/api/v1/integrations/{issue_type}/test-cases/selection", + "mode": "read", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the caller is acting from. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." + } + ], + "query": [ + { + "name": "issue_id", + "type": "string", + "required": true, + "example": "TES-121", + "description": "The external tracker's ticket key whose current test-case selection is wanted. Not validated here: a blank or unknown key returns an empty selection rather than an error." + }, + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer id of the Test Management project the selection is scoped to. This endpoint does not check it is present or numeric, so a missing or non-numeric value fails deeper as a 5xx instead of a clean 400 — always send a real project id." + } + ], + "intent": "Preview the test cases a tracker ticket would link.", + "guidance": [ + "This is the step before linking, not the list of what is already linked. The selection object it returns is the exact object the link call expects, folder structure and all — pass it straight through rather than rebuilding it.", + "The count the link call uses to decide between the immediate and the queued path is the one you carry from this selection. Change the selection by hand and that count stops matching what it resolves to.", + "Unlike its Jira-specific twin, this route validates neither the ticket key nor the project id, so a missing argument surfaces as a server error rather than a 400.", + "During a platform downtime window this returns an empty test-case list rather than a selection object." + ], + "returns": [ + "success", + "selection" + ], + "responses": { + "200": { + "description": "The ticket's linked test cases expressed as a reusable selection - hand this object straight back as the case selection when linking. While the service is unavailable an empty test-case collection is returned in its place: `{success: true, test_cases: []}`, with no `selection` key at all, so check for `selection` before using it.\n", + "schema": { + "type": "object", + "required": [ + "success", + "selection" + ], + "properties": { + "success": { + "type": "boolean", + "description": "True on this response. A fixed acknowledgement, not a report on the selection - an empty selection still comes back as true.", + "example": true + }, + "selection": { + "$schema": "SelectionData", + "description": "The selection as passed through from the linking service. `folders` is an empty map and `unique_test_case_count` is 0 when the ticket has no linked cases in this project." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_configuration_groups", + "method": "GET", + "path": "/api/v1/configurations/groups", + "mode": "read", + "entity": "configuration", + "query": [ + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "example": 1, + "description": "Page number. Page 0 is treated as page 1." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "default": 30, + "example": 30, + "description": "Rows per page, ceiling 30. Anything above 30 — and 0, a negative, or a non-numeric value — is silently replaced with 30 rather than refused, so a big page never arrives and you must walk pages. Above 300 the call is refused outright with a page-size error instead." + } + ], + "intent": "List the account's configuration groups and their ids.", + "guidance": [ + "A group carries only the dimension's name — the selectable values live under it as its options and are fetched separately using the group id returned here, so this is step one of two whenever you need a concrete value.", + "Nothing here is project-scoped: the same groups come back whichever project the user is working in, and they cannot be filtered to one project. Do not offer this as a way to see what a particular project can run against.", + "Page number travels as `p`; a `page` parameter is not read on this call and is ignored, which silently pins you to page 1.", + "There is no name search or sort on this listing — walk the pages and match names yourself." + ], + "returns": [ + "id", + "name", + "is_system", + "group_id", + "created_at" + ], + "paginated": true, + "max_page_size": 30, + "responses": { + "200": { + "$response": "ConfigurationGroupListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_tags", + "method": "GET", + "path": "/api/v1/projects/{project_id}/tags/v3", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "query": [ + { + "name": "entity_type", + "type": "string", + "required": true, + "values": [ + "test_case", + "test_run", + "test_plan", + "test_recording", + "shared_step" + ], + "description": "Which kind of record's labels to list. There is no option meaning 'all kinds'. Leaving it out or sending anything outside the listed set is refused outright rather than answered with an empty list.", + "example": "test_case" + }, + { + "name": "p", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number, counting from 1. Anything that is not a positive whole number is refused before the labels are read.", + "example": 1 + }, + { + "name": "page", + "type": "integer", + "minimum": 1, + "description": "Accepted as an alternative spelling of the page number, and used only when the page number was not supplied under the shorter name. Prefer the shorter name, which every other listing here uses." + }, + { + "name": "per_page", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "How many labels to return per page. The default is small, so ask for the largest page you need. A value above 100, zero, a negative number or anything non-numeric is refused rather than quietly clamped.", + "example": 100 + }, + { + "name": "search", + "type": "string", + "description": "Narrows the list to labels matching this text. Surrounding spaces are trimmed and an all-blank value counts as no filter at all. 'query' and 'q' are accepted as equivalents; whichever of the three is non-blank first is the one used." + } + ], + "intent": "List or search a project's labels, with their ids.", + "guidance": [ + "This is the only label listing that reaches records other than test cases, and the only one that hands back the id a rename or a merge needs. The test-case-only listings return names and usage counts, so start here whenever an id is required downstream.", + "Name the kind of record explicitly every time. There is no way to ask for every kind at once, and a misspelling is refused rather than silently widened.", + "Labels are scoped to a project and to a kind of record, so the same word can exist as separate labels for test cases and for test runs, each with its own id.", + "Ask for a large page in one go rather than walking small pages; the ceiling is 100 per page." + ], + "returns": [ + "tags", + "info", + "id", + "name" + ], + "shape": "discovered", + "paginated": true, + "max_page_size": 100, + "responses": { + "200": { + "description": "A page of labels for the requested kind of record. The body is passed through from the labels service unchanged, so it carries NO success flag - the absence of `success` here is not an error signal. A failure is a 4xx or 5xx with a plain `error` string instead.\n", + "schema": { + "type": "object", + "required": [ + "tags", + "info" + ], + "properties": { + "tags": { + "type": "array", + "description": "The labels in use on the requested kind of record in this project, one entry per label, narrowed by the search text when one was given. An empty list means the project has no labels of that kind yet - it is not an error.", + "items": { + "type": "object", + "description": "A label. `id` is the handle to use wherever a call takes label ids; `name` is what people see. The entry is passed through from the labels service and may carry extra bookkeeping keys such as a usage count and timestamps - read id and name, and treat anything else as informational.", + "properties": { + "id": { + "type": "integer", + "description": "The label's id - the value to pass wherever a call takes label ids." + }, + "name": { + "type": "string", + "description": "The label's text, as people see it." + } + } + } + }, + "info": { + "$schema": "PageInfo", + "description": "Pagination for the label list. Page size is capped at 100 and defaults to 30." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "list_users", + "method": "GET", + "path": "/api/v1/projects/{project_id}/users", + "mode": "read", + "entity": "user", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "example": 11046, + "description": "Integer project id. It gates access only; the returned list is not filtered to this project's members." + } + ], + "intent": "List the account's active users, for assigning a case or run.", + "guidance": [ + "Returns all active users of the caller's account, alphabetical by full name, in one unpaginated response. Appearing here does not mean the user has access to the project.", + "No role field. For project membership with roles use search_project_users; for a paged, name-filterable list of account users use list_project_users.", + "Assign owners and assignees with `id` (the TM user id), not `browserstack_user_id`.", + "`myself` is the caller, reduced to id, full_name and reports_and_notification_enabled; look the caller up in `users` for the full record.", + "Deactivated users and users with no name are omitted, so a colleague missing from the list may simply not have completed onboarding." + ], + "returns": [ + "success", + "users", + "myself", + "id", + "browserstack_user_id", + "full_name", + "email", + "group_id", + "onboarded", + "reports_and_notification_enabled" + ], + "responses": { + "200": { + "$response": "UsersListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "move_folder", + "method": "POST", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}/move", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 54, + "description": "Integer id of the folder to move; everything nested inside it moves with it. Never an FL- string." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "default": true, + "description": "Defaults to true: if a folder with the same name (case-insensitive, trimmed) already exists under the destination, the moved folder is silently renamed to ' (1)' with the name lowercased. Send false to get a 400 on a clash instead." + } + ], + "body": [ + { + "name": "parent_id", + "type": "integer", + "example": 12, + "description": "Integer id of the new parent folder in the SAME project. To move the folder to the project root, omit this field entirely: the server treats a missing key as root, and null cannot be sent through this transport. 0 is not root and is refused with 422. Must differ from folder_id." + } + ], + "intent": "Move a folder and its contents under a different parent.", + "guidance": [ + "Only moves within the same project are possible here; a destination in another project is refused with 422. Cross-project moves exist only in the internal API.", + "To move to root, omit parent_id; the server treats the missing key as root. A body that accidentally lost the key therefore also returns 200 and re-parents the folder to root, so double-check before sending without it. 0 is refused (422) rather than treated as root.", + "Name clashes at the destination are resolved by renaming the moved folder to ' (1)', lowercased, unless auto_rename=false is sent. Read the returned name either way.", + "Every pre-flight failure — same source and destination, unknown destination, unknown source folder, other-project destination — is a 422 whose text sits under `error`, not `message`. Only a failure inside the storage update comes back as 400.", + "Reversible by moving the folder back to its previous parent_id, so read the folder before moving; a name changed by auto-rename is not restored by moving back.", + "Completes synchronously and returns the moved folder; there is no async job id to poll, unlike the internal cross-project move." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "description": "The storage update was refused (e.g. an invalid destination such as the folder's own subtree).", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "description": "A pre-flight validation failed: cross-project destination, destination equal to source, destination not in this project, or source folder not found. The reason is under `error`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "rename_folder", + "method": "PATCH", + "path": "/api/v2/projects/{project_id}/folders/{folder_id}", + "mode": "write", + "entity": "folder", + "path_params": [ + { + "name": "project_id", + "type": "string", + "required": true, + "pattern": "^PR-\\d+$", + "example": "PR-1234", + "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." + }, + { + "name": "folder_id", + "type": "integer", + "required": true, + "minimum": 1, + "example": 54, + "description": "Integer id of the folder to update, as returned by the folder list or create. Never an FL- string. 0 is rejected." + } + ], + "query": [ + { + "name": "auto_rename", + "type": "boolean", + "description": "Only the literal value true has any effect. When true, a new name that clashes with a sibling (case-insensitive, trimmed) is saved as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "json_path": "/folder/name", + "example": "Checkout v2", + "description": "New folder name. Optional server-side despite the spec marking it required — a description-only update is valid. Must be unique among siblings ignoring case, otherwise 400 (unless auto_rename)." + }, + { + "name": "description", + "type": "string", + "json_path": "/folder/description", + "example": "Updated notes", + "description": "New notes for the folder; replaces the existing text. HTML is sanitised on save, so tags may come back stripped or escaped." + } + ], + "intent": "Rename a folder or change its description.", + "guidance": [ + "There is no undo and folders keep no version history: read the folder first and keep the old name and description if you may need to put them back.", + "Neither name nor description is required: send only the one you want to change, and a description-only update is valid.", + "Keys this capability does not declare are silently dropped rather than rejected, so a misspelt field returns 200 with nothing changed. Compare the returned folder to what you sent.", + "A new name matching a sibling's (case-insensitive, trimmed) returns 400 'Folder with same name exists in this location'. auto_rename=true makes the server lowercase the name and append ' (1)' instead — that is a different result from the rename you asked for.", + "Renaming does not move the folder; use the move capability to change its parent." + ], + "returns": [ + "id", + "name", + "description", + "parent_id", + "cases_count", + "sub_folders_count", + "links", + "urls" + ], + "responses": { + "200": { + "$response": "FolderV2WriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_case_linked_issues", + "method": "GET", + "path": "/api/v1/integrations/{issue_type}/test-case/results/{test_case_id}", + "mode": "read", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the caller is acting from. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." + }, + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Integer id of the test case whose details and execution results are wanted. Must belong to the caller's own group." + } + ], + "query": [ + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of execution results. Sending it also switches the read to include every result rather than the server default set, so send p=1 explicitly when you want the full result history — omitting it quietly narrows what comes back." + }, + { + "name": "per_page", + "type": "integer", + "example": 10, + "description": "Results per page. Only read when `p` is also sent; on its own it has no effect." + } + ], + "intent": "Get a test case's linked tracker tickets.", + "guidance": [ + "Everything is nested one level under a `data` wrapper, with the case itself under `test_case` inside it; reading the top level finds nothing.", + "The linked tickets are on the `issues` field of the returned case, each carrying the tracker's own ticket key. That is the list to read before unlinking, because unlinking does not let you choose between several linked tickets.", + "Ownership is checked against the caller's own group, so a case id from another account comes back as a body saying access was denied while the transport status is still 200 — check the success flag rather than the status.", + "During a platform downtime window this returns a success body with a null case instead of failing; treat a null case as 'unknown', not as 'no such case'." + ], + "returns": [ + "id", + "identifier", + "name", + "description", + "preconditions", + "expected_result", + "project_id", + "test_case_folder_id", + "issues", + "status", + "priority", + "case_type", + "automation_state", + "tags", + "custom_fields", + "steps", + "test_case_steps", + "template", + "assignee", + "creator_details", + "created_at", + "updator_details", + "updated_at", + "links", + "test_run_results", + "test_run_results_count", + "test_run_results_issues_count", + "metadata" + ], + "responses": { + "200": { + "description": "`{\"data\": {\"success\": true, \"test_case\": }}`. The case object also carries `test_run_results`, the page of execution results, and `issues`, the tracker tickets it is linked to. Also returned with `test_case` null during a downtime window.\n", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "TestCase" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "unlink_test_case", + "method": "POST", + "path": "/api/v1/integrations/{issue_type}/unlink-test-case", + "mode": "write", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the link belongs to. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." + } + ], + "body": [ + { + "name": "test_case_id", + "type": "integer", + "required": true, + "description": "Integer id of the test case to unlink. This, the caller's group and the tracker type are the whole match key — the case must belong to the caller's own group or the call is refused." + }, + { + "name": "issue_id", + "type": "string", + "example": "TES-121", + "description": "The tracker ticket key the case should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the case, the group and the tracker type only. Send it for the refreshed list that comes back, and see the guidance if the case has more than one linked ticket." + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of the refreshed linked-test-case list returned on success. Pages are fixed at 10 per page. Must be a plain scalar — an array or hash value is ignored and the first page is returned." + } + ], + "intent": "Unlink a test case from a tracker ticket.", + "guidance": [ + "On the failure path the body is just `success` and `status`, with no collection — identical to the test-run unlink, which shares this render. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", + "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. Always read the success flag — checking the status alone reports every miss as a win.", + "Which link is removed is decided by the test case, the caller's group and the tracker type, not by the ticket you name. If the case is linked to several tickets in the same tracker, the first matching link is removed and it may not be the one you meant — read the case's linked tickets first and expect to verify afterwards.", + "On success the refreshed list of test cases still linked to the ticket is returned, so no follow-up read is needed to confirm.", + "Nothing is deleted: the case, the run and the ticket all survive and the same link can be created again.", + "Nothing is written back to the external tracker — the ticket itself is not modified.", + "During a platform downtime window an empty list with a success flag is returned and nothing is unlinked." + ], + "returns": [ + "success", + "test_cases", + "info", + "recent_folder", + "recent_project" + ], + "responses": { + "200": { + "description": "On success, the refreshed list of test cases still linked to the ticket: `{\"success\": true, \"test_cases\": [...], \"info\": {...}, \"recent_folder\": ..., \"recent_project\": ...}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found, and as `{\"success\": false, \"error\": \"Invalid Access\", \"status\": 401}` when the test case is not in the caller's group — the numbers in these bodies are not the transport status.\nrecent_folder and recent_project are screen state carried alongside the result rather than part of it - see their own descriptions. During service downtime the whole body degrades to `{success: true, test_cases: []}`, with no info and no recent_* keys.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "LinkedIssueTestCase" + } + }, + "info": { + "$schema": "PageInfo" + }, + "recent_folder": { + "type": "object", + "nullable": true, + "description": "The folder this person most recently linked a case from, so a linking panel can reopen where they left off. A convenience for whoever is driving the screen, not part of the unlink result - nothing about it reflects the case you just unlinked, and it is unchanged by the unlink. Null when there is no such history. Returned only alongside the first page of the refreshed list; asking for a later page omits it." + }, + "recent_project": { + "type": "object", + "nullable": true, + "description": "The project this person most recently linked a case from. Paired with recent_folder and carrying the same caveats: screen state, null when there is no history, and returned only alongside the first page." + }, + "status": { + "type": "integer", + "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen — a successful unlink omits it. 404 means no matching link was found, which is also what a run or case that was already unlinked returns." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "unlink_test_run", + "method": "POST", + "path": "/api/v1/integrations/{issue_type}/unlink-test-run", + "mode": "write", + "entity": "issue", + "path_params": [ + { + "name": "issue_type", + "type": "string", + "required": true, + "values": [ + "jira-app", + "azure", + "asana", + "linear", + "clickup", + "confluence", + "figma", + "devrev", + "gitlab", + "github", + "custom", + "trello" + ], + "example": "jira-app", + "description": "Which external tracker the link belongs to. Only these values are routable — anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." + } + ], + "body": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer id of the test run to unlink. Must belong to the caller's own group, and the run is looked up inside `project_id`, which therefore becomes mandatory as soon as this is sent." + }, + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Integer id of the project that owns the run. Required whenever `test_run_id` is sent; omitting it is refused with a body saying the project id was not provided." + }, + { + "name": "issue_id", + "type": "string", + "example": "TES-121", + "description": "The tracker ticket key the run should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the run, the group and the tracker type only. Send it for the refreshed list that comes back." + }, + { + "name": "build_id", + "type": "integer", + "description": "Build id for the run, for accounts on build-based run ingestion. Omit the field entirely and the server resolves the run itself — that is the normal call. Sending a value that is not a positive integer is refused with 400." + }, + { + "name": "p", + "type": "integer", + "example": 1, + "description": "Page of the refreshed linked-test-run list returned on success. Pages are fixed at 10 per page." + } + ], + "intent": "Unlink a test run from a tracker ticket.", + "guidance": [ + "On the failure path the whole body is just `success` and `status` — there is no `test_runs` array. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", + "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. The same is true when the project id is missing or the run belongs to another group. Always read the success flag.", + "Which link is removed is decided by the test run, the caller's group and the tracker type, not by the ticket you name. If the run is linked to several tickets in the same tracker, the first matching link is removed — verify afterwards.", + "The run is resolved inside a project, so the project id must accompany the run id even though the ticket is the subject of the call.", + "On success the refreshed list of test runs still linked to the ticket is returned.", + "Nothing is deleted: the run and the ticket both survive and the same link can be created again. Nothing is written back to the external tracker.", + "Read the link first and look at its entity_type. When it is 'Build', this project stores runs as builds and the call needs build_id set to the run's uuid — omitting it returns a 200 with success false and a 404 body that looks exactly like 'no such link'. Confirmed by invocation: two identical calls without build_id were refused, the same call with it succeeded.", + "During a platform downtime window this refuses with a success flag set to false and a 404 in the body, on a 200 — indistinguishable from 'no such link' without checking platform status." + ], + "returns": [ + "success", + "test_runs", + "info" + ], + "responses": { + "200": { + "description": "On success, the refreshed list of test runs still linked to the ticket: `{\"success\": true, \"test_runs\": [...], \"info\": {...}}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found or during downtime, and as `{\"success\": false, \"error\": ..., \"status\": 400|401|404}` for a missing project id, a run outside the caller's group, or a run that does not exist — the numbers in these bodies are not the transport status.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_runs": { + "type": "array", + "items": { + "$schema": "TestRun" + } + }, + "info": { + "$schema": "PageInfo" + }, + "status": { + "type": "integer", + "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen — a successful unlink omits it entirely. 404 means no matching link was found, which is also what you get for a run that was already unlinked." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_tag", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/tags/{id}", + "mode": "write", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + }, + { + "name": "id", + "type": "integer", + "required": true, + "description": "The label's own id, which only the listing that returns ids gives you. The label's name will not work in its place, so look the id up first.", + "example": 1 + } + ], + "body": [ + { + "name": "new_name", + "type": "string", + "required": true, + "description": "The label's new wording. Surrounding spaces are trimmed, and a blank or spaces-only value is refused. This is the only thing a rename changes - nothing else about the label can be edited here.", + "example": "smoke-critical" + } + ], + "intent": "Rename a label across a project.", + "guidance": [ + "The new wording replaces the old one everywhere the label is already applied, in one step, with no per-record confirmation.", + "There is no undo and no saved history for labels, so the only way back is to rename it a second time - note the old wording before you start, because nothing else records it.", + "Only account owners and admins may do this; anyone else is refused, so check who is asking before promising it.", + "This changes wording only. Folding two labels into one is a different operation, and renaming one to match another does not combine them.", + "Look up the id first: this addresses a single label by id, and a project can hold same-named labels for different kinds of record, each needing its own rename.", + "The reply is the whole label record, not just what changed: its id and new wording, the workspace it belongs to, its colour palette, and when it was created and last updated. Nothing else about the label can be edited through this call, but everything about it comes back.", + "A value that was never set is left out of the reply rather than returned as empty, so read a missing colour palette or timestamp as 'not configured', not as a failure." + ], + "returns": [ + "id", + "name", + "group_id", + "color_preset", + "created_at", + "updated_at" + ], + "shape": "discovered", + "responses": { + "200": { + "description": "The renamed label, as a complete label record.", + "schema": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "description": "The label's own id, unchanged by the rename." + }, + "name": { + "type": "string", + "description": "The label's wording after the rename, with surrounding spaces trimmed." + }, + "group_id": { + "type": "integer", + "description": "The workspace the label belongs to. Labels are workspace-wide, not per project." + }, + "color_preset": { + "type": "string", + "description": "The label's colour palette, as one comma-separated list of hex colours rather than a single colour. Preserved by a rename. Omitted when the label has no palette set.", + "example": "#FBF6FE,#F7EAFF,#792395,#2C023F,#5C007A,#EFD4FF" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the label was first created. Omitted when not recorded." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the label was last changed, which this call advances. Omitted when not recorded." + }, + "deleted_at": { + "type": "string", + "nullable": true + }, + "last_used_at": { + "type": "string", + "nullable": true + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "record_status": { + "type": "string", + "example": "active" + }, + "restore_blocked_reason": { + "type": "string", + "nullable": true + }, + "updated_by": { + "type": "integer", + "example": 6013 + } + } + } + } + } + }, + { + "name": "verify_test_case_tags", + "method": "POST", + "path": "/api/v1/projects/{project_id}/test-case/tags/verify_tag", + "mode": "read", + "entity": "tag", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "tags", + "type": "array", + "required": true, + "description": "The label wordings to check, as a list of plain strings. Omit the whole list and the request is refused. An empty list is accepted and simply reports nothing new.", + "example": [ + "smoke", + "regression" + ] + } + ], + "intent": "Check which label names a project already uses.", + "guidance": [ + "Nothing is saved by this check, despite how it reads. A label only comes into being when it is actually applied to a test case, so follow this with the step that puts the label on the case.", + "Two lists come back: one holds the wordings not yet used on the project's test cases, the other holds the ones already in use. Neither is an error - being already in use is usually the answer you wanted.", + "The accompanying message says to verify the labels whenever any wording is already in use. It reads like a failure and is not one; go by the two lists.", + "Comparison is exact, so a difference in capitalisation or spacing counts as a brand new label and will leave you with two near-identical ones if you apply it.", + "Only labels on test cases are considered. A wording already used on test runs or plans still comes back as new here.", + "Every outcome, including the one where no wording is new, is returned as an ordinary success - read the flag and the lists rather than the status." + ], + "returns": [ + "success", + "tags", + "error_tags", + "error" + ], + "responses": { + "200": { + "description": "The verdict on the submitted wordings. 'tags' holds those not yet used on the project's test cases, 'error_tags' those already in use, and 'error' carries an advisory message whenever any wording was already in use.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "description": "False only when none of the submitted wordings were new." + }, + "tags": { + "type": "array", + "description": "Wordings not yet used on this project's test cases.", + "items": { + "type": "string" + } + }, + "error_tags": { + "type": "array", + "description": "Wordings already in use on this project's test cases. Present only when at least one was.", + "items": { + "type": "string" + } + }, + "error": { + "type": "string", + "description": "Advisory message shown when some or all wordings were already in use. Not a transport failure." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "disabled": true + }, + { + "name": "create_shared_component", + "method": "POST", + "path": "/api/v1/projects/{project_id}/shared-components", + "mode": "write", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project the shared field belongs to." + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the block, shown in the Shared Fields list." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "example": "background", + "description": "Which kind to create. OMITTING IT SILENTLY CREATES A `step` - it is not rejected, so a caller meaning to add a background or a precondition and forgetting this key gets a shared step instead, with no error to notice." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The ordered rows making up the block. REQUIRED. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "description": "1-based position. Required on every row." + }, + { + "name": "step", + "type": "string", + "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." + }, + { + "name": "result", + "type": "string", + "description": "Expected result; send an empty string when the kind has no result." + }, + { + "name": "test_data", + "type": "string", + "description": "Additional data for the row." + } + ] + }, + { + "name": "tags", + "type": "array", + "items": { + "type": "string" + }, + "description": "Plain tag name strings on the block itself." + }, + { + "name": "folder_id", + "type": "integer", + "nullable": true, + "description": "Collection to file it under. Omit or send null for the Unassigned root." + } + ], + "intent": "Create a reusable shared field - a step sequence, a Gherkin background block, or a precondition - that many test cases can then reference.", + "returns": [ + "success", + "data", + "id", + "title", + "field_type", + "test_case_count", + "step_count", + "tags", + "folder_id", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedComponentWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ONE CAPABILITY, THREE WORKFLOWS. `field_type` is the only difference between creating a shared step, a Gherkin background and a precondition - they are the same record and the same endpoint, not three features.", + "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "`field_type` defaults to `step`. There is no error for omitting it, so a create meant as a background or precondition succeeds as the wrong kind. Send it explicitly every time.", + "Sibling of create_shared_step on /shared-steps, which creates the same record but cannot set `field_type`. Use this route when the kind matters.", + "The response echoes `shared_step_details` with each row's assigned id; the LISTING returns only `step_count`. Read the echo if you need row ids.", + "All three field_type values can be created, and each lists back under the matching field_type filter." + ] + }, + { + "name": "update_shared_component", + "method": "PATCH", + "path": "/api/v1/projects/{project_id}/shared-components/{shared_component_id}", + "mode": "write", + "entity": "shared_field", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric id of the project." + }, + { + "name": "shared_component_id", + "type": "integer", + "required": true, + "description": "Id of the shared field to change, from list_shared_components." + } + ], + "body": [ + { + "name": "title", + "type": "string", + "required": true, + "description": "Name of the block. REQUIRED ON EVERY CALL even when you are only changing rows - sending rows alone is a 400." + }, + { + "name": "field_type", + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Which kind the block is. Send its existing value unless you intend to change it." + }, + { + "name": "shared_step_details", + "type": "array", + "required": true, + "description": "The FULL replacement row list, not a patch - rows you leave out are removed. REQUIRED ON EVERY CALL even when you are only renaming: sending `title` alone is a 400. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "fields": [ + { + "name": "order", + "type": "integer", + "required": true, + "description": "1-based position. Required on every row." + }, + { + "name": "step", + "type": "string", + "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." + }, + { + "name": "result", + "type": "string", + "description": "Expected result; send an empty string when the kind has no result." + }, + { + "name": "test_data", + "type": "string", + "description": "Additional data for the row." + } + ] + }, + { + "name": "tags", + "type": "array", + "items": { + "type": "string" + }, + "description": "Plain tag name strings; the list you send replaces the block's tags." + }, + { + "name": "folder_id", + "type": "integer", + "nullable": true, + "description": "Collection to move it into." + } + ], + "intent": "Change an existing shared field - its title, rows, tags or collection - with the edit propagating to every test case that references it.", + "returns": [ + "success", + "data", + "id", + "title", + "field_type", + "test_case_count", + "step_count", + "tags", + "folder_id", + "created_by", + "updated_by", + "shared_step_details" + ], + "responses": { + "200": { + "$response": "SharedComponentWriteResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "422": { + "$response": "UnprocessableEntity" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "BOTH `title` AND `shared_step_details` ARE REQUIRED ON EVERY CALL. Sending only one of them is a 400. There is no partial update here despite the PATCH verb.", + "`shared_step_details` REPLACES the row list. Read the block first and send back every row you want to keep, each with its `order`.", + "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", + "The edit propagates to every test case referencing the block - `test_case_count` tells you how many that is before you change it.", + "Sibling of update_shared_step on /shared-steps, which edits the same record but cannot set `field_type`." + ] + } + ], + "schemas": { + "TestRun": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "TR-17" + }, + "uuid": { + "type": "integer", + "example": 17 + }, + "identifier": { + "type": "string", + "example": "TR-17" + }, + "name": { + "type": "string", + "example": "Test Run1-20/05/2025" + }, + "description": { + "type": "string", + "example": "" + }, + "owner": { + "type": "integer", + "example": 2 + }, + "assignee": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "environment": { + "type": "string", + "nullable": true + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "detail": { + "type": "string" + } + } + }, + "test_cases_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ] + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ] + }, + "is_automation": { + "type": "boolean" + }, + "observability_url": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "string" + }, + "configurations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + }, + "metadata": { + "type": "object", + "nullable": true, + "properties": { + "test_plan_error": { + "type": "string", + "nullable": true, + "description": "Why the run could not be linked to a plan, e.g. `PLAN_LINKED`. Present only when a plan link was attempted and refused.", + "example": "PLAN_LINKED" + } + } + }, + "assignee_imported": { + "type": "boolean" + }, + "is_dynamic": { + "type": "boolean" + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "archived_at": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-18T05:00:22.000Z" + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "example": "2026-10-18T00:00:00.000Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner": { + "type": "integer", + "example": 6013 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_sent_date": { + "type": "string", + "nullable": true + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "example": "2026-09-18T00:00:00.000Z" + }, + "sub_plans_count": { + "type": "integer", + "example": 14 + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T12:53:05.000Z" + }, + "user_id": { + "type": "integer", + "example": 6013 + } + } + } + }, + "attachments": { + "type": "array", + "description": "Files attached to the run. Normally returned as `[]` — present as a key but not populated.", + "items": { + "type": "object" + } + }, + "created_by": { + "$schema": "TestRunUserRef", + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." + }, + "updated_by": { + "$schema": "TestRunUserRef", + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Usually null, even on runs whose `run_state` is `done` — a closed run does not necessarily carry a close timestamp. Do not infer closure from this field; read `run_state`." + }, + "closed_by": { + "type": "integer", + "nullable": true, + "description": "Usually null, even on runs in `run_state: done`. Same caveat as closed_at." + } + } + }, + "Project": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the project." + }, + "id": { + "type": "integer", + "description": "Unique ID for the project." + }, + "description": { + "type": "string", + "description": "A brief description of the project." + }, + "identifier": { + "type": "string", + "description": "A short unique identifier (e.g., PR-674)." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp indicating when the project was created." + }, + "import_id": { + "type": "string", + "description": "An optional import identifier. May be null.", + "example": null, + "nullable": true + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Standard error response format.", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Application-specific error code.", + "example": "ERR_INVALID_PARAMS" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "The provided parameters are invalid." + }, + "details": { + "type": "object", + "description": "Optional field with additional error details." + } + } + } + } + }, + "V1TestPlanBulkResponse": { + "type": "object", + "description": "Dual-shape response. Below the bulk threshold the work is done and you get\n`plans` + `count`. Above it the work was handed to a background job and you get\n`async: true` + `unique_id` — the plans are NOT changed yet at that point; re-read to\nconfirm (see the async-jobs concept).\n", + "properties": { + "success": { + "type": "boolean" + }, + "async": { + "type": "boolean", + "description": "Present and true only when the operation was dispatched asynchronously." + }, + "unique_id": { + "type": "string", + "description": "Correlation id of the async job. Present only when async is true." + }, + "plans": { + "type": "array", + "description": "Affected plans. Present only on the synchronous path.", + "items": { + "type": "object" + } + }, + "count": { + "type": "integer", + "description": "Number of affected plans. Present only on the synchronous path." + } + } + }, + "User": { + "type": "object", + "description": "The full user reference used by most TM serializers.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + } + } + }, + "TestCaseStep": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "integer" + }, + "order": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "result": { + "type": "string" + }, + "step": { + "type": "string" + }, + "test_case_id": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TestCase": { + "type": "object", + "description": "The project-level test-case record: TestCaseCore plus the three fields every project-level route adds. The run-mapping rows of list_test_run_test_cases_by_integer_id do NOT return these three and compose on TestCaseCore instead.\n", + "allOf": [ + { + "$schema": "TestCaseCore" + } + ], + "properties": { + "estimated_duration_seconds": { + "type": "integer", + "nullable": true, + "description": "Estimated duration in seconds. THIS is the real value — `estimate` is always null. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + }, + "template_name": { + "type": "string", + "nullable": true, + "description": "Display name of that template; null for cases on the plain-text template. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + }, + "test_case_dataset": { + "type": "object", + "description": "Dataset bound to this case; `{}` when none. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." + } + } + }, + "PageInfo": { + "type": "object", + "description": "Pagination metadata returned with paged API responses.", + "required": [ + "page", + "page_size", + "count", + "prev", + "next" + ], + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "type": "integer", + "description": "Total number of items." + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true + } + } + }, + "TestCaseListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "description": "One row per case-x-configuration mapping in the run.", + "items": { + "$schema": "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId" + } + }, + "info": { + "$schema": "PageInfo" + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level, not inside a row: case ids the review gate excluded from this page. Present only when the gate excluded something.", + "items": { + "type": "integer" + } + } + } + }, + "Folder": { + "type": "object", + "properties": {} + }, + "Configuration": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the configuration." + }, + "group_id": { + "type": "integer", + "description": "ID of the group associated with the configuration." + }, + "name": { + "type": "string", + "description": "Name of the configuration." + }, + "record_status": { + "type": "string", + "description": "Status of the record (e.g., active)." + }, + "is_suggested": { + "type": "boolean", + "description": "Indicates if the configuration is suggested." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp." + }, + "is_valid_on_bstack": { + "type": "boolean", + "example": false + } + } + }, + "Dataset": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "variables": { + "type": "array", + "description": "List of dataset variables/columns.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the variable/column." + }, + "uuid": { + "type": "string", + "description": "Unique identifier for the variable/column." + } + } + } + }, + "rows": { + "type": "array", + "description": "List of dataset rows.", + "items": { + "type": "object", + "properties": { + "row_data": { + "type": "array", + "description": "Array of values for the row.", + "items": { + "type": "string" + } + }, + "row_number": { + "type": "integer", + "description": "Row number in the dataset." + }, + "uuid": { + "type": "string", + "description": "Unique identifier for the row." + } + } + } + }, + "rows_count": { + "type": "integer", + "description": "Total number of rows in the dataset." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was created." + }, + "created_by": { + "$schema": "DatasetUserRef" + } + } + }, + "ExploratorySessionUserRef": { + "type": "object", + "description": "A user reference object returned in exploratory session responses.", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "description": "TCM user ID.", + "example": 1 + }, + "email": { + "type": "string", + "format": "email", + "description": "User email address.", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "description": "User full name.", + "example": "Jane Doe" + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to.", + "example": 10 + } + } + }, + "ExploratorySessionIssue": { + "type": "object", + "description": "A linked issue / defect associated with an exploratory session or session log.", + "required": [ + "issue_id", + "issue_type" + ], + "properties": { + "issue_id": { + "type": "string", + "description": "Identifier of the issue in the external issue tracker (e.g. Jira key).", + "example": "PROJ-123" + }, + "issue_type": { + "type": "string", + "description": "Type of the issue in the external tracker.", + "example": "Bug" + } + } + }, + "ExploratorySessionTimelineEntry": { + "type": "object", + "description": "A single entry on an exploratory session's timeline: {status, elapsed_time, created_by}. It carries no `action` or `timestamp`.", + "properties": { + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "elapsed_time": { + "type": "integer", + "example": 120 + }, + "status": { + "type": "string", + "example": "bug" + } + } + }, + "ExploratorySession": { + "type": "object", + "description": "A full exploratory testing session, as returned by the single-session read, create, update and clone. The LIST route returns a narrower projection — see ExploratorySessionListRow.\n", + "allOf": [ + { + "$schema": "ExploratorySessionCore" + } + ], + "properties": { + "description": { + "type": "string", + "description": "Optional description of the session.", + "example": "Exploring the checkout flow for edge cases.", + "nullable": true + }, + "charter": { + "type": "string", + "description": "Testing charter describing the scope and goals of this session.", + "example": "Explore payment gateway error handling", + "nullable": true + }, + "duration": { + "type": "integer", + "description": "Actual duration tracked during the session in minutes.", + "example": 45, + "nullable": true + }, + "actual_duration": { + "type": "integer", + "description": "Final recorded duration of the session in minutes (set on close).", + "example": 50, + "nullable": true + }, + "session_log_count": { + "type": "integer", + "description": "Number of logs recorded in this session.", + "example": 12 + }, + "closed_by": { + "$schema": "ExploratorySessionUserRef" + }, + "configurations": { + "type": "array", + "description": "Configurations associated with the session.", + "items": { + "type": "object" + } + }, + "issues": { + "type": "array", + "description": "Issues linked to this session.", + "items": { + "$schema": "ExploratorySessionIssue" + } + }, + "timeline": { + "type": "array", + "description": "Ordered list of lifecycle events for this session.", + "items": { + "$schema": "ExploratorySessionTimelineEntry" + } + }, + "owner": { + "type": "integer", + "nullable": true + }, + "record_status": { + "type": "string", + "description": "Lifecycle state of the record, e.g. `active`.", + "example": "active" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "attachment_relations": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Join rows linking the session to its attachments; parallel to `attachments`." + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "ExploratorySessionLog": { + "type": "object", + "description": "A log entry recorded within an exploratory testing session.", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the session log.", + "example": 789 + }, + "session_id": { + "type": "integer", + "description": "ID of the exploratory session this log belongs to.", + "example": 123 + }, + "content": { + "type": "string", + "description": "Rich-text HTML content of the log entry.", + "example": "

Found an issue with the payment button on mobile.

", + "nullable": true + }, + "status": { + "type": "string", + "description": "Test result status for this log entry.", + "enum": [ + "pass", + "fail", + "bug", + "retest", + "block", + "note" + ], + "example": "fail", + "nullable": true + }, + "elapsed_time": { + "type": "integer", + "description": "Time elapsed for this step in seconds.", + "example": 120, + "nullable": true + }, + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "defects": { + "type": "array", + "description": "Defects / issues linked to this log entry.", + "items": { + "$schema": "ExploratorySessionIssue" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the log was created.", + "example": "2026-01-16T10:35:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the log was last updated.", + "example": "2026-01-16T10:40:00Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "attachment_relations": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Join rows linking the log to its attachments; parallel to `attachments`." + } + } + }, + "Filter": { + "type": "object", + "description": "Filter object representing a saved filter view", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the filter" + }, + "name": { + "type": "string", + "description": "Name of the filter" + }, + "entity_type": { + "type": "string", + "description": "Entity type the filter applies to", + "enum": [ + "testcase", + "testplan", + "testrun" + ] + }, + "is_project_level": { + "type": "boolean", + "description": "Whether this filter is available at project level" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the filter was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the filter was last updated" + }, + "owner": { + "type": "integer", + "description": "User ID of the creator" + }, + "project_id": { + "type": "integer", + "description": "Project ID this filter belongs to" + } + } + }, + "ProjectCreateRequest": { + "type": "object", + "required": [ + "project" + ], + "properties": { + "project": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + }, + "ReportType": { + "type": "string", + "description": "Report type, sent as its DISPLAY NAME (not a machine slug).\n\nSeven types produce data. `Defects Summary` and `Defects Detailed Report` are\naccepted on create/update but have no data branch on the server, so such a report\nalways reads back with `report_data: null` — never offer them as a way to report\non defects (use `Test Run Summary`, whose sections include `issues_by_priority` /\n`issues_by_status`).\n\nPick by what the user asked about:\n - test cases created / updated / deleted, churn, top creators → `Test Case Activity`\n - run execution + pass rates → `Test Run Summary`\n - per-case results within runs → `Test Run Detailed Report`\n - plan-level rollup → `Test Plan Summary`\n - requirement/issue coverage → `Requirement Traceability Report`\n - exploratory sessions → `Exploratory Session Summary`\n - per-user workload → `User Workload Report`\n", + "enum": [ + "Test Run Summary", + "Test Run Detailed Report", + "Test Plan Summary", + "Requirement Traceability Report", + "Test Case Activity", + "Exploratory Session Summary", + "User Workload Report", + "Defects Summary", + "Defects Detailed Report" + ], + "example": "Test Run Summary" + }, + "ReportFrequency": { + "type": "string", + "description": "Scheduling cadence. Send `frequency` and `frequency_details` TOGETHER, or omit\nBOTH — omitting both creates a valid unscheduled, on-demand report.\n\nTwo consequences of omitting them: `mail_to` is only persisted for scheduled\nreports (recipients on an unscheduled report are silently dropped), and\n`next_run_at` stays null.\n\nThere is no `once` value — the server's cadence enum is daily/weekly/monthly only.\n", + "enum": [ + "daily", + "weekly", + "monthly" + ], + "example": "weekly" + }, + "ReportTimeframe": { + "type": "string", + "description": "The window a report covers. Also the value of the `reportTimeRange` query param\non the report-detail read.\n\nThe four relative windows compute a date range from \"now\". `custom` computes it\nfrom `custom_date_range` and **requires** that field — sending `custom` without it\nis an unhandled server error, not a 422.\n\nThe five remaining values carry no dates; they select entities through\n`report_filters` instead:\n `specific_test_runs` → report_filters.test_runs\n `specific_test_plans` → report_filters.test_plans\n `specific_test_cases` → report_filters.test_cases\n `specific_sessions` → report_filters.session_ids\n `requirements` → report_filters.requirements\n", + "enum": [ + "last_one_day", + "last_one_week", + "last_one_month", + "last_three_months", + "custom", + "specific_test_runs", + "specific_test_plans", + "specific_test_cases", + "specific_sessions", + "requirements" + ], + "example": "last_one_week" + }, + "FrequencyDetails": { + "type": "object", + "description": "Shape depends on `frequency` — the server rejects the wrong shape with\n\"Invalid report frequency\", which names `frequency` even when `frequency` itself\nwas fine and only `day` was missing:\n\n daily → { \"time\": \"08:00\" }\n weekly → { \"time\": \"08:00\", \"day\": \"monday\" } day = lowercase day name\n monthly → { \"time\": \"08:00\", \"day\": 15 } day = integer 1-28\n\n`time` is 24-hour `HH:MM`, interpreted as UTC.\n", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + } + }, + "ReportOwner": { + "type": "object", + "required": [ + "id", + "email", + "full_name", + "group_id", + "browserstack_user_id", + "onboarded", + "reports_and_notification_enabled" + ], + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "email": { + "type": "string", + "format": "email", + "example": "fake1@example.com" + }, + "full_name": { + "type": "string", + "example": "Fake Name" + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "browserstack_user_id": { + "type": "integer", + "example": 2 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "MailToUser": { + "type": "object", + "required": [ + "users", + "external_mails" + ], + "properties": { + "users": { + "type": "array", + "items": { + "$schema": "ReportOwner" + } + }, + "external_mails": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ReportDetail": { + "type": "object", + "required": [ + "id", + "report_type", + "created_at", + "description", + "title", + "frequency", + "identifier", + "next_run_at", + "report_timeframe", + "group_id", + "frequency_details", + "project_id", + "report_creation_mode", + "owner", + "mail_to" + ], + "properties": { + "id": { + "type": "integer", + "example": 3 + }, + "report_type": { + "$schema": "ReportType" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-12T12:52:17.658Z" + }, + "description": { + "type": "string", + "example": "" + }, + "title": { + "type": "string", + "example": "Test Run Summary" + }, + "frequency": { + "type": "object" + }, + "identifier": { + "type": "string", + "example": "SC-3" + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-13T05:00:00.000Z", + "nullable": true + }, + "report_timeframe": { + "$schema": "ReportTimeframe" + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "frequency_details": { + "type": "object", + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + }, + "required": [ + "time" + ] + }, + "project_id": { + "type": "integer", + "example": 10000 + }, + "report_creation_mode": { + "type": "string", + "enum": [ + "custom_report", + "system_generated" + ], + "example": "custom_report" + }, + "custom_date_range": { + "type": "string", + "example": null, + "nullable": true + }, + "owner": { + "$schema": "ReportOwner" + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "included_projects": { + "type": "object" + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "slack_notification": { + "type": "string", + "nullable": true + }, + "slack_notification_supported": { + "type": "boolean", + "example": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T04:32:35.703Z" + } + } + }, + "CreateReportResponse": { + "type": "object", + "required": [ + "success", + "data", + "project" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$schema": "ReportDetail__CreateReportByIntegerId" + }, + "project": { + "$schema": "Project__CreateReportByIntegerId" + } + } + }, + "FolderHierarchy": { + "type": "object", + "description": "Folder hierarchy mapping. Each key is a folder ID (as a string), and the value is an array of ancestor folder objects ordered from the folder itself up to the root.\n", + "example": { + "9": [ + { + "id": 9, + "name": "1", + "parent_id": null + } + ], + "10": [ + { + "id": 10, + "name": "1.2", + "parent_id": 9 + }, + { + "id": 9, + "name": "1", + "parent_id": null + } + ], + "11": [ + { + "id": 11, + "name": "1.2.2", + "parent_id": 10 + }, + { + "id": 10, + "name": "1.2", + "parent_id": 9 + }, + { + "id": 9, + "name": "1", + "parent_id": null + } + ] + } + }, + "SharedStepBase": { + "type": "object", + "required": [ + "title", + "id", + "created_by", + "updated_by" + ], + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + } + } + }, + "SharedStepDetail": { + "type": "object", + "required": [ + "id", + "step", + "result", + "order" + ], + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string", + "description": "HTML formatted step content" + }, + "result": { + "type": "string", + "description": "HTML formatted expected result content" + }, + "order": { + "type": "integer" + }, + "test_data": { + "type": "string", + "description": "Optional test data for the step" + } + } + }, + "ResultStatus": { + "type": "object", + "description": "A configured status value. Shared by the v1 result reads, the v1 result create and the report detail; the v2 per-case result list returns a cut-down version of its own and does not use this shape.", + "required": [ + "id", + "field_value", + "internal_name", + "field_name", + "colour" + ], + "properties": { + "id": { + "type": "integer", + "example": 22 + }, + "field_value": { + "type": "string", + "example": "Failed", + "description": "Display-case name. This is the value to match on." + }, + "internal_name": { + "type": "string", + "example": "failed", + "description": "Lowercase internal token for the same status." + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "Attachment": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + } + } + }, + "TestResult": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "author": { + "$schema": "User" + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-25T20:02:05.000Z" + }, + "description": { + "type": "string" + }, + "error_description": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "example": null, + "nullable": true + }, + "failure_type": { + "type": "string", + "example": null, + "nullable": true + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "type": "string", + "example": "passed" + }, + "result_status": { + "$schema": "ResultStatus" + }, + "configuration_id": { + "type": "integer", + "description": "ID of the configuration this test result is associated with", + "example": 16, + "nullable": true + }, + "time_elapsed": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "size": { + "type": "string" + } + } + } + }, + "created_by_imported": { + "type": "boolean", + "nullable": true + }, + "issues": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" + }, + "custom_fields": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473/custom-fields" + } + } + }, + "custom_fields": { + "type": "object" + }, + "test_run_step_result": { + "type": "object", + "nullable": true + }, + "updated_by": { + "type": "string", + "nullable": true + } + } + }, + "Report": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Report ID" + }, + "report_type": { + "$schema": "ReportType" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp indicating when the scheduled report was created" + }, + "description": { + "type": "string", + "description": "Description of the scheduled report" + }, + "title": { + "type": "string", + "description": "Title of the scheduled report" + }, + "frequency": { + "type": "object" + }, + "identifier": { + "type": "string", + "description": "The report's SC-NNN identifier" + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "description": "Next scheduled iteration, or null when the report is not scheduled", + "nullable": true + }, + "report_timeframe": { + "$schema": "ReportTimeframe" + }, + "group_id": { + "type": "integer" + }, + "frequency_details": { + "type": "object", + "properties": { + "time": { + "type": "string", + "description": "24-hour HH:MM, UTC.", + "example": "08:00" + }, + "day": { + "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", + "example": "monday" + } + }, + "required": [ + "time" + ] + }, + "project_id": { + "type": "integer" + }, + "report_creation_mode": { + "type": "string", + "enum": [ + "custom_report", + "system_generated" + ] + }, + "custom_date_range": { + "type": "string" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Id of the report owner" + }, + "email": { + "type": "string", + "description": "Email to which the report is to be sent" + }, + "full_name": { + "type": "string", + "description": "Name of the owner" + }, + "group_id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } + } + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "included_projects": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "slack_notification": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-21T13:41:14.555Z" + } + } + }, + "AttachmentResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "filename": { + "type": "string", + "example": "document1.pdf" + }, + "content_type": { + "type": "string", + "example": "application/pdf" + }, + "byte_size": { + "type": "integer", + "example": 102400 + } + } + }, + "configuration": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the configuration." + }, + "group_id": { + "type": "integer", + "description": "ID of the group associated with the configuration." + }, + "name": { + "type": "string", + "description": "Name of the configuration." + }, + "record_status": { + "type": "string", + "description": "Status of the record (e.g., active)." + }, + "is_suggested": { + "type": "boolean", + "description": "Indicates if the configuration is suggested." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Creation timestamp." + }, + "browser": { + "type": "string", + "example": "Chrome" + }, + "browser_version": { + "type": "string", + "example": "146" + }, + "device": { + "type": "string", + "nullable": true + }, + "is_valid_on_bstack": { + "type": "boolean", + "example": false + }, + "os": { + "type": "string", + "example": "Windows" + }, + "os_version": { + "type": "string", + "example": "10" + } + } + }, + "ConfigurationListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful." + }, + "configurations": { + "type": "array", + "description": "List of configuration objects.", + "items": { + "$schema": "configuration" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "DatasetVariable": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset column/variable." + }, + "name": { + "type": "string", + "description": "Name of the column/variable." + }, + "dataset": { + "type": "object", + "description": "Dataset information that this column belongs to.", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "count_rows": { + "type": "integer", + "description": "Number of rows in the dataset." + } + } + } + } + }, + "DatasetVariablesResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the request was successful." + }, + "variables": { + "type": "array", + "description": "List of dataset columns/variables.", + "items": { + "$schema": "DatasetVariable" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "MyselfUser": { + "type": "object", + "required": [ + "id", + "full_name", + "reports_and_notification_enabled" + ], + "properties": { + "id": { + "type": "integer", + "description": "User ID" + }, + "full_name": { + "type": "string", + "description": "Full name of the current user" + }, + "email": { + "type": "string", + "description": "Email address of the current user" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Whether reports and notifications are enabled for the user" + } + } + }, + "UsersV2Response": { + "type": "object", + "required": [ + "success", + "users", + "myself", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "users": { + "type": "array", + "items": { + "$schema": "User" + } + }, + "myself": { + "$schema": "MyselfUser" + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "V1ProjectDetail": { + "type": "object", + "description": "v1 project-detail envelope (GET /api/v1/projects/{project_id}, by INTEGER id). The translation output is data.project.identifier (the PR-NNN form), alongside data.project.id (the integer id you queried by).\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "project": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "PR-NNN identifier — the v2 {project_id}. (Integer -> PR-NNN translation output.)", + "example": "PR-1" + }, + "id": { + "type": "integer", + "description": "Integer project id — the value passed in the path. Same as thProjectId.", + "example": 11046 + }, + "thProjectId": { + "type": "integer", + "example": 11046 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "description": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "nullable": true + }, + "test_runs_count": { + "type": "integer", + "nullable": true + }, + "test_plans_count": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "starred": { + "type": "boolean" + }, + "jira_mapped": { + "type": "boolean" + }, + "links": { + "type": "object" + }, + "import_id": { + "type": "integer", + "nullable": true, + "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." + }, + "normalisedName": { + "type": "string", + "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." + }, + "projectVisibilityBanner": { + "type": "object", + "nullable": true, + "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." + } + } + } + } + } + } + }, + "ProjectMinifyResponse": { + "type": "object", + "required": [ + "success", + "projects" + ], + "properties": { + "success": { + "type": "boolean" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "import_id": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "starred": { + "type": "boolean" + }, + "access_via_secondary_team": { + "type": "boolean", + "example": false + } + } + } + } + } + }, + "ProjectsPaginatedResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "import_id": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "starred": { + "type": "boolean" + }, + "access_via_secondary_team": { + "type": "boolean", + "example": false + } + } + } + }, + "info": { + "$schema": "PageInfo" + } + }, + "required": [ + "info", + "projects", + "success" + ] + }, + "ReportFiltersDetail": { + "type": "object", + "required": [ + "custom_fields", + "test_run_ids", + "first_test_run" + ], + "properties": { + "custom_fields": { + "type": "object" + }, + "test_run_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "first_test_run": { + "type": "object", + "required": [ + "id", + "name", + "created_at", + "identifier", + "is_automation", + "run_state", + "updated_at", + "project_id", + "user_id", + "group_id" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "environment": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "run_state": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + } + } + } + } + }, + "DynamicFilters": { + "type": "object", + "properties": { + "test_run": { + "type": "object", + "required": [ + "created_at", + "status", + "owner", + "automation_status", + "type" + ], + "properties": { + "created_at": { + "type": "string" + }, + "status": { + "type": "array", + "items": { + "type": "integer" + } + }, + "owner": { + "type": "array", + "items": { + "type": "integer" + } + }, + "automation_status": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "ReportData": { + "type": "object", + "required": [ + "test_run_data", + "issues_data", + "test_run_status", + "test_cases_status", + "user_result_stats", + "issues_by_status", + "issues_by_priority" + ], + "properties": { + "test_run_data": { + "type": "object", + "required": [ + "active", + "closed", + "test_cases", + "test_results" + ], + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + }, + "test_cases": { + "type": "integer" + }, + "test_results": { + "type": "integer" + } + } + }, + "issues_data": { + "type": "object", + "required": [ + "total", + "linked_with_test_runs", + "linked_with_test_results" + ], + "properties": { + "total": { + "type": "integer" + }, + "linked_with_test_runs": { + "type": "integer" + }, + "linked_with_test_results": { + "type": "integer" + } + } + }, + "test_run_status": { + "type": "object" + }, + "test_cases_status": { + "type": "array", + "items": { + "type": "object", + "required": [ + "count", + "result_status" + ], + "properties": { + "count": { + "type": "integer" + }, + "result_status": { + "$schema": "ResultStatus" + } + } + } + }, + "user_result_stats": { + "type": "array", + "items": { + "type": "object", + "required": [ + "user_id", + "test_results", + "user_name" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "test_results": { + "type": "integer" + }, + "user_name": { + "type": "string" + } + } + } + }, + "issues_by_status": { + "type": "array", + "items": { + "type": "object" + } + }, + "issues_by_priority": { + "type": "array", + "items": { + "type": "object" + } + }, + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "object" + } + } + }, + "ReportProjectDetail": { + "type": "object", + "required": [ + "id", + "group_id", + "user_id", + "name", + "slug", + "description", + "identifier", + "is_demo", + "publicly_readable", + "created_at", + "source", + "archived", + "team_id", + "default_team_id" + ], + "properties": { + "id": { + "type": "integer", + "example": 10000 + }, + "group_id": { + "type": "integer", + "example": 1 + }, + "user_id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "slug": { + "type": "string", + "example": "Demo-Project-8e37e463" + }, + "description": { + "type": "string", + "example": "This project contains test cases and test runs for the demo project" + }, + "identifier": { + "type": "string", + "example": "PR-3" + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2024-06-28T00:00:00.000Z" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "archived": { + "type": "integer", + "example": 0 + }, + "team_id": { + "type": "integer", + "example": 1 + }, + "default_team_id": { + "type": "integer", + "example": null, + "nullable": true + }, + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "import_id": { + "type": "string", + "nullable": true + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "SelectionData": { + "type": "object", + "description": "A folder-keyed test-case selection: which cases, grouped by the folder they live in.\nAll three keys are required. Widen with `select_all` (whole project) or a folder's\n`test_case_select_all` (whole folder), then subtract with `deselected_test_cases`;\notherwise the cases are listed explicitly in `selected_test_cases`.\n", + "required": [ + "select_all", + "folders", + "unique_test_case_count" + ], + "properties": { + "select_all": { + "type": "boolean", + "description": "Select every case in the project.", + "example": false + }, + "folders": { + "type": "object", + "description": "Map of folder id (as a STRING key) to that folder's selection." + }, + "unique_test_case_count": { + "type": "integer", + "description": "Number of distinct cases the selection resolves to.", + "example": 3 + } + } + }, + "V1TestCaseDetail": { + "type": "object", + "description": "v1 test case by INTEGER ids (GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}). Translation output: data.test_case.identifier is the TC-NNN form. The row is the full shared test-case record, not a stub.\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "description": "The full test-case record. `identifier` is the TC-NNN id and `id` is the integer passed in the path. The display field is `name`; there is no `title`.", + "$schema": "V1TestCaseRecordRow__GetTestCaseByIntegerId" + } + } + } + } + }, + "TestCaseTrend": { + "type": "object", + "required": [ + "graphData", + "field", + "value" + ], + "properties": { + "graphData": { + "type": "object", + "properties": { + "Jun": { + "type": "integer" + }, + "Jul": { + "type": "integer" + }, + "Aug": { + "type": "integer" + }, + "Sep": { + "type": "integer" + }, + "Oct": { + "type": "integer" + }, + "Nov": { + "type": "integer" + }, + "Dec": { + "type": "integer" + }, + "Jan": { + "type": "integer" + }, + "Feb": { + "type": "integer" + }, + "Mar": { + "type": "integer" + }, + "Apr": { + "type": "integer" + }, + "May": { + "type": "integer" + } + } + }, + "field": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "History": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "history record ID" + }, + "entity_id": { + "type": "integer", + "description": "test case ID this history belongs to" + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "version_id": { + "type": "string", + "format": "uuid" + }, + "source": { + "type": "string", + "description": "Source of the change (e.g., \"update\", \"create\")" + }, + "modified_fields": { + "type": "array", + "description": "List of fields that were modified in this version", + "items": { + "type": "string" + } + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "comment": { + "type": "string", + "nullable": true + }, + "user_id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + } + }, + "version_name": { + "type": "string", + "nullable": true + }, + "user": { + "$schema": "User" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "description": "API endpoint to retrieve this specific history record", + "example": "/api/v1/projects/2022091/test-cases/36347792/histories/17604265" + } + } + }, + "origin_channel": { + "type": "string", + "example": "api" + } + } + }, + "HistoryListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "histories": { + "type": "array", + "description": "List of history records for the test case", + "items": { + "$schema": "History__ListTestCaseHistories" + } + }, + "info": { + "$schema": "PageInfo" + }, + "plan_restriction": { + "type": "boolean" + } + } + }, + "TestCaseDiffStep": { + "type": "object", + "required": [ + "id", + "step", + "order", + "result" + ], + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "result": { + "type": "string" + }, + "shared_step_id": { + "type": "integer", + "nullable": true + } + } + }, + "TestCaseConsolidatedResultFolders": { + "type": "object" + }, + "TestCaseConsolidatedListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "V1TestCaseListRow__ListTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "folders": { + "$schema": "TestCaseConsolidatedResultFolders" + } + } + }, + "V1TestPlanDetail": { + "type": "object", + "description": "v1 test-plan detail (GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}, by INTEGER ids). NOTE: there is NO `data` wrapper — the plan sits at the top level under `test_plan`. Translation output: test_plan.identifier (the TP-NNN form).\n", + "properties": { + "success": { + "type": "boolean" + }, + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + } + } + } + } + }, + "ResultCustomField": { + "type": "object", + "required": [ + "id", + "field_type", + "field_name", + "entity_type", + "field_user_name", + "optional", + "is_bulk_editable", + "is_filterable", + "links", + "is_required" + ], + "properties": { + "id": { + "type": "integer" + }, + "field_type": { + "type": "string", + "enum": [ + "field_string", + "field_text", + "field_user", + "field_dropdown", + "field_multi_dropdown", + "field_url", + "field_boolean", + "field_int", + "field_date", + "field_rte" + ] + }, + "field_name": { + "type": "string" + }, + "entity_type": { + "type": "string", + "enum": [ + "TestCase", + "TestResult", + "TestExecution", + "Build" + ] + }, + "field_user_name": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "is_bulk_editable": { + "type": "boolean" + }, + "is_filterable": { + "type": "boolean" + }, + "links": { + "type": "object", + "required": [ + "self" + ], + "properties": { + "self": { + "type": "string" + } + } + }, + "placeholder": { + "type": "string" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "object" + }, + "option_values": { + "type": "array", + "items": { + "type": "object" + } + }, + "field_values": { + "type": "array", + "items": { + "type": "object" + } + }, + "sort_options": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + } + } + }, + "TestResultCustomFieldsResponse": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$schema": "ResultCustomField" + } + }, + "info": { + "$schema": "PageInfo__GetTestResultsCustomFields" + } + } + }, + "V1TestRunDetail": { + "type": "object", + "description": "v1 test-run detail (GET /api/v1/projects/{project_id}/test-runs/{test_run_id}, by INTEGER ids). Translation output: data.test_run.identifier (the TR-NNN form). The integer run id is surfaced as data.test_run.uuid.\n", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_run": { + "$schema": "V1TestRunDetailRow" + } + } + } + } + }, + "TestResultStatusValue": { + "type": "object", + "required": [ + "internal_name", + "is_default", + "colour", + "value", + "name" + ], + "properties": { + "internal_name": { + "type": "string", + "description": "Internal system name for the status" + }, + "is_default": { + "type": "integer", + "description": "Whether this is the default status (1 or 0)", + "enum": [ + 0, + 1 + ] + }, + "colour": { + "type": "string", + "description": "Hex color code for the status" + }, + "value": { + "type": "integer", + "description": "Numeric value identifier for the status" + }, + "name": { + "type": "string", + "description": "Display name of the status" + } + } + }, + "TestResultStatusField": { + "type": "object", + "required": [ + "values", + "total_count", + "links" + ], + "properties": { + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "is_default": { + "type": "integer", + "example": 0 + }, + "name": { + "type": "string", + "example": "Passed" + }, + "value": { + "type": "integer", + "example": 318610 + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "total_count": { + "type": "integer", + "example": 8 + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/test-case/status" + } + } + } + } + }, + "CustomField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier of the custom field" + }, + "field_type": { + "type": "string", + "description": "Type of the custom field", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date" + ] + }, + "field_name": { + "type": "string", + "description": "Name of the custom field" + }, + "is_required": { + "type": "boolean", + "description": "Whether the custom field is required" + }, + "place_holder_text": { + "type": "string", + "description": "Placeholder text for the custom field", + "nullable": true + }, + "default_value": { + "type": "string", + "description": "Default value for the custom field", + "nullable": true + }, + "entity_type": { + "type": "string", + "description": "Type of entity this field is for", + "example": "TestCase" + }, + "options": { + "type": "array", + "description": "Options for dropdown or multi-dropdown fields", + "items": { + "type": "object", + "properties": { + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + } + } + } + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Whether the custom field applies to all projects" + }, + "assigned_projects": { + "type": "array", + "description": "List of project identifiers this field is assigned to", + "items": { + "type": "string" + } + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Whether to link this field to future projects" + } + } + }, + "TestRunFormFieldsResponse": { + "type": "object", + "required": [ + "success", + "default_fields" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "default_fields": { + "type": "object", + "description": "Default field values for test results. Only 'status' field is included by default.", + "properties": { + "status": { + "$schema": "FormFieldDescriptor" + } + } + }, + "custom_fields": { + "type": "array", + "description": "Custom fields array. Only present when 'all' parameter is true.", + "items": { + "$schema": "CustomField" + } + } + } + }, + "DatasetMini": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "Unique identifier for the dataset." + }, + "name": { + "type": "string", + "description": "Name of the dataset." + }, + "rows_count": { + "type": "integer", + "description": "Total number of rows in the dataset." + }, + "test_cases_count": { + "type": "integer", + "description": "Number of test cases using this dataset." + }, + "created_by": { + "$schema": "DatasetUserRef__ListDatasets" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the dataset was last updated." + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } + } + }, + "DatasetsListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the request was successful." + }, + "datasets": { + "type": "array", + "description": "List of datasets.", + "items": { + "$schema": "DatasetMini" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "V1DuplicatesListResponse": { + "type": "object", + "description": "Suggested-duplicate list. NOTE this exact shape with an empty `duplicates` is also what\nthe server returns when the group lacks the dedupe feature flag, so an empty list is not\nevidence that the project has no duplicates — check .../ai-duplicates/status.\n", + "properties": { + "success": { + "type": "boolean" + }, + "duplicates": { + "type": "array", + "items": { + "type": "object" + } + }, + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + } + } + }, + "ExploratorySessionLogListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "session_logs": { + "type": "array", + "items": { + "$schema": "ExploratorySessionLog" + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 50 + }, + "total_count": { + "type": "integer", + "example": 24 + }, + "total_pages": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "ExploratorySessionListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_sessions": { + "type": "array", + "items": { + "$schema": "ExploratorySession" + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "per_page": { + "type": "integer", + "example": 30 + }, + "total_count": { + "type": "integer", + "example": 150 + }, + "total_pages": { + "type": "integer", + "example": 5 + } + } + }, + "count": { + "type": "integer", + "description": "Total number of sessions matching the query.", + "example": 150 + } + } + }, + "ProjectsListResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful.", + "example": true + }, + "projects": { + "type": "array", + "description": "List of project objects.", + "items": { + "$schema": "Project__ListProjects" + } + }, + "info": { + "$schema": "ProjectsPageInfo" + } + } + }, + "ReportScheduleGetResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "Contains an array of Scheduled reports", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "The total number of scheduled reports in the project." + }, + "total_report_count": { + "type": "integer", + "description": "The total number of reports in the project." + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "Tag": { + "type": "object", + "required": [ + "name", + "created_at" + ], + "properties": { + "name": { + "type": "string", + "description": "Tag name", + "example": "AI Generated" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the tag was first used", + "example": "2024-10-08T09:25:09.000Z" + }, + "usage_count": { + "type": "integer", + "description": "Number of test cases using this tag (optional)", + "example": 42 + } + } + }, + "ProjectUserDetail": { + "type": "object", + "required": [ + "id", + "full_name", + "onboarded", + "group_id", + "browserstack_user_id", + "role", + "has_access", + "permissions" + ], + "properties": { + "id": { + "type": "integer", + "description": "User ID", + "example": 240620 + }, + "full_name": { + "type": "string", + "description": "User's full name", + "example": "Jasbir Singh" + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status (1 = completed, 0 = pending)", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "group_id": { + "type": "integer", + "description": "Group this user belongs to", + "example": 1 + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user identifier", + "example": 10328238 + }, + "role": { + "type": "string", + "description": "User role in the system", + "enum": [ + "admin", + "user", + "viewer" + ], + "example": "user" + }, + "has_access": { + "type": "boolean", + "description": "Whether user has access to the project", + "example": true + }, + "permissions": { + "type": "array", + "description": "List of permissions granted to user", + "example": [ + "tc_write", + "project_write", + "test_run_execute" + ], + "items": { + "type": "string", + "enum": [ + "tc_write", + "tc_delete", + "tc_archive_test_cases", + "project_write", + "test_plan_write", + "test_plan_delete", + "test_run_write", + "test_run_delete", + "test_run_execute" + ] + } + }, + "is_delighted_visible": { + "type": "boolean", + "description": "Whether Delighted feedback widget is visible", + "example": false + }, + "is_product_intro_visible": { + "type": "boolean", + "description": "Whether product introduction is visible", + "example": true + }, + "is_onboarding_project_header_visible": { + "type": "boolean", + "description": "Whether onboarding header is visible", + "example": true + }, + "is_lcnc_explore_dismissed": { + "type": "boolean", + "description": "Whether low-code/no-code explore banner is dismissed", + "example": false + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Whether reports and notifications are enabled", + "example": true + }, + "customisable_dashboard_accessible": { + "type": "boolean", + "description": "Whether customizable dashboard is accessible", + "example": true + }, + "webhook_accessible": { + "type": "boolean", + "description": "Whether webhooks are accessible", + "example": true + }, + "is_build_listing_enabled": { + "type": "boolean", + "description": "Whether build listing feature is enabled", + "example": true + }, + "productRole": { + "type": "string", + "description": "Product-level role", + "example": "Product User" + }, + "is_new_project_settings_visible": { + "type": "boolean", + "description": "Whether new project settings UI is visible", + "example": true + }, + "is_jira_app_project_panel_visible": { + "type": "boolean", + "description": "Whether Jira app project panel is visible", + "example": false + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "is_re_arch_enabled": { + "type": "boolean", + "example": true + }, + "is_test_plan_detailed_report_enabled": { + "type": "boolean", + "example": false + } + } + }, + "ReportUpdateResponse": { + "type": "object", + "required": [ + "data", + "report_data", + "report_filters", + "dynamic_filters" + ], + "properties": { + "data": { + "$schema": "ReportDetail__UpdateReport" + }, + "report_data": { + "$schema": "ReportData" + }, + "report_filters": { + "$schema": "ReportFiltersDetail" + }, + "dynamic_filters": { + "$schema": "DynamicFilters" + }, + "filters": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + } + } + }, + "GenericAttachment": { + "type": "object", + "required": [ + "id", + "name", + "url", + "download_url", + "content_type", + "size" + ], + "properties": { + "id": { + "type": "integer", + "description": "Attachment ID", + "example": 10288829 + }, + "name": { + "type": "string", + "description": "Original filename", + "example": "screenshot.png" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Pre-signed S3 URL for viewing (inline, ~26 min expiry)", + "example": "https://example.com/media/genric/1/2921471/86de8094..." + }, + "download_url": { + "type": "string", + "format": "uri", + "description": "Pre-signed S3 URL for downloading (attachment, ~26 min expiry)", + "example": "https://example.com/media/genric/1/2921471/86de8094..." + }, + "content_type": { + "type": "string", + "description": "MIME type of the file", + "example": "image/png" + }, + "size": { + "type": "string", + "description": "Human-readable file size", + "example": "300 KB" + } + } + }, + "TestRunTestCaseV2": { + "type": "object", + "description": "One test case AS MAPPED INTO A TEST RUN — a case-plus-configuration row, not the project-level test case record. A case mapped to several configurations appears once per configuration. Shape as returned by the v2 run test-case listing.", + "properties": { + "identifier": { + "type": "string", + "example": "TC-42" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true, + "description": "The test case's description. Always present as a key; null for cases without one." + }, + "priority": { + "type": "string", + "description": "Resolved label, not the id." + }, + "status": { + "type": "string", + "description": "Test case status label." + }, + "case_type": { + "type": "string" + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." + }, + "latest_result_id": { + "type": "integer", + "description": "Id of the latest result recorded against this row; use it to read or amend that result. Populated even for never-executed rows — the result row is created when the case is mapped into the run — so it cannot be used to detect 'never executed'." + }, + "assignee": { + "type": "string", + "nullable": true, + "description": "Assignee of this row within the run." + }, + "folder_path": { + "type": "array", + "items": { + "type": "integer" + } + }, + "configuration_id": { + "type": "integer", + "nullable": true, + "description": "Configuration this row is for; null when the case is mapped without a configuration." + }, + "dataset": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "description": "Name of the dataset variable (the dataset's column) this value came from. Only ever present on a data-driven row — see the array description; there are no entries at all otherwise." + }, + "row": { + "type": "string", + "description": "The value bound to that variable for this row. Same condition as `variable`." + } + } + }, + "description": "The dataset values bound to this run row, one entry per dataset variable. CONDITIONAL CONTENT — the key is there, but the array is EMPTY for every row that is not dataset-driven, which is most rows: entries exist only when the case was mapped into the run against a dataset. So `variable` and `row` below are only ever seen on data-driven rows. Asking for the compact/minified row shape drops the key altogether instead of returning it empty." + }, + "project_id": { + "type": "string", + "example": "PR-1", + "description": "Project of the run being read." + }, + "source_project_id": { + "type": "string", + "example": "PR-7", + "description": "The project the case originates from, for a case shared in from elsewhere. On the older run backend the rows embedded in a single-run read report the current project rather than the true origin, because the origin is only resolved when the caller is the standalone case listing; the newer backend resolves it per row on both. Treat it as authoritative only when it differs from the project you asked about." + }, + "source_project_name": { + "type": "string", + "nullable": true, + "description": "Display name of the project the case lives in. CONDITIONAL — only the older run backend returns this key at all; on the newer run backend it is never returned, neither in the run's case listing nor in the rows embedded in a single-run read. Where it is returned it is null unless the case is shared in from another project. `source_project_id` is always present, so use that to identify the origin project and treat this as a display-only extra." + }, + "is_shared": { + "type": "boolean", + "description": "Whether this case is shared in from another project." + }, + "execution_id": { + "type": "string", + "nullable": true, + "description": "Identifier of the current execution attempt for this row." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the case was mapped into this run — the row's creation time, not the test case's." + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "When this run row was last updated. Note the name: it is the row's updated_at, renamed on the way out." + }, + "created_by": { + "type": "string", + "nullable": true, + "description": "Email of whoever created this run row." + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Email of whoever last updated this run row; null when it has not been updated since creation." + }, + "urls": { + "type": "object", + "description": "Web-app deep links for the row. Always present. DEFECT — the link is currently built without the run segment, so it does not resolve; construct run links from the run identifier instead.", + "properties": { + "self": { + "type": "string", + "description": "Intended as an absolute link to this case within the run in the web app; currently malformed, see above." + } + } + } + } + }, + "V2BulkResultOutcome": { + "type": "object", + "description": "Per-test-case outcome of a bulk result submission. Reports whether the entry was accepted for async processing, not whether it was applied.", + "properties": { + "test_case_id": { + "type": "string", + "example": "TC-101" + }, + "status": { + "type": "string", + "values": [ + "ACCEPTED", + "FAILED" + ], + "description": "ACCEPTED means queued, not applied." + }, + "message": { + "type": "string", + "example": "Will be processed." + }, + "error": { + "type": "object", + "description": "Present only when status is FAILED.", + "properties": { + "code": { + "type": "string", + "values": [ + "INVALID_STATUS_VALUE", + "REVIEW_STATUS_GATE" + ] + }, + "message": { + "type": "string" + } + } + } + } + }, + "V2TestResultListItem": { + "type": "object", + "description": "One recorded result for a test case in a test run, as the v2 read returns it. Deliberately narrower than the v1 result shape: no author, backtrace, attachments or full status object.", + "properties": { + "id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "description": "Email of the user who recorded the result; null when the user is not in the caller's group.", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "test_case_id": { + "type": "string", + "description": "Echoed back as the TC- identifier that was requested, not a numeric id.", + "example": "TC-101" + }, + "test_run_id": { + "type": "string", + "example": "TR-14" + }, + "result_status": { + "type": "object", + "description": "Cut down to the display-case status name on this endpoint. The id, internal_name, field_name and colour that the v1 result reads return are deliberately dropped here and never appear.", + "required": [ + "field_value" + ], + "properties": { + "field_value": { + "type": "string", + "example": "Passed", + "description": "Display-case status name." + } + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues; empty when the result has none.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "example": "BUG-123" + }, + "issue_type": { + "type": "string", + "example": "jira" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "custom_fields": { + "type": "array", + "description": "An ARRAY of entries, not an object keyed by field name; [] when the result has no custom field values. `field_name` is the field's system name. For dropdown / single-select fields `value` is the selected option's id rather than its label.", + "items": { + "type": "object", + "properties": { + "field_name": { + "type": "string" + }, + "value": { + "description": "Scalar value, or the selected option's id for option-backed fields.", + "nullable": true + } + } + } + }, + "step_result": { + "type": "object", + "description": "Present only for step-level results; null otherwise.", + "nullable": true, + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + }, + "result_status": { + "type": "object", + "description": "Same cut-down shape as the parent result_status.", + "properties": { + "field_value": { + "type": "string" + } + } + } + } + }, + "configuration_id": { + "type": "integer", + "nullable": true + }, + "execution_id": { + "type": "string", + "nullable": true + }, + "dataset": { + "type": "array", + "description": "Dataset row this execution used, as {variable, row} pairs. Empty for non-data-driven runs.", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string" + }, + "row": { + "type": "integer" + } + } + } + }, + "urls": { + "type": "object", + "nullable": true, + "description": "Web link for this result. Null when the result cannot be tied to a project, run, case and run row all at once.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's row inside the run." + } + } + } + } + }, + "V2TestRunResultDetail": { + "type": "object", + "description": "The case-level result written by a v2 result submission, returned under `test-result`. This is the no-step shape: a submission that carried step_results is answered with per-step entries instead and has no `test-result` at all, so nothing here ever describes a step.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the created result. Carry this forward for a follow-up update; it is the id domain the server expects back." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "example": "Passed", + "description": "Display-case status name, echoing the name that was sent." + }, + "issues": { + "type": "array", + "description": "Linked issues as plain KEY STRINGS, deduplicated — the same shape they are sent in, not objects.", + "items": { + "type": "string", + "example": "JIRA-12" + } + }, + "urls": { + "type": "object", + "description": "Web link for this submission.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's ROW IN THE RUN — its final path segment is the run row's id, not the id of the result just created." + } + } + } + } + }, + "Template": { + "type": "object", + "description": "A test-case template as returned in list responses: metadata and counts, without the field layout.", + "required": [ + "id", + "name", + "entity", + "enabled", + "is_default", + "is_system", + "future_projects_applicable", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "integer", + "description": "Template id — a plain integer." + }, + "name": { + "type": "string" + }, + "entity": { + "type": "string", + "description": "What the template applies to. Always TestCase on this surface.", + "example": "TestCase" + }, + "enabled": { + "type": "boolean", + "description": "True when the template is active. False means disabled — still stored, with its fields and project links, and re-enablable." + }, + "step_type": { + "type": "string", + "nullable": true, + "description": "Step style identifier for test-case templates; null for templates that do not define one." + }, + "is_default": { + "type": "boolean", + "description": "True for the workspace default template for this entity type. The default cannot be disabled or deleted." + }, + "is_system": { + "type": "boolean", + "description": "True for a built-in template. System templates cannot be deleted." + }, + "future_projects_applicable": { + "type": "boolean", + "description": "True when projects created later are linked to this template automatically." + }, + "field_count": { + "type": "integer", + "nullable": true, + "description": "Number of fields in the template. Null when the count was not computed for this response." + }, + "linked_to_all_projects": { + "type": "boolean", + "nullable": true + }, + "project_count": { + "type": "integer", + "nullable": true, + "description": "Number of linked projects. Null when the workspace-wide count was skipped server-side — treat null as 'not computed', not zero." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TemplateFieldMapping": { + "type": "object", + "description": "One field placed on a template, as returned by a template read or create. Note these are mapping records, not the ids sent when writing a template.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the mapping row, NOT the field id. When resending a field on an update, use custom_field_id or system_field_id, not this. It is not unique within a response: the duplicated automation row shares its original's value." + }, + "field_category": { + "type": "string", + "description": "Which section the field sits in.", + "values": [ + "description", + "properties", + "step_properties" + ] + }, + "custom_field_id": { + "type": "integer", + "nullable": true, + "description": "Set for a custom field; null for a system field. Which of the two it is also decides what field_data contains and which of its keys exist." + }, + "system_field_id": { + "type": "integer", + "nullable": true, + "description": "Set for a built-in field; null for a custom field." + }, + "order": { + "type": "integer", + "description": "Position within its section." + }, + "is_mandatory": { + "type": "boolean", + "description": "Placement-level required flag, defaulting to false when the stored mapping does not carry one. This is NOT the only required-ness signal: a field definition can separately mark itself required, and the two can disagree. Observed false on every row of every template inspected so far." + }, + "field_data": { + "type": "object", + "description": "The underlying field definition, passed through from the field service unchanged apart from two additions (`field_type_raw`, and `parent_custom_field_id` on nested dropdowns). The exact key set therefore varies by field kind — system fields and custom fields carry different subsets, and step fields carry more than the others. Treat the keys listed here as likely-present rather than guaranteed, and do not assume a missing key means an unset value.", + "properties": { + "field_type": { + "type": "string", + "description": "The product-facing type name in its prefixed form, for example field_string or field_multi_dropdown. For a small number of workspaces a nested-dropdown type outside the ordinary set can be substituted here, along with a parent field id." + }, + "system_name": { + "type": "string", + "description": "Internal name of a built-in field. Built-in fields only." + }, + "optional": { + "type": "boolean", + "description": "Built-in field's own removability flag. Built-in fields only." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "field_name": { + "type": "string", + "description": "The field's name as shown to authors. Carried on both system and custom field definitions; it is the value to match on when resolving a field a user named in words." + } + } + } + } + }, + "TemplateWithFields": { + "type": "object", + "description": "A test-case template together with its full field layout, as returned by a single-template read or a create.", + "allOf": [ + { + "$schema": "Template" + } + ], + "properties": { + "description_fields": { + "type": "array", + "description": "Fields in the description section, ordered.", + "items": { + "$schema": "TemplateFieldMapping" + } + }, + "properties_fields": { + "type": "array", + "description": "Fields in the properties section, ordered. The Review & Approve fields (reviewers, review status) are filtered out of this surface even when stored on the template.", + "items": { + "$schema": "TemplateFieldMapping" + } + }, + "step_fields": { + "type": "array", + "description": "Step-level custom fields, ordered. Always an empty array for workspaces without step custom fields enabled; the key is always present.", + "items": { + "$schema": "TemplateFieldMapping" + } + } + } + }, + "V2PublicTestCase": { + "type": "object", + "description": "A test case as the v2 public project API serialises it on read and on write. NOT the same shape as the `TestCase` schema the older endpoints return: the title is `title` (not `name`), priority/status/case_type are plain display strings (not objects), owner is an email string (not an integer id), custom fields are name/value pairs, and there is no integer `id` — the case is identified by `identifier`.", + "properties": { + "identifier": { + "type": "string", + "description": "TC-NNN, or the third-party identifier when the workspace issues project-level ids." + }, + "title": { + "type": "string", + "description": "The case title. Sent as `name` on a write, returned as `title`." + }, + "folder_id": { + "type": "integer", + "description": "Integer id of the folder the case now sits in." + }, + "priority": { + "type": "string", + "description": "The project's display value, e.g. 'Medium'. Null when unset." + }, + "status": { + "type": "string", + "description": "The project's display value for the case's own lifecycle status, e.g. 'Active'." + }, + "case_type": { + "type": "string", + "description": "The project's display value for the case type, e.g. 'Smoke & Sanity'." + }, + "automation_status": { + "type": "string", + "description": "Internal name, e.g. not_automated / automated / obsolete." + }, + "owner": { + "type": "string", + "description": "Owner's EMAIL, or the imported owner name for cases brought in from another tool. Null when the owner does not resolve to a user in this group." + }, + "description": { + "type": "string" + }, + "preconditions": { + "type": "string" + }, + "template": { + "type": "string", + "description": "Template name, e.g. test_case_steps or bdd." + }, + "is_shared": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links.", + "items": { + "type": "object", + "properties": { + "jira_id": { + "type": "string", + "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made — azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." + } + }, + "description": "Only two keys exist on this shape and both are always present when the array is non-empty — nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." + } + }, + "steps": { + "type": "array", + "description": "Step list, flattened to step/result only — test_data, shared-step links and per-step custom fields are not echoed here.", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + } + } + } + }, + "custom_fields": { + "type": "array", + "description": "Name/value pairs, never the id-keyed map a write sends. A multi-select field carries `values` (array) instead of `value`. Always present, as [] when the case has none.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "attachments": { + "type": "array", + "description": "Files attached to the case. Comes back as [] when the case carries no files. Every entry carries name, size, content_type and url together — these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "File name as uploaded." + }, + "size": { + "type": "string", + "description": "Human-readable size, e.g. '1.2 MB' — not a byte count." + }, + "content_type": { + "type": "string", + "description": "MIME type recorded when the file was uploaded, e.g. image/png." + }, + "url": { + "type": "string", + "description": "Link to the stored file." + } + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "Last update timestamp — named last_updated_at here, not updated_at." + }, + "created_by": { + "type": "string", + "description": "Email of the creating user." + }, + "updated_by": { + "type": "string", + "description": "Email of the last user to write to the case." + }, + "urls": { + "type": "object", + "description": "Web-app link for the case, for showing a human where it lives.", + "properties": { + "self": { + "type": "string" + } + } + } + } + }, + "V2History": { + "type": "object", + "description": "One revision of a test case as returned by the v2 history listing. The internal record id, entity_id, entity_type, project_id, group_id, the nested user object and the links block are stripped before the record is returned.", + "properties": { + "version_id": { + "type": "string", + "format": "uuid", + "description": "Opaque version identifier for this revision." + }, + "version_name": { + "type": "string", + "description": "Positional label for the revision — V1, V2, V3 … — where V1 is the oldest revision visible under the caller's plan. The number is what the single-version read takes.", + "example": "V2" + }, + "source": { + "type": "string", + "description": "What produced the revision: 'create' for the original, 'update' for an edit, 'restore' after a rollback.", + "example": "update" + }, + "modified_fields": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Names of the fields this revision changed. Null on the creating revision." + }, + "modified": { + "type": "object", + "description": "Per-field before/after map, e.g. {\"title\": {\"old\": \"...\", \"new\": \"...\"}}. Only the first record on each page is enriched with this detail; the other rows come back without it. The creating revision never carries it either, having nothing to diff against. Rows whose old and new are both empty are dropped.", + "additionalProperties": true, + "example": { + "title": { + "old": "Original Title", + "new": "Updated Title" + } + } + }, + "comment": { + "type": "string", + "nullable": true, + "description": "Optional note recorded with the change." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the revision was recorded.", + "example": "2025-08-29T08:33:58.000Z" + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Email of the user who made the change. Null when the user id cannot be resolved to a user in this group.", + "example": "user@example.com" + }, + "user_id": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id of the editor — the raw id updated_by is resolved from." + }, + "testcase_id": { + "type": "string", + "description": "Identifier of the test case this revision belongs to — the third-party identifier when one is set, otherwise the TC-NNN identifier. Falls back to TC- if the case cannot be fetched.", + "example": "TC-123" + }, + "origin_channel": { + "type": "string", + "description": "How the edit that produced this revision arrived. One of ui, api, jira_app, azure, import, automation or ai. Revisions written through this API are always recorded as api.", + "enum": [ + "ui", + "api", + "jira_app", + "azure", + "import", + "automation", + "ai" + ], + "example": "api" + } + } + }, + "TestPlanV2": { + "type": "object", + "description": "A test plan or sub-plan as the v2 plan surface serializes it. There is no numeric id field - `identifier` (TP-NNN / STP-NNN) is the only id, and project_id is the PR-NNN identifier rather than the numeric project id. The numeric plan and project ids are recoverable only by reading them out of the web-app link under urls.self. This is the shape every write and every listing returns; only the single-plan read adds the embedded run preview and the relative API links.", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "description": { + "type": "string", + "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." + }, + "active_state": { + "type": "string", + "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", + "enum": [ + "active", + "completed" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." + }, + "test_runs_count": { + "type": "object", + "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "sub_plans_count": { + "type": "integer", + "nullable": true, + "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." + }, + "start_date": { + "type": "string", + "nullable": true, + "format": "date-time", + "example": "2026-01-01T00:00:00Z" + }, + "end_date": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "string", + "description": "PR-NNN identifier of the owning project, not a number.", + "example": "PR-2" + }, + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + } + } + } + } + }, + "TestRunSummaryV2": { + "type": "object", + "description": "A test run as the v2 run listings serialize it — a summary shape shared by the project-wide listing and a plan's linked-run listing. It carries NO numeric id and no uuid: `identifier` (TR-NNN) is the only id present. It also never carries issues, issue_tracker or test_cases; those exist only on the single-run read.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-164" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Rich-text description, HTML-unescaped and with links rehydrated. Empty string rather than null for a run with no description." + }, + "run_state": { + "type": "string", + "description": "Lifecycle state; a brand-new run reports 'new_run'." + }, + "active_state": { + "type": "string", + "description": "Derived: 'closed' once the run is closed, otherwise 'active'.", + "enum": [ + "active", + "closed" + ] + }, + "assignee": { + "type": "string", + "nullable": true, + "description": "Email of the run's owner; null when it cannot be resolved.", + "example": "qa@acme.com" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "description": "DEFECT — currently always an empty array on both run listings, whatever configurations the run actually has. The key is always present; its emptiness carries no information. Read the run itself for its configurations. When populated it is configuration ids (integers), not configuration objects.", + "items": { + "type": "integer" + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "filter_test_cases": { + "type": "object", + "description": "Always present. Carries a single is_dynamic sub-field — whether the run's case list is filter-driven.", + "properties": { + "is_dynamic": { + "description": "Whether the run's case list is filter-driven. NOT consistently typed: the project-wide listing reports it as a boolean, the plan-scoped listing as the integer 0/1, for the same run. Test it truthily; an identity check against false is unsafe." + } + } + }, + "project_id": { + "type": "string", + "description": "The project the request was scoped to, in PR-NNN form.", + "example": "PR-2" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Omitted entirely, rather than sent as null, when the run has no recorded update time." + }, + "links": { + "type": "object", + "description": "API references for the run. Always present with exactly these two keys.", + "properties": { + "self": { + "type": "string", + "description": "Relative API reference for reading this run." + }, + "test_cases": { + "type": "string", + "description": "Relative API reference for this run's test cases." + } + } + }, + "urls": { + "type": "object", + "description": "Web-app deep links for the run. Always present on both run listings.", + "properties": { + "self": { + "type": "string", + "description": "Absolute link to this run in the web app." + } + } + } + } + }, + "V2ReportDetail": { + "type": "object", + "description": "A report's public v2 configuration record. Identifiers replace internal integer ids, and it carries no report data — only how the report is set up.", + "properties": { + "id": { + "type": "string", + "pattern": "^SC-\\d+$", + "example": "SC-42", + "description": "The report's identifier. A per-group sequence number, unrelated to the integer report id the v1 report capabilities take." + }, + "project_id": { + "type": "string", + "pattern": "^PR-\\d+$", + "description": "The owning project's identifier." + }, + "project_ids": { + "type": "array", + "items": { + "type": "string", + "pattern": "^PR-\\d+$" + }, + "description": "Every project the report covers. A single-project report lists just its own." + }, + "report_type": { + "type": "string", + "description": "Display name of the report type, e.g. 'Test Run Summary'." + }, + "title": { + "type": "string", + "description": "Display name. Auto-generated as '-MM/DD/YYYY' when the creator omitted it." + }, + "description": { + "type": "string", + "nullable": true + }, + "report_timeframe": { + "type": "string", + "description": "Rolling window or scope mode, e.g. last_one_week, custom, specific_test_runs." + }, + "report_creation_mode": { + "type": "string", + "values": [ + "custom_report", + "system_generated" + ] + }, + "custom_date_range": { + "type": "string", + "nullable": true, + "description": "Two space-separated YYYY-MM-DD dates; set only for the custom timeframe." + }, + "frequency": { + "type": "string", + "values": [ + "once", + "daily", + "weekly", + "monthly" + ], + "description": "'once' is substituted for an unscheduled report whose stored frequency is null." + }, + "frequency_details": { + "type": "object", + "nullable": true, + "description": "time (HH:MM) and, for weekly/monthly, day." + }, + "next_run_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the schedule next fires. Null for a one-off report." + }, + "is_scheduled": { + "type": "boolean", + "description": "True only when a valid frequency and frequency_details pair was supplied." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "object", + "nullable": true, + "description": "Creator, as email and full_name. No user id is exposed." + }, + "mail_to": { + "type": "object", + "description": "users (email + full_name objects) and external_mails (plain addresses). Populated only for scheduled reports." + }, + "report_filters": { + "type": "object", + "description": "Configured scope echoed back as public identifiers (TR-NNN, TP-NNN, assignee emails). Sub-plans collapse to TP-NNN; values fall back to prefix+internal id if the lookup fails." + }, + "dynamic_filters": { + "type": "object", + "nullable": true, + "description": "Always null on this surface, whatever was sent at create time." + }, + "latest_attachment": { + "type": "object", + "nullable": true, + "description": "Newest generated file in the PROJECT, not necessarily this report's: generated_at, file_type (a MIME type such as application/pdf) and ready, which is true whenever the object is present." + } + } + }, + "AttachmentListItem": { + "type": "object", + "description": "One attachment row as the v2 per-entity attachment listing serializes it - a deliberately thin projection, not the full stored attachment record.", + "properties": { + "id": { + "type": "integer", + "description": "Attachment id. This is the id the delete-attachment capabilities take." + }, + "name": { + "type": "string", + "description": "Original filename as uploaded." + }, + "size": { + "type": "integer", + "description": "File size in bytes. Renamed from the stored byte_size field, which is not present under this name here." + }, + "created_at": { + "type": "string", + "description": "When the file was attached." + }, + "url": { + "type": "string", + "description": "Location to read the file from. There is no separate download endpoint for these attachments." + }, + "data_id": { + "type": "integer", + "description": "Present ONLY on inline attachments (images embedded via the rich-text editor). Its presence is how an inline row is told apart from a real file attachment." + } + } + }, + "TestResultSearchItem": { + "type": "object", + "description": "One test result row from the account-wide result search. Leaner and differently keyed than TestResult: no custom_fields, no step result and no configuration_id, but it carries the owning project, run and case so a cross-project row can be traced back. Several fields exist on the row but are never loaded by this search — see their descriptions.", + "properties": { + "id": { + "type": "integer", + "description": "Result id. Project-scoped single-result reads need this together with project_id." + }, + "author": { + "type": "object", + "description": "Who logged the result. Six keys when the user resolves. When the result came from an import and no matching user exists, only full_name and email are present, sourced from the import; when neither applies the key is omitted entirely.", + "properties": { + "id": { + "type": "integer" + }, + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "updated_by": { + "type": "object", + "nullable": true, + "description": "Who last changed the result. ALWAYS NULL on this endpoint: the account-wide result search does not load the updating user's id, so no user is ever resolved and the object is never populated here - the key is always present and always null. The project-scoped single-result reads return the real object." + }, + "status": { + "type": "string", + "nullable": true, + "example": "blocked", + "description": "Lowercase internal status token, and null on most rows. Read result_status.field_value instead — it is the populated, display-case form of the same outcome." + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatus__SearchTestResults" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Result notes as rich text. User mentions are rewritten to display names in this payload." + }, + "error_description": { + "type": "string", + "nullable": true + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "failure_type": { + "type": "string", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Always null on this endpoint — the account-wide search does not load it. Use a project-scoped result read if you need it." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Always null on this endpoint, as for started_at." + }, + "time_elapsed": { + "type": "string", + "nullable": true, + "description": "Always null on this endpoint, as for started_at." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_by_imported": { + "type": "string", + "nullable": true, + "description": "Name carried over from an import when the result has no BrowserStack author. Always null on this endpoint — not loaded by the account-wide search." + }, + "attachments": { + "type": "array", + "nullable": true, + "description": "ALWAYS NULL on this endpoint - the account-wide result search does not load attachments, so this is never an array here and iterating it without a guard throws. No attachment object is ever produced on this path, so there are no entry fields to read; use the project-scoped result reads, which return real entries." + }, + "issues": { + "type": "array", + "description": "Linked tracker issues. Always an array - empty on a result with no linked defects, so the entry fields below appear only on rows that actually carry a link. This endpoint loads only the issue key and its link time, plus a duplicate `jira_id` mirroring issue_id whatever the tracker actually is. There is no issue_type here, so a row cannot tell you which tracker the link belongs to - the project-scoped reads do carry it.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "example": "BUG-123" + }, + "jira_id": { + "type": "string", + "description": "Copy of issue_id, named jira_id for every tracker. Written for every entry, so it is present whenever the row has a linked issue at all." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the issue was linked to this result." + } + }, + "description": "`jira_id` is written as a copy of `issue_id` on every entry, so the two always agree and always appear together — the name is historical and holds the id in whatever tracker the link uses. This shape carries no tracker name; read the run or case surfaces if you need to know which tracker an id belongs to." + } + }, + "configuration": { + "type": "object", + "nullable": true, + "description": "Configuration the result was logged against. Always requested, so present whatever compact is set to; an empty object when the result has none." + }, + "test_case": { + "type": "object", + "nullable": true, + "description": "Present ONLY when compact=false was sent; absent otherwise." + }, + "test_run": { + "type": "object", + "nullable": true, + "description": "Present ONLY when compact=false was sent. Its `id` is overwritten with the run's TR-NNN identifier string, not a number." + }, + "test_case_id": { + "type": "integer", + "nullable": true, + "description": "Integer case id from the result mapping; omitted when the mapping is absent." + }, + "test_run_id": { + "description": "Integer run id from the result mapping — but replaced by the run's TR-NNN identifier STRING whenever the test_run relation is present (compact=false). Type-check before using it.", + "nullable": true + }, + "test_run_results_mapping_id": { + "type": "integer", + "nullable": true, + "description": "Id of the run/case mapping the result hangs off." + }, + "project_id": { + "type": "integer", + "nullable": true, + "description": "Numeric id of the project the result lives in. This is how you tell cross-project rows apart; it is present only when the search service returned it." + }, + "links": { + "type": "object", + "description": "Present only when the row carried a project_id. Holds `self` only — no custom_fields link on this path.", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" + } + } + } + } + }, + "Comment": { + "type": "object", + "description": "One comment as the v1 serializer emits it — a flat object (the published spec wraps it under an extra `comment` key that the server does not send). The same object is returned on both comment surfaces: a test case's own thread and a test case's thread inside a test run.", + "properties": { + "id": { + "type": "integer", + "description": "Comment id; the handle for later edit/delete.", + "example": 3121 + }, + "user_id": { + "type": "object", + "description": "The AUTHOR, despite the name. `id` is the internal TM user id (not the BrowserStack user id) and is null for comments imported from another tool, whose original author name is carried in full_name.", + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "full_name": { + "type": "string" + }, + "email": { + "type": "string", + "nullable": true + } + } + }, + "group_id": { + "type": "integer", + "description": "Internal TM group id of the owning workspace." + }, + "project_id": { + "type": "integer" + }, + "parent_id": { + "type": "integer", + "nullable": true, + "description": "Always null through this path; replies are not threaded." + }, + "entity_id": { + "type": "integer", + "description": "What the comment is attached to, and it differs by surface: on a test case's own thread this is the test case id, while on a test case's thread inside a test run it is the id of the run/case mapping cell - neither the test case id nor the run id. Read it together with entity_type." + }, + "entity_type": { + "type": "string", + "description": "Which surface the comment lives on: 'TestCase' for a comment on the test case itself, 'TRTC' for a comment on that case inside a test run. The two are separate threads and never overlap.", + "example": "TestCase" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "comment": { + "type": "string", + "description": "HTML body with @mentions rendered to display names and links decorated; can differ from what was posted." + }, + "edited": { + "type": "boolean", + "description": "True once the comment has been changed after posting; always false on a comment that was just created." + }, + "is_editable": { + "type": "boolean", + "description": "False for system/imported comments that cannot be edited; always true on a comment created through the comment endpoints." + } + } + }, + "StatusProgressBreakdown": { + "type": "object", + "description": "Per-result-status progress of a run or one of its configurations. Keys are result-status ids as STRINGS ('1', '2', ...); each value is the count of run test cases currently in that status plus the status definition. Statuses with no cases may be absent. Empty object when nothing has been recorded.", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Number of test cases in the run (or configuration) whose latest result has this status.", + "example": 8 + }, + "result_status": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "field_value": { + "type": "string", + "description": "Display label, e.g. 'Passed'.", + "example": "Passed" + }, + "internal_name": { + "type": "string", + "description": "Stable machine name (passed, failed, blocked, untested, ...). Use this to compare across projects; ids differ per project.", + "example": "passed" + }, + "field_name": { + "type": "string", + "example": "Status" + }, + "colour": { + "type": "string", + "example": "#00FF00" + } + } + } + } + } + }, + "BinnedTestCase": { + "type": "object", + "description": "A test case as it appears in the recycle-bin listing: a slim row from the bin store plus resolved people, not the full TestCase shape.", + "properties": { + "id": { + "type": "integer", + "description": "Integer id — what restore/purge selections take." + }, + "identifier": { + "type": "string", + "example": "TC-3502" + }, + "name": { + "type": "string" + }, + "priority": { + "type": "object", + "description": "The priority as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", + "properties": { + "field_value": { + "type": "string", + "description": "The displayed name — the one to compare against." + }, + "colour": { + "type": "string", + "description": "Presentation only." + }, + "field_name": { + "type": "string", + "description": "Internal field name behind the value." + }, + "value_category": { + "type": "string", + "description": "Grouping the value belongs to." + }, + "internal_name": { + "type": "string", + "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming — `field_value` is what a person sees and can be edited." + } + } + }, + "status": { + "type": "object", + "description": "The status as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", + "properties": { + "field_value": { + "type": "string", + "description": "The displayed name — the one to compare against." + }, + "colour": { + "type": "string", + "description": "Presentation only." + }, + "field_name": { + "type": "string", + "description": "Internal field name behind the value." + }, + "value_category": { + "type": "string", + "description": "Grouping the value belongs to." + }, + "internal_name": { + "type": "string", + "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming — `field_value` is what a person sees and can be edited." + } + } + }, + "owner": { + "type": "integer", + "nullable": true, + "description": "Raw BrowserStack user id of the owner; see owner_details for the person." + }, + "owner_details": { + "type": "object", + "nullable": true, + "description": "Resolved owner {id, browserstack_user_id, full_name, email, group_id}; absent when owner is null or unknown." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "issue_id": { + "type": "string" + }, + "issue_type": { + "type": "string" + } + } + } + }, + "folder_name": { + "type": "string", + "nullable": true + }, + "folder_path": { + "type": "string", + "nullable": true, + "description": "Breadcrumb of the folder the case was in when binned." + }, + "updated_by": { + "type": "integer", + "nullable": true, + "description": "Raw BrowserStack user id of who moved it to the bin; see updator_details." + }, + "updator_details": { + "type": "object", + "nullable": true, + "description": "Resolved person who binned the case {id, browserstack_user_id, full_name, email, group_id}." + }, + "trashed_at": { + "type": "string", + "format": "date-time", + "description": "When the case was moved to the bin." + }, + "days_remaining": { + "type": "integer", + "description": "Days left before automatic permanent purge." + }, + "template": { + "type": "string", + "description": "The template the case was authored on, carried through from the case record." + }, + "created_by": { + "type": "integer", + "description": "Id of the user who created the case. `creator_details` carries the same person resolved." + }, + "creator_details": { + "type": "object", + "description": "The creating user resolved, or null when that user can no longer be found. Distinct from whoever binned the case.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "The account-level user id, not the id used for owner or assignee here." + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer", + "description": "Workspace the user belongs to." + } + } + } + } + }, + "V1DuplicatePair": { + "type": "object", + "description": "One AI-suggested duplicate test-case pair as this product serialises it. Used by the single-duplicate read and as the element type of the duplicates listing and search.", + "properties": { + "id": { + "type": "integer", + "description": "The duplicate id. This is the value the read, resolve and discard calls take." + }, + "ai_score": { + "type": "number", + "description": "Model confidence for the pair. A score below 100 is a semantic match; 100 is an exact one." + }, + "reason": { + "type": "string", + "description": "The model's stated reason for believing the two cases are duplicates." + }, + "duplicate_type": { + "type": "string", + "description": "exact or semantic.", + "values": [ + "exact", + "semantic" + ] + }, + "test_cases": { + "type": "array", + "description": "The two test cases in the pair. Always exactly two — except in a listing filtered by entity_id, where the filtered-on case is removed and only the other one remains.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Integer test case id — what the resolve call takes as source/target/archive id." + }, + "identifier": { + "type": "string", + "description": "Display identifier (TC-NNN), or the third-party identifier when the case came from an external tool." + }, + "folder_id": { + "type": "integer", + "description": "Id of the folder holding the case." + }, + "folder_path": { + "type": "string", + "description": "Human-readable folder path." + }, + "title": { + "type": "string", + "description": "The test case name (renamed from `name` on the way out)." + }, + "description": { + "type": "string", + "description": "Empty string rather than null when the case has no description." + }, + "owner_id": { + "type": "integer", + "description": "Raw BrowserStack user id of the owner." + }, + "owner": { + "type": "string", + "description": "Resolved owner display name; an empty string when the user cannot be resolved in this group." + } + } + } + } + } + }, + "FolderV2": { + "type": "object", + "description": "A folder in its public (v2) shape. One shape for all three folder writes - create, rename and move all return exactly this object under `folder`.", + "properties": { + "id": { + "type": "integer", + "description": "The folder's numeric id. This is the value every later folder call takes in its path - the project is addressed by its PR-NNN identifier, but the folder is addressed by this number." + }, + "name": { + "type": "string", + "description": "The folder's name. On create and rename this is the value you sent, unless the project already had a folder of that name under the same parent and auto-renaming was allowed, in which case the server-chosen name comes back here." + }, + "description": { + "type": "string", + "nullable": true, + "description": "The folder's free-text notes. Null when the folder has none - which is every newly created folder, since create does not accept notes." + }, + "parent_id": { + "type": "integer", + "nullable": true, + "description": "The folder this one now sits under. Null for a folder at the top of the project. After a move this is the destination you asked for, so it is the field to check to confirm the move landed." + }, + "cases_count": { + "type": "integer", + "nullable": true, + "description": "Test cases directly in this folder (not descendants). The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." + }, + "sub_folders_count": { + "type": "integer", + "nullable": true, + "description": "Immediate child folders. The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." + }, + "links": { + "type": "object", + "description": "Ready-made API links for this folder. Always present on a folder write.", + "properties": { + "sub_folders": { + "type": "string", + "description": "Link for listing this folder's immediate child folders. Built with the project's PR-NNN identifier and this folder's numeric id." + } + } + }, + "urls": { + "type": "object", + "description": "Links for a person, not for an API client. Always present on a folder write.", + "properties": { + "self": { + "type": "string", + "description": "Absolute web-app address at which someone can open this folder. Note it is built from the project's NUMERIC id, whereas the API links above use the PR-NNN identifier - do not read a project identifier out of this string." + } + } + } + } + }, + "ConfigurationGroup": { + "type": "object", + "description": "An account-level configuration dimension (for example Operating System, Browser or Device) whose selectable values are held as its options.", + "properties": { + "id": { + "type": "integer", + "description": "Integer id of the group; use it to read or change the group's options." + }, + "name": { + "type": "string", + "description": "Display name of the dimension." + }, + "is_system": { + "type": "boolean", + "description": "True for groups the product ships rather than ones the account created." + }, + "group_id": { + "type": "integer", + "description": "The owning account (BrowserStack group). Identical on every row of a single response." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "LinkedIssueTestCase": { + "type": "object", + "description": "A test case as returned in a tracker ticket's linked-case list. Leaner than the full test case read, and carries issue-panel-only fields.", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "description": "The case's TC-NNN identifier, or the third-party identifier when the case was imported." + }, + "name": { + "type": "string" + }, + "folder_id": { + "type": "integer" + }, + "owner": { + "type": "object", + "description": "The resolved owner user, or null when the owner id cannot be resolved in the caller's group." + }, + "project": { + "type": "object", + "description": "Summary of the owning project, supplied so the panel can render across projects." + }, + "template": { + "type": "string" + }, + "template_step_type": { + "type": "string" + }, + "template_is_system": { + "type": "boolean" + }, + "datasets_linked": { + "type": "boolean" + }, + "custom_fields": { + "type": "array" + }, + "status": { + "type": "object" + }, + "case_type": { + "type": "object" + }, + "automation_state": { + "type": "object" + }, + "priority": { + "type": "string", + "description": "Flattened to the priority's internal name or display value, unlike the object form used elsewhere." + }, + "tags": { + "type": "array" + }, + "issues": { + "type": "array", + "description": "The tracker tickets this case is linked to." + }, + "reviewers": { + "type": "array" + }, + "review_status": { + "type": "string" + }, + "dropdown": { + "type": "object", + "description": "The project's default field option sets, repeated per case so the panel can label status and priority ids." + }, + "self_ui_link": { + "type": "string", + "description": "Web app path for this case — a link for a human, not an API path." + }, + "configuration": { + "type": "object" + }, + "last_test_info": { + "type": "object", + "description": "Present only when the case has been executed at least once. Absent — not null — for a never-run case.", + "properties": { + "last_run_result": { + "type": "string" + }, + "last_run_result_status": { + "type": "object" + }, + "last_run_name": { + "type": "string" + }, + "last_run_id": { + "type": "string", + "description": "The run's TR-NNN identifier, not its integer id, despite the name." + }, + "last_run_link": { + "type": "string" + }, + "last_run_status": { + "type": "string", + "description": "'closed' or 'active'." + } + } + } + } + }, + "SharedComponent": { + "type": "object", + "description": "A shared field: a reusable content block held under Shared Fields and referenced from many test cases. The same record backs all three kinds - step sequence, Gherkin background and precondition - distinguished by `field_type`. These are the fields every route returns, including the LISTING.\n", + "properties": { + "id": { + "type": "integer", + "description": "Shared-field id, from the same id space as the shared-steps endpoints." + }, + "title": { + "type": "string" + }, + "field_type": { + "type": "string", + "values": [ + "step", + "background", + "precondition" + ], + "description": "Which kind of shared field this is. Defaults to `step` when the create omits it.", + "example": "background" + }, + "test_case_count": { + "type": "integer", + "description": "How many test cases reference this block." + }, + "step_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_by": { + "type": "object", + "description": "Reduced user reference - id and full_name only, not the wider User shape other TM serializers return.", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + } + } + }, + "V2TestCaseDetail": { + "type": "object", + "description": "A test case as the v2 public API serializes it (create, get, update and move). Differs from the internal `TestCase` shape: `title` instead of name, `steps` [{step, result}] instead of test_case_steps, owner/created_by/updated_by as emails, custom fields by display name, and NO integer id — `identifier` is the handle.", + "properties": { + "identifier": { + "type": "string", + "description": "TC-NNN, or the customer-defined identifier when the workspace uses its own ids. Use this in later calls.", + "example": "TC-1024" + }, + "title": { + "type": "string", + "description": "The case name (sent as `name` on write)." + }, + "description": { + "type": "string", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "template": { + "type": "string", + "description": "Step style: test_case_steps or test_case_bdd. The template id itself is not returned.", + "example": "test_case_steps" + }, + "folder_id": { + "type": "integer" + }, + "status": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. Active." + }, + "priority": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. High." + }, + "case_type": { + "type": "string", + "nullable": true, + "description": "Display value, e.g. Functional." + }, + "automation_status": { + "type": "string", + "description": "Internal name, e.g. not_automated." + }, + "owner": { + "type": "string", + "nullable": true, + "description": "Owner's email; falls back to the imported owner label for migrated cases." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "jira_id": { + "type": "string", + "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made — azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." + } + }, + "description": "Only two keys exist on this shape and both are always present when the array is non-empty — nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." + }, + "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links." + }, + "steps": { + "type": "array", + "description": "Ordered steps for a classic case; always [] for a Gherkin case.", + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + } + } + } + }, + "feature": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "background": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "scenario": { + "type": "string", + "nullable": true, + "description": "Gherkin cases only; absent on classic cases." + }, + "custom_fields": { + "type": "array", + "description": "One entry per set custom field, keyed by display name. Multi-dropdowns carry `values` (array) instead of `value`. Always present, as [] when the case has none.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "description": "Scalar value; dropdown option label, user email, boolean, etc." + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "File name as uploaded." + }, + "size": { + "type": "string", + "description": "Human-readable size, e.g. '1.2 MB' — not a byte count." + }, + "content_type": { + "type": "string", + "description": "MIME type recorded when the file was uploaded, e.g. image/png." + }, + "url": { + "type": "string", + "description": "Link to the stored file." + } + } + }, + "description": "Files attached to the case. On a create the key is omitted ENTIRELY rather than returned as an empty array — a case created a moment ago has nothing attached — while a read of a case with no files returns []. Test for the key before iterating. Every entry carries name, size, content_type and url together — these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached." + }, + "is_shared": { + "type": "boolean", + "nullable": true + }, + "review_status": { + "type": "string", + "nullable": true, + "description": "Aggregate review state of the case. Omitted unless the project has Review & Approve enabled on a Team-Pro plan, and unset when the case has no review state." + }, + "reviewers": { + "type": "array", + "description": "Omitted entirely unless the project has Review & Approve enabled on a Team-Pro plan; shape depends on the workspace's reviewer read-shape flag (emails with review_status, or user objects).", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "nullable": true, + "description": "Creator's email." + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "Note the name: last_updated_at, not updated_at." + }, + "updated_by": { + "type": "string", + "nullable": true, + "description": "Last editor's email." + }, + "urls": { + "type": "object", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case in the Test Management UI." + } + } + } + } + }, + "GlobalSearchEntityResults": { + "type": "object", + "description": "One entity type's slice of an account-wide search result. Present only for the types that were asked for. Used for the entity types whose rows are handed back as the search index stored them rather than re-serialized: each row carries the record's id, name or title, the project it lives in, and a relevance score, plus the `display_meta` block described on `data` below. Because the rows are passed through, treat any key beyond id, name and project as informational.", + "properties": { + "data": { + "type": "array", + "description": "The matching records for this entity type, best match first. Every row carries a `display_meta` block enriched with the owner's name and the project's name so the hit can be labelled without another call - except on the typeahead fast path, where only owner and project are filled in and the rest of display_meta is skipped. Rows whose project has since been deleted or archived are dropped before you see them, so this list can be shorter than `info.count` implies.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "TestRunCaseBulkEditRow": { + "type": "object", + "description": "A run's case row as returned by the synchronous bulk edit. Built by a different serializer from the run's case LISTING — it carries the mapping cell's own fields (mapping id, result status object, defects, result notes) rather than the listing's projection. Kept separate so corrections to the listing shape do not silently redescribe this one.", + "properties": { + "identifier": { + "type": "string", + "example": "TC-42" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "priority": { + "type": "string", + "description": "Resolved label, not the id.", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "field_name": { + "type": "string", + "example": "priority" + }, + "id": { + "type": "integer", + "example": 318591 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status": { + "type": "string", + "description": "Test case status label." + }, + "case_type": { + "type": "string" + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." + }, + "assignee": { + "type": "string", + "description": "Assignee of this row within the run.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "project_id": { + "type": "string", + "example": "PR-1", + "description": "Project of the run being read." + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "automation_state": { + "type": "object" + }, + "automation_status": { + "type": "object" + }, + "case_type_imported": { + "type": "string" + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "configuration": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:33:18.553Z" + }, + "creator_details": { + "type": "string", + "nullable": true + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "dataset_values": { + "type": "string", + "nullable": true + }, + "defects": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "example": false + }, + "estimate": { + "type": "string", + "nullable": true + }, + "execution_eligible": { + "type": "boolean", + "example": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1985020 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "last_executed_at": { + "type": "string", + "nullable": true + }, + "last_executed_by": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { + "type": "object" + }, + "links": { + "type": "object" + }, + "mapping_id": { + "type": "integer", + "example": 2150428149 + }, + "metadata": { + "type": "object" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "planned_execution_date": { + "type": "string", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority_imported": { + "type": "string" + }, + "project_name": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "result_custom_fields": { + "type": "string", + "nullable": true + }, + "result_notes": { + "type": "string", + "nullable": true + }, + "result_status": { + "type": "object" + }, + "status_imported": { + "type": "string" + }, + "step_count": { + "type": "integer", + "example": 1 + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "__mcp-probe-mergetag-20260922-a" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_assignee": { + "type": "string", + "nullable": true + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_folders_path": { + "type": "object" + }, + "test_case_id": { + "type": "integer", + "example": 1985020 + }, + "test_case_steps": { + "type": "string", + "nullable": true + }, + "test_run_id": { + "type": "integer", + "example": 17578918 + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:33:18.553Z" + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "TestRunV2Response": { + "description": "The test run at the top level under `test_run`, with no `data` envelope — the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_run": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "issues": { + "type": "array", + "description": "The run's linked defects. CONDITIONAL — present but EMPTY when the run has nothing linked; the key is absent entirely when the run payload carries no issues association at all. An absent key therefore means 'not reported', not 'no defects'. Per-entry fields have their own conditions below.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string", + "description": "The ticket's id in the tracker, e.g. XYZ-1 — this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` — the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL — omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" + } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" + } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" + } + } + }, + "test_cases": { + "type": "array", + "description": "The run's test cases, present only on the single-run read; absent on create and clone. The rows use the same element schema as the standalone case listing, deliberately, so a correction to one cannot drift from the other — but a few values differ by caller and say so on the field: steps are never included here, for instance.", + "items": { + "$schema": "TestRunTestCaseV2" + } + } + } + } + } + } + }, + "TestRunWriteV2Response": { + "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape — the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "issues": { + "type": "array", + "description": "The run's linked defects. CONDITIONAL, and usually absent on a write reply: this value is echoed from the update result rather than re-read, and on the newer run backend the write reply carries no linked-defect list at all. Never infer from its absence that the write dropped the links — read the run back to confirm what is linked.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string", + "description": "The ticket's id in the tracker, e.g. XYZ-1 — this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` — the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." + }, + "issue_type": { + "type": "string", + "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL — omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" + } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" + } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" + } + } + } + } + } + } + } + }, + "TestPlanV2Detail": { + "type": "object", + "description": "A test plan or sub-plan as the single-plan read returns it: everything the plan shape carries, plus an embedded preview of the plan's runs and the relative API links that go with it. Only the single-plan read produces these two; a create, an update or a listing never does.", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "description": { + "type": "string", + "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." + }, + "active_state": { + "type": "string", + "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", + "enum": [ + "active", + "completed" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." + }, + "test_runs_count": { + "type": "object", + "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "test_runs": { + "type": "array", + "description": "A preview of at most the first 30 runs in this plan, each with identifier and name alone. Absent - not empty - when the run fetch failed; check test_runs_count before concluding the plan has no runs. Only the single-plan read carries it.", + "items": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "example": "TR-164" + }, + "name": { + "type": "string" + } + } + } + }, + "sub_plans_count": { + "type": "integer", + "nullable": true, + "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." + }, + "parent_plan_id": { + "type": "string", + "description": "The parent's TP-NNN identifier, not its numeric id. Set only when this plan is a sub-plan; on a top-level plan the key is absent from the payload entirely and is never returned as null.", + "example": "TP-10000" + }, + "start_date": { + "type": "string", + "nullable": true, + "format": "date-time", + "example": "2026-01-01T00:00:00Z" + }, + "end_date": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "string", + "description": "PR-NNN identifier of the owning project, not a number.", + "example": "PR-2" + }, + "links": { + "type": "object", + "description": "Relative API paths for this plan. Present only when the run preview was fetched successfully - these appear and disappear together with test_runs. Only the single-plan read carries them.", + "properties": { + "self": { + "type": "string", + "description": "Path to read this plan.", + "example": "/api/v2/projects/PR-2/test-plans/TP-12" + }, + "test_runs": { + "type": "string", + "description": "Path to page through the runs in this plan.", + "example": "/api/v2/projects/PR-2/test-plans/TP-12/test-runs" + } + } + }, + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + }, + "sub_test_plans": { + "type": "string", + "description": "Address of this plan's children listing. Added only when the plan has no parent, so a sub-plan never carries it. Unlike self this is an API address, not a web-app page." + } + } + } + } + }, + "V2TestRunStepResultDetail": { + "type": "object", + "description": "One per-step result written by a v2 result submission that carried step_results, returned as an entry of `test-run-step-results`. One entry per step result written, in the order the steps were sent.", + "properties": { + "id": { + "type": "integer", + "description": "Id of the result row written for this step. Carry it forward for a follow-up update; it is the id domain the server expects back." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true, + "description": "This step's own notes, not the case-level description." + }, + "status": { + "type": "string", + "example": "passed", + "description": "This step's own status, as the LOWERCASE internal token ('passed'/'failed'/'blocked') - not the display-case name a case-level result echoes back. The display-case form of the same status is inside test_run_test_case_step_details as field_value, and the status rolled up for the case as a whole is the response's overall-result-status." + }, + "issues": { + "type": "array", + "description": "Issues linked to this step result, as plain KEY STRINGS, deduplicated - the same shape they are sent in, not objects.", + "items": { + "type": "string", + "example": "JIRA-12" + } + }, + "urls": { + "type": "object", + "description": "Web link for this submission.", + "properties": { + "self": { + "type": "string", + "description": "Deep link to the case's ROW IN THE RUN - its final path segment is the run row's id, not the id of the step result just written." + } + } + }, + "test_run_test_case_step_details": { + "type": "object", + "description": "The step this result belongs to and how it was answered. Present on every entry here - each entry IS a step result - and never on the case-level `test-result`, which only exists when no step results were submitted. Alongside the step result's own ids and timestamps it carries the status twice (`status` as the lowercase internal token, `field_value` as the display-case name) with its `colour`, and the step itself: `step` (the instruction), `result` (the expected result) and `order` (its position in the case).", + "properties": { + "id": { + "type": "integer", + "description": "Id of the step-result record." + }, + "step_mapping_id": { + "type": "integer", + "description": "Id of the step within the case that this result answers." + }, + "status": { + "type": "string", + "example": "passed", + "description": "Lowercase internal token for this step's status." + }, + "field_value": { + "type": "string", + "example": "Passed", + "description": "Display-case name of the same status." + }, + "status_id": { + "type": "integer", + "description": "Id of the configured status value." + }, + "colour": { + "type": "string", + "nullable": true, + "description": "Colour configured for that status." + }, + "step": { + "type": "string", + "nullable": true, + "description": "The step instruction, as rich text." + }, + "result": { + "type": "string", + "nullable": true, + "description": "The expected result for the step, as rich text." + }, + "order": { + "type": "integer", + "nullable": true, + "description": "Position of the step in the case." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "TestCaseFieldValue": { + "type": "object", + "description": "One resolved field value on a test case — the shared descriptor behind case_type, priority, status and automation_state. Identical across the detail, list, folder-list, search, search-v2 and run-mapping routes.", + "properties": { + "id": { + "type": "integer" + }, + "internal_name": { + "type": "string", + "description": "Machine name, e.g. `acceptance`, `low`, `not_automated`." + }, + "colour": { + "type": "string", + "nullable": true + }, + "value_category": { + "type": "string", + "nullable": true, + "description": "Coarse grouping for the value, e.g. `manual` / `automated` on automation_state. Null on most priority, status and case_type values." + }, + "name": { + "type": "string", + "description": "Display label." + } + } + }, + "V1TestRunTestCaseRow": { + "type": "object", + "description": "One test case AS MAPPED INTO A v1 TEST RUN — a case-plus-configuration row, not the project-level record. A case mapped to several configurations appears once per configuration. The row is the shared test-case record (see TestCase) plus the execution fields below; it does NOT carry attachments, estimated_duration_seconds, template_name or test_case_dataset, and it DOES carry step_count.\n\nNOT EVERY WORKSPACE RETURNS EVERY FIELD DECLARED HERE. `execution_eligible` and the `review_status`/`reviewers` pair are gated by a per-workspace feature flag and are mutually exclusive in practice: a workspace returns one side or the other, never both. The declaration is the union, so treat each of those three as absent unless you have seen it on the workspace you are actually reading. The remaining fields are returned everywhere.", + "allOf": [ + { + "$schema": "TestCaseCore" + } + ], + "properties": { + "mapping_id": { + "type": "integer", + "description": "Id of THIS case-x-configuration mapping inside the run. NOT stable: it shifts to the latest result's id after a write, so re-read it before each bulk operation rather than caching it." + }, + "test_case_id": { + "type": "integer", + "description": "Integer id of the underlying project-level test case. `id` on this row is the same case id; `mapping_id` is what identifies the row." + }, + "test_run_id": { + "type": "integer", + "description": "Integer id of the run this mapping belongs to." + }, + "configuration": { + "type": "object", + "nullable": true, + "description": "The configuration this row is mapped to.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "browser": { + "type": "string", + "nullable": true + }, + "browser_version": { + "type": "string", + "nullable": true + }, + "os": { + "type": "string", + "nullable": true + }, + "os_version": { + "type": "string", + "nullable": true + }, + "device": { + "type": "string", + "nullable": true + } + } + }, + "latest_status": { + "type": "string", + "description": "Latest execution status for THIS row, e.g. `untested`.", + "example": "untested" + }, + "result_status": { + "$schema": "ResultStatusFieldValue", + "nullable": true + }, + "result_notes": { + "type": "string", + "nullable": true, + "description": "Notes recorded with the latest result." + }, + "result_custom_fields": { + "type": "array", + "description": "Custom field values recorded with the latest result; `[]` when none.", + "items": { + "type": "object" + } + }, + "defects": { + "type": "array", + "description": "Defects linked to the latest result; `[]` when none.", + "items": { + "type": "object" + } + }, + "dataset_values": { + "type": "array", + "description": "Dataset values bound to this mapping; `[]` when none.", + "items": { + "type": "object" + } + }, + "last_executed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Null until the row has been executed." + }, + "last_executed_by": { + "type": "integer", + "nullable": true, + "description": "Null until the row has been executed." + }, + "planned_execution_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "test_assignee": { + "type": "integer", + "nullable": true, + "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`." + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "description": "True when the underlying case has been removed but the mapping row is still returned." + }, + "project_name": { + "type": "string", + "nullable": true, + "description": "Populated only for cases mapped in from another project; null otherwise." + }, + "test_case_folders_path": { + "type": "array", + "description": "Breadcrumb from root to the case's folder.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + } + } + } + }, + "execution_eligible": { + "type": "boolean", + "description": "Whether this mapping may be executed. Gated by a per-workspace feature flag: a workspace returning this field does not return `review_status`/`reviewers` on these rows, and vice versa. Treat as absent unless seen." + }, + "step_count": { + "type": "integer", + "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) — count `test_case_steps` there instead." + }, + "comments_count": { + "type": "integer" + } + } + }, + "TestRunSelectionCaseRow": { + "type": "object", + "description": "The raw test-case record as returned by the run-selection endpoint. This is NOT the shared TestCase serializer: `automation_status` is a plain string here rather than the {name,value} object, the folder key is `folder_id` rather than `test_case_folder_id`, `type` replaces the `case_type` descriptor, and assignee / priority / status / tags / custom_fields / steps are not returned at all. Do not plan against TestCase fields on this route.\n", + "properties": { + "id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "example": "TC-838569" + }, + "third_party_identifier": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "description": "Case type as a bare string, e.g. `manual`. The descriptor object is not returned here.", + "example": "manual" + }, + "folder_structure": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "nullable": true + }, + "source_entity_id": { + "type": "integer", + "nullable": true + }, + "owner": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id, not the TM user id used by `assignee.id` elsewhere." + }, + "folder_id": { + "type": "integer", + "description": "The case's folder. Called `test_case_folder_id` on every other test-case route." + }, + "group_id": { + "type": "integer" + }, + "template": { + "type": "string", + "example": "test_case_text" + }, + "template_id": { + "type": "integer", + "nullable": true + }, + "import_id": { + "type": "integer", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "updated_by": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id." + }, + "created_by": { + "type": "integer", + "nullable": true, + "description": "BrowserStack user id." + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "automation_status": { + "type": "string", + "description": "A PLAIN STRING on this route, e.g. `not_automated` — elsewhere `automation_status` is a {name, value} object.", + "example": "not_automated" + }, + "automation_id": { + "type": "integer", + "nullable": true + }, + "archived_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Non-null when the case is archived." + }, + "trashed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Non-null when the case is in the bin." + }, + "is_shared": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "nullable": true + }, + "is_project_level": { + "type": "boolean" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "review_sent_date": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "integer", + "nullable": true + } + } + }, + "TestCaseCore": { + "type": "object", + "description": "Fields returned by EVERY test-case row on every route, including the run-mapping rows. Routes add to this; nothing here is route-conditional.\n", + "properties": { + "assignee": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "creator_details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updator_details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "email": { + "type": "string", + "description": "Email address of the user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } + } + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "case_type": { + "$schema": "TestCaseFieldValue", + "nullable": true + }, + "priority": { + "$schema": "TestCaseFieldValue" + }, + "status": { + "$schema": "TestCaseFieldValue", + "nullable": true + }, + "case_type_imported": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "estimate": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "folder": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "history_count": { + "type": "integer", + "nullable": true + }, + "owner": { + "type": "integer", + "nullable": true + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "owner_imported": { + "type": "string" + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "step": { + "type": "string" + }, + "result": { + "type": "string" + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "template": { + "type": "string" + }, + "test_case_folder_id": { + "type": "integer" + }, + "test_case_steps": { + "$schema": "TestCaseStep" + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "metadata": { + "type": "object", + "properties": { + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + }, + "profileId": { + "type": "string", + "nullable": true, + "description": "Low-code profile id. Present under `metadata.lcnc_build_map` but NOT on the top-level `lcnc_build_map`, which carries only resource_path and status." + } + } + }, + "ai_prompt": { + "type": "object", + "properties": { + "status": { + "type": "boolean" + } + } + } + } + }, + "automation_state": { + "$schema": "TestCaseFieldValue", + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement." + }, + "is_shared": { + "type": "boolean", + "description": "True when the case is a shared case rather than one owned outright by its folder." + }, + "template_id": { + "type": "integer", + "nullable": true, + "description": "Id of the template backing this case. Null for cases on the plain-text template." + }, + "template_step_type": { + "type": "string", + "description": "Step type the template implies — `test_case_steps` or `test_case_text`. Mirrors `template`.", + "example": "test_case_steps" + }, + "test_run_results_count": { + "type": "integer", + "nullable": true, + "description": "Count of results recorded for this case across runs. Null on list routes. Known to disagree with the actual number of results — do not rely on it; page the run-results endpoint instead." + }, + "test_run_results_issues_count": { + "type": "integer", + "nullable": true, + "description": "Count of issues raised against those results. Null on list routes; carries the same reliability caveat as test_run_results_count." + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "clone_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/clone", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true - } - ], - "intent": "Copy an existing exploratory session \u2014 its description, timebox, configurations and custom fields, plus its log entries, and optionally its tags, linked issues and owner \u2014 to re-run the same exploration", - "responses": { - "201": { - "$response": "ExploratorySessionCloneSyncResponse" - }, - "202": { - "$response": "ExploratorySessionCloneAsyncResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The clone body is FLAT \u2014 it is not wrapped in an `exploratory_session` object, unlike create and update on this entity.", - "There are two completely different success shapes and you must branch on the source session's size. Fewer than 50 logs: 201 with {success, async:false, unique_id:null, exploratory_session:{...}} and the copy fully built. 50 logs or more: 202 with {success, async:true, unique_id} and NO session object \u2014 the copy exists but you are not told its id.", - "Read session_log_count on the source session first to know which branch you will get. If it is 50 or more, plan to find the new session by listing sessions afterwards (its default title is ' - Copy').", - "unique_id is a realtime broadcast channel name, not a job id \u2014 there is no endpoint that reports the clone's progress or result. The only way to confirm an async clone is to re-read the sessions list.", - "The charter and the folder are NOT copied. The clone lands at the project root with an empty charter even though description, timebox, configurations and custom field values are copied. Set them with an update afterwards if they matter.", - "copy_tags, copy_issues and copy_session_owner all default to FALSE, so a plain clone arrives with no tags, no linked issues and nobody assigned. copy_issues also controls whether each copied log keeps its own linked defects.", - "The clone is always created in the active state, even when the source session is closed.", - "The three copy_* flags must be real JSON booleans \u2014 the request schema rejects the strings \"true\"/\"false\" with 400.", - "If the asynchronous log copy fails, the newly created session is deleted again, so a failed clone leaves nothing behind \u2014 and nothing over this API tells you it happened.", - "The source session is read before anything is created, so an unknown session_id fails as a bare {success:false} 404 with no session created." - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Checkout flow exploration - rerun", - "description": "Title for the new session. Sent at the top level of the body, not inside an exploratory_session wrapper. Defaults to ' - Copy' when omitted \u2014 which is also how you find the copy after an async clone.", - "json_path": "/name" - }, - { - "name": "copy_tags", - "type": "boolean", - "default": false, - "description": "Copy the source session's tags. Must be a real JSON boolean; the string \"true\" is rejected with 400. Defaults to false, so the clone is untagged unless you ask.", - "json_path": "/copy_tags" - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false, - "description": "Copy the linked issues \u2014 both the session's own issue links AND the defects on every copied log entry. Must be a real JSON boolean. Defaults to false.", - "json_path": "/copy_issues" - }, - { - "name": "copy_session_owner", - "type": "boolean", - "default": false, - "description": "Keep the source session's assignee. Must be a real JSON boolean. Defaults to false, which leaves the clone unassigned.", - "json_path": "/copy_session_owner" - } - ], - "returns": [ - "success", - "async", - "unique_id", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "session_state", - "timebox_duration", - "session_log_count", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "created_at", - "updated_at" - ] - }, - { - "name": "clone_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/clone", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/test_plan/name", - "description": "Name of the NEW plan. Required \u2014 there is no server-generated copy name. Omit it (or send blank) and the call is 400 {success: false, message: 'Name field cannot be blank'}." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Description of the new plan. Sanitised against the rich-text allowlist server-side, so the stored value can differ from what you sent. Not copied from the source plan \u2014 omit it and the clone has an empty description." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "json_path": "/test_plan/start_date", - "description": "ISO-8601 date for the new plan. Not inherited from the source." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "json_path": "/test_plan/end_date", - "description": "ISO-8601 date for the new plan. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and clones nothing." - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_plan/owner", - "description": "Owner as the internal TM user id (the `id` from the project users read, NOT a BrowserStack user id or an email). Omitted means the caller. An id that does not resolve inside the caller's group is swallowed rather than rejected and the clone ends up with no real owner." - }, - { - "name": "tags", - "type": "array", - "json_path": "/test_plan/tags", - "description": "Plain tag strings for the new plan. If ANY entry is not a string the whole list is silently replaced with [] \u2014 no error." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_plan/reviewers", - "description": "Reviewer user ids for the new plan. OMIT the key to copy the source plan's reviewers; send [] to clone with no reviewers. Validated against the project's allowed-reviewer list." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_plan/attachments", - "description": "Attachment ids already uploaded via the attachments surface." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_plan/custom_fields", - "description": "Custom-field values for the new plan, keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: } before any cloning starts." - }, - { - "name": "parent_plan_id", - "type": "integer", - "json_path": "/test_plan/parent_plan_id", - "description": "Set it to make the clone a SUB-plan of that parent (integer id). Omitted/null gives a top-level plan. This is fixed at clone time \u2014 a plan cannot be promoted or demoted later." - }, - { - "name": "copy_all_runs", - "type": "boolean", - "default": false, - "json_path": "/test_plan/copy_all_runs", - "description": "Copy every test run linked to the source plan. When true, test_runs is not consulted." - }, - { - "name": "test_runs", - "type": "array", - "json_path": "/test_plan/test_runs", - "description": "Clone only these runs of the source plan, as run ids. Ignored when copy_all_runs is true; send neither and the clone is created with no runs." - }, - { - "name": "copy_all_sub_plans", - "type": "boolean", - "default": false, - "json_path": "/test_plan/copy_all_sub_plans", - "description": "Clone every sub-plan of the source plan under the new plan." - }, - { - "name": "sub_plan_ids", - "type": "array", - "json_path": "/test_plan/sub_plan_ids", - "description": "Clone only these sub-plans (integer ids). Ignored when copy_all_sub_plans is true." - }, - { - "name": "status_ids", - "type": "array", - "json_path": "/test_plan/status_ids", - "description": "Run-level filter: keep only source runs that contain at least one test case in one of these result-status ids. Entries must be integers \u2014 anything else is 400 {success: false, error: 'status_ids must be a list of integers'}. It filters RUNS, not cases: every case inside a kept run is copied." - } - ], - "intent": "Copy a test plan into a brand-new plan, bringing its test runs (and optionally its sub-plans) with it \u2014 the copy is built in the background, so this hands back a job id rather than the new plan", - "responses": { - "200": { - "$response": "TestPlanCloneAcceptedResponse" - }, - "400": { - "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`), non-integer status_ids, or a rejected custom field (`{success: false, error: ...}`). Also used for a body with no `test_plan` wrapper.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "error": { - "type": "string" + }, + "V1TestCaseListRow": { + "type": "object", + "description": "A test-case row as returned by the LIST routes. Adds `step_count`, which the single-case detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "step_count": { + "type": "integer", + "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) — count `test_case_steps` there instead." + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is ASYNC. A 200 means the clone job was queued, not that the plan exists \u2014 the body is {success: true, unique_id} and carries no plan id, no identifier and no name. The source plan is untouched either way.", - "unique_id is only a correlation id for logs; there is no job-status capability for it in this profile. To find the new plan, poll the project's plan listing for the name you sent.", - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own `test_plan` object is rejected before the call as an unknown body key.", - "name is REQUIRED even though this is a clone. There is no automatic 'Copy of ...' name.", - "Nothing except the runs/sub-plans is inherited: description, dates, tags, owner and custom fields all come from THIS request, so a clone sent with only a name produces an otherwise empty plan.", - "Choose the run scope explicitly \u2014 copy_all_runs: true, or an explicit test_runs list. With neither, the job creates the plan with no runs at all and still returns success.", - "Copied runs come over whole: the job forces copy_all_cases, and keeps each case's assignee, issues and tags. status_ids narrows which RUNS are copied, never which cases inside them.", - "Date validation is a silent trap: end_date before start_date returns HTTP 200 with {success: false, error: 'Please check date params'}. Check the `success` field, not the status code.", - "Needs the test-plan write permission." - ], - "returns": [ - "success", - "unique_id" - ] - }, - { - "name": "clone_test_run_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/clone", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Cloned Test Run - 2025", - "description": "Name for the new run. Leave it out and the run is auto-named, which is how projects end up with several identically named clones." - }, - { - "name": "copy_all_cases", - "type": "boolean", - "default": false, - "description": "Copy every case from the source run. WITHOUT this (and without status_id) the clone is created EMPTY \u2014 the default is a bare run, not a copy of the case list." - }, - { - "name": "status_id", - "type": "array", - "example": [ - 1, - 2 - ], - "description": "Copy only the cases whose latest result is one of these numeric status ids (resolve them via the project's result-status field values). Numeric ids only \u2014 this is the filter that is honoured on every backend flow." - }, - { - "name": "status", - "type": "array", - "example": [ - "passed", - "failed" - ], - "description": "Status NAMES to filter the copied cases by. Honoured only on the legacy backend flow; on the current (build-backed) flow it is dropped and a clone whose only case selector was `status` copies nothing and still returns 200. Send status_id instead." - }, - { - "name": "copy_tc_assignee", - "type": "boolean", - "default": false, - "description": "Carry each copied case's assignee across. Only meaningful together with copy_all_cases / status_id \u2014 it applies to the copied rows, not to the run." - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false, - "description": "Carry the linked defects across onto the copied case rows." - }, - { - "name": "copy_tags", - "type": "boolean", - "default": false, - "description": "Copy the source run's tags onto the new run." - }, - { - "name": "source_build_id", - "type": "integer", - "description": "Optional explicit source build to copy cases from, for callers that already resolved it. Normally omitted \u2014 it is looked up from the source run." - } - ], - "intent": "Copy an existing test run into a new one \u2014 optionally carrying over its cases, their assignees, tags and linked defects \u2014 to re-execute the same scope without rebuilding the selection", - "returns": [ - "data", - "success", - "test_run", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "attachments", - "metadata", - "project_id", - "created_at", - "updated_at", - "created_by", - "observability_url", - "links" - ], - "responses": { - "200": { - "$response": "TestRunResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A clone with no case selector produces an EMPTY run. Send copy_all_cases:true, or status_id, or you get a 200 and a run with zero cases \u2014 this is the single most common surprise on this endpoint.", - "Filter the copied cases by status_id (numeric), not by `status` (names). The name form is only read on the legacy backend flow; on the current one it is ignored and the clone silently comes back empty.", - "The clone always starts in run_state `new_run` regardless of the source run's state, and its description is always copied. Tags, defects and per-case assignees are copied only when you ask. Configurations, the owner and the dynamic-filter rule are NOT carried over \u2014 set them afterwards with the edit call.", - "Cloning a run created before the current ingest flow, while asking for its cases, is refused with 422 'The source run has no test cases available to clone. Retry without case selection.' Retrying without copy_all_cases/status_id gives you an empty clone of the header.", - "Population is not synchronous on every path: the run exists as soon as the call returns, but re-read its cases rather than trusting an immediate empty list, and never retry the clone \u2014 that makes a second run.", - "Take `uuid` (the integer) from the response, not `id` \u2014 `id` is the TR-NNN string here as everywhere else on runs.", - "The new run comes back under `data.test_run` (with an underscore). The write endpoints on this entity \u2014 create, edit, assign, close, delete \u2014 use `data.testrun` instead. Do not reuse one reader for both.", - "Needs the test-run write permission; cloning an automation run demands the automation variant of it and 403s for a role that only holds the manual one." - ] - }, - { - "name": "clone_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/clone", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "test_case_selection", - "type": "string", - "values": [ - "all", - "by_results" - ], - "default": "all", - "description": "Which cases of the source run the copy gets: every case, or only those whose latest result matches `statuses`. Any other value is a 400." - }, - { - "name": "statuses", - "type": "array", - "fields": [ - { - "name": "status_name", - "type": "string" - } - ], - "example": [ - "failed", - "blocked" - ], - "description": "Result status NAMES (case-insensitive) \u2014 not ids. Required and non-empty when test_case_selection is \"by_results\"; an unknown name is a 400 naming it. Defaults plus any custom status configured for the group are accepted." - }, - { - "name": "copy_tc_assignee", - "type": "boolean", - "default": true, - "description": "Carry per-case assignees over. Defaults to TRUE here (the v1 clone defaults it to false)." - }, - { - "name": "copy_tags", - "type": "boolean", - "default": true, - "description": "Carry the source run's tags over. Defaults to TRUE here (the v1 clone defaults it to false)." - }, - { - "name": "copy_issues", - "type": "boolean", - "default": false - }, - { - "name": "name", - "type": "string", - "example": "Sprint 24 - Regression Re-Run", - "description": "Name for the new run. Omitted or blank gives \" - Copy\"." - } - ], - "intent": "Copy an existing run into a brand-new run \u2014 the usual way to re-execute just the failed or blocked cases from the last cycle", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Body is flat \u2014 these fields sit at the top level, not inside a `test_run` object like the update calls.", - "Identity is synchronous, data is not: the response carries the new run as soon as it exists, but its case mappings are filled in by a background worker. Reading the new run's test cases immediately can legitimately return zero rows \u2014 retry before concluding the clone was empty.", - "A 202 (instead of 200) means the run was cloned and one bookkeeping leg is still settling: do NOT retry, or you get a second clone.", - "Test plan / sub-plan links are NOT carried over; link the copy afterwards if you need it under a plan.", - "A dynamic source run passes its is_dynamic flag on, but the filter is not re-evaluated, so the copy starts from the source's case list.", - "Cloning an automation run is blocked by the write-permission gate (403) \u2014 this path is for manual runs.", - "The copy is a new run, so nothing about the source changes; discard it by deleting the copy.", - "The clone comes back at the top level as `test_run`, not under `data`. A 202 means the copy is still populating.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunV2Response" - }, - "202": { - "description": "Run cloned, background bookkeeping still in progress. Body is {success: true, build_pending: true, message, test_run}. Treat as success; retrying would create a duplicate run.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "build_pending": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "test_run": { - "$schema": "TestRun" + }, + "V1TestCaseFolderListRow": { + "type": "object", + "description": "The folder listing's row: a list row that also carries `duplicates_tc_count`.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "duplicates_tc_count": { + "type": "integer", + "nullable": true, + "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListFolderTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListFolderTestCases" + } } - } - } - }, - "400": { - "description": "Clone options failed validation \u2014 unknown test_case_selection, missing statuses for \"by_results\", or an unknown status name. Body is {success: false, message}.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" + }, + "V1TestCaseSearchRow": { + "type": "object", + "description": "A search result row: a list row that also carries `attachments`.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object" + } + }, + "case_type": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "priority": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "status": { + "$schema": "SearchFieldValue", + "nullable": true + }, + "automation_state": { + "$schema": "SearchFieldValue", + "nullable": true + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "close_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/close", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Mark an exploratory testing session finished so it leaves the active listing and is archived as completed work", - "responses": { - "200": { - "description": "Exploratory session closed successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This call takes no body at all. The handler's actual_duration pass-through is commented out, so posting a duration here returns 200 and changes nothing. Write actual_duration with the session update call BEFORE closing.", - "Closing is one-way through the API: nothing accepts session_state, and there is no reopen endpoint. Treat it as irreversible even though it is not classed destructive.", - "After closing, the session disappears from the sessions listing unless you pass q[session_state]=closed \u2014 the listing defaults to active.", - "Closing also changes who may delete the session afterwards: deletion of a closed session is gated by the closed-test-run delete permission rather than the ordinary one, so a caller who could have deleted it a moment earlier may now get 403.", - "The response is only {success:true}. Re-read the session to see session_state, closed_at and closed_by.", - "Closing an already-closed session is not rejected here; whatever the store does with it surfaces, if at all, as the generic 400 {success:false, message:'The API request is invalid or improperly formed'}.", - "A session id that does not exist returns a bare {success:false} with 404 and no message." - ], - "returns": [ - "success" - ] - }, - { - "name": "close_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/close", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "intent": "Close a test run \u2014 freeze its results, mark the cycle finished and drop it out of the default run listing", - "returns": [ - "data", - "success", - "testrun", - "project", - "skipped_test_case_ids", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__CloseTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No body at all. This is a fixed transition to run_state `closed`; anything you send is ignored.", - "Closing is final. A snapshot of every result is taken and the run's linked build in Test Observability is closed too, and nothing sets the run back to an active state afterwards \u2014 confirm with the user before closing a run that may need more testing.", - "A closed run disappears from the default project run listing \u2014 it is only visible through the closed-runs listing or a listing that explicitly asks for it. Deleting it afterwards also demands a different permission from deleting an open run.", - "A closed run cannot be reopened. The partial run update answers 404 'Test Run ID is invalid' for a closed run, and the full edit returns success while leaving it closed \u2014 do not report either as a reopen. To keep testing, clone the run (clone_test_run with copy_all_cases) and add cases to the copy; re-link it to its plan afterwards, since the clone does not carry the plan over.", - "The run comes back under `data.testrun` \u2014 one word, no underscore \u2014 alongside `data.project`. The read endpoints use `data.test_run`. The 200 also carries a TOP-LEVEL `skipped_test_case_ids` array (a sibling of `data`, not inside it).", - "Client errors from the backing service come back as `{data: {success: false, error: }}` with the 4xx status, not as the flat error envelope other endpoints use \u2014 read `data.success` before trusting `data.testrun`.", - "Needs the test-run write permission; an automation run demands the automation variant and 403s otherwise." - ] - }, - { - "name": "copy_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folders/copy", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "source_folder_id", - "type": "integer", - "required": true, - "example": 2, - "description": "Integer id of the folder to copy. Must exist in the project named in the path. Blank/absent returns 400 {error:'source_folder_id is required'}." - }, - { - "name": "destination_folder_id", - "type": "integer", - "example": 3, - "description": "Parent folder the copy is placed under, in destination_project_id. NOT required: omitting it (or sending null) is what switches the call into copy-to-root mode, exactly as copy_to_root:true does." - }, - { - "name": "destination_project_id", - "type": "integer", - "example": 10000, - "description": "Destination project. NOT required \u2014 defaults to the project in the path. Cross-project copies are allowed here (unlike cross-project moves, which are blocked when the folder has shared test cases)." - }, - { - "name": "copy_to_root", - "type": "boolean", - "default": false, - "description": "Place the copy at the destination project's root instead of under a parent folder. Accepts the boolean true or the literal string \"true\". It is also forced on whenever destination_folder_id is absent." - } - ], - "intent": "Duplicate a folder \u2014 its whole subtree of subfolders and every test case in them \u2014 into another folder, or to the root of any project you can reach; always runs as a background job", - "returns": [ - "success", - "async", - "unique_id", - "message" - ], - "responses": { - "202": { - "$response": "FolderCopyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - } - }, - "guidance": [ - "This endpoint only queues work. It answers 202 Accepted (not 200) with {success:true, async:true, unique_id, message} the moment validation passes \u2014 nothing has been copied yet when you get the response.", - "There is no way to poll the job over HTTP. `unique_id` is just the X-Request-ID header echoed back (a fresh UUID if you sent none), and progress is pushed over a websocket channel the API does not expose. Confirm the copy by re-reading the destination folder's contents after a delay.", - "`async` and `copy_test_cases` are not read anywhere in the handler or the worker. The copy is always asynchronous, and it always copies the full subtree \u2014 every nested subfolder and every test case. You cannot ask for a folders-only copy.", - "Name clashes are resolved silently, not rejected: an existing folder of the same name in the destination makes the copy land as `copy-of-`, then `copy-of-2-`, `copy-of-3-` and so on. Match on the returned tree, not on the name you sent.", - "Copying a folder into itself or into one of its own descendants is refused with 400 {error:'Cannot copy folder into itself or its subfolder'} \u2014 but only for same-project copies that name a destination folder. Copy-to-root and cross-project copies skip that check entirely.", - "Missing source or destination is 404 with a bare {error:'...'} and no `success` key \u2014 this endpoint does not use the usual {success:false} envelope for that case.", - "If the worker fails partway it rolls the whole copy back, so a failed run should leave no half-copied tree \u2014 but you only learn that by re-reading, not from this response.", - "Requires the folder-create permission on the project in the path." - ] - }, - { - "name": "count_run_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/selection-count", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "select_all", - "type": "boolean", - "required": true, - "example": false, - "description": "Select every case in the project. Marked required because the request body must contain a `selection` object at all \u2014 sending only `configurations` is rejected 400 before anything is counted, and this is the cheapest field that materialises `selection`. Pass false alongside `folders` for a folder-scoped count.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 2, - "description": "The caller's own count of distinct cases in the selection. Echoed through; it does not constrain the answer.", - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. Case ids must be integers \u2014 strings are rejected by the schema.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Map of project ids to their shared test-case selection, same inner shape as `selection`. Top level, NOT inside `selection`." - }, - { - "name": "configurations", - "type": "array", - "required": true, - "example": [], - "description": "Configuration ids (integers). Required by the schema \u2014 send an empty array when there are none; omitting the key is a 400." - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "description": "Per-configuration case mappings. An ARRAY of objects \u2014 an object here is rejected. Unknown keys inside an entry are rejected too.", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Ask how many test cases a folder/case selection will actually resolve to \u2014 including the multiplication by configurations \u2014 before committing it to a run create or edit", - "shape": "discovered", - "responses": { - "200": { - "description": "The number of test-case x configuration rows the selection resolves to.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "count": { - "type": "integer", - "example": 166 + }, + "V1TestCaseRecordRow": { + "type": "object", + "description": "The single-case record as returned by the integer-id read. Carries `attachments` and `comments_count`, and NOT `step_count` — count `test_case_steps` instead.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object" + } + }, + "comments_count": { + "type": "integer" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is schema-validated with unknown top-level keys REJECTED. Only `selection`, `shared_selection`, `configurations` and `trtc_configuration_mappings` are accepted; anything else \u2014 including run fields like `name` or `filters` \u2014 returns 400 {error: 'Invalid JSON data', details: [...]} before any counting happens.", - "`selection` and `configurations` are both mandatory. A body with a selection but no `configurations` key is refused; pass `configurations: []` when the run has none.", - "The answer is the number of case-times-configuration rows the run would get, not the number of distinct test cases \u2014 with two configurations selected the count is roughly double `unique_test_case_count`. That is the point of the call.", - "Read-only and side-effect free: it resolves the selection but creates nothing. Use it before a create or edit when the user needs to see the size of what they are about to commit.", - "The body must be sent as application/json; a form-encoded body skips the schema check entirely and then fails further in with a less useful error.", - "The response is flat \u2014 {success, count} with no `data` envelope." - ], - "returns": [ - "success", - "count" - ] - }, - { - "name": "create_bulk_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_cases", - "type": "array", - "required": true, - "description": "One UNWRAPPED object per case (no per-item `test_case` key), max 10 on the synchronous path. Several fields are read from the FIRST item only and applied to all \u2014 see guidance. Send `template` and `test_case_steps` on every item.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body as well as in the path; the path folder is not substituted in." - }, - { - "name": "attachments", - "type": "array", - "description": "Blob ids. Taken from the FIRST item only and copied onto EVERY case in the batch. A blob belonging to another group fails the whole request with 400." - }, - { - "name": "automation_state", - "type": "integer", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Use `automation_status` here, or edit the cases afterwards." - }, - { - "name": "automation_status", - "type": "string" - }, - { - "name": "case_type", - "type": "integer" - }, - { - "name": "custom_fields", + }, + "V1TestCaseDetailRow": { "type": "object", - "description": "Taken from the FIRST item only and applied to EVERY case in the batch. Per-item custom field values are silently discarded \u2014 create cases with differing custom fields one at a time, or edit them afterwards." - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted by the allow-list and then never written; every read serialises `estimate` as null. Do not report an estimate as saved." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Set it with an edit after creation." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean" - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer" - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer" - }, - { - "name": "review_status", - "type": "string", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. The created cases take the project's default review state." - }, - { - "name": "reviewers", - "type": "array", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Set reviewers with an edit after creation." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list." - }, - { - "name": "status", - "type": "integer" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Also decides, from the FIRST item only, which system template id every case in the batch falls back to (`test_case_steps` when the first item omits it) \u2014 so send it on every item, and keep the batch homogeneous." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string", - "description": "NOT accepted on the bulk path \u2014 dropped by the allow-list. Use `template`." - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is dropped and the case is created with no steps, still 200." - }, - { - "name": "metadata", - "type": "string", - "description": "JSON-encoded string of opaque metadata (this is where an AI prompt reference rides). Unparseable JSON degrades to `{}` rather than erroring." - } - ] - }, - { - "name": "unique_id", - "type": "string", - "description": "Client-supplied idempotency key. When present AND the group has the async bulk-create flag on, the request is queued instead: 202 with the echoed `unique_id`, the \u226410 item limit is replaced by a 500-item cap, and completion arrives over the WebSocket channel. Without the flag it is inert." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Create (or reuse) a subfolder named `scenario_name` under the path folder and put every case in the batch there instead. Requires `scenario_name`. If the batch then fails, a subfolder created for it is deleted again." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is set." - } - ], - "intent": "Create several test cases in one folder in a single call (up to 10) \u2014 the call to use whenever you have more than one case to add.", - "returns": [ - "success", - "data", - "test_cases", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "description": "Test cases created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__CreateBulkTestCases" - } + "description": "The slide-over detail row: the single-case record plus the resolved template and dedupe extras. Does not carry `step_count`.\n", + "allOf": [ + { + "$schema": "V1TestCaseRecordRow" + } + ], + "properties": { + "folder": { + "type": "object", + "nullable": true, + "description": "DETAIL ROUTES ONLY (get_test_case_detail). Observed null in every response seen." + }, + "testcase_template": { + "type": "object", + "description": "DETAIL ROUTES ONLY. The resolved template layout — a large nested object." + }, + "duplicates_tc_count": { + "type": "integer", + "nullable": true, + "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." + }, + "folder_path": { + "type": "string", + "description": "DETAIL ROUTE ONLY, and only when `include=folder_path` is sent." + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseDetail" }, - "path_folders": { - "type": "array", - "items": { - "$schema": "PathFolder__CreateBulkTestCases" - } + "priority": { + "$schema": "TestCaseFieldValue__GetTestCaseDetail" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseDetail" }, - "request_trace_id": { - "type": "string" + "test_case_steps": { + "$schema": "TestCaseStep__GetTestCaseDetail" } - } } - } - } - }, - "202": { - "description": "Accepted for asynchronous processing (feature-flag gated; requires unique_id). Completion is broadcast on common_channel_ with operation bulk_create and the echoed unique_id.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean" - }, - "unique_id": { - "type": "string" - }, - "scenario_name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "message": { - "type": "string" + }, + "V1TestRunDetailRow": { + "type": "object", + "description": "A single v1 test run as returned by the detail route: the shared run record plus its linked issues, which the list and search rows do not carry.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "issues": { + "type": "array", + "description": "Linked issues. Returned by the single-run detail route; not present on the list or search rows.", + "items": { + "type": "object" + } + }, + "test_result_issues": { + "type": "string", + "nullable": true, + "description": "Issues raised against this run's results. Not returned by every workspace — treat as absent unless seen." + }, + "observability_id": { + "type": "integer", + "example": 17574995 + }, + "created_by": { + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", + "$schema": "TestRunUserRef__GetTestRunByIntegerId" + }, + "updated_by": { + "nullable": true, + "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", + "$schema": "TestRunUserRef__GetTestRunByIntegerId" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Items are UNWRAPPED \u2014 `test_cases` is a plain array of case objects with no per-item `test_case` key. A non-array, or more than 10 items, is a 400 naming the problem.", - "Not everything single-create accepts survives here: `automation_state`, `review_status`, `reviewers`, `send_for_review`, `shared_precondition_id`, `template_step_type` and `estimated_duration_seconds` are all dropped by this route's allow-list, without error. Create then edit if you need them.", - "`custom_fields` and `attachments` are read from the FIRST item and applied to all of them. A batch where cases differ in custom field values will silently come out wrong \u2014 create those one at a time.", - "`test_case_folder_id` is required inside each item even though the folder is already in the path; omitting it is the usual cause of a 422 wrapping an upstream 404.", - "Steps only come from `test_case_steps`. A `steps` key is dropped, and the cases are still created \u2014 with no steps and a 200 \u2014 so verify against the response.", - "The created cases are at `data.test_cases`, with `data.path_folders` and a `data.request_trace_id` for the AI enrichment that runs afterwards. Take each new case's integer `id` and TC-NNN `identifier` from there.", - "With `unique_id` and the async flag enabled, you instead get 202 `{success, async, unique_id, count}` and NO cases \u2014 the work finishes on a WebSocket channel you cannot read through this API, so poll the folder listing instead.", - "Any unexpected failure inside this route is flattened to 500 `{success:false, message:\"Failed to create test cases.\"}` \u2014 that message covers backend rejections too, so do not read it as an outage." - ] - }, - { - "name": "create_configuration", - "method": "POST", - "path": "/api/v1/projects/{project_id}/configurations", - "mode": "write", - "entity": "configuration", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Create a named configuration \u2014 an OS / browser / device combination that test runs track their per-case results against", - "returns": [ - "success", - "configuration", - "id", - "name", - "group_id", - "record_status", - "is_suggested", - "created_at" - ], - "responses": { - "200": { - "$response": "ConfigurationCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Display name of the configuration, e.g. \"Chrome 120 on Windows 11\". This is the ONLY field the handler reads \u2014 os, os_version, browser, device and every other attribute the read returns cannot be set here and are silently ignored if sent." - } - ], - "guidance": [ - "`name` is the only input. The structured attributes (os, browser, device, versions) that come back on the listing cannot be set through this call; anything else you put in the body is dropped without an error.", - "Group-scoped, not project-scoped: `project_id` matches the route but is never sent onward, so the configuration is created at workspace level and appears in EVERY project in the group. There is no such thing as a project-private configuration here.", - "No request-body schema is registered for this action, so an omitted or empty `name` is not caught locally \u2014 whatever the backing service does with it is what you get, and the failure arrives without a field name.", - "The created record comes back under `configuration` (not `data`) beside `success:true`. Carry `configuration.id` \u2014 that integer is what a test run's configuration list takes; the run APIs want configuration ids, not configuration objects or names.", - "Check the configuration listing first. Configurations accumulate at workspace level and duplicates by name are not prevented here, so search before creating.", - "There is no delete-configuration or rename-configuration capability in this profile \u2014 a configuration created by mistake has to be cleaned up in the Test Management UI.", - "This action is missing from the RBAC permission map, so it is not permission-gated even though the rest of the configurations controller is (the listing needs test_run_view and the group/option writes need test_run_write)." - ] - }, - { - "name": "create_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces from storage." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id the option is added to. Not validated by this handler." - } - ], - "body": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label. Must be present \u2014 a null or missing value is a 400 'Invalid Params'. Must be unique within the dataset; a duplicate is rejected with the upstream message relayed." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "REQUIRED \u2014 a missing or null value is a 400. Use false for every option of a multi_dropdown, and true for at most one option of a dropdown; this layer does not enforce either rule." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "nested_dropdown children only \u2014 the option id this one hangs under, from the dataset options listing. Forwarded only when non-empty; meaningless on a flat dropdown." - } - ], - "intent": "Add one more choice to a dropdown-style custom field \u2014 a new value in an existing dataset, for every project that dataset covers", - "responses": { - "200": { - "$response": "CustomFieldAdminOptionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`is_default` is required and must be present, not null: omitting it (or option_value) is a 400 {success:false, error:'Invalid Params'}, whatever else is wrong.", - "The new option appears in every project the dataset is linked to, not just one. Check the dataset's project list before adding.", - "Only set is_default true for a single-select `dropdown`, and only for one option; for a multi_dropdown every option must be is_default false. Nothing in this layer enforces it \u2014 a second default is accepted here and the behaviour downstream is undefined.", - "`parent_option_id` is forwarded only when non-empty, and only means anything for a nested_dropdown. Get the parent id from the dataset options listing.", - "The created option comes back under `data` with its `id` \u2014 capture it, that is what the update and delete calls take.", - "A duplicate option label is rejected upstream and relayed verbatim ('Value with same name already exists. Use a unique name.'); other upstream failures are masked as 400 'The API request is invalid or improperly formed'." - ], - "returns": [ - "success", - "data", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id" - ] - }, - { - "name": "create_custom_field_dataset", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id to hang the dataset off. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "body": [ - { - "name": "linked_projects", - "type": "array", - "description": "Integer project ids this dataset applies to \u2014 this is what makes the field visible in a project. Must be an array (or omitted) or the call is a 400. Note the spelling differs from the project-mapping call, which uses link_projects / unlink_projects." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "default": false, - "description": "Auto-link projects created after this dataset. Must be a literal true/false; any other value is a 400." - }, - { - "name": "options", - "type": "array", - "description": "The allowed values, for dropdown-style fields. Each entry needs option_value and a non-null is_default or the call is a 400.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "Must be present (not null). False for every option of a multi_dropdown; at most one true for a dropdown." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "For nested_dropdown children only \u2014 the option this one hangs under." - } - ] - } - ], - "intent": "Attach another option set and project scope to an existing custom field \u2014 the way to give one field different dropdown values in different projects", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A dataset is the pairing of an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, is invisible in every project even though the definition exists.", - "`linked_projects` takes INTEGER project ids, not PR-NNN identifiers. A non-array value is a 400 'Invalid Params'; a valid array of ids that are not real projects fails upstream and comes back masked.", - "`linked_to_all_projects` is NOT read by this endpoint \u2014 it is silently ignored. It works only inside the `datasets` entries of the create-a-field call. To cover every project from here, enumerate the ids, or use the dataset project-mapping call with select_all.", - "A project can belong to only one dataset per field. Linking a project that is already on another dataset of the same field fails upstream, and this endpoint does NOT relay the reason \u2014 you get 400 {success:false, message:'The API request is invalid or improperly formed'}. Read the field's existing datasets first.", - "Options are only meaningful for dropdown / multi_dropdown / nested_dropdown fields, but nothing rejects them on other types. Each option needs option_value and a non-null is_default; for a multi_dropdown every is_default must be false, and for a dropdown at most one may be true.", - "The response is the created dataset under `data`; take `data.id` \u2014 it is the dataset_id every option and project-mapping call needs.", - "This is additive and reversible by deleting the dataset, but deleting it later destroys the values stored against its options." - ], - "returns": [ - "success", - "data", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects" - ] - }, - { - "name": "create_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields", - "mode": "write", - "entity": "custom_field", - "body": [ - { - "name": "field_name", - "type": "string", - "required": true, - "maxLength": 256, - "example": "Test Environment", - "description": "Display label. Stripped of surrounding whitespace before the reserved-name check and before storage. Blank or longer than 256 characters \u2192 400 'Invalid Params'." - }, - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Data type. Use THIS vocabulary \u2014 the legacy `field_*` spellings that the project form-fields read returns (field_dropdown, field_string, \u2026) are rejected here. `url` and `nested_dropdown` are not valid when entity_type is TestCaseStep." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Which entity the field hangs off; a field belongs to exactly one and cannot be moved later. Any other value \u2192 400 'Invalid Params'. TestCaseStep additionally requires the group's step-custom-fields flag." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "description": "Whether the field must be filled. Must be a literal JSON boolean \u2014 omitting it, or sending \"true\" as a string, is a 400." - }, - { - "name": "datasets", - "type": "array", - "required": true, - "description": "REQUIRED for every field type \u2014 omitting it (or sending a non-array) is a 400. Carries the option values and the project scope. An empty array passes validation but leaves the field linked to nothing.", - "fields": [ - { - "name": "linked_projects", - "type": "array", - "description": "Integer project ids to link. Every entry must be a positive integer; PR-NNN identifiers are rejected." - }, - { - "name": "unlinked_projects", - "type": "array", - "description": "Integer project ids to exclude; only read when linked_to_all_projects is true." - }, - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "true makes the handler resolve every project in the workspace (max 3000) into linked_projects before sending upstream." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Auto-link projects created later." - }, - { - "name": "options", - "type": "array", - "description": "Option values for dropdown-style types. Each needs option_value (non-blank) and a non-null is_default; nested_dropdown entries may carry a `children` array of the same shape." - } - ] - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "Hint text shown in the form. Stripped before storage; longer than 256 characters \u2192 400." - }, - { - "name": "default_value", - "type": "boolean", - "description": "Honoured ONLY when field_type is `boolean`, and then it must be a literal true/false. For every other field type the handler overwrites it with null, so a default cannot be set at create time." - }, - { - "name": "levels", - "type": "array", - "description": "REQUIRED when field_type is `nested_dropdown`: at least 2 entries, each with a non-blank `name` and `level_type`. Ignored for every other type.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true - }, - { - "name": "level_type", - "type": "string", - "required": true - } - ] - }, - { - "name": "parent_custom_field_id", - "type": "integer", - "description": "Adds a child level under an existing nested_dropdown field. When present the handler validates ONLY field_name and forwards the rest untouched \u2014 field_type, is_required and datasets are not checked at all." - } - ], - "intent": "Define a new custom field for the workspace \u2014 pick its data type, whether it is mandatory, and (via datasets) which projects get it and what option values it offers", - "responses": { - "200": { - "$response": "CustomFieldAdminWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`datasets` is required for EVERY field type, dropdown or not. The validator only checks it is an array, so `datasets: []` passes \u2014 and creates a field linked to no project, invisible everywhere. Send one entry carrying `linked_projects` if you want the field to appear.", - "`is_required` must be a literal JSON boolean. The string \"true\" fails the check and the whole call comes back 400 {success:false, error:'Invalid Params'} with no indication of which field was wrong \u2014 that one error covers field_type, field_name, is_required, place_holder_text, default_value, levels and datasets alike.", - "`default_value` is read ONLY when field_type is `boolean`; for every other type the handler forces it to null before sending upstream. Setting a default on a string/int/date field through this call is impossible.", - "field_name is stripped before both the duplicate check and storage, so \" Priority \" is stored as \"Priority\" and collides with the reserved name. Reserved names (Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status) return 400 'Field with same name already exists. Use a unique name.'; TestPlan and ExploratorySession fields may reuse 'Automation Status' and 'Type of Test Case'.", - "A duplicate-name rejection from storage is relayed verbatim (400 with the upstream message). Every OTHER upstream failure on this call is masked to {success:false, message:'The API request is invalid or improperly formed'} \u2014 do not try to parse a reason out of it.", - "Inside a `datasets` entry, `linked_to_all_projects: true` makes the handler expand the link list to every project in the workspace (capped at 3000) before sending. That flag works HERE but is ignored by the standalone add-a-dataset call.", - "entity_type `TestCaseStep` is accepted only when the group has step custom fields enabled; otherwise 400 'Step custom fields are not enabled for this group.'. Step fields also cannot be `url` or `nested_dropdown`.", - "The response echoes only the stored field attributes \u2014 no options, no project links. Read the field back, or read the project's form fields, to confirm what the datasets actually produced.", - "Nothing here is project-scoped: the definition is created for the whole workspace and the datasets decide where it shows up. Name the projects you are about to affect before creating." - ], - "returns": [ - "success", - "data", - "id", - "field_name", - "field_type", - "entity_type", - "is_required", - "default_value", - "place_holder_text" - ] - }, - { - "name": "create_dataset", - "method": "POST", - "path": "/api/v1/projects/{project_id}/datasets", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "json_path": "/dataset/name", - "description": "Dataset name. Must be unique within the project: a collision comes back as 400 {success:false, message:'Dataset name already exists.'}, which is the one 400 here that is NOT a schema rejection." - }, - { - "name": "variables", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 40, - "json_path": "/dataset/variables", - "description": "The columns, in order \u2014 row_data positions map to this array by index. Duplicate names (compared case-insensitively) fail the call. A column with a blank name, or one empty in every row, is SILENTLY DROPPED with its slice of every row, so the stored column list can be shorter than what you sent. 40 is the default cap; groups on the raised-limit flag get 100.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", - "description": "Must start with a letter and contain only letters, digits and underscores \u2014 no spaces, hyphens or leading digits." - }, - { - "name": "uuid", - "type": "string", - "description": "Omit on create; the server mints it. Present only so the value read back can be sent to update unchanged." - } - ] - }, - { - "name": "rows", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 100, - "json_path": "/dataset/rows", - "description": "The data rows. Heavily normalised before the write: all-blank rows are dropped, cell values are whitespace-trimmed, rows whose trimmed values are identical are de-duplicated to the first occurrence, and row_number is then REASSIGNED 1..N in the order you sent them \u2014 the numbers you supply are ignored. 100 rows is the default cap; groups with the raised-limit flag get 200.", - "fields": [ - { - "name": "row_number", - "type": "integer", - "required": true, - "description": "Required by the schema but overwritten server-side with the row's final 1-based position. Send anything valid; do not rely on it round-tripping." - }, - { - "name": "row_data", - "type": "array", - "required": true, - "minItems": 1, - "description": "Cell values, positional against `variables`. Every element must be a STRING \u2014 a number or null is a schema rejection. After empty columns are pruned the remaining length must equal the remaining column count, else 422 'Row data size mismatch with column count.'" - }, - { - "name": "uuid", - "type": "string", - "description": "Omit on create; the server mints it." - } - ] - }, - { - "name": "tags", - "type": "array", - "maxItems": 20, - "json_path": "/dataset/tags", - "description": "Free-text labels on the dataset. Plain strings, each at most 50 characters, at most 20. Accepted but undocumented in the spec.", - "fields": [ - { - "name": "tag", - "type": "string", - "maxLength": 50 - } - ] - }, - { - "name": "metadata", - "type": "object", - "json_path": "/dataset/metadata", - "description": "Free-form key/value bag stored with the dataset, at most 20 top-level keys. Only settable here \u2014 the update endpoint accepts it through validation and then drops it, so metadata cannot be changed after creation through this API." - } - ], - "intent": "Create a dataset \u2014 a named table of columns (variables) and rows that drives data-driven execution, so one test case runs once per row with the row's values substituted in", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "tags", - "metadata", - "created_at", - "created_by" - ], - "responses": { - "201": { - "description": "Dataset created. The handler renders 201 Created, not 200.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__CreateDataset" + }, + "V1TestRunListRow": { + "type": "object", + "description": "A v1 test run as returned by the LIST and SEARCH routes: the shared run record plus `build_meta`, which the single-run detail route does not carry.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_meta": { + "type": "object", + "nullable": true, + "description": "Automation build this run is linked to — {id, name, creationType, buildNormalisedName, …}. Populated for runs created from an automation build." + } } - } - } - }, - "400": { - "description": "Either a request-schema rejection ({error:'Invalid JSON data', details:[...]}) or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:...}. 'Feature not available' = the group lacks Team Pro (entitlement, not payload). Any other message is a dataset-shape validation failure." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key.", - "Nothing outside the declared keys is allowed: the request schema sets additionalProperties:false at the top level, inside `dataset`, and inside each variable and row object. A misspelled or extra key fails the whole call with 400 {error:'Invalid JSON data', details:[...]} rather than being ignored.", - "Send Content-Type: application/json. The schema check is skipped for any other content type, and the handler then crashes on a missing `variables` instead of returning a clean 400.", - "What you get back is not what you sent: blank rows and all-blank columns are stripped, duplicate rows collapsed, cells trimmed, and row_number renumbered 1..N. Read `dataset.variables` and `dataset.rows` from the response rather than assuming your payload was stored verbatim.", - "422 is overloaded on every dataset endpoint. {message:'Feature not available'} means the group lacks the Team Pro plan \u2014 stop, do not retry with a different body. Any other 422 message ('Maximum 40 columns are allowed', 'Duplicate variable names found: ...', 'Row data size mismatch with column count.', 'Please add non null/ valid data.') is a payload problem you can fix.", - "Success is 201, not 200, and the dataset is at the top level as `dataset` \u2014 there is no `data` envelope.", - "Carry `dataset.uuid` forward: it is the id every other dataset call takes, and the integer id used internally is never exposed.", - "Needs the test-case write permission (tc_write), not a dataset-specific one." - ] - }, - { - "name": "create_exploratory_session", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "example": "Checkout flow exploration", - "description": "Title of the exploratory session. The only field the store requires. Send it as `title` \u2014 `name` is accepted by the request schema and then silently dropped.", - "json_path": "/exploratory_session/title" - }, - { - "name": "description", - "type": "string", - "example": "Explore edge cases in the checkout flow.", - "description": "Optional free-text description.", - "json_path": "/exploratory_session/description" - }, - { - "name": "charter", - "type": "string", - "example": "Focus on payment error handling", - "description": "Testing charter describing the scope and goals. Note that cloning a session does NOT copy the charter.", - "json_path": "/exploratory_session/charter" - }, - { - "name": "timebox_duration", - "type": "integer", - "example": 60, - "description": "Planned duration of the session. TM's own exported reports render this value as minutes, while the upstream store documents the field as seconds \u2014 it is passed through unconverted, so agree the unit with the product before relying on it.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/timebox_duration" - }, - { - "name": "owner", - "type": "integer", - "example": 42, - "description": "TCM user id of the session owner. This is the field to use: it is translated to the upstream user id here. An id matching no user in this group is accepted and stored as no assignee.", - "json_path": "/exploratory_session/owner" - }, - { - "name": "assignee", - "type": "integer", - "example": 42, - "description": "NOT a safe alias on create \u2014 unlike `owner` it is forwarded without translation to an upstream user id, so it sets the wrong user. Use `owner` instead. Ignored entirely when `owner` is also present.", - "json_path": "/exploratory_session/assignee" - }, - { - "name": "folder_id", - "type": "integer", - "example": 456, - "description": "Folder to place this session in; a numeric string is also accepted. Omit to create it at the project root.", - "json_path": "/exploratory_session/folder_id" - }, - { - "name": "test_plan_id", - "type": "integer", - "example": 77, - "description": "Integer id of the test plan to link this session to (not a TP-NNN identifier). Only reachable through the wrapped body \u2014 the unwrapped fallback does not permit it.", - "json_path": "/exploratory_session/test_plan_id" - }, - { - "name": "duration", - "type": "integer", - "example": 45, - "description": "Tracked duration. Accepted on create but normally left to be set later by the update call.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/duration" - }, - { - "name": "actual_duration", - "type": "integer", - "example": 50, - "description": "Final recorded duration. Accepted on create but normally set by the update call before closing \u2014 the close call does not accept it.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/actual_duration" - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "mobile" - ], - "description": "Plain tag strings. The request schema rejects any non-string entry.", - "json_path": "/exploratory_session/tags" - }, - { - "name": "configurations", - "type": "array", - "example": [ - 1, - 3 - ], - "description": "Configuration ids (integers, or numeric strings) \u2014 not the configuration objects returned on read. An id that does not exist in this project surfaces only as the generic upstream 400.", - "json_path": "/exploratory_session/configurations" - }, - { - "name": "attachments", - "type": "array", - "example": [ - 101, - 102 - ], - "description": "Integer blob ids from a prior generic attachment upload \u2014 never file content. A blob already used by another entity is refused with 422. Unknown ids error only when none of them resolve; otherwise they are silently skipped.", - "json_path": "/exploratory_session/attachments" - }, - { - "name": "issues", - "type": "array", - "description": "Issues to link. Each entry is {issue_id, issue_type} and may also carry metadata {url, ticket_id, title, display_category, issue_category}. A list of bare id strings is coerced to jira issues. A list of anything else is silently reduced to no issues. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. Linking is applied asynchronously after the 201.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/exploratory_session/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "101": "Regression", - "102": 42 - }, - "description": "Custom field values as a map of {custom_field_id: value}, converted server-side into the upstream write shape. This is NOT the array shape the read endpoints return. Resolve the ids for entity type ExploratorySession first; a misspelled key is dropped rather than rejected.", - "json_path": "/exploratory_session/custom_fields" - } - ], - "intent": "Start a new exploratory testing session in a project \u2014 give it a charter, a timebox, an owner, tags, configurations and a folder \u2014 so that observations can be logged against it", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "created_at", - "updated_at" - ], - "responses": { - "201": { - "description": "Exploratory session created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" + }, + "ExploratorySessionCore": { + "type": "object", + "description": "Fields returned for an exploratory session on BOTH the list and the single-session read.\n", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the exploratory session.", + "example": 123 + }, + "identifier": { + "type": "string", + "description": "ES-NNN form. NOTE: the single-session read takes the INTEGER `id` in its path — passing this identifier returns 404.", + "example": "ES-295" + }, + "title": { + "type": "string", + "description": "Title of the exploratory session.", + "example": "Checkout flow exploration" + }, + "session_state": { + "type": "string", + "description": "Current lifecycle state of the session.", + "enum": [ + "active", + "closed" + ], + "example": "active" + }, + "timebox_duration": { + "type": "integer", + "description": "Planned duration of the session in minutes.", + "example": 60, + "nullable": true + }, + "assignee": { + "$schema": "ExploratorySessionUserRef" + }, + "created_by": { + "$schema": "ExploratorySessionUserRef" + }, + "tags": { + "type": "array", + "description": "Tags associated with the session.", + "example": [ + "regression", + "payment" + ], + "items": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the session was created.", + "example": "2026-01-16T10:30:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the session was last updated.", + "example": "2026-01-16T11:00:00Z" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "test_plan_id": { + "type": "integer", + "nullable": true + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 naming a missing `exploratory_session` means the body reached the server empty, not that you must nest it.", - "`title` is the only field the store requires. Everything else is optional.", - "Set the assignee with `owner` (a TCM user id) \u2014 NOT with `assignee`. On this create path only `owner` is translated to the upstream user id; `assignee` is forwarded raw and lands as a wrong or invalid user. On update both are translated, so the two endpoints disagree.", - "An `owner` that resolves to no user in this group does not fail: the session is created 201 with no assignee at all. Re-read the session if the assignment matters.", - "`issues` is forgiving in dangerous ways. A list of bare strings is accepted and coerced to {issue_id, issue_type:'jira'}. Jira ids are validated against ^[A-Z][A-Z0-9]*-[0-9]+$ and a bad one fails the whole call. But a list whose entries are neither strings nor objects carrying both issue_id and issue_type is silently replaced with an empty list \u2014 the session is created with no issues and no error. Each entry may also carry a `metadata` object ({url, ticket_id, title, display_category, issue_category}) even though only issue_id/issue_type are declared.", - "Issue linking happens asynchronously after the 201, so the session in the response does not yet show the links. Re-read it, or read the session's defects, to confirm.", - "`attachments` are integer ActiveStorage blob ids from a prior generic attachment upload \u2014 not file content, and not URLs. A blob already attached to another entity is refused with 422 'the attachment has been referenced elsewhere'. Unknown ids only error when NONE of them exist; a mixed list quietly attaches the ids that resolve and drops the rest.", - "`session_state` and `name` pass the request schema and are then dropped by the parameter filter \u2014 a session cannot be created closed, and the title must be sent as `title`.", - "`custom_fields` goes in as a map of {custom_field_id: value} and comes back as the normalised array shape; the two are not symmetric.", - "Take `exploratory_session.id` (integer) from the response and carry it as session_id into every log, defect, close, clone, update and delete call for this session.", - "Any upstream validation failure \u2014 an unknown configuration id, a folder in another project, a malformed value \u2014 arrives as 400 {success:false, message:'The API request is invalid or improperly formed'}. The message is the same for every cause, so it never tells you which field was wrong." - ] - }, - { - "name": "create_exploratory_session_log", - "method": "POST", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "body": [ - { - "name": "content", - "type": "string", - "example": "

Tapped checkout \u2014 spinner appeared but no order was created.

", - "description": "Rich-text HTML for the entry. Sanitised on write: anchors and images pointing outside this project/group are stripped. Reference already-uploaded images by blob id in tags here \u2014 this is the only way to attach media to a log.", - "json_path": "/session_log/content" - }, - { - "name": "status", - "type": "string", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "example": "fail", - "description": "Outcome recorded for this step. `skipped` is valid. Any other value is rejected by the request schema with 400 before the handler runs.", - "json_path": "/session_log/status" - }, - { - "name": "elapsed_time", - "type": "integer", - "example": 120, - "description": "Time spent on this step, in seconds. Must be an integer \u2014 a numeric string is rejected by the request schema.", - "json_path": "/session_log/elapsed_time" - }, - { - "name": "defects", - "type": "array", - "description": "Issues raised by this observation. Each entry is {issue_id, issue_type} and may carry a metadata object. Bare id strings are coerced to jira issues; a list whose entries lack issue_id or issue_type is silently reduced to no defects. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. Linked asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/session_log/defects" - } - ], - "intent": "Record one observation during an exploratory session \u2014 a note about what was tried and what happened, its pass/fail/bug outcome, the time it took and any defects it raised", - "returns": [ - "success", - "session_log", - "id", - "session_id", - "hashed_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "attachment_relations", - "created_at", - "updated_at" - ], - "responses": { - "201": { - "description": "Session log created successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_log": { - "$schema": "ExploratorySessionLog" + }, + "ExploratorySessionListRow": { + "type": "object", + "description": "An exploratory session as returned by the LIST route: the shared core plus `summary` and the resolved `test_plan`. It does NOT carry charter, description, durations, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations — read a single session for those.\n", + "allOf": [ + { + "$schema": "ExploratorySessionCore" + } + ], + "properties": { + "summary": { + "type": "string", + "nullable": true, + "description": "Returned on the LIST route; absent from the single-session read." + }, + "test_plan": { + "type": "object", + "nullable": true, + "description": "Resolved plan. Returned on the LIST route; absent from the single-session read." + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. A 400 saying 'session_log is required' means the body was empty, not that you must nest it.", - "This is a child of a session: the session_id in the path is the parent, and the `id` you get back is the log_id you must carry \u2014 together with the same session_id \u2014 into the update and delete calls. A log id from one session is not addressable under another.", - "`status` accepts pass, fail, bug, retest, block, note and skipped. `skipped` is real and is missing from older declarations of this field; anything outside the seven is rejected by the request schema with 400.", - "Every field is optional \u2014 a log with no content, status or elapsed_time is created successfully. Nothing here rejects an empty observation.", - "`content` is rich-text HTML and is sanitised on write: anchors and images pointing outside this project or group are stripped, so the stored content can differ from what you sent.", - "Screenshots are not uploaded through this call. Upload the image first through the generic attachment upload, then reference the returned blob id in an tag inside `content`; the handler derives the attachment links from the blob ids it finds in the HTML. If ANY referenced blob id is not visible to your group or project, EVERY attachment link for the call is dropped and the log is still created 201 with no images attached.", - "`defects` behaves like the session's issues: bare id strings are coerced to jira issues, entries missing issue_id or issue_type silently reduce the list to empty, and a jira id must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the whole call fails. A per-entry metadata object is accepted even though only issue_id and issue_type are declared.", - "Defect linking is applied asynchronously after the 201, so the log echoed back may not show the links yet \u2014 re-read the logs, or the session's defects, to confirm.", - "`elapsed_time` is seconds.", - "There is no bulk log-create on this path; one call per observation." - ] - }, - { - "name": "create_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/filter", - "mode": "write", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "description": "Display name of the saved view. Must be unique for this project and entity \u2014 a duplicate is rejected by the backing service as a 400 'Filter name already exists'. There is also a server-side maximum length, also reported as a 400." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Which list this view filters. Validated by a request-body schema before the handler runs, so an unlisted value returns 400 'Invalid JSON data' with the accepted set echoed back. Cannot be inferred \u2014 it is required even though the conditions live in `filters`." - }, - { - "name": "is_project_level", - "type": "boolean", - "default": false, - "description": "true publishes the view to everyone on the project; false/omitted keeps it to you. Must be a real JSON boolean \u2014 the string \"true\" fails schema validation with 400." - }, - { - "name": "filters", - "type": "object", - "required": true, - "description": "The condition set the view applies, in the product UI's filter-state shape \u2014 NOT the flat id lists the entity search takes. The UI only applies a view whose conditions are in this shape: {systemFields: {priority: [{labelInnerText: 'High', label: 'High', value: 9262}]}, extraSystemFields: {issue_type: {label: 'Jira', value: 'jira'}}, matchAllMatchAny: {global: {value: 'all'}, priority: {value: 'any'}}}. Each selected option carries the project's numeric option id as `value` (resolve it with get_project_form_fields or get_system_field_values \u2014 the ids differ per project) and its display name as `label` and `labelInnerText`. `extraSystemFields.issue_type` is REQUIRED even when the view does not filter on linked issues: without it the view saves but the UI does not apply it. matchAllMatchAny carries `global` ('all' = match every filter, 'any' = match any one) plus one entry per filtered field ('any' = contains any one value, 'all' = contains all values). Nothing inside is validated, so a wrong shape still saves with a success response. Sent only when non-empty: `{}` is treated as absent and the backing service then rejects the create with 400 'Filters cannot be empty'." - } - ], - "intent": "Save a reusable named filter view on a project \u2014 the condition set behind a test case, test run, test plan or exploratory-session list \u2014 so the same selection can be reapplied later or shared with everyone on the project", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is flat \u2014 there is no wrapper object. It is also closed: any key other than name, entity, is_project_level and filters is rejected with 400 'Invalid JSON data' by the request-body schema before the handler sees it.", - "name AND entity are both required on create even though the conditions live in `filters`. entity is what binds the view to a list type; it is not derived from the contents of `filters`.", - "`filters: {}` is dropped as blank before the request is built, so an empty object behaves exactly like omitting the key and the create then fails downstream. Send at least one real condition.", - "Conditions are not checked against the project's custom fields at write time. A condition on a custom field that does not exist is accepted here and is silently pruned \u2014 permanently \u2014 the first time anyone reads the filter back.", - "Carry `data.id` from the response: that integer is the filter_id every later read, update and delete takes.", - "`extraSystemFields.issue_type` names the issue tracker the view's linked-issues condition searches, and must always be present. Send {label: 'Jira', value: 'jira'} unless the view filters on linked issues from another tracker. The allowed values are the product's own tracker names \u2014 jira, azure, asana, linear, clickup, devrev, github, gitlab, zoho, trello \u2014 not the integration route names (`jira-app` and the rest), which are a different vocabulary. Nothing checks that the project has that tracker connected; get_project_settings shows which trackers a project uses. To filter on another tracker's linked issues, take the value from the `issue_type` those issues carry on the test case.", - "Only the backing service's 400 is translated into `{success:false, message:...}`; any other failure from it is re-raised and surfaces as a 500, so a 500 here is not necessarily a server bug.", - "Not permission-gated \u2014 the filter controller has no entry in the RBAC map, so anyone with product access to the project can create a project-level view." - ] - }, - { - "name": "create_project", - "method": "POST", - "path": "/api/v1/projects", - "mode": "write", - "entity": "project", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Checkout Regression", - "json_path": "/project/name", - "description": "Project name. Leading/trailing whitespace is stripped. Not checked for uniqueness \u2014 a duplicate name is accepted." - }, - { - "name": "description", - "type": "string", - "json_path": "/project/description", - "description": "Free text. HTML is escaped server-side, so what comes back can differ from what you sent." - }, - { - "name": "access_type", - "type": "string", - "values": [ - "private", - "restricted", - "org_wide" - ], - "json_path": "/project/access_type", - "description": "Visibility of the new project. Only honoured when project-level RBAC is enabled for the group \u2014 otherwise accepted and ignored. When it IS enabled, a value outside this set fails the whole request with 400 'Invalid access type'." - }, - { - "name": "users", - "type": "array", - "fields": [ - { - "name": "user", - "type": "string" - }, - { - "name": "role", - "type": "string" - } - ], - "json_path": "/project/users", - "description": "Per-user grants applied at creation, as [{user, role}]. Only read when access_type is also sent AND project-level RBAC is on; ignored entirely otherwise." - } - ], - "intent": "Create a new project \u2014 the top-level container every folder, test case, run, plan and report lives under; returns both the integer id the v1 routes take and the PR-NNN identifier the v2 routes take", - "returns": [ - "data", - "success", - "project", - "id", - "identifier", - "name", - "description", - "created_at", - "thProjectId", - "normalisedName", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "starred", - "jira_mapped", - "user_role", - "permissions", - "links", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project created. The project is at data.project \u2014 same envelope and same serializer as the v1 project-detail read.", - "schema": { - "$schema": "V1ProjectDetail" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `project` wrapper is added for you from each field's json_path, and sending your own `project` object is rejected before the call as an unknown body key.", - "`name` is the only field that must be present. It is stripped of surrounding whitespace, and name uniqueness is not enforced here \u2014 creating a second project with an existing name succeeds and gives you two projects that are indistinguishable by name.", - "`description` is HTML-escaped server-side, so the stored value can differ from what you sent.", - "`access_type` and `users` are read only when project-level RBAC is enabled for the group; with that off both keys are accepted and silently ignored, and the project is created with the group default. With it on, an access_type outside private/restricted/org_wide fails the whole call with 400 'Invalid access type'.", - "The new project is at `data.project` \u2014 this v1 route wraps its payload in a `data` envelope. Carry `data.project.id` (integer) for every other the v1 surface project route and `data.project.identifier` (PR-NNN) for anything under the v2 surface; the two id forms are not interchangeable.", - "Requires the project_create permission under RBAC; without it the call is 403 'This action is not permitted for the user'.", - "There is no delete-project capability in this index, so a project created by mistake cannot be removed through the registry \u2014 only through the UI." - ] - }, - { - "name": "create_report_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "projectId", - "type": "integer", - "example": 10000, - "description": "Integer project id. Alias for `project_id`; either satisfies the project requirement on create, and the project in the path is used when both are absent." - }, - { - "name": "project_id", - "type": "integer", - "example": 10000, - "description": "Integer project id the report is primarily filed under. Must be one of `project_ids` when that is also sent." - }, - { - "name": "project_ids", - "type": "array", - "example": [ - 10000, - 10001 - ], - "description": "Integer project ids for a cross-project report. Max 60 (max 5 for User Workload Report), and only accepted for the seven multi-project types \u2014 any other type with more than one id is 400. The first entry becomes the primary project when `project_id` is omitted." - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary", - "description": "Report type as its DISPLAY NAME, not a slug. `Defects Summary` and `Defects Detailed Report` are accepted but have no server-side data branch, so such a report always reads back `report_data: null` \u2014 for defect figures use `Test Run Summary` and its `issues_by_priority` / `issues_by_status` sections. `Test Plan Detailed Report` is feature-flagged: an unflagged group gets 403." - }, - { - "name": "title", - "type": "string", - "example": "Weekly Test Case Activity" - }, - { - "name": "description", - "type": "string", - "example": "Testing" - }, - { - "name": "report_timeframe", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "The window the report covers, and the value of `reportTimeRange` on the summary-by-type read. The four relative windows and `custom` compute dates; `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500, not a 422). The remaining five carry no dates \u2014 each selects entities through one matching `report_filters` key." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "example": "custom_report", - "description": "`system_generated` is the one-click report the product creates for you; `custom_report` is user-configured. For a Requirement Traceability Report, `system_generated` makes the server auto-populate `report_filters.requirements` with every requirement in the project \u2014 do not also send that filter." - }, - { - "name": "grain", - "type": "string", - "values": [ - "test_case", - "test_plan" - ], - "default": "test_case", - "description": "Requirement Traceability Report only: `test_plan` renders Requirement to Test Plan to Sub-Test Plan rows. Any other report type is 400, and it is single-project only. Immutable \u2014 a later edit that changes it is refused 400 (resending the same value is fine)." - }, - { - "name": "is_dynamic", - "type": "boolean", - "description": "Whether the report re-resolves its selection each run. Omit and the server infers it from whether any `dynamic_filters` value is non-blank." - }, - { - "name": "test_run", - "type": "object", - "fields": [ - { - "name": "created_at", - "type": "string", - "required": true - }, - { - "name": "status", - "type": "array", - "required": true - }, - { - "name": "owner", - "type": "array", - "required": true - }, - { - "name": "automation_status", - "type": "array", - "required": true - }, - { - "name": "type", - "type": "array", - "required": true - } - ], - "json_path": "/dynamic_filters/test_run", - "description": "Dynamic (re-evaluated each run) run selection. Accepted under either `dynamic_filters` or `filters`; presence of any non-blank value here is what flips the report to dynamic when `is_dynamic` is omitted." - }, - { - "name": "status", - "type": "array", - "json_path": "/report_filters/status" - }, - { - "name": "priority", - "type": "array", - "json_path": "/report_filters/priority" - }, - { - "name": "case_type", - "type": "array", - "json_path": "/report_filters/case_type" - }, - { - "name": "assignee", - "type": "array", - "json_path": "/report_filters/assignee" - }, - { - "name": "automation_status", - "type": "array", - "json_path": "/report_filters/automation_status" - }, - { - "name": "automation_state", - "type": "array", - "json_path": "/report_filters/automation_state" - }, - { - "name": "test_runs", - "type": "array", - "description": "Run ids, and they must be INTEGERS \u2014 a quoted id or a TR-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_runs.", - "json_path": "/report_filters/test_runs" - }, - { - "name": "test_plans", - "type": "array", - "description": "Plan ids, and they must be INTEGERS \u2014 a quoted id or a TP-NNN identifier fails the whole request 400 'Invalid report filters'. Pairs with report_timeframe=specific_test_plans.", - "json_path": "/report_filters/test_plans" - }, - { - "name": "test_cases", - "type": "object", - "description": "Case selection \u2014 pairs with report_timeframe=specific_test_cases. Must be an OBJECT (an array is rejected 400 'Invalid report filters'), folder-keyed like SelectionData. Folder ids are STRING keys; case ids are INTEGERS (the numeric `id`, not TC-NNN). A wrong-but-object shape saves an UNSCOPED project-wide report at 200.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "required": true - }, - { - "name": "folders", + }, + "UserCore": { "type": "object", - "required": true - }, - { - "name": "unique_test_case_count", - "type": "integer", - "required": true - } - ], - "json_path": "/report_filters/test_cases" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cross-project case selection: { \"\": } for the report's non-primary projects. A project id outside `project_ids` is dropped silently.", - "json_path": "/report_filters/shared_selection" - }, - { - "name": "session_ids", - "type": "array", - "description": "Exploratory session ids \u2014 pairs with report_timeframe=specific_sessions.", - "json_path": "/report_filters/session_ids" - }, - { - "name": "requirements", - "type": "object", - "description": "{ issue_type: [issue_id, ...] } \u2014 pairs with report_timeframe=requirements. Must be an object; an array is rejected 400. Auto-populated by the server when report_creation_mode is system_generated on a traceability report.", - "json_path": "/report_filters/requirements" - }, - { - "name": "test_plan_selection", - "type": "object", - "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. `select_all` is required on each entry and must be a real boolean; a malformed entry is silently discarded rather than rejected.", - "json_path": "/report_filters/test_plan_selection" - }, - { - "name": "test_runs_by_project", - "type": "object", - "description": "Cross-project run selection: { \"\": [run_id, ...] }. Keys must be numeric strings and values arrays of numeric ids or the request is 400. When present it REPLACES the flat `test_runs` key, which is then ignored.", - "json_path": "/report_filters/test_runs_by_project" - }, - { - "name": "test_plans_by_project", - "type": "object", - "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Same validation as test_runs_by_project, and it replaces the flat `test_plans` and `test_plan_selection` keys.", - "json_path": "/report_filters/test_plans_by_project" - }, - { - "name": "builds", - "type": "array", - "json_path": "/report_filters/builds", - "description": "Internal build ids paired 1:1 with `test_runs`. Sent back by the read path for round-tripping an edit; do not invent values." - }, - { - "name": "projects", - "type": "array", - "json_path": "/report_filters/projects" - }, - { - "name": "folder", - "type": "array", - "json_path": "/report_filters/folder" - }, - { - "name": "test_case_tags", - "type": "array", - "json_path": "/report_filters/test_case_tags" - }, - { - "name": "tc_custom_fields", - "type": "object", - "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on the other types the server never reads it and drops it silently. Persisted, and read back, under the name `custom_fields`.", - "json_path": "/report_filters/tc_custom_fields" - }, - { - "name": "custom_date_range", - "type": "string", - "example": "2025-04-16 2025-04-24", - "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED whenever report_timeframe is `custom` \u2014 omitting it is a 500, not a validation error. Ignored (and nulled on edit) for every other timeframe." - }, - { - "name": "users", - "type": "array", - "description": "TM user ids to mail the report to. Every id must belong to your group or the whole request is 400 'Invalid mail to users'. Only persisted when the report is scheduled.", - "json_path": "/mail_to/users" - }, - { - "name": "external_mails", - "type": "array", - "description": "Email addresses outside the group. Only persisted when the report is scheduled.", - "json_path": "/mail_to/external_mails" - }, - { - "name": "frequency", - "type": "string", - "values": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly", - "description": "Scheduling cadence. Send `frequency` and `frequency_details` together or omit BOTH \u2014 omitting both creates a valid unscheduled, on-demand report (and `mail_to` is then dropped silently). The enum is daily/weekly/monthly only: `once` passes body validation and then raises 'Unsupported frequency type' as a 500." - }, - { - "name": "time", - "type": "string", - "example": "08:00", - "description": "24-hour HH:MM, UTC. Required for every cadence; missing or malformed is 400 'Invalid report frequency'.", - "json_path": "/frequency_details/time" - }, - { - "name": "day", - "type": "string", - "example": "monday", - "description": "Required for weekly \u2014 a lowercase English day name, nothing else \u2014 and for monthly, where it is an integer 1-28 (29-31 are rejected). Omit for daily.", - "json_path": "/frequency_details/day" - } - ], - "intent": "Save a report definition over a project's runs, plans, cases, sessions or requirements \u2014 scheduled to email on a cadence, or unscheduled and read on demand", - "returns": [], - "responses": { - "200": { - "$response": "CreateReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Four body fields are required by the validator, not just the three the spec marks: `report_type`, `report_timeframe`, `report_creation_mode`, and a project (either `project_id`/`projectId` in the body or the project in the path). Any one missing comes back 400 `Validation error. Invalid report type|timeframe|creation mode.` \u2014 the message names which.", - "This returns HTTP 200, not 201, despite creating a record. The new report is at `data`; carry `data.id` (an integer) forward as the `report_id` for every other report call. `data.identifier` is the SC-NNN display string and is NOT accepted anywhere as an id.", - "Creating the report does NOT compute it. The response `data` is the definition only; there is no `report_data` here. Read the report afterwards to get figures, and name the sections you want.", - "`report_timeframe` and `report_filters` must agree. The five entity timeframes each read exactly one filter key \u2014 specific_test_runs reads `test_runs`, specific_test_plans reads `test_plans`, specific_test_cases reads `test_cases`, specific_sessions reads `session_ids`, requirements reads `requirements`. Naming the timeframe and leaving its filter empty saves a report that resolves to nothing, at 200.", - "`report_filters.test_runs` and `.test_plans` must be arrays of INTEGERS. A string id (\"123\") or a TR-NNN/TP-NNN identifier fails the whole request with 400 `Validation error. Invalid report filters.`, and `test_cases` / `requirements` must be objects, not arrays.", - "Scheduling is inferred, never declared: the report is scheduled iff BOTH `frequency` and `frequency_details` are present. `mail_to` is only persisted for a scheduled report \u2014 recipients sent on an unscheduled one are dropped silently and `next_run_at` stays null. Use the send-email-now capability to mail an unscheduled report.", - "`report_timeframe: custom` without `custom_date_range` is an unhandled 500, not a 422 \u2014 the server splits the value unconditionally. Send \"YYYY-MM-DD YYYY-MM-DD\", space separated.", - "`frequency: once` is accepted by body validation and then raises 'Unsupported frequency type' as a 500. Omit both scheduling fields for a one-off report instead.", - "Cross-project: send `project_ids` (integers). At most 60, and only for Test Run Summary, Test Plan Summary, Test Run Detailed Report, Test Plan Detailed Report, Test Case Activity, Requirement Traceability Report and User Workload Report \u2014 anything else is 400. User Workload Report caps at 5 projects. When both are sent, `project_id` must be one of them; when only `project_ids` is sent the first entry becomes the primary project.", - "`report_creation_mode: system_generated` on a Requirement Traceability Report makes the server fetch every linked requirement in the project and populate `report_filters.requirements` for you \u2014 do not also send that filter.", - "`grain: test_plan` is Requirement Traceability Report only, single-project only, and immutable after creation (a later edit that changes it is refused 400). Omit it for the normal test-case grain.", - "Requires the report_create permission on the project; 403 otherwise. `Test Plan Detailed Report` additionally needs a per-group feature flag and returns 403 `Operation not permitted` without it.", - "The write also syncs the report to Test Observability. If that sync fails the created row is deleted again and the call errors \u2014 a 5xx here means nothing was kept." - ] - }, - { - "name": "create_root_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folders", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Root Folder A", - "json_path": "/folder/name", - "description": "Folder name. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." - }, - { - "name": "notes", - "type": "string", - "example": "This folder contains all regression test cases", - "json_path": "/folder/notes", - "description": "Free-text description, HTML allowed. It is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. Note the key is `notes`: `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Send the literal string \"true\" to have a clashing name auto-suffixed instead of rejected. Any other value, and any boolean, is treated as false \u2014 the comparison is against the string \"true\"." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Integer ids of already-uploaded attachment blobs to bind to the folder. Each must belong to this group/project and must not already be attached to anything else \u2014 a reused blob fails the whole call with 422 and the bare string \"the attachment has been referenced elsewhere\"." - } - ], - "intent": "Create a top-level folder in a project \u2014 the first thing you need before test cases can be filed anywhere", - "returns": [ - "data", - "success", - "folder", - "project", - "id", - "name", - "notes", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__CreateRootFolder" - }, - "400": { - "$response": "BadRequest" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `name` and `notes` flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", - "Uniqueness is always enforced against sibling root folders. A duplicate name comes back with the entity store's own message and status relayed verbatim \u2014 this is one of the few folder endpoints that does NOT mask the upstream text. Send auto_rename:\"true\" if you want it renamed instead.", - "The created folder is at `data.folder`, not at the top level. `data.project` carries the project it landed in. The id you carry forward is `data.folder.id`, a plain integer.", - "Passing the literal path segment `new` as project_id CREATES a default project and puts the folder in it. Only do that deliberately.", - "Requires the folder-create permission; 403 otherwise.", - "Folders keep no version history, so a folder created here can only be undone by deleting it \u2014 and folder deletion is itself not reversible through this API." - ] - }, - { - "name": "create_shared_step", - "method": "POST", - "path": "/api/v1/projects/{project_id}/shared-steps", - "mode": "write", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the shared step block. Only presence of the key is checked here \u2014 an empty string passes this layer and is rejected by the backing service, so a blank title fails late with a different error than a missing one." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Shared-field folder to file the block under. The key is forwarded only when present; omit it (or send null) to leave the block at the root (Unassigned)." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "description": "Plain tag name strings on the shared step itself (separate from the tags on the test cases that embed it). Accepted here but previously undeclared." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The ordered step rows. Required \u2014 omitting the key entirely fails local validation with a flat 400 'Validation Error' that names no field. An empty array IS accepted and creates a titled block with no steps.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "minimum": 1, - "description": "1-based position. Every row needs order >= 1; a missing or 0 order fails the whole request, not just that row." - }, - { - "name": "step", - "type": "string", - "description": "Step body, HTML. Each row must carry at least one of step or result or the whole request is rejected." - }, - { - "name": "result", - "type": "string", - "description": "Expected result, HTML. Each row must carry at least one of step or result." - }, - { - "name": "test_data", - "type": "string" - } - ] - } - ], - "intent": "Create a reusable shared step block in a project \u2014 a titled set of step/result rows that many test cases can then embed by reference instead of copying", - "returns": [ - "success", - "data", - "id", - "title", - "step_count", - "test_case_count", - "tags", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedStepsPostResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "render_image_urls", - "type": "boolean", - "default": false, - "description": "When true, inline image attachments referenced in the step/result HTML are returned as resolved URLs rather than raw references." - } - ], - "guidance": [ - "The body is flat \u2014 title, folder_id, tags and shared_step_details sit at the top level with no wrapper.", - "shared_step_details is required even when empty. Every row must have order >= 1 and at least one of step/result; one bad row fails the entire call with a bare 400 `{data:null, success:false, message:\"Validation Error\"}` that does not say which row or which field.", - "Success is 200 with `{data, success:true}` \u2014 not 201, and the body is not empty.", - "Carry `data.id`. That integer is the shared_step_id you put on a test case's `test_case_steps[].shared_step_id`, and the id for every later read, update and delete of this block.", - "Creating a shared step attaches it to nothing. Embedding is done from the test case side; this call only makes the block available.", - "step and result are HTML. Inline attachment references are validated server-side and an unresolvable one fails the call with 400 'attachment in text is invalid'.", - "Requires tc_write \u2014 the same permission as editing a test case." - ] - }, - { - "name": "create_step_result", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/step/{step_id}", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (this route only matches digits; a PR-NNN identifier never reaches the API)." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run id. This v1 route accepts both the bare integer and the TR-NNN identifier." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1 - }, - { - "name": "step_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric id of the step on the test case (from the case detail), not its position in the step list. The route only matches digits." - } - ], - "body": [ - { - "name": "status", - "type": "string", - "values": [ - "untested", - "passed", - "failed", - "retest", - "blocked", - "skipped", - "in_progress" - ], - "example": "passed", - "json_path": "/test_run_step_result/status", - "description": "Step outcome as a NAME. This is the value that is actually written. A same-named key at the top level of the body is validated but never written." - }, - { - "name": "status_id", - "type": "integer", - "json_path": "/test_run_step_result/status_id", - "description": "Numeric status id. Accepted and forwarded, but the step surface is name-based \u2014 prefer `status` unless you know the backing service expects an id here." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run_step_result/description", - "description": "Note for this step. Rich text; links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent. Also copied onto the case-level result." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_run_step_result/attachments", - "example": [ - 10288829 - ], - "description": "Integer blob ids from a prior generic attachment upload (or {id: } objects) \u2014 NOT filenames and NOT file bytes. An unresolvable or already-used blob fails the whole call with 422." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_run_step_result/custom_fields", - "description": "Custom field values keyed by custom field id. Copied onto the case-level result as well as the step." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run_step_result/issues", - "description": "Linked defects. Each entry must be an OBJECT; a bare string is dropped by the parameter filter with no error, so the link is silently never created." - } - ], - "intent": "Log the outcome of ONE step inside a test case's execution in a run \u2014 the per-step pass/fail you record while working through a manual test case, as opposed to the case's overall result", - "responses": { - "200": { - "description": "The full test result for the case, at the top level of the body (no envelope). The step's own outcome is nested under `test_run_step_result`.", - "schema": { - "$schema": "TestResult__CreateStepResult" - } - }, - "400": { - "description": "Either a duplicate request inside the one-second lock ({success:false, message:'Request is already being processed'}) or a top-level `status` outside the supported step statuses ({error:'Validation error'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Caller lacks execute permission on this test run ({success:false, message:\"You don't have permission to execute test cases in this test run\"})." - }, - "422": { - "description": "An attachment blob id could not be resolved or is already referenced elsewhere ({base: [reason]})." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `test_run_step_result` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. That object is forwarded to the backing service essentially verbatim, so only the keys below are acted on locally and unrecognised keys are passed through rather than rejected.", - "The step's status travels as a NAME inside the wrapper (`status`), not as the numeric status id the case-level result endpoints take. The supported names are untested, passed, failed, retest, blocked, skipped, in_progress.", - "There is a decoy: a TOP-LEVEL `status` key is validated against that same list and 400s {error:'Validation error'} when it is not in it \u2014 but it is not the value that gets written. Sending only a top-level status passes validation and logs nothing.", - "Repeat calls for the same project/run/case/step within one second are refused with 400 {success:false, message:'Request is already being processed'} by a one-second lock. That is a de-duplication guard, not a validation failure \u2014 pause and retry.", - "Logging a step result also rewrites the CASE's result in that run: if the wrapper carries any of description, attachments, custom_fields or issues, those are copied onto the case-level result and its status is taken from the step write. A status-only step write leaves the case result's fields alone.", - "`attachments` here are integer blob ids from a prior generic attachment upload \u2014 not filenames and not file content. An id that cannot be resolved, or a blob already attached elsewhere, fails the whole call with 422.", - "Requires execute permission on the run (403 otherwise). `step_id` is the numeric id of the step on the test case \u2014 read it from the case detail; it is not the step's 1-based position.", - "The 200 body is the full CASE result object at the top level \u2014 no envelope, no `success` key, and no separate step-result record. The step's own outcome is nested under `test_run_step_result` on it." - ], - "query": [ - { - "name": "configuration_id", - "type": "integer", - "description": "Target the execution for one configuration when the case runs against a configuration matrix. Forwarded as configId." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target one specific run/case row. Also selects the newer write path, on which the response is an optimistic payload built from the submitted body rather than a re-read of the stored result." - } - ], - "returns": [ - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "attachments", - "issues", - "test_run_step_result", - "links" - ] - }, - { - "name": "create_sub_folder", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mkdir", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/folder/name", - "description": "Name of the new subfolder. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves." - }, - { - "name": "notes", - "type": "string", - "json_path": "/folder/notes", - "description": "Free-text description, HTML allowed; sanitised server-side so the stored value can differ from what you sent. The key is `notes` \u2014 `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Integer ids of already-uploaded attachment blobs. Each must belong to this group/project and must be unattached; a reused blob fails the call with 422 and the bare string \"the attachment has been referenced elsewhere\"." - } - ], - "intent": "Create a folder inside an existing folder \u2014 use this to build out a nested test-case tree one level at a time", - "returns": [ - "data", - "success", - "folder", - "id", - "name", - "notes", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__CreateSubFolder" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `folder` wrapper is added for you from each field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key.", - "`folder_id` in the path is the PARENT. It is fetched first, so a folder id from another project comes back as a bare 404 {success:false} with no message telling you which id was wrong.", - "folder_id must be a positive integer. Literal 0 raises an unhandled ArgumentError and surfaces as a 500, not a 400.", - "Sibling-name uniqueness is always checked and the upstream message is relayed verbatim on failure (unusually \u2014 most folder endpoints mask it). auto_rename:\"true\" turns the rejection into a silent rename.", - "The new folder is at `data.folder`; there is no `folder` at the top level and no `project` key on this action (unlike root creation). Carry `data.folder.id` forward.", - "Requires the folder-create permission." - ] - }, - { - "name": "create_template", - "method": "POST", - "path": "/api/v2/templates", - "mode": "write", - "entity": "template", - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Template name. Must be unique within the workspace for the entity type; a duplicate is rejected upstream with a 400 carrying the reason.", - "minLength": 1, - "example": "Regression Template" - }, - { - "name": "entity", - "type": "string", - "description": "What the template applies to. Only test-case templates are supported today; leave this out and it defaults to TestCase.", - "values": [ - "TestCase" - ], - "default": "TestCase" - }, - { - "name": "description_fields", - "type": "array", - "description": "Ordered fields placed in the template's description section. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean", - "description": "True for a built-in field, false or absent for a custom field." - }, - { - "name": "is_mandatory", - "type": "boolean", - "description": "Whether a test case using this template must fill the field." - } - ] - }, - { - "name": "properties_fields", - "type": "array", - "description": "Ordered fields placed in the template's properties section. Unlike an update, a create does NOT auto-add the Review & Approve system fields \u2014 the template gets exactly the ids you send. Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - }, - { - "name": "linked_projects", - "type": "object", - "description": "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", - "fields": [ - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Automatically link projects created later.", - "default": false - }, - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "Link every existing project.", - "default": false - }, - { - "name": "link_projects", - "type": "array", - "description": "Integer project ids to link. These are the numeric project ids, not the PR- identifiers." - }, - { - "name": "unlink_projects", - "type": "array", - "description": "Integer project ids to unlink. Meaningless on a create." - } - ] - } - ], - "intent": "Create a new test-case template \u2014 define which description and property fields a test case gets and, optionally, which projects it applies to.", - "guidance": [ - "NON-REMOVABLE SYSTEM FIELDS 17 AND 18 MUST BE INCLUDED or the create fails with 400 `templateFieldValidationError: Non-removable System fields not present in template.` These are the Review & Approve pair. You cannot discover them by reading an existing template: get_template DROPS them from its response (`field_count` 16 vs 14 entries returned, on every template including one just created with all 16), so a read-then-clone round trip always fails. Add {id:17,...} and {id:18,...} to properties_fields explicitly.", - "Each entry is `{id, order, is_mandatory, is_system}` where `id` is the SYSTEM FIELD id (or custom field id with is_system false) - NOT the template-field mapping id that a template read returns under `id`. Reading a template and posting its entries back unchanged fails: the read's `id` is the mapping row, and the field id is in `system_field_id`.", - "Object, and the server's own error echo names the keys: `{link_projects:[,...], linked_to_all_projects:, link_to_future_projects:}`. Note `link_projects` and `link_to_future_projects`, not project_ids / future_projects_applicable - the latter are what the RESPONSE uses, so the request and response spell the same two concepts differently. Omit the object entirely and the template is created linked to nothing.", - "Paid plans only. On a free plan the gate answers with HTTP 200 and a body of success:false / 'This Feature is only for paid customers'. Check the success flag, not the status code, before reporting the template as created.", - "Nothing is validated locally \u2014 name, field ids and project ids all go straight upstream, so a malformed body surfaces as a 400 with a single message rather than a per-field error list.", - "A create does not auto-inject the Review & Approve system fields, but a later update of properties_fields will. The two calls therefore do not behave the same way on the same field list.", - "The 201 body carries the full template including its fields \u2014 read it rather than re-fetching, and keep the returned id.", - "Success is 201 with the full template echoed, linked to the named project. A 403 'This action is not permitted for the user' is a permission gap on the calling credential, not the paid-plan gate." - ], - "returns": [ - "success", - "template", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at", - "description_fields", - "properties_fields", - "step_fields" - ], - "responses": { - "201": { - "$response": "TemplateDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_case_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Create one test case at the project level \u2014 the call to use to seed the FIRST case in a project that has no folders yet, via create_at_root.", - "returns": [ - "success", - "data", - "test_case", - "folder", - "project", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "$schema": "V1TestCaseCreateResponse__CreateTestCaseByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This route has no folder in its path, so you must supply one: either `test_case.test_case_folder_id` (an existing folder) plus a top-level `folder_id` naming the same folder, or `create_at_root: true` to have the folder minted.", - "Without a resolvable folder the create still reaches the backend and then fails while building the response, surfacing as a 422 rather than a clear validation error. Resolve the folder id first.", - "`create_at_root: true` only works on a project with ZERO folders \u2014 it creates a folder called 'New Folder' and puts the case there. On a project that already has folders it is 400 'Root Folder Already Exists'.", - "For any project that already has folders, prefer the folder-scoped create instead; this route exists for the empty-project case.", - "Steps only come from `test_case_steps`, and only when `template`/`template_step_type` selects a step-shaped template \u2014 the default is `test_case_text`, which stores no steps and still returns 200.", - "The new case comes back at `data.test_case`; take its integer `id` and its TC-NNN `identifier` from there. `data.folder`, `data.project` and `data.path_folders` describe where it landed.", - "If the project has e-signature enabled and you set `review_status: in_review` (or `send_for_review`), the call needs a `test_case.reauth_token`; without it the response is 401 `{success:false, error_code:\"reauth_required\"}`.", - "A test case keeps version history, so an edit is recoverable \u2014 but nothing undoes a create except deleting the case." - ], - "body": [ - { - "name": "test_case", - "type": "object", - "required": true, - "description": "The case to create. Everything about the case goes inside this wrapper \u2014 the handler requires it, so a flat body is a 400 before any field is read.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body \u2014 the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." - }, - { - "name": "automation_state", - "type": "integer" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." - }, - { - "name": "case_type", - "type": "integer", - "description": "Numeric case-type option id, or the project's display name for it." - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Ignored on create \u2014 dataset mappings are always regenerated from `test_case_dataset`." - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer", - "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Dropped on create \u2014 the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." - }, - { - "name": "status", - "type": "integer", - "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here \u2014 it is written through as the id and breaks the field." - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string" - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." - } - ] - }, - { - "name": "create_at_root", - "type": "boolean", - "description": "Create the project's first folder ('New Folder') and put this case in it. Only valid when the project has NO folders \u2014 otherwise 400 'Root Folder Already Exists'." - }, - { - "name": "folder_id", - "type": "integer", - "description": "TOP-LEVEL folder id, outside `test_case`. This is what the ownership check reads and what the success response's folder block is built from; without it (and without `create_at_root`) the response build fails and you get a 422." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there. The subfolder is deleted again if the create then fails." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." - } - ] - }, - { - "name": "create_test_cases", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_case", - "type": "object", - "required": true, - "description": "The case to create. Everything about the case goes inside this wrapper \u2014 the handler requires it, so a flat body is a 400 before any field is read.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Case title. Required." - }, - { - "name": "test_case_folder_id", - "type": "string", - "required": true, - "description": "Target folder's integer id. Required in the body \u2014 the project-level route has no folder in its path, and with `create_at_root` the folder is minted for you instead." - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the new case. A blob from another group fails the request with 422." - }, - { - "name": "automation_state", - "type": "integer" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, as an internal_name string (e.g. `not_automated`). Applied only when `automation_state` is omitted." - }, - { - "name": "case_type", - "type": "integer", - "description": "Numeric case-type option id, or the project's display name for it." - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "estimate", - "type": "string", - "description": "Accepted and then never written; every read serialises `estimate` as null. Use `estimated_duration_seconds`." - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. This is the field that actually stores an estimate." - }, - { - "name": "expected_result", - "type": "string" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Ignored on create \u2014 dataset mappings are always regenerated from `test_case_dataset`." - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "owner", - "type": "integer", - "description": "TM user id. An id that is not a member of the group is replaced with the caller instead of being rejected." - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "priority", - "type": "integer", - "description": "Numeric priority option id, or the project's display name for it (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state. Never left untouched on create: when omitted it is set from the project's review-approve setting, falling back to `pending`." - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled; otherwise dropped without error. Including the owner is rejected with 422 'Owner cannot be a reviewer'." - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Dropped on create \u2014 the create flow already notifies from `reviewers` + `review_status`. Setting `review_status: in_review` is what triggers the send-for-review path, including its re-authentication gate." - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body." - }, - { - "name": "status", - "type": "integer", - "description": "Numeric status option id. Unlike priority/case_type, a display NAME is not resolved here \u2014 it is written through as the id and breaks the field." - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "template", - "type": "string", - "description": "Step-shape template name. Defaults to `test_case_text` when neither this nor `template_step_type` is sent, which silently discards `test_case_steps`." - }, - { - "name": "template_id", - "type": "integer" - }, - { - "name": "template_step_type", - "type": "string" - }, - { - "name": "test_case_dataset", - "type": "object" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The only key read for steps. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A `steps` key instead is dropped and the case is created with no steps, still 200." - } - ] - }, - { - "name": "create_at_root", - "type": "boolean", - "description": "Not useful on this route \u2014 the folder is already in the path. It only has an effect on a project with zero folders, where it mints 'New Folder'; otherwise it turns the call into a 400 'Root Folder Already Exists'." - }, - { - "name": "create_subfolder", - "type": "boolean", - "description": "Compared to the literal boolean `true`. Creates or reuses a subfolder named `scenario_name` under `test_case.test_case_folder_id` and puts the case there instead of in the path folder." - }, - { - "name": "scenario_name", - "type": "string", - "description": "Name of the subfolder to create or reuse when `create_subfolder` is true." - } - ], - "intent": "Create one test case in a folder \u2014 the normal single-case create. For more than one case, use the bulk create instead.", - "returns": [ - "success", - "data", - "test_case", - "folder", - "project", - "path_folders", - "request_trace_id" - ], - "responses": { - "200": { - "$schema": "V1TestCaseCreateResponse__CreateTestCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the plural name this creates exactly ONE case: the body is a single `test_case` object, not a list. The bulk route is the one that takes an array.", - "`test_case.test_case_folder_id` is required in the BODY as well as in the path \u2014 the path folder is not substituted in, and omitting it is the most common create failure (a 422 wrapping an upstream 404).", - "Steps only come from `test_case_steps`, and only when the template is step-shaped. Neither `template` nor `template_step_type` sent means `test_case_text`, which stores no steps and still returns 200 \u2014 verify `test_case_steps` in the response.", - "priority and case_type accept either the numeric option id or the project's display name; `status` does NOT \u2014 a status name is written through as an id. Resolve the ids from the project's system field values first.", - "`review_status` is never left alone on create: omitted, it is set from the project's review-approve setting, falling back to `pending`. `reviewers` is only honoured on a Team-Pro workspace with review-approve enabled, and including the owner is a 422.", - "The new case is at `data.test_case` \u2014 its integer `id` and TC-NNN `identifier` are both there. `data.request_trace_id` refers to the AI enrichment that continues after the response.", - "Linked `issues` are processed asynchronously after the response, so a 200 does not mean the links exist yet." - ] - }, - { - "name": "create_test_plan_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/test_plan/name", - "description": "Plan name. REQUIRED \u2014 a missing or blank name is 400 {success: false, message: 'Name field cannot be blank'}, and nothing else in the body is looked at." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text. Sanitised against the RTE allowlist and rewritten server-side, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "description": "ISO-8601 date. Stored as midnight UTC. An unparseable value is silently dropped rather than rejected.", - "format": "date", - "json_path": "/test_plan/start_date" - }, - { - "name": "end_date", - "type": "string", - "description": "ISO-8601 date. If it is earlier than start_date the call returns HTTP 200 with {success: false, error: 'Please check date params'} and creates nothing.", - "format": "date", - "json_path": "/test_plan/end_date" - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "ACCEPTED BUT NEVER APPLIED ON CREATE \u2014 it is not forwarded upstream, so every new plan starts as `new`. Move it on with a follow-up plan update.", - "json_path": "/test_plan/plan_status" - }, - { - "name": "owner", - "type": "integer", - "description": "Owner as the internal TM user id (the `id` from the project users read \u2014 NOT a BrowserStack user id and not an email). Omit the key to own it yourself. An id that does not resolve inside the caller's group is swallowed, not rejected, and the plan is created with no real owner.", - "json_path": "/test_plan/owner" - }, - { - "name": "parent_plan_id", - "type": "integer", - "description": "Parent plan's INTEGER id. Set it to create a SUB-plan \u2014 the result carries an STP-NNN identifier. Null/omitted means a top-level plan. This choice is effectively permanent: a plan can never later be demoted into a sub-plan or promoted out of one.", - "json_path": "/test_plan/parent_plan_id" - }, - { - "name": "tags", - "type": "array", - "description": "Plain tag strings. If ANY entry is not a string the whole list is silently replaced with [] \u2014 the plan is created with no tags and no error.", - "json_path": "/test_plan/tags" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer user ids. Attach-only \u2014 adding reviewers does NOT send the plan for review, and reviewers are only emailed once the plan is actually in review. Rejected if the project restricts reviewers and one is not on the allowed list.", - "json_path": "/test_plan/reviewers" - }, - { - "name": "attachments", - "type": "array", - "description": "Ids of attachments already uploaded via the attachments surface. An id that does not verify against this group returns 422 with the verifier's own body, not the usual {success, error} envelope.", - "json_path": "/test_plan/attachments" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom-field values keyed as the test-plan form-fields read returns them. A bad key or value is 400 {success: false, error: }.", - "json_path": "/test_plan/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked tracker issues. Structured \u2014 NOT a flat array of keys. A bare string entry is coerced to {issue_id: }, losing the tracker type. Only url and ticket_id are kept inside metadata; other metadata keys are dropped.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_plan/issues" - }, - { - "name": "test_runs", - "type": "array", - "description": "Test runs to link, as run INTEGER ids (a run's `uuid`, not its TR-NNN `id`). Linking MOVES a run: it leaves whatever plan held it, with nothing in the 200 to say so.", - "json_path": "/test_plan/test_runs" - } - ], - "intent": "Create a test plan to group test runs for a release or milestone \u2014 or create a SUB-plan under an existing plan by setting parent_plan_id", - "responses": { - "200": { - "description": "The created plan under a top-level `test_plan` key (no `data` wrapper). `identifier` is the `TP-NNN` (or `STP-NNN` for a sub-plan) and `id` is the integer the other v1 routes take.\n", - "schema": { - "$schema": "V1TestPlanDetail__CreateTestPlanByIntegerId" - } - }, - "400": { - "description": "Missing name (`{success: false, message: \"Name field cannot be blank\"}`) or a rejected custom field (`{success: false, error: ...}`). A body with no `test_plan` wrapper lands here too.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "error": { - "type": "string" + "description": "The four fields every expanded user reference carries, on every serializer.\n", + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the user." + }, + "full_name": { + "type": "string", + "description": "Full name of the user." + }, + "onboarded": { + "type": "integer", + "description": "Onboarding status of the user." + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Also used for an upstream payload rejection: `{success: false, error: \"Invalid Params\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" + }, + "TestRunUserRef": { + "type": "object", + "description": "The user reference embedded in a test run's created_by / updated_by. Carries group_id and browserstack_user_id but NOT reports_and_notification_enabled.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "browserstack_user_id": { + "type": "integer", + "description": "BrowserStack user ID associated with this user." + }, + "group_id": { + "type": "integer", + "description": "Group ID the user belongs to." + } } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Every field goes inside a `test_plan` object. A flat body is rejected before any field is read.", - "name is the only required field, and it is checked first \u2014 a 400 {success: false, message: 'Name field cannot be blank'} means exactly that and nothing about the rest of the body.", - "plan_status is accepted and then ignored. A new plan is always `new`; drive it to started/completed with the plan update capability, which needs the matching date in the same request.", - "Bad dates are a 200 trap: end_date < start_date returns HTTP 200 carrying {success: false, error: 'Please check date params'}. Branch on `success`, never on the status code.", - "An upstream rejection of the payload is reported as HTTP 404 with {success: false, error: 'Invalid Params'} \u2014 a 404 from this endpoint usually means a bad body, not a missing project.", - "Linked issues are processed on a background job, so they are usually absent from the plan you get back in this response even though the call succeeded. Re-read the plan to see them.", - "Carry `test_plan.id` (the integer) forward for every other v1 plan route; `test_plan.identifier` is the TP-NNN (STP-NNN for a sub-plan) used for display and by the newer API surface.", - "The reply is the re-read plan under a top-level `test_plan` key \u2014 there is no `data` wrapper." - ], - "returns": [ - "success", - "test_plan", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "start_date", - "end_date", - "owner", - "assignee", - "created_by", - "tags", - "issues", - "attachments", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "total_test_runs_count", - "test_plan_progress_v2", - "record_status", - "project_id", - "created_at", - "updated_at", - "links" - ] - }, - { - "name": "create_test_result_for_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "status_id", - "type": "integer", - "description": "Result status id \u2014 resolve the name (\"Passed\", \"Failed\") to an id first; this field takes the id. Effectively required, though a missing value is accepted and leaves the result unstatused.", - "json_path": "/test_result/status_id" - }, - { - "name": "description", - "type": "string", - "json_path": "/test_result/description" - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_result/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects. Each entry is an OBJECT \u2014 a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_result/issues" - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_result/attachments" - }, - { - "name": "elapsed_time", - "type": "integer", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration this result is for. TOP level \u2014 the server does not read it from inside `test_result`, so nesting it silently logs the result against the default configuration." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target a specific run/case mapping. Top level." - }, - { - "name": "test_case_count", - "type": "integer", - "description": "Total number of test cases linked to the automation execution" - } - ], - "intent": "Create a new test result for a test case in a test run", - "guidance": [ - "This is how a pass or fail is recorded against a case in a run. Editing the run or the case does not record an outcome." - ], - "returns": [ - "id", - "status", - "backtrace", - "configuration_id", - "created_at", - "created_by_imported", - "custom_fields", - "description", - "error_description", - "expanded", - "failure_type", - "finished_at" - ], - "responses": { - "201": { - "$response": "TestResultCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_results_for_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - } - ], - "body": [ - { - "name": "results", - "type": "array", - "minItems": 1, - "description": "Bulk form: one entry per test case. Mutually exclusive with the single form below \u2014 send `results` OR `test_result`, never both. Each entry needs `test_result` plus `test_case_id` or `execution_id`. No other keys are allowed on an entry.", - "fields": [ - { - "name": "test_case_id", - "type": "string", - "description": "Test case identifier, e.g. TC-101. Required unless execution_id is given." - }, - { - "name": "execution_id", - "type": "string", - "description": "Automation execution id, as an alternative target to test_case_id." - }, - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "test_result", + }, + "DatasetUserRef": { "type": "object", - "description": "The outcome itself: status (name), description, issues, step_results. Same shape as the single-result `test_result` below." - }, - { - "name": "attachments", - "type": "array", - "maxItems": 10, - "description": "Multipart file part filenames for this entry. Ignored on a plain JSON request." - } - ] - }, - { - "name": "status", - "type": "string", - "description": "Result status by NAME as configured for the project (\"Passed\", \"Failed\", \"Untested\"). v1's numeric status_id is NOT accepted here. Required unless step_results is sent instead.", - "json_path": "/test_result/status" - }, - { - "name": "description", - "type": "string", - "description": "Notes on the outcome. Rich text is accepted. Silently truncated at 16 KB rather than rejected.", - "json_path": "/test_result/description" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects as plain KEY STRINGS (e.g. [\"JIRA-12\"]) \u2014 unlike v1, which takes objects. Pair with issue_tracker to type them.", - "json_path": "/test_result/issues" - }, - { - "name": "issue_tracker", - "type": "object", - "description": "Types the linked issues, e.g. {\"name\": \"jira\"}. Only read from inside test_result.", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "json_path": "/test_result/issue_tracker" - }, - { - "name": "elapsed_time", - "type": "integer", - "description": "Execution time in seconds. Stored as time_elapsed.", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Result custom fields keyed by field name; resolved against the project's TestResult custom fields.", - "json_path": "/test_result/custom_fields" - }, - { - "name": "step_results", - "type": "array", - "minItems": 1, - "description": "Per-step outcomes. Send these INSTEAD of a top-level status; each entry needs `status` plus `step_id` or `step_index`. The parent case status is rolled up from the steps.", - "fields": [ - { - "name": "step_id", - "type": "number" - }, - { - "name": "step_index", - "type": "number" - }, - { - "name": "status", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "issues", - "type": "array" - }, - { - "name": "created_at", - "type": "string" - }, - { - "name": "updated_at", - "type": "string" - } - ], - "json_path": "/test_result/step_results" - }, - { - "name": "test_case_id", - "type": "string", - "example": "TC-101", - "description": "Single form: which case in the run this result is for. TOP level \u2014 not inside test_result. One of test_case_id, execution_id or shared_test_case_ids is required." - }, - { - "name": "execution_id", - "type": "string", - "description": "Single form: target an automation execution instead of a case identifier. Top level." - }, - { - "name": "shared_test_case_ids", - "type": "object", - "description": "Single form: target a case shared from another project, as {project identifier: [test case identifiers]}. Must resolve to EXACTLY ONE case \u2014 zero or several gives 400 \"Test Case ID is invalid\"." - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration this result is for. TOP level. Omitting it logs against the default configuration." - }, - { - "name": "attachments", - "type": "array", - "maxItems": 10, - "description": "Filenames of multipart file parts to attach to this result. Only binds on a multipart/form-data request whose JSON body rides in a `payload` part; on a plain JSON request it is accepted and silently does nothing. Each entry is a bare filename string of 1-255 characters." - }, - { - "name": "validate_tc", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Bulk only. \"true\" pre-checks every test_case_id exists in the project and 400s listing the invalid ones. Rejected outright when the request carries 300 or more unique test case ids." - } - ], - "intent": "Record the outcome of running test cases in a test run \u2014 pass/fail/blocked with notes, per-step results, linked defects and attachments \u2014 for one case or for many in a single call", - "guidance": [ - "Two shapes on one endpoint: `results: [...]` for many cases, or `test_result` plus one target (`test_case_id`, `execution_id` or `shared_test_case_ids`) for one. Sending both, neither, or any unrecognised top-level key is rejected 400 as invalid params.", - "The single form answers with `test-result` for an ordinary submission, but a submission carrying `step_results` answers with `test-run-step-results` and `overall-result-status` INSTEAD \u2014 there is no `test-result` key on that response at all. Branch on which key is present rather than assuming.", - "Status is a NAME configured for the project, not an id. An unconfigured name fails that entry with INVALID_STATUS_VALUE.", - "Each submission ADDS a result to the case's history in this run; it never replaces the previous one. To move many cases to one status instead, use bulk_set_test_result_status.", - "The bulk form is queued, not applied inline: 200 means accepted. Read `results[]` \u2014 every entry is ACCEPTED or FAILED. A mix of good and bad entries still returns HTTP 200 but with `success: false` and `status: \"PARTIAL\"`; only an all-bad request returns 400. Treating 200 as 'all logged' will misreport.", - "`test-result.issues` comes back as an array of plain issue KEY STRINGS, matching the way issues are sent in, not as objects.", - "`test-result.urls.self` points at the case's ROW IN THE RUN, so its last path segment is the run row's id, not the id of the result just created. Use `test-result.id` for anything that addresses the result itself.", - "A project can restrict which review statuses may be executed. Blocked cases come back with a REVIEW_STATUS_GATE error and are never logged, whatever the status you sent.", - "Attachments require a multipart/form-data request: JSON body in a `payload` part, each file in its own part, and every name in `attachments` must match a part's filename or the request is rejected. Limits are 10 files per result, 100 per request, 50 MB per file, and filenames must be unique across the request.", - "The `attachments` array in the response is the bind result, not the stored attachment records \u2014 each entry carries the attachment id, the filename, a time-limited url and the expiry of that url. `attachment_errors` appears only when a bind failed, and `dropped_attachments` only on a bulk request where a rejected entry or an unresolvable inline reference left files behind.", - "Attachments cannot be combined with step_results on the same bulk entry \u2014 that request is rejected 400.", - "Success is HTTP 200 here, not the 201 the v1 create returns.", - "Requires the execute permission on the run; a caller without it gets 403 before anything is written." - ], - "returns": [ - "success", - "test-result", - "test-run-step-results", - "overall-result-status", - "results", - "status", - "message", - "attachments", - "attachment_errors", - "dropped_attachments" - ], - "responses": { - "200": { - "$response": "V2TestRunResultSubmitResponse" - }, - "400": { - "description": "Invalid params, unconfigured status for every entry, every case blocked by the review-status gate, or a multipart/attachment rejection. Bulk bodies carry per-case `results` entries with an `error.code` such as INVALID_STATUS_VALUE or REVIEW_STATUS_GATE.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - }, - "results": { - "type": "array", - "items": { - "$schema": "V2BulkResultOutcome" - } - }, - "dropped_attachments": { - "type": "array", - "items": { - "type": "object" - } + "description": "The user reference embedded in a dataset's created_by. Carries `role`, which no other user serializer returns, and omits browserstack_user_id / group_id.\n", + "allOf": [ + { + "$schema": "UserCore" + } + ], + "properties": { + "role": { + "type": "string", + "description": "The user's role on the dataset's project, e.g. `owner`.", + "example": "owner" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "description": "Indicates if reports and notifications are enabled for the user." + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Rich-text description referenced an attachment that could not be resolved.", - "schema": { - "type": "string", - "example": "attachment in rich text editor is invalid" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "filters", - "type": "object", - "example": { - "priority": [ - "High" - ], - "folder_ids": [ - 105 - ] - }, - "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body \u2014 not inside `test_run`. This does NOT select cases: it never back-fills existing ones, so a create whose only case-bearing key is `filters` returns 200 with an EMPTY run. Use `test_case_ids` or `selection` to put cases in the run, and send `filters` only in addition, when the user asked the run to stay in sync.\nSending a non-empty " - }, - { - "name": "dynamic_filter", - "type": "object", - "example": { - "owner": [], - "status": [], - "priority": [] - }, - "description": "Filter criteria (backward compatible format - use `filters` instead). Top level, same as `filters`, including that it selects no cases and flips the run dynamic." - }, - { - "name": "test_case_ids", - "type": "array", - "example": [ - 2336 - ] - }, - { - "name": "auto_assign", - "type": "boolean" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Selection of cases shared in from other projects. Top level, same as `selection`." - }, - { - "name": "run_state", - "type": "string", - "required": true, - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "new_run", - "description": "MANDATORY. The v1 endpoint validates this against the enum and hard-rejects anything else (including a missing key) with `400 \"invalid data\"`. Use `new_run` for a freshly created run. Note: v2 defaulted this to `new_run` server-side; v1 does NOT.", - "json_path": "/test_run/run_state" - }, - { - "name": "name", - "type": "string", - "example": "Regression \u2013 Login", - "json_path": "/test_run/name" - }, - { - "name": "description", - "type": "string", - "example": "", - "json_path": "/test_run/description" - }, - { - "name": "owner", - "type": "integer", - "example": 2, - "description": "TM user id of the run owner. Unresolvable ids are silently treated as unassigned rather than erroring.", - "json_path": "/test_run/owner" - }, - { - "name": "is_dynamic", - "type": "boolean", - "example": false, - "description": "Keep the run's membership live against `filters`. Must be sent INSIDE `test_run` \u2014 v1 does not read a top-level `is_dynamic` and will silently create a static run if you put it there.", - "json_path": "/test_run/is_dynamic" - }, - { - "name": "configurations", - "type": "array", - "example": [], - "description": "Configuration ids to run against \u2014 ids, not the configuration objects returned on read.", - "json_path": "/test_run/configurations" - }, - { - "name": "test_plans", - "type": "array", - "example": [], - "description": "Test plan ids. Only the first entry is linked; a run belongs to at most one plan.", - "json_path": "/test_run/test_plans" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login" - ], - "json_path": "/test_run/tags" - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues" - }, - { - "name": "environment", - "type": "string", - "json_path": "/test_run/environment" - }, - { - "name": "attachments", - "type": "array", - "json_path": "/test_run/attachments" - }, - { - "name": "metadata", - "type": "object", - "json_path": "/test_run/metadata" - }, - { - "name": "build_group_id", - "type": "integer", - "json_path": "/test_run/build_group_id" - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 83, - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "json_path": "/selection/folders" - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Create a test run for a project", - "guidance": [ - "Discovering cases does not put them in a run. Carry the case ids you found into this call \u2014 a create whose only case-bearing field is a filter rule succeeds and creates an EMPTY run.", - "run_state is required and is validated before the selection, so a bare 'invalid data' error means a bad run field, not a bad selection.", - "A filter rule also makes the run keep syncing as cases change, even if you did not ask for that. Create runs static unless the user explicitly wants a run that keeps updating.", - "Dynamic runs match on priority, status, category, owner, automation status, automation state, folder, tags and issues only. Creation and update dates are echoed back but never matched, so a 'last 7 days' dynamic run silently drops the date window and keeps collecting.", - "Preview the selection first if the count matters \u2014 it reports what the selection resolves to before anything is created." - ], - "returns": [ - "id", - "identifier", - "name", - "active_state", - "assignee_imported", - "created_at", - "description", - "environment", - "is_automation", - "is_dynamic", - "observability_url", - "owner" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__CreateTestRunByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "delete_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id holding the option. Not validated by this handler." - }, - { - "name": "option_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric option id to delete, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." - } - ], - "intent": "Permanently remove one choice from a dropdown-style custom field, dropping that value from every case that had it selected", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible, and it takes data with it: every case, result or plan holding this option loses that value, across every project the dataset covers. There is no bin.", - "If the value is simply wrong, rename it through the option update instead \u2014 a rename keeps the option id, so nothing stored is lost.", - "On a nested_dropdown, deleting a parent option orphans the children that pointed at it; list the options first and delete the subtree deliberately.", - "None of the three path ids is validated in this layer, so a wrong or mismatched triple reaches storage and returns a bare 404 {success:false} with no message \u2014 indistinguishable from a permissions failure.", - "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change after the call returns.", - "Success is only `{success: true}`; capture the option's label and id beforehand if you need to report what went." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_custom_field_dataset", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler \u2014 a wrong value surfaces as a bodyless 404 from storage." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Not validated by this handler; a dataset that belongs to a different field returns the same bodyless 404." - } - ], - "intent": "Remove one option set and its project scope from a custom field \u2014 the field survives, but every value its projects stored through this dataset is gone", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. The dataset, all of its options, and the values recorded against those options in every project the dataset linked are removed together. There is no bin.", - "Count and name the affected projects first (read the dataset, or the field with its datasets). Callers routinely think a dataset belongs to one project.", - "Neither path id is validated before the upstream call, so a wrong or mismatched pair reaches storage and returns a bare 404 {success:false} with no message. Confirm the pair with a dataset read first.", - "Saved report filters referencing this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape after the call returns.", - "If you only want to stop one project seeing the field, unlink that project through the dataset project-mapping call instead \u2014 narrower, though it also drops that project's stored values.", - "Success is only `{success: true}`; nothing about what was removed is echoed back." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/delete", - "mode": "destructive", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id, as returned by the workspace field list. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Permanently remove a custom field from the workspace, together with every value stored against it in every project that used it", - "responses": { - "200": { - "$response": "CustomFieldAdminAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible and workspace-wide. The field, its datasets, its options and every value stored on every test case / result / plan in every linked project go at once. There is no bin and no restore.", - "Read the field first and report how many projects its datasets link to before asking to proceed \u2014 the caller usually thinks of a field as belonging to one project.", - "A side effect reaches outside the field: saved report filters that referenced this field are cleaned up asynchronously afterwards, so a report's advanced filter can change shape minutes after the call returns.", - "The path id is the numeric custom field id. The route only matches digits, and an id of 0 returns 400 'Invalid Params'. A well-formed id that does not exist comes back as a bare 404 {success:false} with no message \u2014 the same body a permissions failure would give.", - "Success is only `{success: true}`; nothing is echoed back, so capture the field's name and id before deleting if you need them for the report." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_dataset", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "destructive", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string. There is no integer-id form of this route." - } - ], - "intent": "Permanently delete a dataset and all of its rows", - "responses": { - "200": { - "description": "Dataset deleted.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "DatasetV2 deleted successfully" + }, + "ProjectsPageInfo": { + "type": "object", + "description": "The projects listing's pagination envelope: PageInfo plus `starred_count`, which no other paginated read returns.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "starred_count": { + "type": "integer", + "example": 0 + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project; nothing was deleted." - }, - "422": { - "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible and unguarded: there is no soft delete, no recycle bin and no restore endpoint for datasets, and the handler performs no check for test cases still bound to this dataset \u2014 it deletes and reports success either way.", - "Check what depends on it first with the dataset's linked-test-cases listing, and read the dataset itself if the contents matter, because neither is recoverable afterwards.", - "An unknown uuid is 404 {success:false, message:'Dataset not found.'} \u2014 the lookup happens before the delete, so a 404 means nothing was removed.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad request.", - "The success body is {success:true, message:'DatasetV2 deleted successfully'} \u2014 the internal 'DatasetV2' name is not a typo you can rely on staying.", - "Needs the test-case write permission (tc_write)." - ], - "returns": [ - "success", - "message" - ], - "disabled": true - }, - { - "name": "delete_exploratory_session", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "destructive", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Permanently remove an exploratory session and everything recorded inside it \u2014 every log entry and every defect link goes with it", - "responses": { - "200": { - "description": "Exploratory session deleted successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Forbidden. Insufficient permissions to delete this session.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "message": { - "type": "string", - "example": "You don't have permission to delete this exploratory session" + }, + "FilterDetailsFolderNode": { + "type": "object", + "description": "A folder as returned inside `filter_details.folders` — the shared folder record plus the tree/selection fields that only this projection carries.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "folder_tree": { + "type": "object" + }, + "only_child_selected_parent_ids": { + "type": "string", + "nullable": true + }, + "only_selected_parent_ids": { + "type": "integer", + "example": 764138 + }, + "partial_selected_parent_ids": { + "type": "string", + "nullable": true + }, + "select_all_parent_ids": { + "type": "integer", + "example": 764093 + }, + "folders": { + "type": "object", + "properties": { + "ancestors": { + "type": "string", + "nullable": true + }, + "folder_path": { + "type": "string", + "example": "/764093/" + }, + "id": { + "type": "integer", + "example": 764093 + }, + "name": { + "type": "string", + "example": "__readonly__" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "selected_all": { + "type": "boolean", + "example": true + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + } + } + } } - } - } - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "There is no recycle bin for exploratory sessions. Unlike test cases, a deleted session has no restore path: it and all of its log entries are gone.", - "Permission depends on the session's state, and the handler reads the session before deciding: a closed session needs the closed-test-run delete permission, an active one the ordinary test-run delete permission. The refusal is 403 {success:false, message:\"You don't have permission to delete this exploratory session\"} and nothing is deleted.", - "Because the session is fetched first, an unknown session_id fails as a bare {success:false} 404 with no message.", - "Delete the logs individually first only if you want them removed selectively \u2014 deleting the session needs no cleanup of its children.", - "The response is only {success:true}; there is no echo of what was removed, so capture anything you need from the session before calling this." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_exploratory_session_log", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", - "mode": "destructive", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - }, - { - "name": "log_id", - "type": "integer", - "required": true, - "example": 789 - } - ], - "intent": "Permanently remove one observation from an exploratory session, discarding its note, outcome, elapsed time and defect links", - "responses": { - "200": { - "description": "Session log deleted successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Both ids are integers and both must match: log_id is only addressable under the session_id that owns it.", - "There is no bin or restore for session logs \u2014 the entry, its embedded screenshots' links and its defect links are gone.", - "This removes one log only. It does not touch the issues linked at session level, so a defect raised here can still appear in the session's defects listing through the session-level link.", - "The session's session_log_count drops, which can move a session back below the 50-log threshold that pushes a clone onto the asynchronous path.", - "The response is only {success:true}. An unknown log id surfaces as a bare {success:false} 404 with no message.", - "Deleting a log needs the test-run delete permission \u2014 the same permission as deleting a whole session, with no separate closed-session variant at log level." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_filter", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "destructive", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete a saved filter view from a project permanently", - "responses": { - "200": { - "$response": "FilterDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Removes the saved view only. The test cases, runs or plans it selected are untouched \u2014 this deletes a query, not data.", - "There is no undo and no bin for filter views, and no version history to restore from. Recreating one means re-authoring the whole condition set, so read the filter first if you might want it back.", - "A project-level view is deleted for everyone on the project, not just for you, and the filter controller is not RBAC-gated \u2014 any user with product access to the project can delete a view someone else created.", - "The success body is `{success:true}` and nothing else: no id echo, no copy of what was deleted, nothing to carry forward.", - "Deleting an id that does not exist comes back as 400 `{success:false, message:...}` rather than 404 \u2014 only the backing service's 400 is translated here, and its other failures surface as a 500." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_folder_and_contents", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm", - "mode": "destructive", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Permanently delete a folder together with everything beneath it \u2014 every nested subfolder and every test case in the whole subtree", - "returns": [ - "data", - "success", - "folder", - "path_folders", - "id", - "name", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "description": "Folder and its contents deleted successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder" + }, + "V1TestRunCasesRunRow": { + "type": "object", + "description": "The run header returned alongside a run's test cases. Adds the build and sharing fields that the run list and single-run detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } + "build_serial_id": { + "type": "integer", + "example": 1 }, - "success": { - "type": "boolean" + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is recursive and total. It removes the folder, all of its descendant folders, and every test case in that entire subtree \u2014 not just the cases filed directly in it. There is no option to keep the contents or to delete the folder only.", - "Run the folder remove-summary dry run first. It is the only way to see, before committing, how many active test cases, archived test cases, subfolders and low-code test recordings this call will destroy.", - "Recoverability depends on a group feature flag you cannot read from this API. When Move to Bin is enabled for the group, the folder's TEST CASES land in the project recycle bin and can be listed and restored from there until they auto-purge. When it is not enabled, the test cases are deleted outright.", - "The FOLDERS themselves are never recoverable, under either setting. Nothing in this API restores a deleted folder \u2014 the bin holds test cases only, and restored cases do not bring their folder tree back with them. Treat the folder structure as gone and be ready to recreate it.", - "Do not plan on the undo-remove endpoint as your way back. It is a dead alias that re-issues this same delete; it does not restore anything (see the flag on delete_folder_and_subtree).", - "Linked low-code test recordings belong to a different product and are destroyed too. The summary's has_other_product_entity flag is true exactly when the subtree contains such recordings \u2014 check it before deleting if anyone else depends on them.", - "Requires the folder-delete permission (403 otherwise), which is a higher bar than the edit permission most other folder writes need.", - "On success the deleted folder is echoed at `data.folder` with cases_count and total_cases_count forced to 0 \u2014 those zeros are cosmetic, not a report of what was removed. `data.path_folders` is the surviving breadcrumb of the deleted folder's parent, useful for re-pointing a UI.", - "Failure does not use the usual envelope: an upstream rejection is relayed as the raw upstream body with the upstream status code, so do not expect {success:false, message} to be there to parse.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." - ], - "disabled": true - }, - { - "name": "delete_report_schedule", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/reports/schedules/{schedule_id}", - "mode": "destructive", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "schedule_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[query]", - "type": "string", - "description": "Only filters the listing returned AFTER the delete (matches report type, title, or owner name) \u2014 it does not scope what is deleted." - }, - { - "name": "q[scheduled]", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Only the exact string `true` does anything (restricts the returned listing to scheduled reports). `false` or any other value is ignored and returns everything." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the listing returned after the delete. Fixed 30 per page; past the last page returns 400." - } - ], - "intent": "Permanently delete a report \u2014 the whole saved report and its filters, not just its email schedule", - "returns": [ - "success", - "data", - "scheduled_report_count", - "total_report_count", - "info" - ], - "responses": { - "200": { - "$response": "DeleteScheduleResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This deletes the report record itself. Despite the path, there is no way to remove only the schedule: to stop the emails but keep the report, edit it and omit `frequency` / `frequency_details` instead.", - "The delete is unconditional and silent. A `schedule_id` that does not exist, or belongs to another group, still returns 200 with `success: true` \u2014 there is no 404 and no count of what was removed. Confirm by checking that the returned `data` list no longer contains the id.", - "The report is matched on group + id only; the project in the path does not scope it, so an id belonging to a sibling project in the same group IS deleted.", - "The response body is not the deleted report \u2014 it is the CURRENT page of the caller's remaining reports, plus `scheduled_report_count` and `total_report_count`. Treat it as a fresh listing.", - "That embedded listing is paged at a fixed 30 per page, honours `p`, and `p` past the last page returns 400 `page_out_of_bounds`. Do not page it by re-issuing the delete \u2014 call list_schedules instead.", - "Deleting also removes the report's filter rows and mail recipients and propagates a delete to Test Observability; there is no undo and no restore endpoint. Requires the report_delete permission (403 otherwise)." - ], - "disabled": true - }, - { - "name": "delete_shared_step", - "method": "DELETE", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "destructive", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete a shared step from a project permanently \u2014 every test case that embeds the block loses it, in one operation", - "responses": { - "200": { - "$response": "SharedStepDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the widest single delete in the shared-step surface. A shared step is embedded by reference by many test cases at once, so removing it takes that block out of all of them together. The blast radius is not visible from the id \u2014 read the shared step, or find its row in the shared-step listing, and look at `test_case_count`. That number is exactly how many test cases this affects; anything above zero means you are editing that many cases, not deleting an unused fragment.", - "The delete is not blocked by references. A block used by hundreds of cases deletes as readily as an unused one, and nothing warns you.", - "There is no undo: shared steps have no bin, no archive and no version history. Recreating a block with the same title and steps does NOT re-link the test cases that referenced the old one \u2014 their references are gone and each case has to be re-edited by hand. Capture the block's steps (and, if it matters, which cases used it) before deleting.", - "Deleting the block is not the way to stop one test case using it \u2014 edit that test case's steps instead. This call is for retiring a fragment project-wide.", - "Success is 200 with `{success:true}`, not 204 with an empty body. Do not treat a body-less 204 as the success signal.", - "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}` before any call is made \u2014 and the route segment only matches digits, so a non-numeric id never reaches the handler at all.", - "Requires tc_write, the same permission as editing a test case; there is no separate shared-step delete permission, so anyone who can edit cases can do this." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_template", - "method": "DELETE", - "path": "/api/v2/templates/{id}", - "mode": "destructive", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id \u2014 a plain integer with no prefix, discovered from the template list.", - "pattern": "^[0-9]+$" - } - ], - "intent": "Permanently delete a template together with its field and project links \u2014 there is no bin and no undo; disable it instead if you might want it back.", - "guidance": [ - "This removes the template record itself, along with its field mappings and its project mappings. Test Management exposes no restore for a deleted template.", - "If the goal is only to stop people picking the template for new test cases, DISABLE it instead \u2014 disabling is reversible and keeps the fields and project links intact.", - "System templates and the workspace default template cannot be deleted; the attempt comes back as a 400 with the reason.", - "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is deleted \u2014 check the flag, not the status code.", - "Success returns only success:true, with no record of what was removed. Read the template first if you need its name or field list for the record." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TemplateWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "delete_test_case_attachment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments/{attachment_id}/delete", - "mode": "destructive", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "attachment_id", - "type": "string", - "required": true - } - ], - "intent": "Remove one file from a test case and purge the stored blob \u2014 takes a screenshot, log or document off the case for good", - "returns": [ - "data", - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "$response": "TestCaseAttachmentDeleteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "LIKELY BROKEN, and dangerous to retry. It matches the attachment by comparing a stored integer id against the id from the path, which arrives as text, so the match fails and the next line dereferences nothing \u2014 a bare 500. Because the purge is queued before the response, treat a 500 here as an UNKNOWN outcome rather than a failure, and re-read the case before assuming nothing was removed.", - "The response is wrapped in `data`: read data.attachments for the removed record and data.success. This action wraps; the attachment list and upload on the same path do not. Reading a top-level `attachments` finds nothing.", - "Irreversible. The link is removed AND the underlying stored file is scheduled for purge, so the file itself goes, not just the association. There is no undo and no recycle bin for attachments.", - "`attachment_id` must be an id from this case's own attachment listing. The handler looks it up inside the case's in-memory attachment list and does not return a clean 404 when it is absent, so verify the id with a list read first rather than probing.", - "`folder_id` is in the route but is NOT used to find the case \u2014 the lookup is by project and test case id, so a stale folder id after a move still resolves.", - "Note the shape: a POST to .../delete, not a DELETE.", - "The v2 attachment surface is the maintained one for listing and reading attachments; use this only when you specifically need to remove a v1 test-case attachment." - ], - "disabled": true - }, - { - "name": "delete_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", - "mode": "destructive", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Permanently delete a test plan or sub-plan \u2014 there is no recycle bin and no undo; archive it instead if there is any chance it is wanted back", - "responses": { - "200": { - "description": "Plan deleted: `{success: true}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No such plan: `{success: false, error: \"Test Plan not found\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. Plans have no bin and no version history, so a deleted plan cannot be restored by any capability here \u2014 the reversible option is bulk-archive, which keeps the plan and can be undone with bulk-retrieve.", - "The path segment is the plan's INTEGER id. A TP-NNN / STP-NNN identifier does not match the route and never reaches the handler.", - "The only miss signal is 404 {success: false, error: 'Test Plan not found'}; a success is a bare {success: true} with no echo of what was removed. Read the plan first if you need to tell the user what is about to go.", - "Needs the test-plan DELETE permission, which is separate from the write permission used by create/update.", - "Deleting emits an activity-feed event and a personal alert to the plan's owner, so the side effect is visible outside this API.", - "What happens to the plan's sub-plans and to the test runs it held is decided upstream and is not echoed in the response \u2014 read the sub-plans and linked runs first if that matters." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_test_result", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-results/{test_result_id}/delete", - "mode": "destructive", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_result_id", - "type": "integer", - "required": true - } - ], - "intent": "Delete one logged test result by its id \u2014 removes that single execution record while leaving the case's other attempts in the run intact", - "responses": { - "200": { - "description": "Result deleted.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Caller is neither the result's author nor a project admin." - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Authorship-gated: only the user who logged the result may delete it, unless the caller is a project admin. Anyone else gets 403 {success:false, message:\"You can delete only results logged by you, unless you're a project admin\"} and nothing is removed.", - "Irreversible \u2014 there is no recycle bin for results. After the delete the case's outcome in that run rolls back to its previous attempt, or to unexecuted if this was the only result.", - "Note the shape: a POST to .../delete, not a DELETE, and `test_result_id` must be numeric \u2014 the route only matches digits, so a TR-/TC-style identifier 404s at routing.", - "To remove several results use the bulk delete instead: it authorises per id and reports what it skipped, whereas this one refuses the entire call on the first authorisation failure.", - "Success is only {success: true} \u2014 the deleted record is not echoed back, so capture anything you need from it first." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "delete_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/delete", - "mode": "destructive", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "intent": "Permanently delete a test run and every result recorded in it \u2014 there is no bin and no restore", - "returns": [ - "data", - "success", - "testrun", - "project", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "project_id", - "created_at", - "updated_at", - "links" - ], - "responses": { - "200": { - "$response": "TestRunWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Irreversible. A test run has no version history and no recycle bin: the run, its case mappings and every result recorded against them go, and nothing in this API brings them back. Confirm with the user before calling.", - "Deleting a CLOSED run needs a different permission (closed-run delete) from deleting an open one \u2014 a role that can delete working runs can be 403'd on a finished one. Automation runs demand the automation variant again.", - "The run's build in Test Observability is archived FIRST, and a rejection there aborts the delete, so a failure can leave the run intact. Re-read before assuming it is gone.", - "The run is also stripped out of every saved report filter that referenced it; those reports silently narrow rather than erroring.", - "The deleted run is echoed back under `data.testrun` \u2014 one word, no underscore \u2014 with `data.project`. Do NOT read `data.test_run` here; that key does not exist on this response.", - "The path accepts either the TR-NNN identifier or the integer id, but the body key and every downstream reference wants the integer from `uuid`." - ], - "disabled": true - }, - { - "name": "discard_duplicate", - "method": "PUT", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", - "mode": "write", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id, from the `id` of a listing row. Here it is a path segment \u2014 unlike the resolve call, which takes it in the body." - } - ], - "body": [ - { - "name": "discard_reason", - "type": "string", - "description": "Short reason code or label for why this is not a duplicate. Flat in the body (no wrapper object). Optional and unvalidated \u2014 no closed set is enforced, and omitting it stores null." - }, - { - "name": "user_feedback", - "type": "string", - "description": "Free-text feedback. Flat in the body. Stored under a different name (`discard_feedback`) than the one you send, so do not expect it echoed back under this key." - } - ], - "intent": "Dismiss a duplicate suggestion as wrong \u2014 removes it from the review queue and feeds the reason back to the model, without touching either test case", - "responses": { - "200": { - "description": "Duplicate discarded. Body is {success:true} only.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "The caller lacks the test-case write permission (tc_write)." - }, - "404": { - "description": "{success:false, error:'Duplicate not found.'} \u2014 no such pair for this project, or it is no longer in `suggested` status." - }, - "409": { - "description": "{success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} \u2014 a dedupe data migration is in progress. Nothing was discarded; retry later." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the 'not a duplicate' verdict. Neither test case is touched \u2014 nothing is archived, merged, commented on or tagged. Use the resolve call when a case should actually go away.", - "The body is flat: `discard_reason` and `user_feedback` sit at the top level, with no wrapper object and no request-schema check, so a misspelled key is silently dropped and you get 200 with the feedback lost.", - "One-way. The pair moves to `discarded` and leaves every listing; there is no un-discard endpoint and the pair can no longer be read, so confirm the verdict before calling.", - "Suggested-only: a pair already merged, archived or discarded is 404 {success:false, error:'Duplicate not found.'} \u2014 usually 'already handled' rather than 'never existed'.", - "Undeclared until now, and retryable: while a dedupe data migration is in progress this is refused with 409 {success:false, error:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. Nothing was discarded; retry later.", - "Note the envelope differs from the resolve call, which uses `errors` for its failures where this one uses `error`.", - "Needs the test-case write permission (tc_write) \u2014 lighter than the delete permission the resolve call demands." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "download_report", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/download", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv", - "pdf", - "xlsx" - ], - "example": "csv", - "description": "Export format, as a single STRING. Anything outside the three values is 422 `Invalid file_type.`. Note the same-named field on send-email-now is an ARRAY." - }, - { - "name": "required", - "type": "object", - "description": "Optional column selection, honoured only for `Test Run Detailed Report` and `Test Plan Detailed Report`; ignored elsewhere. Shape `{ \"\": { \"fields\": [\"id\",\"title\",...] } }`; `fields` may also be a comma-separated string, and `[\"all\"]` expands to every supported column. Invalid names are NOT rejected here \u2014 the export silently reverts to defaults." - }, - { - "name": "columns", - "type": "object", - "description": "Alias for `required`, and it WINS when both are sent. Same shape and same silent-fallback behaviour." - } - ], - "intent": "Kick off an async export of a report to CSV, PDF or XLSX \u2014 returns a job channel, not the file", - "returns": [ - "channel", - "success" - ], - "responses": { - "200": { - "$response": "DownloadReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This returns immediately with `{ channel: , success: true }` and nothing else. The export runs in a background worker and the finished file's URL is pushed over a realtime channel named from that uuid \u2014 there is NO endpoint to poll it and no way to fetch the artefact through the API. If you need the file itself, use the send-email-now capability and have it mailed, or hand the channel to a UI client.", - "No bytes come back from this call and no download URL appears anywhere in the response \u2014 a `channel` that you cannot resolve is the whole result, so do not report the export as delivered.", - "`file_type` is a single STRING here (`csv` / `pdf` / `xlsx`). The same-named field on send-email-now is an ARRAY \u2014 they are not interchangeable. An unsupported value is 422 `Invalid file_type.`, not 400.", - "Column selection (`required`, or its alias `columns`, which wins when both are sent) is only honoured for `Test Run Detailed Report` and `Test Plan Detailed Report`. On every other report type it is ignored. Shape is `{ \"\": { \"fields\": [...] } }` with entities test_run_test_case / test_run / test_result (and test_plan for the plan report).", - "Unlike the on-screen read, this path does NOT validate your column list: a misspelled field name is not an error here \u2014 the worker silently falls back to the report's default columns, so the export arrives missing what you asked for. Verify field names against the report read first.", - "`fields: [\"all\"]` for an entity expands to every supported column for that entity.", - "The report is matched on group + id, so the project in the path does not scope the lookup; an unknown id is 404 `Report not found.`. A `Test Plan Detailed Report` on a group without the feature flag is 403 `Operation not permitted`. Requires the report_view permission." - ], - "disabled": true - }, - { - "name": "edit_test_case_partial", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit-v2", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append \u2014 any currently-attached id missing from the array is detached. Read the case's existing attachments first and send them back alongside the new ids.", - "json_path": "/test_case/attachments" - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, given as an internal_name string (e.g. `not_automated`, `automated`). Applied only when `automation_state` is omitted.", - "json_path": "/test_case/automation_status" - }, - { - "name": "case_type", - "type": "integer", - "example": 490, - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "123": "option_value", - "456": [ - "multi", - "select" - ] - }, - "json_path": "/test_case/custom_fields", - "description": "Map of custom-field id to value, applied only when the key is present AND non-empty \u2014 `{}` is a no-op here (it is the BULK edit that 500s when the key is missing). Values replace the field's current value." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description" - }, - { - "name": "estimate", - "type": "string", - "description": "ACCEPTED BUT NOT PERSISTED \u2014 passes validation and is then dropped before the write, on this path as well as create. Use `estimated_duration_seconds`; never report an estimate as saved.", - "json_path": "/test_case/estimate" - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS; `null` clears it. This is the field that actually stores an estimate.", - "json_path": "/test_case/estimated_duration_seconds" - }, - { - "name": "expected_result", - "type": "string", - "description": "Overall expected outcome, distinct from a step's own `result`.", - "json_path": "/test_case/expected_result" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Must be `true` for a `test_case_dataset` change to be applied; with any other value the dataset payload is ignored.", - "json_path": "/test_case/is_dataset_modified" - }, - { - "name": "issues", - "type": "array", - "example": [ - { - "issue_id": "TM-1234", - "issue_type": "jira" - } - ], - "description": "Linked issues/defects. Processed ASYNCHRONOUSLY after the response returns, so a 200 does not mean the links exist yet \u2014 re-read the case to confirm.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "name", - "type": "string", - "example": "Verify login functionality", - "description": "The name/title of the test case.", - "json_path": "/test_case/name" - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_case/owner", - "description": "TM user id. Unlike the POST edit path, an id that does not resolve is silently skipped rather than rejected, so the owner is left unchanged with a 200." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "example": 483, - "json_path": "/test_case/priority", - "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state, e.g. `pending` / `in_review` / `approved`. Unlike POST .../edit, omitting it here leaves the stored value untouched.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Honoured only on a Team-Pro workspace with review-approve enabled on the project \u2014 otherwise silently ignored. Including the owner is rejected with 422 'Owner cannot be a reviewer'. This path does NOT run the send-for-review checks or send review emails.", - "json_path": "/test_case/reviewers" - }, - { - "name": "status", - "type": "integer", - "example": 496, - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "steps", - "type": "string", - "description": "LEGACY \u2014 use `test_case_steps` instead. This param skips the request allowlist and is read with JSON.parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200.", - "json_path": "/test_case/steps" - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "smoke" - ], - "description": "Tag names. `[]` removes all tags; omit the field to keep the existing ones.", - "json_path": "/test_case/tags" - }, - { - "name": "template", - "type": "string", - "description": "Template NAME (the same values the create paths accept) \u2014 not an id. Sending an integer is rejected with 422 \"Invalid template value \"; use `template_id` for the numeric custom-template reference.", - "json_path": "/test_case/template" - }, - { - "name": "template_id", - "type": "integer", - "description": "Custom-template id.", - "json_path": "/test_case/template_id" - }, - { - "name": "template_step_type", - "type": "string", - "description": "Takes precedence over `template` when both are sent.", - "json_path": "/test_case/template_step_type" - }, - { - "name": "test_case_dataset", - "type": "object", - "description": "Dataset binding for a data-driven case, as an OBJECT \u2014 the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, read it and send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as returned. It is a full replace of the case's binding, and on this path it is applied only when `is_dataset_modified` is true. The edit answers only {success:true}, so re-read the case (or the dataset's linked-test-cases listing) to confirm the binding landed.", - "fields": [ - { - "name": "variables", - "type": "array" - }, - { - "name": "rows", - "type": "array" - } - ], - "json_path": "/test_case/test_case_dataset" - }, - { - "name": "test_case_folder_id", - "type": "string", - "description": "Move the case to a different folder (integer id). Dropped server-side when it matches the current folder, so passing the existing folder is a safe no-op.", - "json_path": "/test_case/test_case_folder_id" - }, - { - "name": "test_case_steps", - "type": "array", - "example": [ - { - "order": 1, - "step": "Navigate to the login page", - "result": "The login page is displayed" - }, - { - "order": 2, - "step": "Submit valid credentials", - "result": "The user lands on the dashboard" - } - ], - "description": "The structured step list \u2014 same shape as the create body. `order` is filled in by position when omitted. `[]` removes all steps; omit the field to keep them.", - "fields": [ - { - "name": "order", - "type": "integer" - }, - { - "name": "step", - "type": "string" - }, - { - "name": "result", - "type": "string" - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps" - }, - { - "name": "third_party_identifier", - "type": "string", - "json_path": "/test_case/third_party_identifier", - "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." - }, - { - "name": "isMergedDuplicate", - "type": "boolean", - "json_path": "/test_case/isMergedDuplicate", - "description": "Compared to the literal boolean `true`. Tells the backend to keep the case's existing attachments rather than reconciling them against `attachments`. Used by the duplicate-merge flow." - } - ], - "intent": "Change just the fields you name on one test case, leaving everything you omit untouched \u2014 the safe path for an inline single-field edit.", - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TestCaseWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "The response is `{success: true}` and NOTHING ELSE. It does not return the updated case, so re-read the case if you need to confirm or show the new values. (The POST .../edit sibling does return `data.test_case`.)", - "Only the keys present in `test_case` are applied \u2014 this is the partial-update path. The POST .../edit on the same case rebuilds more of the record, including resetting `review_status`.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "`steps` is legacy and is read with a JSON parse, so it must be a JSON-encoded STRING. Sending a real JSON array parses to `[]` and WIPES the case's steps while still returning 200. Use `test_case_steps`.", - "`attachments` is the complete set the case should end up with, not an append \u2014 anything currently attached and missing from the array is detached. `test_case_steps: []` and `tags: []` likewise clear; omit the key to keep what is there.", - "A workflow that forbids the status transition comes back as 422 with `code: \"transition_not_allowed\"` and a `violations` list \u2014 that is a rule rejection, not bad input.", - "This path does not accept `send_for_review` or `shared_precondition_id`, and does not run the review-payload checks; use the POST .../edit path for a send-for-review flow.", - "Every successful edit writes a new version, so an edit is recoverable by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "edit_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/edit", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "attachments", - "type": "array", - "json_path": "/test_case/attachments", - "description": "The COMPLETE set of attachment blob ids the case should end up with, not a list to append \u2014 anything currently attached and missing from the array is detached. Read the case's existing attachments first." - }, - { - "name": "automation_state", - "type": "integer", - "json_path": "/test_case/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Legacy alias for `automation_state`, taken as an internal_name string (e.g. `not_automated`, `automated`). Only applied when `automation_state` is omitted. Prefer `automation_state`.", - "json_path": "/test_case/automation_status" - }, - { - "name": "case_type", - "type": "integer", - "json_path": "/test_case/case_type", - "description": "Numeric case-type option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "custom_fields", - "type": "object", - "json_path": "/test_case/custom_fields", - "description": "Map of custom-field id to value. Values replace the field's current value; a field id the project does not define is rejected by the backend with a field-named 400 message." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description" - }, - { - "name": "estimate", - "type": "string", - "description": "ACCEPTED BUT NOT PERSISTED \u2014 the field passes request validation and is then dropped before the write on both the create and the edit paths. Use `estimated_duration_seconds` instead; do not report an estimate as saved.", - "json_path": "/test_case/estimate" - }, - { - "name": "estimated_duration_seconds", - "type": "integer", - "description": "Per-case estimated execution time in SECONDS. `null` clears it. This is the field that actually persists an estimate.", - "json_path": "/test_case/estimated_duration_seconds" - }, - { - "name": "expected_result", - "type": "string", - "description": "Overall expected outcome (distinct from a step's own `result`). Rich-text field \u2014 send plain text/HTML, not a JSON document.", - "json_path": "/test_case/expected_result" - }, - { - "name": "is_dataset_modified", - "type": "boolean", - "description": "Set with `test_case_dataset` on an edit to signal the dataset changed; on create the mappings are always regenerated and this is ignored.", - "json_path": "/test_case/is_dataset_modified" - }, - { - "name": "issues", - "type": "array", - "json_path": "/test_case/issues", - "description": "Linked issues as `[{issue_id, issue_type, metadata?}]` (bare strings are coerced to ``issue_id``). Processed ASYNCHRONOUSLY after the response, so a 200 does not mean the links exist yet." - }, - { - "name": "name", - "type": "string", - "json_path": "/test_case/name" - }, - { - "name": "owner", - "type": "integer", - "json_path": "/test_case/owner", - "description": "TM user id. An id that is not a member of the group is silently replaced with the caller rather than rejected." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions" - }, - { - "name": "priority", - "type": "integer", - "json_path": "/test_case/priority", - "description": "Numeric priority option id, or the project's display name (a string is resolved by name, an integer by id)." - }, - { - "name": "review_status", - "type": "string", - "description": "Review state, e.g. `pending` / `in_review` / `approved`. When omitted it is set from the project's review-approve setting, falling back to `pending` \u2014 it is never left untouched on create or on POST .../edit.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer TM user ids (emails also resolve). Only honoured when the workspace is on a Team-Pro plan AND the project has review-approve enabled; otherwise silently ignored. Including the `owner` is rejected with 422 \"Owner cannot be a reviewer\".", - "json_path": "/test_case/reviewers" - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "EDIT PATHS ONLY \u2014 drives the per-reviewer review-request email. Dropped without error on create (the create flow already notifies from `reviewers` + `review_status`) and not accepted by PATCH .../edit-v2.", - "json_path": "/test_case/send_for_review" - }, - { - "name": "shared_precondition_id", - "type": "integer", - "description": "Link a shared precondition. Only applied when `preconditions` is sent in the same body.", - "json_path": "/test_case/shared_precondition_id" - }, - { - "name": "status", - "type": "integer", - "json_path": "/test_case/status", - "description": "Numeric status option id. CUSTOM STATUS VALUES ARE FULLY SUPPORTED - send the id and they write correctly. A display NAME IS NOT RESOLVED on this field, and the failure is SILENT AND DESTRUCTIVE: sending a string returns 200 and sets status to NULL, wiping whatever the case had. That happens even for a name that exists - `\"No\"` nulls the field just as `\"Inactive\"` does. Contrast `priority` and `case_type` on the same call, which DO resolve names. Resolve the id first with get_system_field_values (field_name=status); its options are under `status.values[]` with the id in `value`, and custom options are the ones with no `colour`." - }, - { - "name": "tags", - "type": "array", - "json_path": "/test_case/tags" - }, - { - "name": "template", - "type": "string", - "json_path": "/test_case/template", - "description": "Step-shape template NAME, not an id. When neither this nor `template_step_type` is sent it DEFAULTS to `test_case_text` on this path \u2014 which silently discards `test_case_steps`. Always send it on an edit that touches steps." - }, - { - "name": "template_id", - "type": "integer", - "json_path": "/test_case/template_id" - }, - { - "name": "template_step_type", - "type": "string", - "description": "Step shape \u2014 `test_case_steps` | `test_case_text` | `test_case_bdd`. OPTIONAL; when sending `template_id`, use that template's own `step_type` from the templates listing rather than assuming.", - "json_path": "/test_case/template_step_type" - }, - { - "name": "test_case_dataset", - "type": "object", - "description": "Dataset binding for a data-driven case, as an OBJECT \u2014 the same shape the product's editor sends: {variables: [{dataset_uuid, name}], rows: [{row_number, row_data: [..], uuid}]}. To link a library dataset, send one variables entry per column carrying that dataset's uuid as dataset_uuid, plus its rows as the dataset read returns them. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here; on an edit, send `is_dataset_modified: true` alongside it.", - "fields": [ - { - "name": "variables", - "type": "array" - }, - { - "name": "rows", - "type": "array" - } - ], - "json_path": "/test_case/test_case_dataset" - }, - { - "name": "test_case_folder_id", - "type": "string", - "description": "Target folder's integer id. On create this is REQUIRED IN THE BODY as well as in the path \u2014 omitting it is the most common create failure (422 wrapping an upstream 404). Integer or string are equivalent; the server coerces with .to_i, so the distinction does not matter.", - "json_path": "/test_case/test_case_folder_id" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "The ONLY key the server reads for steps \u2014 on single create, bulk create and the update paths alike. Items are `{step, result, test_data?, order?, shared_step_id?}`; `order` is filled in by position when omitted. A differently named key such as `steps` is silently ignored and the create still returns 200 for a case with NO steps, so verify against `test_case_steps` in the response.", - "json_path": "/test_case/test_case_steps" - }, - { - "name": "steps", - "type": "string", - "json_path": "/test_case/steps", - "description": "LEGACY \u2014 use `test_case_steps`. Read with a JSON parse, so it must be a JSON-encoded STRING. A real JSON array parses to `[]` and WIPES the case's steps while still returning 200." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "description": "One-shot re-authentication token, required only when the project has e-signature on send-for-review enabled AND this edit sets `send_for_review` or `review_status: in_review`. Missing or expired is 401 with `error_code: \"reauth_required\"`." - }, - { - "name": "third_party_identifier", - "type": "string", - "json_path": "/test_case/third_party_identifier", - "description": "External identifier, only read in workspaces that issue project-level ids. A value already used in the project is rejected." - }, - { - "name": "isMergedDuplicate", - "type": "boolean", - "json_path": "/test_case/isMergedDuplicate", - "description": "Compared to the literal boolean `true`. Keeps the case's existing attachments instead of reconciling them against `attachments`. Used by the duplicate-merge flow." - } - ], - "intent": "Edit one test case through the full edit form \u2014 rename it, rewrite its steps, preconditions and expected result, retag it, or send it for review.", - "returns": [ - "success", - "data", - "test_case" - ], - "responses": { - "200": { - "$response": "V1TestCaseWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "STATUS TAKES AN ID, NOT A NAME - and a name silently nulls it. Custom status ids write and read back correctly, while the string form returns 200 and leaves status null. `priority` and `case_type` accept either on this same call, which makes status easy to get wrong. If a custom state 'is not valid', check you sent the id.", - "This is the FULL edit path, not a patch: `review_status` is recomputed on every call (omitted, it falls back to the project's review-approve default), and `template` defaults to `test_case_text` when you omit it, which discards any `test_case_steps` you sent. Send the case's current template unless you mean to change it.", - "For a targeted single-field change prefer the PATCH .../edit-v2 sibling, which applies only the keys you send.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "Array fields REPLACE rather than append: `attachments`, `tags` and `test_case_steps` are each the complete intended set, and `[]` clears. Read the case first and send the union.", - "Reviewers and the owner cannot overlap: this path rejects it up front with 400 `code: \"OWNER_AS_REVIEWER\"`, and sending `send_for_review` with no reviewers is 400 `code: \"NO_REVIEWERS\"`. (Create, which skips that check, surfaces the same conflict as a 422.)", - "If the project has e-signature enabled, an edit that sets `send_for_review` or `review_status: in_review` needs `test_case.reauth_token`; without it the answer is 401 `{success:false, error_code:\"reauth_required\"}`.", - "The updated case comes back at `data.test_case` \u2014 note the `data` envelope, and note that `estimate` always serialises as null regardless of what you sent.", - "Every successful edit writes a new version, so this is recoverable by restoring the previous version \u2014 subject to the workspace's plan, which may only keep 30 days of history." - ] - }, - { - "name": "edit_test_run", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "body": [ - { - "name": "run_state", - "type": "string", - "required": true, - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "description": "MANDATORY on every edit, even one that is not changing the state \u2014 resend the run's current value. It is validated against the enum before anything else, so a missing or misspelled value returns 400 {success:false, message:'invalid data'} with no hint about which field was wrong. `closed` is one-way: a closed run cannot be reopened, and this call does not do it. The full edit: on a closed run it returns success and leaves the run closed.", - "json_path": "/test_run/run_state" - }, - { - "name": "name", - "type": "string", - "example": "Regression - Login", - "description": "Only applied when non-empty. Sending \"\" leaves the existing name rather than clearing it.", - "json_path": "/test_run/name" - }, - { - "name": "description", - "type": "string", - "example": "", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent. Unlike name, an empty string here DOES clear the description.", - "json_path": "/test_run/description" - }, - { - "name": "owner", - "type": "integer", - "example": 2, - "description": "Run owner as a TM user id (a BrowserStack user id is also accepted and resolved). An id that matches nobody in the group is silently treated as unassigned \u2014 the run's owner is cleared rather than the call failing.", - "json_path": "/test_run/owner" - }, - { - "name": "is_dynamic", - "type": "boolean", - "example": false, - "description": "Keep the run's membership live against `filters`. On EDIT a TOP-LEVEL `is_dynamic` is also read and OVERRIDES this one; on create only the nested form exists. Send it in one place only. Omit the key entirely to leave the run's current dynamic flag alone \u2014 sending false on a full resend flips a dynamic run back to static.", - "json_path": "/test_run/is_dynamic" - }, - { - "name": "configurations", - "type": "array", - "example": [], - "description": "Configuration ids (integers) the run executes against \u2014 not the configuration objects returned on read.", - "json_path": "/test_run/configurations" - }, - { - "name": "test_plans", - "type": "array", - "example": [], - "description": "Test plan ids. Only the FIRST entry is used; a run belongs to at most one plan. Send the plan's integer id.", - "json_path": "/test_run/test_plans" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login" - ], - "description": "Plain tag strings. The list you send REPLACES the run's tags \u2014 omit the key to leave them alone, since there is no history to restore a cleared list from.", - "json_path": "/test_run/tags" - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "description": "Linked defects. Each entry is an object; a bare string is coerced to {issue_id: ...} and loses the tracker type. Linking kicks off an asynchronous fetch of the ticket metadata, so titles appear on a later read.", - "json_path": "/test_run/issues" - }, - { - "name": "environment", - "type": "string", - "json_path": "/test_run/environment" - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment blob ids to attach to the run. Sending [] to remove every attachment only works where the clear-all behaviour is enabled for the group; elsewhere an empty array is a no-op. An id that does not verify fails the whole edit with 422 'attachment is invalid'.", - "json_path": "/test_run/attachments" - }, - { - "name": "metadata", - "type": "object", - "description": "Free-form metadata. Only forwarded when non-empty; sending {} does not clear it.", - "json_path": "/test_run/metadata" - }, - { - "name": "build_group_id", - "type": "integer", - "description": "Only forwarded when present; there is no way to unset it through this call.", - "json_path": "/test_run/build_group_id" - }, - { - "name": "filters", - "type": "object", - "example": { - "priority": [ - "High" - ], - "folder_ids": [ - 105 - ] - }, - "fields": [ - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "automation_status", - "type": "array" - }, - { - "name": "automation_state", - "type": "array" - }, - { - "name": "folder_ids", - "type": "array" - }, - { - "name": "issue_ids", - "type": "array" - }, - { - "name": "reviewers", - "type": "array" - }, - { - "name": "review_status", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "description": "Forward-sync rule for a DYNAMIC run, at the TOP level of the body \u2014 NOT inside `test_run`. It selects nothing and never back-fills, so an edit whose only case-bearing key is `filters` returns 200 having added none. To add cases use bulk_update_test_run_test_cases with add_test_cases \u2014 test_case_ids and selection here replace the run's membership rather than adding to it. Every `owner`/`reviewers` entry must resolve to a user in this group or the edit 400s. Labels are resolved to ids for you." - }, - { - "name": "dynamic_filter", - "type": "object", - "example": { - "owner": [], - "status": [], - "priority": [] - }, - "description": "Backward-compatible alias of `filters`, same top-level position and same semantics. `filters` wins when both are sent." - }, - { - "name": "test_case_ids", - "type": "array", - "example": [ - 2336 - ], - "description": "Test case ids \u2014 but NOT added to the run: together with `selection` this becomes the run's COMPLETE case list, so any case already in the run and missing from what you send is REMOVED, along with its results and assignee, and the call still succeeds. To add cases, use bulk_update_test_run_test_cases with add_test_cases, which leaves existing cases untouched. Top level, not inside `test_run`. Must be an array \u2014 a scalar is rejected." - }, - { - "name": "auto_assign", - "type": "boolean", - "description": "Top level. Forwarded to the backing service to auto-assign the newly added cases." - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cases shared in from other projects, as a map of project id to a selection object with the same inner shape as `selection`. Top level." - }, - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Make every case in the project the run's case list. Like the rest of the selection this is not additive \u2014 it replaces the run's membership, so it only adds cases in the sense that the whole project ends up in the run. To add specific cases, use bulk_update_test_run_test_cases with add_test_cases.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 83, - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "trtc_configuration_mappings", - "type": "array", - "description": "Per-configuration case mappings, at the TOP level of the body. An ARRAY of objects.", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - }, - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "linked_test_cases", - "type": "array" - }, - { - "name": "unlinked_test_cases", - "type": "array" - } - ] - } - ], - "intent": "Rewrite a test run through the full edit form \u2014 rename it, move its state on, change owner, tags, configurations or linked plan. NOT the way to add cases: the case selection you send becomes the run's entire membership, so add cases with bulk_update_test_run_test_cases (add_test_cases) instead", - "returns": [ - "data", - "success", - "testrun", - "project", - "skipped_test_case_ids", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "attachments", - "metadata", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "project_id", - "created_at", - "updated_at", - "created_by", - "updated_by", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunWriteResponse__EditTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send every field flat, including the case-selection keys. Each field's json_path decides where it lands \u2014 the run's own fields go inside a `test_run` object and the selection keys (test_case_ids, selection, shared_selection, filters, dynamic_filter, trtc_configuration_mappings, auto_assign, is_dynamic) stay beside it at the top level. Building either level yourself is rejected before the call as an unknown body key.", - "run_state is mandatory on every call, including edits that only rename the run. It is validated first, so a bare 400 'invalid data' almost always means a missing or misspelled run_state \u2014 not a bad selection.", - "NOT ADDITIVE: the case selection you send (test_case_ids + selection) REPLACES the run's membership, and run fields you omit \u2014 the owner among them \u2014 are cleared. The call still returns success. ALWAYS call get_test_run_edit_selection first and resend what it returns \u2014 `selection`, `shared_selection`, `trtc_configuration_mappings` and every run field \u2014 changing only what you mean to change, after checking the stored selection still matches the run's current cases (it is not updated by the add-only and remove calls, so a stale one re-adds removed cases); that read-then-resend is how the product UI edits a run without losing anything. To only add cases, use bulk_update_test_run_test_cases with add_test_cases instead, which adds without disturbing existing cases, their row ids or assignees. Results on removed cases cannot be restored.", - "There is no undo. A test run keeps no version history, so an overwritten name, a replaced tag list or a cleared description cannot be restored \u2014 confirm destructive-looking edits with the user first.", - "Changing which cases the run covers is queued: the call returns before the new cases are attached, and an immediate re-read of the run's cases can be short. Re-read rather than retrying, which can double-add.", - "`filters` is a sync rule, not a selection. An edit carrying only `filters` returns 200 and adds nothing, while quietly marking the run dynamic if you also sent is_dynamic.", - "is_dynamic is read from BOTH the top level and inside `test_run`, and the top-level one wins on this endpoint. Pick one place. Omitting it entirely is the only way to leave the run's current mode untouched.", - "An owner id that matches nobody in the group unassigns the run instead of erroring \u2014 but an unresolvable user inside `filters.owner` or `filters.reviewers` hard-fails the whole edit with 400 'invalid data'. The two behave oppositely.", - "The updated run comes back under `data.testrun` \u2014 one word, no underscore \u2014 with `data.project`, plus a TOP-LEVEL `skipped_test_case_ids` array listing cases the review gate refused to add. Check it: those cases are silently missing from the run.", - "Backing-service client errors are returned as `{data: {success: false, error: }}` with the 4xx status, so read `data.success` before reading `data.testrun`.", - "Needs the test-run write permission, with the automation variant demanded on automation runs." - ] - }, - { - "name": "get_test_run_edit_selection", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/edit/selection", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Either the TR-NNN identifier or the bare integer \u2014 the v1 run routes accept both forms in the path." - } - ], - "intent": "Read a test run's stored case selection and editable fields \u2014 the exact state to resend when editing the run through the full edit form, so the edit does not drop cases, configurations or the owner", - "returns": [ - "data", - "success", - "selection", - "shared_selection", - "test_run", - "dynamic_filter", - "filters", - "trtc_configuration_mappings" - ], - "responses": { - "200": { - "$response": "TestRunEditSelectionResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read this before every edit_test_run call. The full edit replaces whatever it is not sent, so resend `data.selection` as `selection`, `data.shared_selection` as `shared_selection` and `data.trtc_configuration_mappings` as `trtc_configuration_mappings` unchanged, together with every field of `data.test_run` \u2014 name, description, run_state, owner, is_dynamic, configurations, tags, issues and test_plans.", - "`configurations` and `test_plans` come back on `data.test_run` as objects; the edit takes plain integer ids, so map each to its `id` before resending. Tags are sent as their plain strings.", - "`selection` carries a top-level `select_all`, a `unique_test_case_count` and `folders`. `selection.folders` is keyed by folder id AS A STRING. In each entry `test_case_select_all: true` means every case in that folder \u2014 including cases added to it later \u2014 with `deselected_test_cases` as the exceptions; `false` means only the ids in `selected_test_cases`. Do not rebuild this from the run's case listing: whether a folder was stored as all-or-some exists only here, and getting it wrong adds or drops cases.", - "To add cases through the edit, merge the new case ids into `selection.folders[\"0\"].selected_test_cases` (create that entry with test_case_select_all: false and empty lists if it is absent) \u2014 the product resolves each case's real folder. When the goal is only to add cases, bulk_update_test_run_test_cases with add_test_cases is simpler and cannot remove anything.", - "`selection` can be `{}`, and a run with no configurations still returns one default entry in `trtc_configuration_mappings` \u2014 {configuration_id: null, select_all: true, linked_test_cases: [], unlinked_test_cases: []}. Resend both as returned rather than omitting them.", - "`dynamic_filter` and `filters` carry the same rule twice. For a dynamic run, resend it under both keys plus a top-level `is_dynamic`; for a static run they are null and should be left out.", - "The stored selection is NOT kept in step with bulk_update_test_run_test_cases or update_test_run: cases removed or added that way can still be listed here, or be missing, and a folder can still be stored as select-all. Before resending it, compare `selection.unique_test_case_count` with `test_run.test_cases_count` and the run's case listing; when they disagree, resending as-is re-adds removed cases and pulls in whole folders \u2014 rebuild the folders entries from the run's current cases, or avoid the full edit.", - "Everything is under `data`, including `success` \u2014 there is no top-level `success` on this response. Closed runs can be read.", - "A read only: it changes nothing and needs no permission beyond access to the project." - ] - }, - { - "name": "export_dashboard_analytics", - "method": "POST", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/download", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "csv" - ], - "example": "csv", - "json_path": "/q/type", - "description": "Export format. Only the literal 'csv' is accepted \u2014 any other value is dropped and the export record is created with no file type, while the worker still writes CSV. Must be nested under `q`; a top-level `type` is never read." - }, - { - "name": "created_at", - "type": "string", - "example": "3_month", - "json_path": "/q/created_at", - "description": "Relative window on creation date, _[_gte]. 'all_time' is honoured here and applies no bound. Send this explicitly whenever you send any other filter." - }, - { - "name": "closed_at", - "type": "string", - "example": "3_month", - "json_path": "/q/closed_at", - "description": "Relative window on close date, same token format. Only read when created_at is absent or 'all_time'. 'all_time' is NOT honoured on this key \u2014 it becomes 1_year." - }, - { - "name": "owner", - "type": "string", - "example": "3700,3701", - "json_path": "/q/owner", - "description": "Comma-separated TM user ids. Unresolvable ids are dropped silently; if none resolve the filter is omitted rather than erroring." - } - ], - "intent": "Queue a CSV export of the whole project dashboard (active + closed runs, issue counts, case-type split, automation stats) and get back an export id \u2014 the file itself is delivered out of band", - "returns": [ - "success", - "export_id" - ], - "responses": { - "200": { - "$response": "DashboardExportInitiatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The inputs are the same q[...] filter object the dashboard reads take, NOT flat body fields. The handler reads params[:q]; a top-level {\"type\":\"csv\"} is ignored and the export runs unfiltered. On this POST the object may be sent in the JSON body as {\"q\": {...}} or as a query string \u2014 both land in the same place.", - "CSV is the only format. Anything other than the literal 'csv' is discarded by the handler, and the worker writes a CSV regardless \u2014 there is no PDF or Excel export behind this route despite what the format name suggests.", - "You cannot collect the file through this API. The worker uploads the CSV to S3 and pushes the URL over a websocket channel addressed to the requesting user; it never attaches the file to the export record and never advances the export's status past 'pending'. The generic export status/download endpoints therefore report pending forever and 404 on download for this export type, and neither is exposed as a capability here. Treat the returned export_id as a correlation token, not as something you can redeem.", - "Date filtering follows the dashboard rules: q[created_at] wins over q[closed_at], and sending any q key without an explicit q[created_at] defaults both to 'all_time', which on the closed_at path becomes the last 1 year \u2014 so an owner-only filter silently produces a 12-month export.", - "The response is immediate and says nothing about whether the export succeeded; a failure happens later in the background and is only signalled on the websocket.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API." - ] - }, - { - "name": "get_active_test_runs_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/active-test-runs-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart the result-status breakdown across the project's currently active test runs \u2014 how many results are passed / failed / blocked / untested right now", - "shape": "discovered", - "responses": { - "200": { - "$response": "ActiveTestRunsInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The payload is an OBJECT with a `data` array, not a bare array: read `data`, not the response root.", - "When more than six result statuses exist, the response is collapsed: only the top six by count survive and everything else is merged into a single row named 'Other'. That 'Other' row has NO `result_status` object \u2014 it carries a `links.filter` URL instead \u2014 so code that reads row.result_status.id must tolerate its absence.", - "Counts are results across active runs, not runs and not test cases. There is no drill-down here; to see the runs themselves use the test-run listings.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ], - "returns": [ - "data", - "name", - "y", - "result_status", - "links" - ] - }, - { - "name": "list_archived_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/archived_test_cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List the test cases that have been archived in a project \u2014 the source of the ids the bulk-retrieve (un-archive) call needs.", - "returns": [ - "success", - "test_cases", - "info", - "cases_count" - ], - "responses": { - "200": { - "$response": "ArchivedTestCasesListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-gated, and it fails OPEN-LOOKING: on a workspace that is not a paid plan this returns HTTP 200 with `{success:false, error:\"...\"}` and no test cases. Check `success`, not the status code, before concluding the archive is empty.", - "Project-wide only \u2014 there is no folder scoping on this route. Each row carries its original folder path so you can tell where a case will be restored to.", - "Take the integer `id` of each row: that is what bulk-retrieve takes to un-archive them.", - "Paged with `p`; there is no per_page on this route, so walk pages until `info.next` is null.", - "`cases_count` in the response is keyed by folder id, and on this route that key is null because no folder was scoped \u2014 read `info.count` for the total instead." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no per_page here; the page size is the server default." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort token (`-ASC|DESC`). `case_type` is translated to the backend's `category` for you." - } - ], - "paginated": true - }, - { - "name": "get_archived_test_plan_count", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/archive_count", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "How many test plans in this project are archived \u2014 the badge number for the archived view; use the archived-plans listing when you need the plans themselves", - "shape": "discovered", - "responses": { - "200": { - "description": "Archived plan count: `{success, count}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "count": { - "type": "integer" + }, + "SearchFieldValue": { + "type": "object", + "description": "The field-value descriptor as returned by the two SEARCH routes, which additionally embed the per-project binding rows.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "array", + "description": "Per-project binding rows for this value. Returned ONLY by the two search routes (search_project_entities, search_project_entities_by_filter); absent from the detail, list, folder-list and run-mapping routes.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "custom_values_id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "dataset_id": { + "type": "integer", + "nullable": true + } + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" + }, + "ResultStatusFieldValue": { + "type": "object", + "description": "The field-value descriptor as returned for a RESULT status. This serializer names the label `field_value`; every other consumer names it `name`. Both are present and they agree.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_value": { + "type": "string", + "example": "Passed" + } } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a fixed literal route on the project's plan collection, not a plan-scoped read \u2014 it takes no plan id and no filters. Counting archived plans is all it does.", - "Counts top-level archived plans only, matching the archived listing; archived sub-plans are not included, so the number can look low against what a user expects.", - "Gated on the same group archive flag as archive/restore: when it is off the call is 404 {success: false, errors: 'Feature not available'} \u2014 a feature gap, not an empty project.", - "Needs the test-plan DELETE permission, not view. Someone who can read plans normally may still be 403'd here.", - "The read is forced onto the primary store, so the number is correct immediately after an archive or restore rather than lagging behind it.", - "A count the upstream page info does not carry comes back as 0 rather than an error, so 0 can mean 'none archived' or 'upstream returned no paging info'." - ], - "returns": [ - "success", - "count" - ] - }, - { - "name": "get_automation_stats", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/automation-stats", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Get the project's automation coverage \u2014 what percentage of its test cases are automated, with the automated / manual / total counts behind it", - "returns": [ - "success", - "automated_coverage", - "automated_test_cases", - "manual_test_cases", - "total_test_cases", - "empty_data" - ], - "responses": { - "200": { - "$response": "AutomationStats" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the one dashboard chart assembled in this service rather than passed through, so the field names below are exact: `automated_coverage` is a percentage (0\u2013100, a float), and the three counts are test cases.", - "`manual_test_cases` is derived as total minus automated, so every automation state that is not 'automated' \u2014 including 'in progress' or unset \u2014 is counted as manual.", - "When the project has no test cases, `empty_data` is true and `automated_coverage` is computed from a zero denominator; treat empty_data:true as 'no data' and ignore the coverage number entirely rather than rendering it.", - "Returns counts only \u2014 there is no list of which cases are automated. Use the test case search with an automation_state filter for that.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_closed_test_runs", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/closed", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List a project's finished (closed) test runs \u2014 the ones the default run listing leaves out \u2014 to report on completed cycles", - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "build_meta", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListClosedTestRuns" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no page-size parameter here: the page is fixed at 30 rows, so walk `info` rather than asking for more." - } - ], - "guidance": [ - "This is the counterpart to the main run listing, which hides closed runs. Neither one alone is 'all our runs' \u2014 answering that question means reading both.", - "Page size is fixed at 30 and cannot be raised; only `p` moves. Stop when `info.next` is null.", - "Each run's `id` is the TR-NNN STRING and the integer lives in `uuid`. Carry `uuid` anywhere a numeric run id is needed \u2014 passing the string through reaches the backing service uncoerced and fails there.", - "The response is flat: {success, test_runs, info, count} with no `data` envelope, unlike the single-run write endpoints on this entity.", - "`count` is an object of per-state totals for the whole project, not the size of this page \u2014 the page size is in `info`.", - "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent. Do not read a zeroed `overall_progress` as 'nothing was executed'." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_closed_test_runs_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how many test runs were closed per month over the selected window \u2014 the project's throughput trend", - "returns": [ - "data", - "empty_data" - ], - "responses": { - "200": { - "$response": "ClosedTestRunsInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Reads the CLOSE date, so q[closed_at] is the filter that matters here, not q[created_at]. Sending q[created_at] shadows q[closed_at] entirely (created_at wins) and you get a creation-window chart instead.", - "Each entry in `data` is a positional pair \u2014 period label first, count second \u2014 not a {month, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", - "`empty_data` is the reliable 'nothing to plot' signal \u2014 the series is padded, so a non-empty `data` array does not mean there were any closed runs.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_closed_test_runs_split", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/closed-test-runs-split", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart closed test runs per month split by result status \u2014 a stacked view of how each month's closed runs finished", - "returns": [ - "data", - "date_list", - "empty_data", - "name", - "result_status" - ], - "responses": { - "200": { - "$response": "ClosedTestRunsSplit" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each entry in `data` is one status series: `name` plus a `data` array of per-period counts aligned positionally with the top-level `date_list`. The Nth number in a series belongs to the Nth label in date_list; there is no month key on the numbers themselves.", - "Filters on close date \u2014 q[closed_at] is the meaningful key, and q[created_at] shadows it when both are sent.", - "This route caches for six hours for one specific large project, so a freshly closed run may not appear immediately there.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_configurations", - "method": "GET", - "path": "/api/v1/projects/{project_id}/configurations", - "mode": "read", - "entity": "configuration", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "List the configurations a project's test runs can execute against \u2014 search by name or fetch specific ids, to resolve the configuration_id a run needs", - "returns": [ - "success", - "configurations", - "info", - "id", - "name", - "record_status", - "is_suggested", - "group_id", - "created_at" - ], - "responses": { - "200": { - "description": "Successfully retrieved configurations", - "schema": { - "$schema": "ConfigurationListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search. `name` is an accepted alias and `q` WINS when both are sent." - }, - { - "name": "name", - "type": "string", - "description": "Alias for `q`. Only used when `q` is absent." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `page` is an accepted alias and `p` WINS. Only `p` is validated \u2014 a non-positive or non-numeric `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`, while a bad `page` is forwarded as-is." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page. `count` is an accepted alias and `count` WINS when both are sent. Forwarded unchanged to the backing service, which owns the real ceiling \u2014 nothing bounds it here." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "description": "Alias for `per_page`, and the one that wins when both are sent." - }, - { - "name": "ids", - "type": "string", - "example": "101,102,103", - "description": "Comma-separated configuration ids to fetch, as a STRING \u2014 not a JSON array. Split on commas before being forwarded." - }, - { - "name": "type", - "type": "string", - "values": [ - "system", - "custom", - "all" - ], - "description": "Configuration source filter, validated locally: anything else returns 400 `{success:false, error:\"Invalid type parameter. Must be one of: system, custom, all\"}`." - }, - { - "name": "custom", - "type": "boolean", - "default": false, - "description": "CHANGES THE RESPONSE KEY. When 'true' the rows come back under `custom_configurations` instead of `configurations`; everything else about the response is identical. Code that always reads `configurations` gets nothing back." - }, - { - "name": "is_suggested", - "type": "boolean", - "description": "Forwarded unchanged to the backing service to filter on whether the configuration is a suggested/default one. Ignored on the `custom=true` branch." - } - ], - "guidance": [ - "The `custom=true` branch renames the array: rows arrive under `custom_configurations`, not `configurations`. Read the key that matches the request you made \u2014 this is the easiest way to get an apparently empty result from a call that worked.", - "Group-scoped, not project-scoped: the list is the workspace's configurations and `project_id` only matches the route. The same set comes back for every project in the group.", - "`ids` is a comma-separated string, not an array; sending `ids[]=101&ids[]=102` does not filter.", - "Alias pairs and which one wins: q beats name, p beats page, count beats per_page. Only `p` is validated, so a typo'd `page` is forwarded and ignored rather than rejected.", - "Rows carry os, os_version, device, browser, browser_version and is_valid_on_bstack in addition to id/name/group_id/record_status/is_suggested \u2014 more than the Configuration schema in this index describes. `is_valid_on_bstack` is what tells you the combination is still runnable on BrowserStack.", - "Carry `id` from the row: the run APIs take configuration ids, not names or objects.", - "Paged via `info` {page, page_size, count, prev, next}; stop when `next` is null.", - "Requires test_run_view \u2014 configurations are gated on the test-run permissions, not the test-case ones." - ], - "paginated": true - }, - { - "name": "get_custom_field_dataset", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id, from the field read or from the create-dataset response. 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Read one dataset of a custom field \u2014 which projects it covers and, on request, the option values it holds", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Options are NOT returned by default. Pass fetch_options=true or you get project links only and will wrongly conclude the dataset has no values.", - "There is no `info` in this response even though the linked-projects list is paged: `p` is honoured, the page size is fixed at 30, and you get no total and no next-page marker. Use `linked_project_count` on the dataset to tell whether more pages exist.", - "`linked_to_all_projects` is computed here by comparing linked_project_count against the workspace project count \u2014 it is derived per request, not stored.", - "Both path ids must be positive integers; 0 in either position is a 400 'Invalid Params'. A well-formed id that does not exist is a bare 404 {success:false} with no message.", - "Nothing checks that the dataset actually belongs to the field in the path before the upstream call \u2014 a mismatched pair surfaces as that same bodyless 404." - ], - "query": [ - { - "name": "fetch_options", - "type": "boolean", - "default": false, - "description": "Include the dataset's option values in the response. Omit it and `options` is absent entirely \u2014 which reads identically to a dataset that has none." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the dataset's linked projects. Page size is fixed at 30 and `per_page` is ignored; no page metadata is returned." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects", - "options" - ] - }, - { - "name": "get_custom_field_definition", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. The route matches digits only; 0 is rejected as 'Invalid Params'." - } - ], - "intent": "Read one custom field's stored definition together with its datasets and the projects each dataset is linked to \u2014 the call to make before any edit, since edits here are full replaces", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this to build an update body: the edit call requires field_name, field_type and is_required together and clears place_holder_text and default_value when they are omitted.", - "`entity_type` is NOT in this response. If you need to know whether the field belongs to TestCase, TestResult, TestPlan or ExploratorySession, take it from the workspace field list instead.", - "The response carries datasets with their project links but NOT their option values. Read a dataset's options through the dataset options list.", - "Paging is per dataset row and is controlled only by `p`; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata.", - "`linked_to_all_projects` on a dataset is computed by comparing its linked project count against the workspace project count \u2014 it is derived, not a stored flag.", - "A field id that does not exist comes back as a bare 404 {success:false} with no message; id 0 is a 400 'Invalid Params'." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the field's datasets. The only pagination control \u2014 `per_page` is read but discarded, the page size is always 30." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "info", - "id", - "field_name", - "field_type", - "system_name", - "is_required", - "place_holder_text", - "is_bulk_editable", - "is_filterable", - "default_value", - "datasets", - "levels", - "type" - ] - }, - { - "name": "get_custom_field_values", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route also matches the literal 'new', which returns an empty 200 rather than an error. PR-NNN identifiers do not match the route." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-case", - "test-result", - "test-plan", - "exploratory-session" - ], - "description": "Hyphenated, lower-case entity segment. Anything outside this set fails the route constraint and 404s. It must match the field's own entity type or the lookup returns nothing." - }, - { - "name": "field_id", - "type": "string", - "required": true, - "example": "4821", - "description": "Numeric custom field id, from the workspace field list or the project's form fields. Whatever you send here becomes the top-level key of the response, verbatim." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Substring search over the option labels (or user names for a `user` field). Forwarded upstream as the value filter." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, forwarded unchanged. There is no per-page control on this endpoint \u2014 the backing service owns the page size; read it back from `info`." - }, - { - "name": "parent_option_id", - "type": "integer", - "description": "nested_dropdown only: return the child options of this parent instead of the level's own values. Get the parent id from a previous call on the parent level." - } - ], - "intent": "List the values one custom field offers inside a given project \u2014 the dropdown choices, or the users for a user field \u2014 so you can pick a valid value before writing it onto a test case or result", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "CustomFieldValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - } - }, - "guidance": [ - "This is the project-scoped value lookup, not a definition read. Use it to turn a value a human named into something you can send; the field's definition, type and project scope live in the workspace field list.", - "The response has no fixed top-level key: the payload sits under the field id exactly as you spelled it in the path (`{\"4821\": {field_type, values}, info, success}`). Read it back by that same string, not by a constant name.", - "`field_type` comes back in the LEGACY spelling \u2014 field_dropdown, field_multi_dropdown, field_string, field_user, field_boolean \u2026 \u2014 not the canonical vocabulary the admin write calls take. Translate before feeding it anywhere else.", - "A nested_dropdown field has no legacy spelling, so its `field_type` comes back null. Do not treat that as 'field not found'.", - "For a nested_dropdown, pass `parent_option_id` to get the children of one parent; without it you get the level's own values.", - "A field that does not exist, or any upstream failure, returns 400 {success:false, error:'Something went wrong'} \u2014 there is no 404 here and no way to tell a bad field id from a backend outage.", - "A project_id the route accepts but which is not numeric (the literal 'new') returns 200 with field_type null, values null and info null \u2014 an empty success, not an error.", - "The entity_type segment is hyphenated and lower-case (test-case / test-result / test-plan / exploratory-session); anything else does not match the route and 404s before reaching the API.", - "Boolean fields have their values converted to real true/false before rendering; every other type returns them as stored." - ], - "returns": [ - "success", - "info", - "field_type", - "values" - ] - }, - { - "name": "get_dataset", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits, so a PR-NNN identifier never reaches the API." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string, as returned by the list or create calls. There is no integer-id form of this route." - } - ], - "intent": "Read one dataset in full \u2014 its columns, every row of data, tags and metadata \u2014 by dataset uuid", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "created_at", - "created_by" - ], - "responses": { - "200": { - "description": "The dataset with its full column and row contents.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__GetDataset" + }, + "PathFolder": { + "type": "object", + "description": "A folder as it appears in a breadcrumb list (`path_folders`, `destination_path_folders`). The shared folder record plus the row's own audit and lineage fields, which the folder LISTING routes do not return.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13482" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:31:46.000Z" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" - }, - "422": { - "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan. Entitlement, not payload." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the only dataset call that returns the actual data: the list endpoint returns counts and metadata but no `variables` or `rows`.", - "Read this before any update \u2014 update is a full replace with no history, so the response here is the only copy of the prior contents you will get.", - "`rows_count` is computed from the rows in this payload, so it always agrees with `rows.length`.", - "`created_by` is resolved against users in the caller's group; a creator who has left resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", - "An unknown uuid is 404 {success:false, message:'Dataset not found.'} \u2014 note the doubled space in the message if you are matching on it.", - "422 {message:'Feature not available'} means the group lacks the Team Pro plan. That is an entitlement answer, not a bad request; do not retry.", - "The dataset is at the top level as `dataset`; there is no `data` envelope." - ] - }, - { - "name": "list_dataset_linked_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}/test-cases", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The dataset's uuid string, from the dataset list or a create response." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, forwarded as-is to the backing store. A non-numeric or non-positive value is rejected globally with 400 {success:false, error:'Invalid Params'}. No page-size parameter is exposed." - } - ], - "intent": "List the test cases bound to a dataset \u2014 check what a dataset drives before you edit or delete it", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "A page of linked test cases. Note there is no `success` key in this envelope.", - "schema": { - "type": "object", - "required": [ - "test_cases", - "info" - ], - "properties": { - "test_cases": { - "type": "array", - "description": "Test cases linked to the dataset, forwarded from the backing store; the per-row field set is not shaped by this service.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" + }, + "V1TestRunWriteRow": { + "type": "object", + "description": "The run as echoed by a run WRITE and by the run-cases read: the shared run record plus the build and sharing fields the run list and single-run detail routes do not return.\n", + "allOf": [ + { + "$schema": "TestRun" + } + ], + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" + }, + "build_serial_id": { + "type": "integer", + "example": 1 + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true + } } - } - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Either 'Dataset not found.' (no such dataset) or 'No test cases found for dataset .' (the backing store returned nothing)." - }, - "422": { - "description": "`Feature not available` \u2014 the group lacks the Team Pro plan. Not a payload error; do not retry with a different body." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Run this before deleting or rewriting a dataset \u2014 nothing else warns you that test cases depend on it, and the delete gives no preview.", - "This response has NO `success` key, unlike every other dataset endpoint: it is exactly {test_cases, info}. Do not branch on `success` here.", - "Two different 404s: an unknown dataset uuid fails in the lookup with 'Dataset not found.', while a dataset the backing store returns nothing at all for fails with 'No test cases found for dataset .'. Neither distinguishes 'exists but has no linked cases' reliably \u2014 treat a 404 as 'nothing to read', not as proof the dataset is gone.", - "The test-case rows are passed through from the backing store unchanged; their field set is not shaped by this service and is not declared here.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint." - ], - "returns": [ - "test_cases", - "info" - ] - }, - { - "name": "get_dataset_variables", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets/variables", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[name]", - "type": "string", - "description": "Partial, case-insensitive match on the column name. This is the search this endpoint exists for." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}. There is no page-size parameter; read `info.page_size` for what you got." - } - ], - "intent": "List every dataset column defined anywhere in the project, each with the dataset it belongs to \u2014 use this to find which dataset holds a variable name you saw in a test case", - "returns": [ - "success", - "variables", - "info", - "uuid", - "name", - "dataset" - ], - "paginated": true, - "responses": { - "200": { - "description": "A page of dataset columns, each with its owning dataset, plus paging metadata under `info`.", - "schema": { - "$schema": "DatasetVariablesResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is project-wide, not per dataset. `variables` on this path is a fixed route segment, not a dataset uuid \u2014 it lists columns across every dataset in the project. To see one dataset's columns, read that dataset instead.", - "Each row carries its owning dataset inline as `dataset` {uuid, name, count_rows}, so this call resolves a bare variable name to the dataset uuid you need for every other dataset operation.", - "Column names are not unique across datasets \u2014 the same name in two datasets is two rows here. Disambiguate on `dataset.uuid`.", - "The page size is set by the backing store and cannot be requested from here; page with `p` and read `info` for page/page_size/next.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate shared by every dataset endpoint.", - "`success`, `variables` and `info` are all top level \u2014 `variables` is the array itself, not a wrapper object." - ] - }, - { - "name": "get_dedupe_status", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/status", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - } - ], - "intent": "Check whether duplicate detection has ever run for a project and where the latest scan got to \u2014 the only way to tell 'dedupe is off or has never run' from 'it ran and found nothing'", - "returns": [ - "status", - "job_id", - "scheduled_run_time", - "actual_end_time", - "updated_at", - "message" - ], - "responses": { - "200": { - "description": "The latest dedupe job for the project. When no job has ever run, only {status:'NOT_ENABLED', message:'No dedupe job found for this project'} is returned and the job fields are absent.", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "Latest job status, or the synthesised 'NOT_ENABLED' when no job exists.", - "values": [ - "NOT_ENABLED", - "QUEUED", - "PENDING", - "IN_PROGRESS", - "CLUSTER", - "CLUSTERING", - "COMPLETED", - "FAILED", - "RETRY", - "RETRY_FAILED", - "STATISTICAL_FAILED", - "STATISTICAL_RETRY", - "CLUSTERING_FAILED", - "CLUSTERING_RETRY", - "CANCELLED", - "BACKFILLING", - "LLM_ANALYSIS_SKIPPED", - "CLUSTERING_LIMIT" - ] - }, - "job_id": { - "type": "integer", - "description": "Pipeline job id. Absent when status is NOT_ENABLED." - }, - "scheduled_run_time": { - "type": "string", - "format": "date-time", - "description": "When the scan was scheduled to run. Nullable; absent when status is NOT_ENABLED." - }, - "actual_end_time": { - "type": "string", - "format": "date-time", - "description": "When the scan finished. Null while the job is still in flight; absent when status is NOT_ENABLED." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Last status change. Absent when status is NOT_ENABLED." - }, - "message": { - "type": "string", - "description": "Present only with NOT_ENABLED: 'No dedupe job found for this project'." + }, + "V1TestCaseWriteRow": { + "type": "object", + "description": "The test-case row as echoed by a WRITE and by global search: the project-level record plus the extras those responses attach.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + } } - } - } - }, - "400": { - "description": "{error:'Missing project_id'} \u2014 unreachable through this route, where project_id is a required path segment." - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Call this first whenever a duplicates listing comes back empty. An empty listing is returned both when the feature is off and when a completed scan found nothing; only this endpoint separates them.", - "`NOT_ENABLED` is synthesised, not stored: it means no scan job row exists for this project at all, and it arrives with an explanatory `message` and none of the job fields. Every other value is a real job status and comes with job_id and timestamps.", - "Only the MOST RECENT job is reported, by last update. There is no history and no way to page back to earlier runs.", - "Statuses are not just running/done. COMPLETED, FAILED, CANCELLED, RETRY_FAILED, STATISTICAL_FAILED, CLUSTERING_FAILED, CLUSTERING_LIMIT and LLM_ANALYSIS_SKIPPED are terminal; QUEUED, PENDING, IN_PROGRESS, CLUSTER, CLUSTERING, BACKFILLING, RETRY, STATISTICAL_RETRY and CLUSTERING_RETRY mean a scan is still in flight and the duplicates listing may be incomplete.", - "This is a plain read of the last job record \u2014 it does not start a scan, and nothing in this API can. Scans are scheduled by the dedupe pipeline.", - "Unlike the rest of this family, no role permission is checked here; project access is enough.", - "The documented 400 for a missing project id is unreachable through this route, since the id is a required path segment." - ] - }, - { - "name": "get_duplicate_source_test_case", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}/source_tc", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id of an ALREADY-MERGED pair. The route only matches digits." - } - ], - "intent": "Read the pre-merge snapshot of the test case that was folded away when a duplicate was merged \u2014 the only record of what the archived case contained at merge time", - "shape": "discovered", - "responses": { - "200": { - "description": "The pre-merge snapshot, wrapped in a `data` envelope: {data:{success:true, test_case:{...}}}. `test_case` is the frozen full test-case serialisation stored at merge time (steps, attachments with signed URLs, template); its field set is whatever was captured then and is not shaped by this endpoint.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true + }, + "FilterWriteRow": { + "type": "object", + "description": "A filter as echoed by create/update: the shared filter record plus the ownership and lifecycle fields the filter LISTING does not return.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 }, - "test_case": { - "type": "object", - "description": "Frozen pre-merge serialisation of the source test case." + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { + "type": "string", + "example": "active" } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "description": "Two distinct bodies: {data:{success:false, error:'Source test case snapshot not available'}} when the pair merged but retained no snapshot, and {success:false, error:'Duplicate not found.'} (no `data` key) when the pair is unknown or was never merged." - }, - "422": { - "description": "{success:false, message:'Invalid source_tc data'} \u2014 the stored snapshot could not be parsed." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This works only for a pair whose status is `merged` \u2014 the exact opposite of the read/resolve/discard calls, which work only while a pair is `suggested`. Calling it on a live suggestion is a 404.", - "The snapshot is written only by the merge path. A pair resolved by archiving instead of merging is also marked merged but stores no snapshot, so it answers 404 with the message 'Source test case snapshot not available'. Treat that as 'not retained', not as a retryable error.", - "The two 404s have DIFFERENT envelopes. Missing snapshot: {data:{success:false, error:'Source test case snapshot not available'}}. Unknown or unmerged pair: {success:false, error:'Duplicate not found.'} with no `data`. Check for `data` before reading `error`.", - "Success is wrapped: the test case is at `data.test_case`, not at the top level. This is the only duplicate endpoint with a `data` envelope.", - "The snapshot is a frozen serialisation taken at merge time, including steps, attachments (with freshly signed, short-lived URLs) and the template \u2014 it is not re-read from the live test case, so it will not reflect anything that happened afterwards.", - "A stored snapshot that cannot be parsed comes back 422 {success:false, message:'Invalid source_tc data'} \u2014 a data fault, not something a different request fixes.", - "To find the merged pair id in the first place: it is no longer in the duplicates listing, so you need the id you saw before resolving, or the id embedded in the merge comment left on the surviving test case." - ], - "returns": [ - "data", - "success", - "test_case" - ], - "disabled": true - }, - { - "name": "get_duplicate", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates/{duplicate_id}", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id; this route only matches digits." - }, - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer duplicate id, taken from the `id` of a row in the duplicates listing or search. The route only matches digits." - } - ], - "intent": "Read one suggested duplicate pair \u2014 the two test cases side by side with their titles, descriptions, folders and owners, plus the confidence score and the model's reason \u2014 so a human can decide how to resolve it", - "shape": "discovered", - "responses": { - "200": { - "description": "The duplicate pair with both test cases resolved, at the top level as `duplicate`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "duplicate": { - "$schema": "V1DuplicatePair" + }, + "TestRunWriteResponse__EditTestRun": { + "type": "object", + "description": "TestRunWriteResponse as returned by `edit_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17577900 + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "updated_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "issues": { + "type": "string", + "nullable": true + } + } + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "{errors:'Duplicate not found.'} \u2014 no such pair in this project, or its status is no longer `suggested`. Or {errors:'Test case not found.'} \u2014 the pair exists but a member test case could not be resolved." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Suggested-only. A pair that has already been merged, archived or discarded answers 404 {errors:'Duplicate not found.'} \u2014 here a 404 usually means 'already handled', not 'never existed'. Check the listing before concluding the id is wrong.", - "The error key is `errors` (plural), not `error` or `message`, on both 404 paths from this endpoint.", - "A second 404, {errors:'Test case not found.'}, means the pair exists but one of its two test cases could not be resolved (deleted or moved out of reach). The pair is unusable \u2014 it is also silently dropped from the listings for the same reason.", - "Unlike the listings, this has NO feature-flag gate: a single pair stays readable by id even for a group whose dedupe listing returns empty.", - "The pair is at the top level as `duplicate`; there is no `data` envelope. Inside it, `test_cases` always holds exactly two entries and each entry's `id` is the integer test-case id you pass to the resolve call as source or target.", - "`owner` is a resolved display name and is an empty string when the owner cannot be resolved; `owner_id` is the raw BrowserStack user id. `description` is '' rather than null when absent.", - "`ai_score` is the model's confidence and `duplicate_type` is exact vs semantic \u2014 read both before merging, since an exact pair and a low-confidence semantic pair warrant different treatment." - ], - "returns": [ - "success", - "duplicate", - "id", - "ai_score", - "reason", - "duplicate_type", - "test_cases", - "identifier", - "folder_id", - "folder_path", - "title", - "description", - "owner_id", - "owner" - ] - }, - { - "name": "list_entity_filter_details", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity}/filter-details", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "trtc", - "test-runs" - ], - "example": "test-cases", - "description": "Which entity's filter vocabulary to resolve against \u2014 this also decides which user fields are resolved. 'test-plans' is allowed by the route but rejected by the handler with 400." - } - ], - "query": [ - { - "name": "q[status]", - "type": "string", - "example": "9319,9321", - "description": "Comma-separated numeric status IDs to resolve. Returned under filter_details.status as objects with id, field_value, internal_name and colour." - }, - { - "name": "q[priority]", - "type": "string", - "example": "550376,9261", - "description": "Comma-separated numeric priority IDs to resolve; returned under filter_details.priority." - }, - { - "name": "q[case_type]", - "type": "string", - "example": "9379,9375", - "description": "Comma-separated numeric case-type IDs to resolve; returned under filter_details.case_type." - }, - { - "name": "q[automation_state]", - "type": "string", - "example": "automated", - "description": "Numeric ids, or the literal word 'automated' / 'manual' which is expanded to this project's ids first; returned under filter_details.automation_state." - }, - { - "name": "q[automation_status]", - "type": "string", - "example": "passed,failed", - "description": "Comma-separated values; split on commas and echoed back as an array, not resolved against anything." - }, - { - "name": "q[folder_ids]", - "type": "string", - "example": "3306878,3669741", - "description": "Comma-separated folder ids; returned under filter_details.folders as a folder tree with paths and ancestors. At most 600 folders are resolved." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids. Resolved for entity=test-cases and entity=test-runs only; ignored for trtc." - }, - { - "name": "q[reviewers]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=test-cases only." - }, - { - "name": "q[assignee]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=trtc only." - }, - { - "name": "q[last_executed_by]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids. Resolved for entity=trtc only." - }, - { - "name": "q[test_case_tags]", - "type": "string", - "example": "[\"regression\",\"smoke\"]", - "description": "Must be a JSON-encoded string containing an array of tag names \u2014 it is JSON.parse'd and echoed back. A bare comma-separated list raises rather than filtering. `q[tags]` is not a recognised key." - }, - { - "name": "q[custom_fields]", - "type": "string", - "example": "{\"45\":[\"abc\"]}", - "description": "Custom-field selections keyed by field id; returned resolved under filter_details.custom_fields. Date custom fields echo back the raw value you sent rather than the normalised one." - }, - { - "name": "q[result_custom_fields]", - "type": "string", - "example": "{\"88\":[\"a\",\"b\"]}", - "description": "Test-result custom-field selections; resolved separately and returned under filter_details.result_custom_fields." - }, - { - "name": "q[configuration_id]", - "type": "string", - "example": "101,102", - "description": "Comma-separated configuration ids; resolved to configuration objects under filter_details.configurations." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Echoed back verbatim under filter_details.created_at \u2014 not parsed, not validated." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "3_month", - "description": "Echoed back verbatim under filter_details.updated_at." - }, - { - "name": "q[latest_status]", - "type": "string", - "example": "passed,failed", - "description": "Comma-separated values, split and echoed back as an array." - } - ], - "intent": "Resolve a set of filter IDs you already hold into their display objects \u2014 turn priority/status/case-type/custom-field ids, folder ids and user ids into names, colours and folder paths so a saved filter can be rendered; it does NOT list the filter options available in a project", - "returns": [ - "success", - "filter_details", - "priority", - "status", - "case_type", - "automation_state", - "automation_status", - "custom_fields", - "result_custom_fields", - "folders", - "configurations", - "created_at", - "updated_at", - "test_case_tags", - "latest_status", - "owner", - "reviewers", - "assignee", - "last_executed_by" - ], - "responses": { - "200": { - "description": "Everything is nested under `filter_details`; only the keys you supplied under q are present, and an empty q yields filter_details:{}.", - "schema": { - "type": "object", - "required": [ - "success", - "filter_details" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "filter_details": { - "type": "object", - "description": "Resolved form of the filters that were sent. Keys absent from the request are absent here.", - "properties": { - "priority": { - "type": "array", - "description": "Resolved priority options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } - }, - "status": { - "type": "array", - "description": "Resolved status options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } + }, + "TestRunWriteResponse__AssignTestRunOwner": { + "type": "object", + "description": "TestRunWriteResponse as returned by `assign_test_run_owner`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17577900 + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "run_state": { + "type": "string", + "example": "new_run" + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "example": 4 + }, + "test_plans": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:33.000Z" + }, + "updated_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "uuid": { + "type": "integer", + "example": 17577900 + } + } + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "success": { + "type": "boolean", + "example": true + } + } + } + } + }, + "Folder__BulkCopyTestCases": { + "type": "object", + "description": "Folder as returned by `bulk_copy_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" }, - "case_type": { - "type": "array", - "description": "Resolved case-type options for the ids that were sent.", - "items": { - "$schema": "ResultStatus__ListEntityFilterDetails" - } + "deleted": { + "type": "boolean", + "example": false }, - "automation_state": { - "type": "array", - "description": "Resolved automation-state options.", - "items": { - "$schema": "ResultStatus" - } + "entity_type": { + "type": "string", + "nullable": true }, - "automation_status": { - "type": "array", - "description": "The q[automation_status] value split on commas and echoed back unresolved.", - "items": { - "type": "string" - } + "identifier": { + "type": "string", + "example": "FD-13482" }, - "test_case_tags": { - "type": "array", - "description": "The JSON-decoded q[test_case_tags] value, echoed back.", - "items": { - "type": "string" - } + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" }, - "latest_status": { - "type": "array", - "description": "The q[latest_status] value split on commas and echoed back.", - "items": { - "type": "string" - } + "parent_id": { + "type": "string", + "nullable": true }, - "created_at": { - "type": "string", - "nullable": true, - "description": "Echo of q[created_at], unparsed." + "source": { + "type": "string", + "example": "TCM" }, "updated_at": { - "type": "string", - "nullable": true, - "description": "Echo of q[updated_at], unparsed." - }, - "custom_fields": { - "type": "object", - "description": "Resolved custom-field values keyed by field id. Date fields carry the raw value that was sent, not the normalised one." + "type": "string", + "example": "2026-09-22T09:31:46.000Z" }, - "result_custom_fields": { - "type": "object", - "description": "Resolved test-result custom-field values keyed by field id." + "group_id": { + "type": "string", + "example": "3452" }, - "configurations": { - "type": "array", - "description": "Resolved configuration objects for q[configuration_id].", - "items": { - "$schema": "Configuration" - } + "id": { + "type": "integer", + "example": 765811 }, - "owner": { - "type": "array", - "description": "Resolved users for q[owner] \u2014 test-cases and test-runs only.", - "items": { - "$schema": "User__ListEntityFilterDetails" - } + "is_automation": { + "type": "boolean", + "example": false }, - "reviewers": { - "type": "array", - "description": "Resolved users for q[reviewers] \u2014 test-cases only.", - "items": { - "$schema": "User" - } + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" }, - "assignee": { - "type": "array", - "description": "Resolved users for q[assignee] \u2014 trtc only.", - "items": { - "$schema": "User" - } + "project_id": { + "type": "integer", + "example": 1234 }, - "last_executed_by": { - "type": "array", - "description": "Resolved users for q[last_executed_by] \u2014 trtc only.", - "items": { - "$schema": "User" - } + "sub_folders_count": { + "type": "integer", + "example": 6 }, - "folders": { - "$schema": "FilterDetailsFolderNode" + "total_cases_count": { + "type": "integer", + "example": 35 } - } - } - } - } - }, - "400": { - "description": "Unsupported entity (including test-plans, which the route allows but the handler rejects), or malformed filter values." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is an echo/hydration endpoint, not a discovery one. It returns only the keys you actually passed under `q`: send q[status]=9319 and you get back `status` resolved; send nothing and you get {success:true, filter_details:{}} with a 200. To enumerate the options a project offers, use the system/custom field value capabilities instead.", - "The result is nested one level down under `filter_details` \u2014 read filter_details.status, filter_details.priority, filter_details.folders. The keys are not at the top level.", - "Only three entity values work here: test-cases, trtc and test-runs. 'test-plans' passes the route constraint but is not in the handler's map and returns 400 \u2014 the route and the handler disagree.", - "Which user fields get resolved depends on the entity: owner and reviewers for test-cases, assignee and last_executed_by for trtc, owner for test-runs. A user id sent under the wrong key for that entity is ignored without comment.", - "Filter keys are renamed internally (q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id) but you send the un-suffixed names. q[automation_state] additionally accepts the literal words 'automated' / 'manual'.", - "q[test_case_tags] must be a JSON-ENCODED STRING of an array; it is JSON.parse'd and echoed back verbatim. `q[tags]` is not a key this endpoint knows and is dropped silently.", - "q[created_at] and q[updated_at] are echoed back exactly as sent \u2014 they are not parsed or normalised here.", - "Folder resolution caps out at 600 folders per call, so a very wide folder selection comes back truncated rather than paged.", - "q[query] has no effect on this endpoint \u2014 it is sanitised and then ignored." - ] - }, - { - "name": "get_exploratory_session", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "intent": "Read one exploratory session in full \u2014 its charter, state, timings, assignee, tags, configurations, linked test plan, linked issues, custom fields and how many logs it holds \u2014 before reading its logs or changing it", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "closed_by", - "closed_at", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "timeline", - "created_at", - "updated_at", - "project_id", - "group_id", - "owner", - "record_status", - "attachment_relations" - ], - "responses": { - "200": { - "description": "Successfully retrieved the exploratory session.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "session_id is a plain integer. The route segment only matches digits, so the display identifier (ES-NNN) never reaches the API \u2014 it fails as a routing 404, not as an API error.", - "Read `session_log_count` first: it tells you how many log pages to walk, and it is the same number that decides whether a clone of this session runs synchronously or asynchronously (50 or more logs switches clone to the async path).", - "custom_fields is always present on this read \u2014 normalised into the flat TCM shape, and an empty array when the session has none. That normalisation is applied here, on create, update and clone, but NOT on the sessions listing.", - "assignee, created_by and closed_by are expanded into {id, email, full_name, group_id} objects by looking the user up locally. If the stored user is not a TM user in this group the field comes back null even though a user is set upstream.", - "A session id that does not exist comes back as a bare {success:false} with the 404 status and no message \u2014 the upstream error text is deliberately not relayed.", - "The session is at the top level under `exploratory_session`. There is no `data` envelope on any endpoint of this entity.", - "Requires the test-run view permission: exploratory sessions are gated by the test run permission set, not by a permission of their own.", - "The path takes the INTEGER session id, not the ES-NNN identifier \u2014 ES-NNN returns 404. The session's display field is `title`; there is no `name`. `summary` and `test_plan` come back from the LIST route only, not from this one." - ] - }, - { - "name": "get_exploratory_session_summary", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/exploratory-session-summary", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "mode", - "type": "string", - "values": [ - "time_period", - "session_selection" - ], - "default": "time_period", - "description": "`time_period` scopes by dates and ignores session_ids; `session_selection` scopes by session_ids and ignores the dates. Mismatched parameters are dropped silently, not rejected." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2025-05-01", - "description": "Inclusive start, YYYY-MM-DD. Only read in time_period mode; omitted entirely from the upstream call when absent." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2025-05-31", - "description": "Inclusive end, YYYY-MM-DD. Only read in time_period mode." - }, - { - "name": "session_ids", - "type": "string", - "example": "101,102,103", - "description": "Comma-separated session ids as a STRING, not an array. Only read in session_selection mode." - } - ], - "intent": "Get exploratory-session testing figures for a project directly \u2014 sessions, outcomes and top testers over a date range or a chosen set of sessions, with no saved report needed", - "shape": "discovered", - "responses": { - "200": { - "description": "Summary figures under `data`.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "Exploratory-session summary payload \u2014 session counts, outcome breakdown, `sessions` (with `assignee` expanded to a user object) and `top_testers` (each `{ user, bug_count }`, `user` null when the tester is no longer in the group)." - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this for an ad-hoc question about exploratory testing. It computes live from the project and needs no report to exist; the saved equivalent is an `Exploratory Session Summary` report read through the report capabilities.", - "The figures are under `data`, not at the top level. `data.top_testers[].user` and `data.sessions[].assignee` are expanded from ids into user objects server-side, and come back `null` for a tester who is no longer a TM user.", - "`mode` decides which of the other parameters matter: `time_period` (the default) uses `start_date` / `end_date` and ignores `session_ids`; `session_selection` uses `session_ids` and ignores the dates. Sending the wrong pair for the mode is not an error \u2014 it is silently ignored and you get an unintended scope.", - "`session_ids` is a comma-separated STRING here, not an array. Dates are plain YYYY-MM-DD.", - "Omitted parameters are dropped rather than defaulted locally, so the upstream service picks the window \u2014 always send explicit dates when the range matters.", - "This route only exists under a project; it reads the project from the path and requires the report_view permission (403 otherwise)." - ], - "returns": [ - "success", - "data" - ] - }, - { - "name": "get_filter", - "method": "GET", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "read", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one saved filter view \u2014 its name, scope and the exact condition set it applies \u2014 before reusing, editing or deleting it", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This read is not side-effect free. The handler validates every custom-field condition in the saved view against the project's current custom fields and, when any no longer exist, it rewrites the stored filter without them. Reading a filter can permanently change it.", - "So `data.filters.customFields` can legitimately be a subset of what was saved, and a second read will not put the pruned conditions back. Treat what you get as the new truth.", - "The entity_type to custom-field-entity map has no entry for `testrun`, so a run filter is validated against TEST CASE custom fields and conditions that were valid for runs can be pruned. Be cautious about reading run filters you do not want altered.", - "Known defect: if the custom-field lookup or the write-back fails with a 400, the action returns without rendering anything at all \u2014 you get an empty/500 response rather than the filter. Fall back to the filter listing, which does no validation.", - "The filter is under `data` with a sibling `success`; the conditions are at `data.filters` and the list type at `data.entity_type` (not `entity`, which is the request-side spelling).", - "Only a 400 from the backing service is translated to `{success:false, message:...}`; anything else from it surfaces as a 500, so an unknown filter_id does not reliably produce a 404." - ], - "disabled": true - }, - { - "name": "get_folder_remove_summary", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rm-summary", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Dry-run a folder deletion \u2014 count the test cases, subfolders and cross-product recordings that would be destroyed \u2014 before committing to it", - "returns": [ - "success", - "has_other_product_entity", - "deleted_entity_count", - "active_test_case_count", - "archived_test_case_count", - "sub_folders", - "test_recordings_count" - ], - "responses": { - "200": { - "$response": "RemoveFolderSummaryResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read-only despite the delete machinery behind it: the handler calls the delete path with dry_run set, so nothing is removed. This is the safety check to run before deleting a folder.", - "It is nonetheless gated on the folder-DELETE permission, not the view permission. A caller who can read folders but not delete them gets 403 here.", - "The counts are nested under `deleted_entity_count` \u2014 active_test_case_count, archived_test_case_count, sub_folders and test_recordings_count all live inside that object, not at the top level.", - "`has_other_product_entity` is derived from one thing only: it is true exactly when test_recordings_count is greater than zero, meaning the subtree holds low-code test recordings owned by another product that the delete would take with it.", - "The folder is not fetched before the dry run, so a folder id that does not exist in this project surfaces as a bare 404 {success:false} with no message identifying the id.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500." - ] - }, - { - "name": "get_folder_tree", - "method": "GET", - "path": "/api/v1/projects/{project_id}/repository/mapping", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Fetch a project's entire folder tree in one call \u2014 every root folder with its children nested under `contents` \u2014 to resolve a folder name to the integer id every other folder and test-case call needs", - "shape": "discovered", - "responses": { - "200": { - "description": "Folder tree.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "structure": { - "type": "array", - "items": { - "type": "object" - } } - } - } - }, - "401": { - "description": "Unauthorized." - } - }, - "query": [ - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Sibling ordering. Comma-separated combinations are accepted and each element is validated; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." - }, - { - "name": "shared", - "type": "boolean", - "description": "Any truthy presence switches the tree to the shared-folder view. Presence alone is what counts \u2014 sending shared=false still enables it, because only presence is tested." - } - ], - "guidance": [ - "This returns the WHOLE tree in one response and is not paginated \u2014 no page or per-page parameter is read. On a large project it is a big payload; use the per-folder listing instead if you only need one level.", - "The tree is at `structure`, a top-level array. There is no `data` envelope on this endpoint. Children hang off each node's `contents` array, recursively.", - "Folder ids in the tree are plain integers, and they are what every other folder capability expects in its path.", - "An unresolvable project (including the literal path segment `new`) returns 200 with {structure: [], success: true}, not a 404. An empty structure is therefore ambiguous between 'no folders' and 'no such project' \u2014 confirm the project separately.", - "When Review & Approve is switched on for the project, the per-folder test-case counts in the tree are silently narrowed to review-eligible statuses. If you also pass a review_status filter, it is intersected with the project's eligible set \u2014 and when the intersection is empty your filter is discarded and the project's set is used instead, so you can get counts you did not ask for rather than an error.", - "Counts here are aggregates over the tree; they are not a list of test cases. Read test cases through the test-case listings." - ], - "returns": [ - "success", - "structure" - ] - }, - { - "name": "list_group_users", - "method": "GET", - "path": "/api/v1/group/users-v2", - "mode": "read", - "entity": "user", - "query": [ - { - "name": "q", - "type": "string", - "description": "As a bare string: filters on FULL NAME only, as a SQL LIKE '%value%'. Email is not matched by this form and there is no partial-email search. Whitespace is not trimmed." - }, - { - "name": "q[email]", - "type": "string", - "example": "qa@acme.com", - "description": "Exact email lookup \u2014 no partial matching. Returns at most one user, and this branch OMITS the `info` pagination block entirely, so a reader that always dereferences info.next breaks on it." - }, - { - "name": "q[id]", - "type": "string", - "example": "14,27", - "description": "Comma-separated Test Management user ids. This does NOT filter \u2014 it only re-orders, putting the named ids first and returning the rest of the workspace behind them. It also raises the page size to the number of ids when that exceeds 30." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Previously undeclared. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Has no effect on the q[email] branch, which is unpaginated." - } - ], - "intent": "List or look up the people in this workspace \u2014 search by name, resolve an exact email, or read your own identity \u2014 to get the user id that owner and assignee fields take", - "returns": [ - "success", - "users", - "myself", - "info", - "id", - "browserstack_user_id", - "email", - "full_name", - "group_id", - "onboarded", - "reports_and_notification_enabled" - ], - "responses": { - "200": { - "$schema": "UsersV2Response__ListGroupUsers" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Scope is the whole WORKSPACE (group), not a project \u2014 every active user in the group is returned regardless of which projects they can see, and no project id can be supplied. This is not a way to answer 'who is on this project'.", - "`q[id]` is a sort, not a filter. Passing ids re-orders the full user list so those ids come first; you still get everyone. Filtering to specific people is not supported here \u2014 match on the ids yourself after reading.", - "The response shape varies by branch: the default, name-search and id branches return `{users, myself, success, info}`, but the `q[email]` branch returns `{users, myself, success}` with NO `info` at all.", - "`id` is the Test Management user id; `browserstack_user_id` is the separate BrowserStack account id for the same person, and the two are different numbers. Owner and assignee fields on cases and runs take the TM `id`; some listing filters take browserstack_user_id instead. Sending the wrong one usually resolves to nobody rather than erroring, so check which the target capability wants.", - "Only active users are returned. A deactivated person cannot be resolved here, which means an assignee who has left the workspace cannot be looked up to explain an existing assignment.", - "`myself` is the calling user as {id, full_name, email, reports_and_notification_enabled} \u2014 the cheapest way to get your own TM user id, with no search needed.", - "Page size is fixed at 30 (the q[id] branch excepted, where it becomes the number of ids); there is no per_page parameter.", - "No project is involved, so this works without a project_id and is not project-permission gated." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_issues_count_info", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/issues-count-info", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how many defects/issues were linked from this project per period \u2014 the defect-discovery trend", - "returns": [ - "data", - "empty_data" - ], - "responses": { - "200": { - "$response": "IssuesCountInfo" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each entry in `data` is a positional pair \u2014 period label first, count second \u2014 not a {label, count} object; the CSV exporter reads them by index. Read them positionally and do not rely on key names.", - "This counts issues per period; it does not list them. There is no way to reach the individual defects from this response.", - "`empty_data` is the 'nothing to plot' flag; the series is padded with zeroes otherwise.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_project_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Get a project by INTEGER id (v1) \u2014 full detail + its PR-NNN identifier", - "returns": [ - "data", - "success", - "project", - "id", - "identifier", - "name", - "description", - "created_at", - "thProjectId", - "normalisedName", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "starred", - "jira_mapped", - "links", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project detail; data.project.identifier is the PR-NNN form.", - "schema": { - "$schema": "V1ProjectDetail__GetProjectByIntegerId" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No project with that integer id, OR the project exists but is not visible to this user \u2014 both render as 404 {success:false}." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The path segment only matches digits (or the literal string 'new'), so a PR-NNN identifier never reaches this action \u2014 it is a routing 404, not an API error.", - "This is the integer-id -> PR-NNN translation step: read `data.project.identifier` and carry it into any the v2 surface route, which will not accept the integer.", - "The project sits under a `data` envelope: `data.project`. The paginated/minified project listings do NOT use `data` \u2014 they return `projects` at the top level. Do not reuse a path across the two.", - "A project that exists but is not visible to this caller comes back as 404 {success:false} \u2014 the underlying 403 is deliberately rewritten. You cannot distinguish 'no such project' from 'no access' on this route.", - "`test_cases_count`, `test_runs_count` and `test_plans_count` are fetched with the detail here (they are absent from the lean listings), which is the reason to use this call rather than filtering a listing.", - "`permissions` is the caller's own permission list for this project and `user_role` their role name \u2014 both reflect the requesting user, not the project.", - "Requires the project_view permission under RBAC." - ] - }, - { - "name": "get_project_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/form-fields-v2", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. PR-NNN identifiers do not match the route; the literal 'new' matches the route but is rejected with 400 'Invalid Params: project_id must be a valid integer'." - } - ], - "intent": "Get the test-case form for one project \u2014 the custom fields it shows, the built-in system fields, and the pick lists behind the standard priority / state / type / automation-status dropdowns", - "returns": [ - "success", - "custom_fields", - "default_fields", - "system_fields", - "id", - "field_name", - "field_type", - "entity_type", - "field_user_name", - "optional", - "is_required", - "is_bulk_editable", - "is_filterable", - "links", - "placeholder", - "default_value", - "option_values", - "field_values", - "sort_options" - ], - "responses": { - "200": { - "$response": "ProjectFormFieldsV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the read to make before creating or editing a test case: it tells you which fields that project actually shows, which are mandatory, and what the standard dropdowns accept.", - "The response is three lists at the top level \u2014 `custom_fields`, `default_fields`, `system_fields` \u2014 with NO `data` envelope and no pagination. Every custom field the project has comes back in one call.", - "`field_type` inside `custom_fields` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean \u2026), not the canonical vocabulary the admin create/update calls take. Translate in both directions.", - "TestCase only. Result, plan, exploratory-session and step custom fields are served by different endpoints and never appear here.", - "`optional` and `is_required` are both present and are inverses of one another; prefer is_required.", - "Each row carries `links.self` \u2014 the ready-made path to that field's values in this project (or to the project user list for a `user` field). Follow it rather than building the path.", - "`option_values` / `field_values` appear only when the upstream row carried them, so an absent key does not mean the field has no options \u2014 follow links.self to be sure.", - "project_id must be numeric. The route also matches the literal 'new', but this handler rejects a non-numeric value with 400 'Invalid Params: project_id must be a valid integer'.", - "`automation_status` under default_fields is assembled in this layer, not read from storage, and the automation-status custom field is omitted for groups without that feature." - ] - }, - { - "name": "get_project_settings", - "method": "GET", - "path": "/api/v1/projects/{project_id}/settings", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "in_review_count", - "type": "string", - "values": [ - "true" - ], - "description": "Compute meta_data.in_review_count and meta_data.test_plan_in_review_count for the CALLING user. Without it both are returned as 0 rather than omitted, so an unrequested 0 is not evidence of an empty queue." - }, - { - "name": "fetch_dedupe_button", - "type": "string", - "values": [ - "true" - ], - "description": "Append a duplicate-review marker string to the project_settings array ('show_review_duplicates', 'disable_review_duplicates' or 'hide_review_duplicates'). Any failure computing it is swallowed and no marker is appended." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the underlying settings rows (30 per page) before they are flattened into project_settings[0]." - } - ], - "intent": "Read a project's configuration flags \u2014 review/approval gating, the custom test-case id prefix, time-tracking, Part 11 re-auth, Jira/Azure linking \u2014 and optionally the caller's own pending-review counts", - "returns": [ - "success", - "project_settings", - "info", - "meta_data", - "in_review_count", - "test_plan_in_review_count" - ], - "responses": { - "200": { - "description": "project_settings[0] is a flat attribute->value object (all values are strings); later elements, when present, are bare marker strings. info and meta_data are omitted entirely when the project has no stored settings.", - "schema": { - "type": "object", - "required": [ - "success", - "project_settings" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "project_settings": { - "type": "array", - "description": "Element 0 is a single object mapping each stored setting name to its string value. Any further elements are duplicate-review marker strings appended when fetch_dedupe_button=true. Empty array when nothing is stored.", - "items": { - "description": "Either the flattened settings object or a marker string." - } - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the underlying settings rows. Absent when the project has no stored settings." - }, - "meta_data": { - "type": "object", - "description": "Per-caller review counts. Present whenever settings exist; both fields are 0 unless in_review_count=true was passed.", - "properties": { - "in_review_count": { - "type": "integer", - "description": "Test cases in review where the calling user is a reviewer.", - "example": 4 - }, - "test_plan_in_review_count": { - "type": "integer", - "description": "Test plans in review for the calling user. Best-effort \u2014 a backend error yields 0.", - "example": 2 - } - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`project_settings` is an ARRAY, but not a list of settings. Element 0 is a single flat object holding every stored setting as attribute -> value; any further elements are bare marker STRINGS ('show_review_duplicates' / 'disable_review_duplicates' / 'hide_review_duplicates') appended only when fetch_dedupe_button=true. Read project_settings[0] for the settings and expect mixed types in the rest of the array.", - "A project with no stored settings returns {success:true, project_settings:[]} with NO `info` and NO `meta_data` at all \u2014 not an object of defaults. Absence of a key means 'never set', and the effective behaviour is the group default, which is not visible here.", - "Every value is stored as a string (the settings are an EAV store), so booleans come back as \"true\"/\"false\" and numbers as strings. Compare against strings, not native types.", - "`meta_data` is always present when any setting exists, but both counts are hard-zero unless you pass in_review_count=true \u2014 a 0 you did not ask for means 'not computed', not 'nothing in review'.", - "in_review_count counts test cases where the CALLER is a reviewer and the case is in review; it is per-user, not per-project. test_plan_in_review_count is computed on a separate path and is best-effort \u2014 a backend hiccup silently yields 0.", - "The underlying rows are paged at 30 per page (`p` selects the page) before being flattened, so a project with an unusually large settings set can produce an incomplete first-page object.", - "This read is not feature-gated, but the matching write is: update_project_settings returns 422 unless the new-project-settings flag is on for the group or user." - ] - }, - { - "name": "list_project_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users-v2", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "example": "jane", - "description": "Flat string: case-sensitive LIKE match on full_name (substring). Paginated, 30 per page." - }, - { - "name": "q[email]", - "type": "string", - "example": "jane@acme.com", - "description": "Exact email lookup, at most one row. This branch returns NO `info` block \u2014 do not read pagination from it. An unknown address returns an empty `users` array, not a 404." - }, - { - "name": "q[id]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids. This does NOT filter \u2014 it moves those users to the front of the full group list. Page size grows to the id count when more than 30 ids are given." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number (the param is `p`, not `page`). Overflow returns the last page rather than an empty one." - } - ], - "intent": "Find the users in this group \u2014 by name fragment, by exact email, or by id \u2014 and get the caller's own identity back alongside them; use this to resolve a person's name or email to the numeric user id that owner/assignee/reviewer filters and writes require", - "returns": [], - "responses": { - "200": { - "$schema": "UsersV2Response__ListProjectUsers" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the path, this is GROUP-scoped, not project-scoped: it lists every active user in the caller's group and never filters by the project in the URL. The project_id only has to be a real project the caller can see.", - "`q` is polymorphic and the three forms behave differently. `q=jane` (a flat string) is a name LIKE match and is paginated. `q[email]=jane@acme.com` is an exact lookup that returns at most one user AND OMITS the `info` block entirely. `q[id]=12,34` reorders those ids to the front of the full list rather than filtering to them \u2014 it is a sort, not a filter.", - "Page size is fixed at 30 and there is no size parameter. The one exception is `q[id]` with more than 30 ids, where the page grows to fit the id list.", - "Use `p` for the page number (not `page`). An out-of-range page returns the LAST page rather than an empty list, so never treat a non-empty response as proof the page number was valid.", - "`myself` is the caller's own record and is always present, but it is a reduced shape \u2014 id, full_name, email, reports_and_notification_enabled only, with no browserstack_user_id.", - "The `id` on each user is the TM user id. That is what the owner/assignee/reviewer search filters and test-run writes expect; `browserstack_user_id` is a different number and is only correct where a capability explicitly says BrowserStack id.", - "Only active users are returned \u2014 a deactivated user who still owns test cases will not appear here, so an unresolvable owner id is not necessarily invalid." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_projects_basic", - "method": "GET", - "path": "/api/v1/projects/basic", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Validated as an integer; a non-integer returns 400." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page. Validated as an integer here but NOT bounded in this service before being forwarded to the backing store; 300 is the documented maximum." - }, - { - "name": "q[query]", - "type": "string", - "example": "checkout", - "description": "Free-text match on project name." - }, - { - "name": "q[identifier]", - "type": "string", - "example": "PR-60863", - "description": "Exact PR-NNN identifier lookup. Note the matched rows do not echo `identifier` back." - } - ], - "intent": "Look up projects by name or by PR-NNN identifier, or page through them cheaply \u2014 the lean listing, and the only one with an identifier search; note the rows it returns do NOT include the identifier itself", - "returns": [], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectsPaginatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`q` must be a structured object here \u2014 q[query] for a name match, q[identifier] for an exact PR-NNN lookup. A flat `q=foo` string returns 400 'Invalid Params: q must be an object with identifier/query keys'. This is the exact opposite of list_projects, which demands a flat string.", - "Asymmetry worth planning around: you can FILTER by q[identifier], but the rows come back without `identifier` \u2014 the serializer here omits it. If you need the PR-NNN form back, use list_projects or the single-project read.", - "Only q[query] and q[identifier] are read; any other key under q is dropped silently and the search runs unfiltered, which reads as 'matched everything' rather than as an error.", - "Default page size is 30 (not 300). `count` is forwarded to the backing service and is not bounded in this codebase \u2014 300 is the documented ceiling, so treat larger values as unsupported rather than assuming they are clamped.", - "Rows carry `permissions` (the caller's own rights on that project) but no counts \u2014 test_cases_count/test_runs_count are absent from this payload. Fetch the single project if you need them.", - "The response is `projects` + `info` at the top level; there is no `data` envelope." - ] - }, - { - "name": "get_projects_minify", - "method": "GET", - "path": "/api/v1/projects/minify", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 300, - "description": "Rows per page. Clamped to 300 \u2014 a larger value is silently reduced, not rejected. Omitting it gives 300, not 30." - } - ], - "intent": "Page through every project in the group as a flat dropdown list, 300 at a time \u2014 the fastest way to enumerate all projects, but it cannot search and it returns no pagination metadata", - "returns": [], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectMinifyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "There is no search on this route at all \u2014 no `q` in any form. To find a project by name or identifier use get_projects_basic.", - "The response has NO `info` block: `{success, projects}` only. You cannot tell from one page whether more exist \u2014 keep requesting pages until one comes back with fewer than `count` rows (or empty).", - "Default page size is 300, not 30, and `count` is hard-clamped to 300: a larger value is silently reduced rather than rejected. That makes this the cheapest full enumeration in the index.", - "Rows are the same lean dropdown shape as get_projects_basic \u2014 id, name, description, starred, the caller's `permissions` \u2014 with no identifier and no test case / run counts.", - "`p` is validated globally: a non-positive or non-numeric page returns 400 'Invalid Params'." - ] - }, - { - "name": "get_report_attachment_url", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/attachments/{attachment_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. Part of the lookup key \u2014 an attachment filed under a different project is a 404 even with the correct id." - }, - { - "name": "attachment_id", - "type": "integer", - "required": true, - "description": "Integer stored-file id for the attachment. Not a report id and not an export channel id; no report capability returns one." - } - ], - "intent": "Exchange a report attachment's id for a short-lived direct download URL", - "returns": [ - "url", - "success" - ], - "responses": { - "200": { - "description": "Successfully retrieved report attachment URL", - "schema": { - "type": "object", - "required": [ - "url", - "success" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com/download/report.pdf" - }, - "success": { - "type": "boolean", - "example": true + }, + "TestCase__BulkCopyTestCases": { + "type": "object", + "description": "TestCase as returned by `bulk_copy_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkCopyTestCases" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns a presigned URL you can fetch yourself \u2014 the bytes never pass through this API. It expires in 100 minutes, so fetch it promptly rather than storing it.", - "`attachment_id` is a stored-file id, not a report id and not a channel id. No capability in this index hands one out: the report reads do not return attachment ids, and the export path only broadcasts its result over a realtime channel. In practice you need the id from an attachment link the product rendered elsewhere.", - "A miss returns HTTP 404 with a normal-looking body `{ url: null, success: false }` rather than an error envelope \u2014 check `success`, not just the status, and never treat `url: null` as a valid URL.", - "The lookup is scoped by BOTH the attachment id and the `project_id` in the path (plus your group), so the right id under the wrong project is a 404. Requires the report_view permission." - ], - "disabled": true - }, - { - "name": "get_report_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "sections", - "type": "string", - "example": "test_case_created_priority,test_case_top_creators", - "description": "Comma-separated widget names to compute, from the report type's allow-list. Anything else fails the whole call 400 `Invalid sections passed`. Omit for the report's default (often nearly empty) payload." - }, - { - "name": "is_print", - "type": "boolean", - "description": "Send `true` to compute EVERY section for `Test Run Summary` and `Test Plan Summary` in one call, ignoring `sections`. Ignored by the other report types. Compared as the literal string 'true'." - }, - { - "name": "report_data_only", - "type": "boolean", - "description": "Return ONLY the `report_data` object, dropping the definition and filters. Any non-empty value is truthy \u2014 `report_data_only=false` also strips the envelope. Omit it entirely for the full body." - }, - { - "name": "widget_type", - "type": "string", - "description": "Required when `sections` names a `*_drilldown` widget; the allowed set differs per drilldown (see get_report_section). Missing or unknown is 400." - }, - { - "name": "segment", - "type": "string", - "description": "Drilldown sub-slice, sent as a JSON-ENCODED STRING (e.g. `{\"outcome\":\"bugs_found\"}`), not a nested object. Unparseable values are ignored rather than rejected." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for the row-bearing sections (drilldowns, trtc_details, traceability rows). Does not page the definition." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table. Ignored by the other report types, which use their own page size." - }, - { - "name": "today", - "type": "string", - "example": "2025-05-13", - "format": "date", - "description": "Overrides the server's notion of 'today' for Test Plan Summary overdue/near-completion maths. Unparseable values fall back to the real date silently." - }, - { - "name": "issue_categories", - "type": "string", - "example": "epic,story", - "description": "Requirement Traceability Report only: restrict to these issue types. Accepts a comma-separated string or a repeated array param. The catch-all value `other` is stripped before the upstream call." - } - ], - "intent": "Read one saved report \u2014 its definition, resolved filters, and computed figures \u2014 the general-purpose report read", - "returns": [ - "data", - "report_filters", - "report_data", - "dynamic_filters", - "project" - ], - "paginated": true, - "responses": { - "200": { - "$response": "GetReportDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the read to reach for when you have a report id and want everything: the definition is at `data`, the resolved filter selection at `report_filters`, and the numbers at `report_data`. Use get_report_section instead when you want one widget and nothing else.", - "`report_data` is computed per report type and is mostly EMPTY unless you name sections. A bare read fills a Test Case Activity report's headline counts but leaves Test Run Summary's issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null. A null section means NOT COMPUTED \u2014 never read it as 'no data'. Request the section, then judge.", - "Shortcut: `is_print=true` forces ALL sections for `Test Run Summary` and `Test Plan Summary` in a single call, which is usually what you want rather than guessing section names. User Workload Report already returns every section when `sections` is omitted.", - "`report_data_only` is a trap: any non-empty value is truthy, INCLUDING the string \"false\". Sending `report_data_only=false` collapses the response to the bare `report_data` object with no `data`, no `report_filters` and no envelope. Omit the parameter entirely when you want the full body.", - "A section name not in the report type's allow-list is 400 `Invalid sections passed` for the whole call. Do not answer that by retrying without `sections` \u2014 you get a stripped body, not the data. Fix the name (see get_report_section for the per-type lists).", - "A report saved with `report_timeframe: specific_test_plans` whose plan filters have since gone returns 422 `This report is invalid. Please create a new Report`. That report cannot be read at all; recreate it.", - "`Defects Summary` and `Defects Detailed Report` have no data branch on the server \u2014 they always read back `report_data: null` no matter what you ask for. For defect figures use a Test Run Summary and its issues_by_priority / issues_by_status sections.", - "The report is resolved by group + id; the project in the path does not scope the lookup. 404 `Report Not Found` means no such report in your group. Requires the report_view permission.", - "`*_drilldown` sections (tr_drilldown, tc_activity_drilldown, es_drilldown, tp_drilldown) return PAGINATED ROWS and additionally require `widget_type` \u2014 never tally those rows into a total, read the aggregate section instead." - ] - }, - { - "name": "get_report_section", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/{section_name}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - }, - { - "name": "section_name", - "type": "string", - "required": true, - "example": "test_case_created_priority", - "description": "One widget name from the report type's allow-list (see guidance for the per-type lists). A name outside the list is 400 `Invalid sections passed`; report types with no list accept anything and return a default payload." - } - ], - "query": [ - { - "name": "widget_type", - "type": "string", - "description": "Required for the `*_drilldown` sections and validated against a set that differs per drilldown (see guidance). Missing or unknown is 400 `invalid widget_type`." - }, - { - "name": "segment", - "type": "string", - "example": "{\"outcome\":\"bugs_found\"}", - "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object. For es_drilldown + session_outcomes the `outcome` value is validated (400 otherwise); elsewhere an unparseable value is ignored." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for row-bearing sections." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page, forwarded only for the `Test Run Detailed Report` detail table; ignored elsewhere." - } - ], - "intent": "Fetch one report widget on its own \u2014 a single chart, table or drill-down slice from a saved report, without the report definition", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "The bare section payload (the report read's `report_data` object) \u2014 no success flag and no envelope. `null` for report types with no data branch.", - "schema": { - "type": "object", - "properties": { - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "issues_by_priority": { - "type": "string", - "nullable": true - }, - "issues_by_status": { - "type": "string", - "nullable": true - }, - "issues_data": { - "type": "string", - "nullable": true - }, - "test_cases_status": { - "type": "string", - "nullable": true - }, - "test_run_data": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 44 + }, + "TestCaseFieldValue__BulkCopyTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_copy_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" + } + } + }, + "TestCase__BulkReplaceTestCaseFields": { + "type": "object", + "description": "TestCase as returned by `bulk_replace_test_case_fields`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" }, - "closed": { - "type": "integer", - "example": 1 + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" }, - "test_cases": { - "type": "integer", - "example": 158 + "priority": { + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" }, - "test_results": { - "type": "integer", - "example": 9 + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "step_count": { + "type": "integer", + "example": 1 } - } - }, - "test_run_status": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "string", - "nullable": true - }, - "user_result_stats": { - "type": "string", - "nullable": true - } - } - } - }, - "400": { - "description": "Section name not in the report type's allow-list, or a missing/invalid widget_type or segment on a drilldown section." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "The report's saved plan selection no longer resolves \u2014 the report must be recreated." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is the BARE widget payload. This route runs the report read with `report_data_only` forced on, so there is no `success`, no `data` and no `report_filters` \u2014 just the section object keyed by the section name.", - "The section name must be in the report type's allow-list or the call is 400 `Invalid sections passed`. Test Run Summary: test_run_data, issues_data, test_run_status, test_cases_status, user_result_stats, issues_by_priority, issues_by_status, time_tracking, estimated_vs_actual, tr_drilldown. Test Run Detailed Report: trtc_details, trtc_filter_options, test_run_data, issues_data, trtc_performance, tr_drilldown.", - "Test Case Activity: test_case_created_count, test_case_updated_count, test_case_deleted_count, test_case_automation_coverage, test_case_top_creators, test_case_trend, test_case_created_priority, test_case_ai_coverage, test_case_list, tc_activity_drilldown. Test Plan Summary: test_run_data, test_run_status, test_plans_progress, test_plans_near_completion, overdue_test_plans, test_plans_execution_trend_list, test_plans_execution_trend_graph, test_result_user_stats, test_run_requirements, test_result_defects, defects_by_status, defects_by_priority, tp_drilldown. Exploratory Session Summary: es_drilldown. User Workload Report: cards, user_table, workload_distribution, execution_trend, status_distribution.", - "Requirement Traceability Report, Test Plan Detailed Report, Defects Summary and Defects Detailed Report have NO allow-list, so any section name is accepted and forwarded \u2014 a typo there returns an empty or default payload at 200 instead of a 400.", - "On `Defects Summary` / `Defects Detailed Report` the body is literally `null`: those types have no data branch at all. That is the report type, not a failure of your section name.", - "Drill-down sections need `widget_type` and reject anything outside their own set. tr_drilldown: active_runs, closed_runs, tr_performance, total_test_cases, linked_issues, requirements_linked, runs_by_state, defects_linked, assignee_breakdown, tcs_by_status. tc_activity_drilldown: created_test_cases, updated_test_cases, deleted_test_cases, automation_coverage, ai_coverage, trend, priority, top_creators. es_drilldown: total_sessions, linked_test_plans, session_outcomes. tp_drilldown has no allow-list and passes through.", - "For `es_drilldown` with widget_type `session_outcomes`, `segment` must carry an `outcome` from bugs_found, issues_found, needs_followup, skipped, no_issues, no_activity \u2014 anything else is 400.", - "Drill-downs return PAGINATED ROWS, not aggregates. Page them with `p`; never sum rows into a count \u2014 read the matching aggregate section instead.", - "Every precondition of the full report read still applies: 404 for an unknown report, 422 for a specific_test_plans report whose plan filters are gone, and the report_view permission.", - "The section name occupies a wildcard path segment. Two-segment paths with a reserved word (for example `detail`) are matched by other routes first, so only use real section names here." - ] - }, - { - "name": "get_report_summary_by_type", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "test_runs_summary", - "test_runs_details", - "test_plan_details", - "requirement_traceability_report" - ], - "description": "Which renderer to run. This is NOT checked against the report's stored report_type \u2014 a mismatch still computes and returns 200. Any other value is 400 `Report type not found`." - } - ], - "query": [ - { - "name": "reportTimeRange", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "Window to compute over. Required for test_runs_summary / test_runs_details \u2014 missing or unrecognised is an unhandled 500, not a 400. `custom` reads dates off the REPORT's stored custom_date_range. Ignored by requirement_traceability_report; optional for test_plan_details." - }, - { - "name": "sections", - "type": "string", - "example": "test_run_data,test_run_status", - "description": "Comma-separated widgets. test_runs_details defaults to `test_run_data,issues_data,trtc_performance` when omitted. Not allow-list-checked on this route." - }, - { - "name": "widget_type", - "type": "string", - "values": [ - "active_runs", - "closed_runs", - "tr_performance", - "total_test_cases", - "linked_issues", - "requirements_linked", - "runs_by_state", - "defects_linked", - "assignee_breakdown", - "tcs_by_status" - ], - "description": "Only read when `sections` includes `tr_drilldown`, and then required \u2014 an unknown value is 400 `invalid widget_type`." - }, - { - "name": "segment", - "type": "string", - "description": "Drilldown sub-slice as a JSON-ENCODED STRING, not a nested object." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number for the row-bearing renderers (test_runs_details, test_plan_details, traceability)." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page for the test_runs_details table. The upstream ceiling is not enforced here." - }, - { - "name": "required", - "type": "object", - "description": "Column selection for test_runs_details / test_plan_details: `{ \"\": { \"fields\": [...] } }`, entities test_run_test_case / test_run / test_result (plus test_plan). `fields` may be a comma-separated string, and `[\"all\"]` expands to every supported column. Unknown names are 400 here." - }, - { - "name": "issue_categories", - "type": "string", - "example": "epic,story", - "description": "requirement_traceability_report only: restrict to these issue types. Comma-separated string or repeated array param; the catch-all `other` is stripped before the upstream call." - }, - { - "name": "today", - "type": "string", - "example": "2025-05-13", - "format": "date", - "description": "Overrides 'today' for plan overdue/near-completion maths. Unparseable values fall back silently." - } - ], - "intent": "Render a saved report through one of four fixed renderers \u2014 run summary, run detail table, test plan detail table, or requirement traceability", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "ReportSummaryResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The `report_type` path segment selects a RENDERER, not the report's own type, and the server never checks the two agree. Pointing `test_runs_summary` at a Test Case Activity report computes a run summary from that report's filters and returns 200 \u2014 so the figures may not be the report the user asked about. Prefer get_report_detail unless you specifically want one of these four renderings.", - "The path segment takes four values, one more than the spec lists: `test_runs_summary`, `test_runs_details`, `test_plan_details` and `requirement_traceability_report`. Anything else is 400 `Report type not found`, as is omitting it.", - "`reportTimeRange` is genuinely required for `test_runs_summary` and `test_runs_details`: omitting it, or sending a value outside the timeframe enum, is an unhandled 500 rather than a validation error. `test_plan_details` falls back to the report's saved timeframe, and `requirement_traceability_report` ignores it entirely.", - "`reportTimeRange=custom` reads the dates off the REPORT's stored `custom_date_range`, not from the request \u2014 on a report saved without one it is a 500. Use the report's own timeframe value unless you know it has a custom range.", - "The response is the renderer's payload merged onto `{ success: true }` \u2014 the keys differ per renderer, so read what came back rather than assuming a fixed shape.", - "`test_runs_details` defaults `sections` to `test_run_data,issues_data,trtc_performance`, pages with `p` / `per_page`, and accepts a column selection under `required` which IS validated here (an unknown field name is 400 `Invalid fields: ... for `).", - "`test_plan_details` is feature-flagged: a group without the flag gets 403 `Operation not permitted`.", - "`requirement_traceability_report` accepts `issue_categories` (comma-separated or repeated) to restrict issue types; the catch-all value `other` is stripped before the upstream call.", - "`widget_type` here is the tr_drilldown set, and only applies when `sections` includes `tr_drilldown`. 404 `Report Not Found` for an unknown id; requires the report_view permission." - ], - "returns": [ - "success" - ] - }, - { - "name": "list_root_folders", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folders", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "folder_name", - "type": "string", - "example": "Folder_123", - "description": "Name search across the project's folders, not just root ones. It is trimmed and ignored when blank, so an all-whitespace value quietly returns the unfiltered root listing instead of an empty result." - }, - { - "name": "include_folder_hierarchy", - "type": "boolean", - "default": false, - "description": "Adds a `folder_hierarchy` map of folder id to its ancestor chain \u2014 the only way to learn a matched folder's path from this endpoint. Enabled only by the literal string \"true\" (case-insensitively); any other value is false." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "description": "Page number, and the switch that turns paging on at all. With `p` absent NO page parameters are sent upstream, `per_page` and `case_count` are ignored entirely, and you get the upstream default page. Always send `p` if you intend to page." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "description": "Rows per page. Read only when `p` is also present, and forwarded upstream unchanged \u2014 this service enforces no ceiling of its own, so the real maximum is the upstream one (documented as 100; not enforceable from this codebase). Trust `info` for what you actually got." - }, - { - "name": "case_count", - "type": "boolean", - "default": true, - "description": "Send the literal string \"false\" to drop the expensive per-folder test-case counts and the total count, keeping only subfolder counts \u2014 a worthwhile speedup on wide projects. Read only when `p` is present; any value other than \"false\" leaves counts on." - }, - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Ordering. Comma-separated combinations are accepted; anything outside this set returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort silently gains a secondary created_at-asc tiebreak." - } - ], - "intent": "List a project's root folders, or search folders anywhere in the project by name \u2014 the paginated way in when the full tree is too big to pull at once", - "returns": [ - "folders", - "info", - "folder_hierarchy", - "id", - "name", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "$response": "RootFolderDetailsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Without `folder_name` or a filter this returns ROOT folders only \u2014 it is not a flat list of every folder in the project. To reach nested folders either search by name, ask for the hierarchy, or walk the tree.", - "Paging is conditional on `p`. Sending only `per_page` does nothing at all: the handler never builds page parameters unless `p` is present, so you silently get the upstream default page size.", - "The payload is {folders: [...], info: {...}} with no `data` envelope, and `folder_hierarchy` appears only when you asked for it. Page forward using `info`, not by counting rows.", - "Folder ids are plain integers. `parent_id` is null on root folders.", - "Test-case filters passed under `q` narrow which folders come back (folders whose cases match). Using a 'contains only' match type without the feature flag returns 403 {success:false, message:'This feature is not available'} \u2014 that is a feature gap, not a permission problem.", - "Requires the folder-view permission." - ] - }, - { - "name": "get_selected_report_testcases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/selection/edit", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "intent": "Read a report's saved test-case selection in the folder-keyed shape the editor uses \u2014 the value to round-trip back when editing the report", - "returns": [ - "success", - "selection_data", - "selection", - "shared_selection" - ], - "responses": { - "200": { - "$response": "GetReportTestCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Use this before editing a report's case selection. The report edit treats `report_filters.test_cases` as authoritative and deletes what you omit, and a flat id list cannot express folder-level or project-level 'select all' \u2014 this returns the exact object to send back.", - "`selection` is the primary project's saved selection (`{}` when nothing is saved); `shared_selection` is `{ \"\": }` for the report's other projects on a cross-project report, and `{}` otherwise. `selection_data` is the project's full folder/case tree used to render the picker, not the saved selection \u2014 do not confuse the two.", - "Despite the name it is not traceability-specific: it works for any report type. It simply returns an unfiltered project selection tree when the report has no case filters saved.", - "Unlike the other report reads, the `project_id` in the path IS used \u2014 `selection_data` is built for that project, so pass the report's own project or the tree will not match the saved ids.", - "Case ids inside the selection are INTEGERS (the numeric id), not TC-NNN identifiers.", - "404 `Report Not Found` for an id that is not in your group. Requires the report_view permission." - ] - }, - { - "name": "get_shared_step", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "read", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the block's step ROWS (not over shared steps). A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 150, - "description": "Step rows per page. Forwarded unchanged to the backing service, which owns the ceiling \u2014 it is not bounded here. Previously undeclared, which is why long blocks silently truncated at 150." - }, - { - "name": "paginated", - "type": "boolean", - "default": false, - "description": "Does NOT control whether the rows are paginated \u2014 they always are. It only controls whether `page_info` is included in the response. Send 'true' or you get a page of rows with no way to tell that more exist." - } - ], - "intent": "Read one shared step with its full ordered step/result rows \u2014 the block that every referencing test case renders, and the row ids those references point at", - "returns": [ - "success", - "data", - "id", - "title", - "created_by", - "updated_by", - "shared_step_details", - "page_info" - ], - "paginated": true, - "responses": { - "200": { - "$schema": "SharedStepsIDResponse__GetSharedStep" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The rows are always fetched a page at a time (150 by default) regardless of `paginated`. What `paginated=true` buys you is the `data.page_info` block; without it a block with 400 steps returns its first 150 and looks complete. Always send paginated=true when you care about the whole list.", - "Each row in `data.shared_step_details` carries its own `id` \u2014 that integer is the `shared_step_detail_id` a referencing test-case step holds. Carry the row id, not its `order`, when you need to line up a test case's steps with this block.", - "Send these rows back verbatim (with their ids) when updating the block; the update replaces the whole array, so anything you did not read here is lost on the next write.", - "`test_case_count` and `step_count` appear only when the backing service returns them on this path \u2014 do not rely on them here. The shared-step listing always carries both.", - "A zero or non-numeric shared_step_id is rejected locally with 400 `{data:null, success:false, message:\"Validation Error\"}`; a block that does not exist returns 404 `{data:null, success:false}`.", - "`created_by`/`updated_by` are null when the original author is no longer a user in this workspace.", - "Requires tc_view." - ] - }, - { - "name": "get_shared_steps", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-steps", - "mode": "read", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. There is no per_page here \u2014 the page size is whatever the backing service defaults to." - }, - { - "name": "q[query]", - "type": "string", - "description": "Free-text match on the shared step TITLE only \u2014 it does not search step or result text. A bare scalar ``q` foo` is ignored rather than rejected, so the nested form is required." - }, - { - "name": "q[tags]", - "type": "string", - "description": "Filter to shared steps carrying this tag. Match mode is fixed to 'any' server-side; a match_object you send is not read." - }, - { - "name": "q[empty][system_fields][]", - "type": "array", - "values": [ - "tags" - ], - "description": "Find blocks with the named field empty. Only `tags` is honoured \u2014 any other field name is filtered out silently and the condition simply disappears." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Restrict to one shared-field folder. Forwarded only when present." - }, - { - "name": "include_sub_collections", - "type": "boolean", - "default": false, - "description": "Include blocks in sub-folders of folder_id. Only the literal string 'true' turns it on." - }, - { - "name": "field_type", - "type": "string", - "description": "Shared-field type filter, shared with the shared-components listing. Leave unset for ordinary shared steps." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort column, forwarded unchanged to the backing service, which owns the accepted set and the default." - }, - { - "name": "pre_fetch", - "type": "boolean", - "default": false, - "description": "Inline the first row's step details under `pre_fetched_shared_step`. Only takes effect on page 1 and only for the first row \u2014 it is a UI optimisation, not a way to fetch details in bulk." - } - ], - "intent": "List a project's shared steps with how many steps each holds and how many test cases embed it \u2014 use this to find a shared_step_id and to size the blast radius before editing or deleting a block", - "returns": [ - "success", - "data", - "info", - "pre_fetched_shared_step", - "id", - "title", - "step_count", - "test_case_count", - "created_by", - "updated_by" - ], - "paginated": true, - "responses": { - "200": { - "$response": "SharedStepsGetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`test_case_count` on each row is the number of test cases embedding that block. It is the number to read before any edit or delete, and this listing is the cheapest place to get it.", - "`step_count` is how many step rows the block holds; the rows themselves are not in this response \u2014 read the block by id for those.", - "Search is title-only via `q[query]`. Sending `q` as a bare string is silently ignored, so a search that returns everything usually means the parameter was flat instead of nested.", - "Page with `p`; there is no page-size parameter on this listing at all, so you cannot pull it in fewer, larger pages. Walk until `info.next` is null.", - "`created_by` and `updated_by` are resolved from BrowserStack user ids to `{id, full_name}` where that person still exists in this workspace, and come back **null** when they do not. A null author means a missing or removed user, not a missing field.", - "Requires tc_view." - ] - }, - { - "name": "get_system_field_values", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/{field_name}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "field_name", - "type": "string", - "required": true, - "values": [ - "priority", - "status", - "case_type", - "automation_state", - "defects" - ] - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Filter the options by display value (substring match)." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - } - ], - "intent": "List the option ids and display names a project defines for one system field (priority, status, case type, automation state) \u2014 resolve these before writing any of them.", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "SystemFieldValuesResponse" - }, - "401": { - "$response": "Unauthorized" - } - }, - "guidance": [ - "This is the lookup that makes writes correct: priority, status, case type and automation state are per-project configurable, and the bulk-edit paths require the NUMERIC id, not the display name.", - "The list is keyed by the field name you asked for, so the options are at `.values`, not at a fixed key. Each entry is `{value: , name: }` \u2014 `value` is the id to send.", - "`field_name` is one of priority, status, case_type, automation_state, defects. Any other name is not rejected by the route; it resolves to nothing and you get an empty list back with `success: true`.", - "`q` filters the options by value text and `p` pages them \u2014 a project with many options needs paging, so check `info` rather than assuming one page is the whole vocabulary.", - "Custom fields are not here; this covers system fields only." - ], - "returns": [ - "success", - "values", - "value", - "name", - "info" - ] - }, - { - "name": "get_template", - "method": "GET", - "path": "/api/v2/templates/{id}", - "mode": "read", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id \u2014 a plain integer with no prefix. Discover it from the template list; a non-numeric value does not reach this endpoint at all.", - "pattern": "^[0-9]+$" - } - ], - "intent": "Get one template in full, including every description, property and step field it carries \u2014 read this before editing a template so you can resend the complete field set.", - "guidance": [ - "This is the only read that returns the field layout; the list returns metadata and counts only.", - "The field count is not calculated on this read and always comes back empty here, even though the same field is populated on the template list. Take the count from the list when you need it; do not read the empty value as a template with no fields.", - "The Review & Approve property fields (reviewers, review status) are always filtered out of this response, on every workspace, even when the template stores them. Do not conclude from a read that they are absent.", - "The properties list contains a synthetic extra row: whenever the automation-status field is present, a duplicate of it is emitted with its internal name rewritten to the automation-state spelling, sharing the original's mapping id, field id and position. It is one stored field presented as two rows.", - "A field update replaces the whole section, so treat this response as the baseline: drop what you want gone, keep everything else, then send the full array back. Two things make that unsafe to do literally - the duplicated automation row above, and the Review & Approve fields that were filtered out - so reconcile both before resending rather than echoing the arrays verbatim.", - "The step-field list is emptied for workspaces without step custom fields enabled, while the key itself is always present. An empty list therefore means either 'not enabled here' or 'none configured', and this response cannot tell you which.", - "For a custom field, the field definition reports its type twice under different names: one is the product-facing type name, the other is the stored type name. Both are text - neither is a number - and for a small number of workspaces the product-facing one can be replaced with a nested-dropdown type that does not appear in the ordinary set.", - "There are two independent required-ness signals and they can disagree: the mandatory flag on the placement row, and a required flag inside the field definition itself. A field can be required by its own definition while the placement row says it is not.", - "An unknown or out-of-workspace id comes back as a 404 saying the template was not found, not as an empty success." - ], - "returns": [ - "success", - "template", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at", - "description_fields", - "properties_fields", - "step_fields" - ], - "responses": { - "200": { - "$response": "TemplateDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "list_test_case_attachments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List the files attached to one test case \u2014 id, filename, MIME type and size per file, so you can see what evidence is on the case and pick an id to remove", - "returns": [ - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "description": "Attachments retrieved successfully", - "schema": { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentResponse" - } - }, - "success": { - "type": "boolean" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DOES NOT WORK. Returns a bare 500 on any request. The handler calls a collection method on a value that is a plain array on this path, so it raises before serialising. Read a case's attachments through the entity-attachments listing instead.", - "Metadata only: id, filename, content_type, byte_size. There is NO url and no download endpoint on this v1 surface \u2014 if you need to read the file, use the v2 attachment listing, which returns a readable URL per file.", - "`attachments` and `success` are at the top level; there is no `data` envelope here, even though the sibling delete on the same path wraps its payload in one.", - "Not paginated and not filterable \u2014 every attachment on the case comes back in one array.", - "`folder_id` is part of the route but is not used to locate the case; the lookup is by project and test case id, so a stale folder id after a move still resolves.", - "An empty array means the case has no attachments; it is not an error." - ], - "disabled": true - }, - { - "name": "get_test_case_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one test case by its INTEGER id inside a folder \u2014 the v1 lookup, and the way to translate an integer id into the TC-NNN identifier the v2 surface needs.", - "returns": [ - "success", - "data", - "test_case", - "id", - "identifier", - "name" - ], - "responses": { - "200": { - "description": "Test case detail; data.test_case.identifier is the TC-NNN form.", - "schema": { - "$schema": "V1TestCaseDetail" - } - }, - "401": { - "description": "Unauthorized \u2014 authentication missing or invalid." - }, - "404": { - "description": "No test case with that integer id in that folder (or not visible)." - } - }, - "guidance": [ - "All three path segments are plain integers here, and the route only matches digits \u2014 a TC-NNN or PR-NNN identifier never reaches the API and 404s at the router.", - "The folder id must be the case's CURRENT folder. A correct case id under the wrong folder is a 404, not a redirect.", - "The case is at `data.test_case` \u2014 there is a `data` envelope on this v1 read. `data.test_case.identifier` is the TC-NNN form (or the workspace's third-party identifier when the workspace issues project-level ids); the field is called `name`, not `title`.", - "`estimate` always comes back null on every read path \u2014 the stored estimate, if any, is in `estimated_duration_seconds`.", - "This returns the case record only. For its linked runs, results and issue metadata use the detail read instead." - ] - }, - { - "name": "get_test_case_count_trend", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-count-trend", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how the project's test case count grew month by month, broken down by case type \u2014 use this for 'how fast is the suite growing'", - "returns": [ - "data", - "empty_data", - "order" - ], - "responses": { - "200": { - "$response": "TestCaseCountTrend" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`data` is keyed BY CASE TYPE NAME, not by month \u2014 the keys are whatever case types this group has defined (including custom ones), so treat it as an open map and iterate it rather than looking up fixed names.", - "The monthly numbers sit one level deeper, under each entry's `graphData`, keyed by three-letter month abbreviation (Jan\u2026Dec) for the trailing 12 months. `data.` itself is not the series.", - "Read `order` for the intended display sequence of those keys; object key order is not meaningful.", - "`Total` is a sibling key in the same map alongside the real case types (the CSV exporter special-cases it), so summing every key double-counts.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "get_test_case_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/detail", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "include", - "type": "string", - "values": [ - "folder_path" - ], - "example": "folder_path", - "description": "Comma-separated extras. `folder_path` is the only value read; anything else is ignored silently." - }, - { - "name": "exclude", - "type": "string", - "example": "results,defects", - "description": "Comma-separated relations to leave out (`results`, `defects`, `issue_metadata`). Passing it also switches off the heavier slide-over aggregation, so the response is smaller in more ways than the names suggest." - }, - { - "name": "hasDuplicates", - "type": "boolean", - "description": "When true, populates `duplicates_tc_count` from the dedupe store. Left null otherwise, and degraded to 0 rather than erroring if that store is unavailable." - } - ], - "intent": "Read one test case with its full slide-over detail \u2014 steps, custom fields, template, attachments and its linked runs, results and defects.", - "returns": [ - "data", - "success", - "test_case", - "is_archived", - "linked_issues", - "pagy" - ], - "responses": { - "200": { - "$response": "V1TestCaseDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The case is at `data.test_case`, and `success` is INSIDE `data` alongside it, not at the top level. `data.is_archived` tells you the case is archived rather than live.", - "By default this returns counts, not the run results themselves. Pass `exclude` to switch into the lighter mode, and read the paginated runs-results / runs-issues endpoints when you need the actual rows.", - "`include=folder_path` adds `data.test_case.folder_path` \u2014 the human-readable folder path. It is the only supported value.", - "All three path segments are plain integers and the route only matches digits; the folder id must be the case's current folder or it is a 404.", - "The template is resolved for you: `data.test_case.testcase_template` carries the field layout, and `using_fallback_template` is set when the case's own template could not be found and a system template was substituted.", - "`estimate` is always null here; the real value is `estimated_duration_seconds`." - ] - }, - { - "name": "list_test_case_histories", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List a test case's revision trail \u2014 who changed it, when, which fields moved, and the ids you need to view, diff or restore an earlier version", - "returns": [ - "success", - "histories", - "info", - "plan_restriction", - "id", - "entity_id", - "entity_type", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "user", - "user_id", - "comment", - "created_at", - "project_id", - "group_id", - "links" - ], - "responses": { - "200": { - "description": "Successfully retrieved test case histories", - "schema": { - "$schema": "HistoryListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only the FIRST record on each page carries the per-field `modified` map (old/new values). Every other record has `modified_fields` (the field NAMES only). To describe what actually changed in any other revision you must read that revision individually \u2014 one extra call per revision.", - "`plan_restriction: true` means the caller is not on Enterprise or Team Pro and the response was clamped to the last 30 days of revisions. Older revisions are simply absent with no other marker \u2014 never report 'no earlier changes' while that flag is true.", - "`version_name` (V1, V2, \u2026) is computed from the record's POSITION within the visible total, so labels renumber as old revisions age out of a restricted window. Carry the numeric `id` forward to the single-revision read, the diff and the restore \u2014 never the V label.", - "Page size is fixed at 30; `p` is the only paging control. Counts and page metadata are under `info`.", - "`user` is resolved from the actor's id on the way out, and `links.self` points at the single-revision read for that record.", - "Revisions track edits to the test case itself \u2014 not executions of it. Result history lives on the test run.", - "A `source` of 'create' marks the original revision; it has no diff to fetch and is skipped by the enrichment above." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Page size is fixed at 30 server-side; there is no per_page on this endpoint." - } - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "get_test_case_history_diff", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/diff", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "versions[]", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two NUMERIC history record ids from the revision listing, old first. MUST BE SENT AS A REPEATED PARAMETER - `versions[]=141552670&versions[]=104806106`. A comma-joined single value (`versions[]=141552670,104806106`) is NOT parsed and the route answers a bare 404 {success:false}, which reads as 'case or revision not found' when the ids are perfectly valid.", - "style": "repeated" - } - ], - "intent": "Compare two revisions of a test case field by field \u2014 what the name, description, preconditions, steps, status, priority, owner, tags, defects, attachments, reviewers and custom fields looked like in each", - "returns": [ - "success", - "diff", - "links" - ], - "responses": { - "200": { - "$response": "HistoryDiffResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type does not support comparison ({success:false, message:'Not supported for build'})." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SEND `versions[]` AS TWO REPEATED PARAMETERS, not one comma-joined value. The joined form returns a bare 404 that looks like a missing record; the repeated form works.", - "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is compared \u2014 fall back to reading the two revisions individually, which is not plan-gated.", - "`versions[]` takes exactly two NUMERIC history record ids from the revision listing, in the order old then new: the first becomes `old` in every entry. The V1/V2 labels are not accepted here.", - "Omitting `versions[]` is not validated \u2014 both ids silently become 0 and the call fails downstream instead of telling you the parameter was missing. Always send two real ids.", - "Only fields that actually differ appear under `diff`, and only from a fixed comparable set: name, description, preconditions, automation_status, category, status, priority, owner, assignee, folder, tags, issues, custom_fields, test_case_steps, attachments, reviewers, review_status, review_sent_date. Anything outside that set is invisible here regardless of whether it changed.", - "Values differing only by type (1 versus \"1\") are treated as unchanged, so the diff will not show spurious id edits.", - "Steps come back as whole step arrays under old and new rather than a line-by-line diff \u2014 turning that into a readable 'what changed' summary is the caller's job.", - "`links` maps each version id to its single-revision read, which is where the rest of that snapshot lives." - ] - }, - { - "name": "get_test_case_history", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "history_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one revision of a test case in full \u2014 the per-field before/after for that single change and who made it; the follow-up read after finding a revision in the trail", - "returns": [ - "success", - "history", - "id", - "entity_id", - "entity_type", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "user", - "user_id", - "comment", - "created_at", - "project_id", - "group_id", - "links" - ], - "responses": { - "200": { - "description": "Successfully retrieved test case history", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "history": { - "$schema": "History__GetTestCaseHistory" } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`history_id` is the numeric `id` from the revision listing, not the V1/V2 position label the listing also renders.", - "This read ALWAYS carries the full `modified` map. The listing only populates it for the first record on each page, so this is the call to make whenever you need actual old/new values for a specific revision.", - "The record is at the top level as `history` alongside `success` \u2014 no `data` envelope.", - "Unlike the diff and the restore, this read is not plan-gated. The listing that discovers ids is windowed to 30 days on non-Enterprise/Team Pro plans, though, so an older revision may simply be unreachable rather than forbidden.", - "Rows with no data on either side are dropped from `modified`, and the `modified_fields` banner is reconciled against what survived \u2014 so the two always agree, and a field named in neither genuinely did not change." - ] - }, - { - "name": "get_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/tags", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "List the tag names currently on one test case \u2014 to see what it is labelled with before filtering, grouping or re-tagging", - "returns": [], - "responses": { - "200": { - "$response": "TestCaseTagListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a BARE JSON ARRAY of the case's tags. There is no `data`, no `success` and no test case object in it \u2014 anything reading `data.test_case` from this call gets nothing. Read the array itself.", - "Despite the path segments, this returns tags only. For the test case's own fields (name, description, owner, status, steps) use a test-case read capability instead.", - "All three path ids are required and folder_id must be the folder the case actually lives in \u2014 the case is looked up by the triple, so a stale folder_id resolves to no test case rather than to the case in its new folder. Re-read the case's folder after any move.", - "Tags are referenced by NAME everywhere in this product; there are no tag ids in this response and none are needed to filter by tag.", - "This is read-only. To change a case's tags, write the test case's `tags` field \u2014 there is no add/remove-tag capability in this profile.", - "The `tags_show` action is absent from the RBAC permission map, so unlike the rest of the test-case surface it is not permission-gated." - ] - }, - { - "name": "get_test_case_type_split", - "method": "GET", - "path": "/api/v1/projects/{project_id}/dashboard-analytics/test-case-type-split", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart how the project's test cases divide across case types (functional, regression, smoke, custom types\u2026) \u2014 the current composition of the suite", - "returns": [ - "data", - "empty_data", - "name", - "y", - "value", - "field" - ], - "responses": { - "200": { - "$response": "TestCaseTypeSplit" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Each row carries both `y` and `value`; they are the same count under two names (`y` is the chart's series value). `field` is the underlying field key and `name` its display label.", - "Case types are group-configurable, so the set of rows is whatever this group has defined \u2014 never assume a fixed list.", - "This is a snapshot of the current suite, not a trend; use get_test_case_count_trend for growth over time.", - "The filter trap: sending ANY q key without an explicit q[created_at] makes the handler default both created_at and closed_at to 'all_time', and the closed_at path rewrites 'all_time' to '1_year' \u2014 so filtering only by owner silently narrows the chart to the last 12 months. Sending no q at all applies no date bound. Always pass q[created_at] explicitly whenever you pass any other filter.", - "project_id is the INTEGER id; the route segment only matches digits (or the literal 'new'), so a PR-NNN identifier is a routing 404 that never reaches the API.", - "A project that exists but is not visible to this caller renders as 404 {success:false}, not 403 \u2014 'no such project' and 'no access' are indistinguishable here." - ], - "query": [ - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative date window on creation date, as _, optionally suffixed _gte for 'older than' (e.g. 3_month, 7_day_gte). 'all_time' is honoured on this key and applies no bound. An unrecognised unit raises server-side rather than returning 400." - }, - { - "name": "q[closed_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, applied to close date. Only read when q[created_at] is absent or 'all_time' \u2014 created_at wins. 'all_time' is NOT honoured here: it is silently rewritten to 1_year." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (resolve them with list_project_users). Translated to BrowserStack ids server-side; ids that do not resolve are dropped, and if none resolve the owner filter is omitted entirely rather than returning an empty chart." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Echo the filters you sent back, resolved, as a `filter_details` key merged into the response (owner ids become user objects). Has no effect unless q is also present." - } - ] - }, - { - "name": "list_test_run_test_cases_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Rows per page. Anything outside 1..100, or non-numeric, silently falls back to 30 instead of erroring \u2014 a request for 500 returns 30 rows and looks like a short run." - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority,tags,status", - "default": "owner,automation_status,automation_state,priority,tags,issues,reviewers", - "description": "COMMA-SEPARATED LIST OF COLUMNS to include on each row \u2014 not a mode. Omit it and the default column set above is used. `case_type` and `defects` are accepted as aliases of the stored column names." - }, - { - "name": "required[custom_fields]", - "type": "string", - "description": "Comma-separated test-case custom field ids to include on each row." - }, - { - "name": "required[result_custom_fields]", - "type": "string", - "description": "Comma-separated test-result custom field ids to include on each row." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-asc", - "description": "Column and direction, as `-`. TRAP: the column must also appear in required[fields] or the call fails with 'Sort by column is not present in required fields'. Custom fields (cf#) are exempt." - }, - { - "name": "shared", - "type": "boolean", - "default": false, - "description": "Return ONLY the cases shared into this run from other projects. Default (false) returns the run's full list including shared ones." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Restrict the list to the slice of the run contributed by one source project. Digits only; anything else is rejected." - }, - { - "name": "q", - "type": "object", - "description": "Filter object (same shape the run-detail search takes) applied to the rows. Sent as bracketed query params." - } - ], - "intent": "List the test cases inside a test run with their latest result, assignee and configuration \u2014 the execution grid for the run, and the source of the mapping ids the bulk operations take", - "shape": "discovered", - "responses": { - "200": { - "description": "The run's test cases for the requested page. Flat body: {success, info, test_cases}, plus a top-level skipped_test_case_ids array when the review gate excluded any case. Each row is one case x configuration mapping and carries both `id` (test case) and `mapping_id` (the run mapping).", - "schema": { - "$schema": "TestCaseListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are per case-times-configuration mapping, not per test case: a case running against three configurations appears three times, with the same `id` and three different `mapping_id` values. Count distinct `id` if you want a case count.", - "`mapping_id` is the id every bulk operation on this run takes \u2014 assigning, bulk-editing results, unlinking. `id` is the test case. Passing `id` where `mapping_id` is expected returns 200 and changes nothing.", - "sort_by silently depends on required[fields]: sorting by a column you did not ask for errors out. Add the column to required[fields] whenever you sort by it.", - "per_page tops out at 100 and out-of-range values fall back to 30 rather than erroring, so always read `info.page_size` for what you actually got and `info.count` for the total.", - "The 200 can carry a top-level `skipped_test_case_ids` array \u2014 cases the review gate excluded from the run. Their absence from the list is not a data error.", - "`latest_status` is 'untested' for a case that has never been executed; it is never blank.", - "The response is flat \u2014 {success, info, test_cases} with no `data` envelope.", - "Needs the test-run view permission, with the automation variant demanded on automation runs." - ], - "returns": [ - "success", - "info", - "test_cases", - "skipped_test_case_ids", - "id", - "identifier", - "name", - "mapping_id", - "test_case_id", - "test_run_id", - "latest_status", - "result_status", - "result_custom_fields", - "result_notes", - "defects", - "configuration", - "test_assignee", - "last_executed_by", - "last_executed_at", - "planned_execution_date", - "execution_eligible", - "owner", - "priority", - "tags", - "created_at", - "updated_at" - ], - "paginated": true, - "max_page_size": 100, - "disabled": true - }, - { - "name": "list_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Walk pages until `info.next` is null; `p` advances by the page size, not by how many rows came back." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", - "minimum": 1, - "maximum": 100, - "default": 30 - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." - }, - { - "name": "required[custom_fields]", - "type": "string", - "example": "121,119", - "description": "Comma-separated custom field ids to include on each row." - }, - { - "name": "all_folders", - "type": "string", - "values": [ - "true" - ], - "example": "true", - "description": "Send the string `true` to list the WHOLE project. Without it this route is scoped to no folder and comes back effectively empty \u2014 this is the single flag that makes the endpoint do what its name suggests. It also adds a `folders` block to the response." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-ASC", - "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." - } - ], - "intent": "List a project's test cases across every folder \u2014 the project-wide repository read. Requires all_folders=true.", - "returns": [ - "success", - "test_cases", - "info", - "folders" - ], - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "description": "Successfully retrieved test cases", - "schema": { - "$schema": "TestCaseConsolidatedListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`all_folders` is compared as the STRING `true` \u2014 a JSON boolean does not match. It is what makes this read project-wide; without it no folder scope is applied and the response is not the project's case list, while still reporting `success: true`.", - "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else \u2014 including a value above 100 \u2014 so `info.page_size` is what you actually got, not what you asked for.", - "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", - "Walk `p` until `info.next` is null. `p` advances by the page size rather than by the number of rows returned, so a partial read cannot be recovered by paging further.", - "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null on every row.", - "A backend 404 is swallowed into an empty list with `success: true` rather than surfacing \u2014 another reason a zero count is not proof the project is empty." - ] - }, - { - "name": "get_test_plan_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Read one test plan or sub-plan in full by its integer id \u2014 name, dates, status, owner, tags, custom fields, reviewers, progress rollup, and its TP-NNN / STP-NNN identifier", - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "assignee", - "created_by", - "test_runs_count", - "total_test_runs_count", - "test_results_trend_v2", - "links" - ], - "responses": { - "200": { - "description": "Test plan detail; `test_plan.identifier` (no `data` wrapper) is the TP-NNN / STP-NNN form.", - "schema": { - "$schema": "V1TestPlanDetail__GetTestPlanByIntegerId" - } - }, - "401": { - "description": "Unauthorized \u2014 authentication missing or invalid." - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No test plan with that integer id, not visible to this user, or archived and read without is_archived=true. Body is a bare `{success: false}` with no message.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "is_archived", - "type": "boolean", - "description": "Opt in to reading an ARCHIVED plan. Without it an archived plan is 404 \u2014 the read is active-only by default. Pass the literal string 'true'." - } - ], - "guidance": [ - "The path segment is the plan's INTEGER id; the route only matches digits, so a TP-NNN / STP-NNN identifier gives a routing 404 and never reaches the handler. Resolve the integer from the plan listing first.", - "This is the read for one plan. Two sibling routes on the same collection path are NOT plan reads and take no id: the archived listing and the archived count.", - "An archived plan reads as 404 unless you pass is_archived=true. If a plan a user named looks missing, retry with that flag before concluding it was deleted.", - "There is no `data` wrapper: the plan is at the top level under `test_plan`, and `test_plan.identifier` is the TP-NNN / STP-NNN form while `test_plan.id` is the integer every v1 route wants.", - "A 404 body is a bare {success: false} with no message \u2014 it carries nothing that distinguishes 'no such plan' from 'not visible to you'.", - "test_plan_progress is ALWAYS an empty object; the real rollup is test_plan_progress_v2 (counts keyed by result-status id). Same for test_results_trend vs test_results_trend_v2.", - "custom_fields comes back as [] (an empty array, not an object) when the plan has none.", - "parent_plan is present only on a sub-plan; on a top-level plan parent_plan_id is null and sub_plans_count tells you whether it has children.", - "review_status, review_sent_date, reviewers and review_settings appear only when the plan review workflow is configured for the project \u2014 do not treat their absence as 'not reviewed'.", - "Sub-plans and their runs are not embedded. Use the plan's test-runs listing (with include_sub_test_plan_runs) to see what is under it." - ] - }, - { - "name": "get_test_plan_execution_trend", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/execution-trend", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "test_plan_id", - "type": "integer", - "description": "Scope the trend to this plan (INTEGER id). Mutually exclusive with test_run_id \u2014 exactly one of the two must be present." - }, - { - "name": "test_run_id", - "type": "integer", - "description": "Scope the trend to this run instead of a plan. Mutually exclusive with test_plan_id." - }, - { - "name": "today", - "type": "string", - "format": "date", - "description": "Anchor date the trend window ends on. Defaults to the server's current date; an unparseable value silently falls back to it rather than erroring." - } - ], - "intent": "Execution-trend chart series (results over time) for exactly ONE test plan or ONE test run \u2014 the Insights trend graph; scope it with test_plan_id or test_run_id, never both", - "shape": "discovered", - "responses": { - "200": { - "description": "`{success, data}` \u2014 the requested section sits at `data.test_plans_execution_trend_graph`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object", - "properties": { - "test_plans_execution_trend_graph": { - "type": "object", - "description": "The trend series for the scoped plan or run." + }, + "TestCaseFieldValue__BulkReplaceTestCaseFields": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_replace_test_case_fields`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" } - } - } - } - } - }, - "400": { - "description": "Neither or both of test_plan_id / test_run_id supplied. `{success: false, message: ...}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a project-level chart route, not a plan read: the plan or run is chosen by QUERY parameter, and the path carries only the project. It is a different endpoint from the plan detail read even though it sits on the same collection path.", - "Exactly one scope. Both ids \u2192 400 {success: false, message: 'Provide either test_plan_id or test_run_id, not both'}; neither \u2192 400 {success: false, message: 'test_plan_id or test_run_id is required'}. Note the error key is `message`, not `error`.", - "The series is nested: read data.test_plans_execution_trend_graph. `data` is the report envelope and carries only the section that was requested.", - "Ids are forwarded to the reporting backend uncoerced \u2014 send the INTEGER plan id / run id, not TP-NNN or TR-NNN.", - "There is nothing to create, update or delete here; this returns chart data only. For the burndown view there is a separate chart route that accepts a plan and rejects a run.", - "Needs the test-plan VIEW permission (unlike the archived reads, which need delete)." - ], - "returns": [ - "success", - "data", - "test_plans_execution_trend_graph" - ] - }, - { - "name": "get_test_plan_linked_sessions_chart_data", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/linked-sessions-chart-data", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "intent": "Chart data for the exploratory sessions linked to one test plan \u2014 the plan's sessions insight tile", - "shape": "discovered", - "responses": { - "200": { - "description": "`{success, data}` \u2014 linked-session chart data for the plan.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "type": "object" } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes the plan's INTEGER id in the path and nothing else: no query parameters, no paging, no date window.", - "The payload is unwrapped for you \u2014 where the upstream reply nests everything under `result`, that inner object is what lands in `data`.", - "A plan with no linked exploratory sessions returns 200 with an empty/zeroed `data`, not a 404. Do not read an empty chart as a missing plan.", - "This route is served by the exploratory-sessions handler rather than the test-plan one, so it is not behind the test-plan view permission the other plan reads use.", - "Chart data only \u2014 there is no session list here. Use the exploratory-session listing capabilities to get the sessions themselves." - ], - "returns": [ - "success", - "data" - ] - }, - { - "name": "list_test_plan_test_runs_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page \u2014 the page size is fixed at 30." - }, - { - "name": "q", - "type": "string", - "description": "Free-text search over the linked runs. Supplying it also turns off the analytics rollup, so progress fields come back thinner than on an unsearched page." - }, - { - "name": "include_sub_test_plan_runs", - "type": "boolean", - "description": "Also return the runs linked to this plan's sub-plans. Off by default, so a run a user attributes to the plan can be missing simply because it hangs off a sub-plan." - }, - { - "name": "compact", - "type": "boolean", - "description": "Ask upstream for the slim linkage payload (identity fields only, no pagination) used to pre-seed an edit form. Do not combine it with paging expectations." - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Required to see the runs of an ARCHIVED plan \u2014 without it an archived plan's run list comes back empty rather than erroring." - } - ], - "intent": "List the test runs linked to a test plan, with each run's state and progress \u2014 optionally including the runs that belong to its sub-plans", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListTestPlanTestRunsByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The plan is addressed by INTEGER id; the route only matches digits, so a TP-NNN identifier 404s at routing.", - "Each row's `id` is the TR-NNN string and the integer lives in `uuid`. Take `uuid` for anything that wants a numeric run id \u2014 including the plan's own unlink call and the plan update's test_runs list.", - "Sub-plan runs are excluded by default. Pass include_sub_test_plan_runs=true before telling a user a run is not in the plan.", - "An archived plan returns an empty run list unless is_archived=true is passed \u2014 empty is not proof the plan has no runs.", - "Page size is fixed at 30 and there is no per_page parameter; page with `p` and stop using the `info` envelope. `count` is {active, closed} across the whole linkage, not the current page.", - "Rows are run summaries, not run detail: `overall_progress_by_status_id` is the useful rollup and `overall_progress` is the legacy one. To see a run's cases, read the run's test cases separately." - ], - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "run_state", - "active_state", - "owner", - "assignee", - "tags", - "is_automation", - "is_dynamic", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "test_plans", - "project_id", - "created_at", - "updated_at" - ], - "max_page_size": 30 - }, - { - "name": "get_test_results_custom_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-results/custom-fields", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. PR-NNN identifiers do not match the route." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control on this endpoint \u2014 the page size is fixed at 30." - } - ], - "intent": "List the custom fields a project shows on a test RESULT \u2014 the extra fields a tester fills when recording a pass or fail, with the ids and types needed to send values", - "returns": [], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "TestResultCustomFieldsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Result fields only. This endpoint hard-codes entity_type=TestResult, so nothing here overlaps the test-case form fields \u2014 a field defined against TestCase never appears, whatever the project.", - "`field_type` uses the LEGACY spelling (field_dropdown, field_string, field_user, field_boolean \u2026), not the canonical vocabulary the admin create/update calls take. A nested_dropdown field has no legacy spelling and comes back as null.", - "Page size is fixed at 30 and cannot be raised: `per_page` is accepted on the wire and discarded, and only `p` is read. Page until `info.next` is null.", - "`optional` and `is_required` are inverses of each other; prefer is_required.", - "Each row's `links.self` is the ready-made path to that field's allowed values scoped to this project \u2014 follow it instead of building the path, and note it resolves to the project user list for a `user` field.", - "`option_values` / `field_values` are present only when the upstream row carried them, so an absent key is not proof the field has no options.", - "project_id is the integer id; PR-NNN identifiers do not match the route. An unknown project is rejected by the project lookup before the fields are read." - ] - }, - { - "name": "list_test_results_for_test_case_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "read", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Read the result history for one test case inside one test run \u2014 every attempt logged against that case with its status, author, note, attachments, linked defects and custom fields; the read for 'what happened when we executed this case?'", - "returns": [ - "success", - "test-results", - "info", - "unique_issues", - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "custom_fields_count", - "test_run_step_result", - "attachments", - "issues", - "links" - ], - "responses": { - "200": { - "$response": "TestResultListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The list key is `test-results` with a HYPHEN. Reading response['test_results'] finds nothing on a perfectly populated response \u2014 this endpoint is the odd one out.", - "Newest first: the first entry is the latest attempt, and it is exactly the one the PATCH-latest capability edits.", - "Page size is FIXED at 30 and is not caller-controllable \u2014 `p` is the only paging input, and there is no per_page. Paging metadata is under `info` (count / page / page_size / next / prev).", - "With a configuration matrix you get rows for every configuration mixed together. Narrow with configuration_id, or with mapping_id for one specific run/case row, before concluding anything about 'the' result.", - "`unique_issues` at the top level is the de-duplicated set of defects linked across all the results returned \u2014 use it rather than unioning each result's own issues.", - "Custom fields arrive already grouped and typed per result, with `custom_fields_count` beside them; per-step outcomes hang off each result as `test_run_step_result`.", - "An empty list means the case has never been executed in this run \u2014 it is not an error and not a permission problem." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Page size is fixed at 30 server-side and cannot be raised." - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Restrict to results logged against one configuration." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Restrict to one specific run/case mapping row." - } - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "list_test_results_for_test_case", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/results", - "mode": "read", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-123", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Test run identifier in TR- form. A bare integer does not match the route and 404s." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-101", - "description": "Test case IDENTIFIER (TC- form), resolved by lookup \u2014 not the numeric internal id the v1 route takes. An identifier that does not resolve in this project returns 404 \"Test case not found\"." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Note the name is `p`, not `page`; anything else is ignored and page 1 comes back." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Results per page. ONLY 30 or 300 are accepted \u2014 any other value is rejected 400 \"Invalid page_size\"." - }, - { - "name": "count", - "type": "integer", - "values": [ - 30, - 300 - ], - "description": "Alias for page_size, and it WINS when both are sent. Same 30-or-300 restriction." - } - ], - "intent": "Read what was recorded for one test case in one test run \u2014 every execution logged against it, with status, notes, linked defects, custom fields and configuration", - "guidance": [ - "Returns the case's result history in this run, one entry per recorded execution \u2014 not just the latest outcome. Take the newest entry if you only want the current status.", - "Only genuinely logged executions appear here. A case that has been added to the run but never executed has no entry at all, even though the run's case grid shows it with a result id \u2014 that id is the run row, not a result.", - "`result_status` on this endpoint is deliberately cut down to `field_value` alone (display case, e.g. 'Passed'). There is no id, internal_name, field_name or colour here, unlike the v1 result reads. Match on the display-case name, or resolve the project's configured statuses separately if you need the id or colour.", - "`custom_fields` is an ARRAY of entries, each carrying a field_name and a value \u2014 not an object keyed by field name. It is empty when the result has no custom field values. field_name is the field's system name, and for dropdown / single-select fields the value is the selected option's id rather than its label, so resolve the option separately if you need text.", - "Both ids are identifiers: TR- for the run, TC- for the case. Passing internal numeric ids does not match the route (run) or does not resolve (case).", - "Page size is a closed set: 30 or 300, nothing in between. The page parameter is `p`.", - "`urls.self` is a deep link to the case's row in the run. It is omitted (the key comes back null) when the result cannot be tied to a project, run, case and run row all at once.", - "`step_result` is null for case-level results and only populated for step-level ones; `configuration_id` is null when the run row has no configuration; `dataset` is empty for non-data-driven runs.", - "Results are listed under `test_results` with an underscore here \u2014 the v1 list uses `test-results` with a hyphen, so a client written against v1 reads an empty list and reports no results." - ], - "returns": [ - "success", - "test_results", - "info", - "id", - "created_at", - "updated_at", - "created_by", - "description", - "test_case_id", - "test_run_id", - "result_status", - "issues", - "custom_fields", - "step_result", - "configuration_id", - "execution_id", - "dataset", - "urls" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "V2TestResultListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "integer", - "required": true - } - ], - "intent": "Get a test run by INTEGER id (v1) \u2014 full detail + its TR-NNN identifier", - "guidance": [ - "The run's id field is the display identifier string, while uuid is the integer. Take uuid whenever you carry this run into another call: the display string is rejected downstream." - ], - "returns": [ - "id", - "identifier", - "name", - "uuid" - ], - "responses": { - "200": { - "description": "Test run detail; data.test_run.identifier is the TR-NNN form.", - "schema": { - "$schema": "V1TestRunDetail" - } - }, - "401": { - "description": "Unauthorized \u2014 authentication missing or invalid." - }, - "404": { - "description": "No test run with that integer id (or not visible to this user)." - } - } - }, - { - "name": "get_test_run_detail", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/detail", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer project id. The v1 routes only match digits here, so a PR-NNN identifier never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Either the TR-NNN identifier or the bare integer \u2014 the v1 run routes accept both forms in the path." - } - ], - "intent": "Open a test run's detail view \u2014 its header, progress counts, linked defects and the dynamic-filter rule behind it. This does NOT return the run's test cases; list those separately", - "shape": "discovered", - "responses": { - "200": { - "$schema": "TestRunResponse__ListTestRunCases" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the path, this returns NO per-case rows. It is the run header enriched with linked issues and the dynamic-filter rule; the run's cases come from the run's test-cases listing.", - "This is the only read that returns the run's filter rule, and it returns it twice under two names \u2014 `dynamic_filter` and `filters` \u2014 with identical content. Read either.", - "It also returns `issues` (defects linked to the run) and `test_result_issues` (defects linked to individual results), which the plain run read omits.", - "The run's `id` is the TR-NNN STRING; the integer is in `uuid`. Take `uuid` whenever a numeric run id leaves this read.", - "The run sits under `data.test_run` \u2014 with an underscore, inside a `data` envelope. The write endpoints on this entity use `data.testrun` instead, and the v2 run read has no `data` at all.", - "`trtc_folder_progress_enabled` is a group feature flag, and a lookup failure is reported as false \u2014 treat false as 'not available', not as a confirmed setting.", - "Needs the test-run view permission, with the automation variant demanded on automation runs." - ], - "returns": [ - "data", - "success", - "test_run", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "attachments", - "metadata", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "closed_at", - "closed_by", - "created_by", - "updated_by", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ] - }, - { - "name": "list_test_run_test_cases", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/test-cases", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." - }, - { - "name": "page_size", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page. Defaults to 30 when omitted. Above 300 the request errors ('Page limit exceeds the maximum limit of 300'); with fetch_steps=true the ceiling drops to 30." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "description": "Alias for page_size and read first when both are sent. Same ceilings." - }, - { - "name": "fetch_steps", - "type": "boolean", - "default": false, - "description": "Include each case's steps. Pass the literal string 'true'. Doing so lowers the maximum page size from 300 to 30." - }, - { - "name": "minify", - "type": "boolean", - "default": false, - "description": "Compact row shape. Pass the literal string 'true'." - }, - { - "name": "is_shared", - "type": "boolean", - "description": "Restrict to cases shared in from another project." - }, - { - "name": "source_project_id", - "type": "integer", - "description": "Numeric id of the origin project, to narrow shared cases to one source project." - }, - { - "name": "include_subfolders", - "type": "boolean", - "default": false, - "description": "Make folder_id include all descendant folders." - }, - { - "name": "match", - "type": "object", - "description": "Per-field operator, e.g. match[tags]=all&match[assignee]=none. Multi-value fields (tags) take all|any|none|only; single-value fields take only any|none \u2014 anything else is a 400. match[global]=all (default) ANDs the fields, match[global]=any ORs them. `only` needs a group entitlement, else 403." - }, - { - "name": "latest_status", - "type": "string", - "description": "Comma-separated latest execution statuses (untested, passed, failed, retest, blocked, skipped, in_progress, plus any custom group status). Unknown values are a 400 listing what is valid." - }, - { - "name": "assignee", - "type": "string", - "description": "Comma-separated assignee emails. Use match[assignee]=none for unassigned cases. An email that does not resolve in the group is a 400, never a silently ignored filter." - }, - { - "name": "priority", - "type": "string", - "description": "Priority label, resolved to an id inside the group." - }, - { - "name": "tags", - "type": "string", - "description": "Comma-separated tags. Combine with match[tags]=all|any|none|only." - }, - { - "name": "folder_id", - "type": "string", - "description": "Folder id. Pair with include_subfolders=true to include descendants." - }, - { - "name": "configuration", - "type": "string", - "description": "Configuration label, resolved to an id inside the group." - }, - { - "name": "name", - "type": "string", - "description": "Substring match on the test case name." - }, - { - "name": "identifier", - "type": "string", - "description": "Exact test case identifier, e.g. TC-42." - }, - { - "name": "query", - "type": "string", - "description": "Free-text search across name and identifier." - }, - { - "name": "test_case_status", - "type": "string", - "description": "Test case status label, resolved to an id inside the group." - }, - { - "name": "test_case_owner", - "type": "string", - "description": "Comma-separated test case owner emails." - }, - { - "name": "case_type", - "type": "string", - "description": "Category / type label, resolved to an id inside the group." - }, - { - "name": "automation_status", - "type": "string", - "description": "Automation status label, resolved to an id inside the group." - }, - { - "name": "created_by", - "type": "string", - "description": "Comma-separated creator emails." - }, - { - "name": "updated_by", - "type": "string", - "description": "Comma-separated last-updater emails." - }, - { - "name": "template", - "type": "string", - "description": "Template label, resolved to an id inside the group." - }, - { - "name": "created_between", - "type": "string", - "description": "Two comma-separated dates (start,end) bounding creation time." - }, - { - "name": "updated_between", - "type": "string", - "description": "Two comma-separated dates (start,end) bounding last-update time." - } - ], - "intent": "List the test cases inside a run with their latest result, assignee and configuration \u2014 the read to answer \"what is still untested / failing in this run?\"", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size` (or `count`). The default page is 30 rows and the hard ceiling is 300; asking for more errors instead of clamping.", - "fetch_steps=true drops the ceiling to 30 rows per page, so a run of any size takes many more calls with steps on. It also adds a `steps` array to each row, which is otherwise absent.", - "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 'request_limit_exceeded'. Filter the run down rather than walking a huge run to the end.", - "Filters fail closed \u2014 an unknown status, an unresolvable email or an invalid match mode returns 400 listing the valid values, so a filtered read never quietly degrades into the whole run.", - "Rows are per case-and-configuration mapping: a case mapped to three configurations comes back as three rows, each with its own configuration_id and latest_status. configuration_id is null for cases with no configuration.", - "Rows carry more than the core execution fields: is_shared, source_project_name, execution_id, created_at, last_updated_at, created_by and updated_by arrive on every row, plus a urls object. The created/updated stamps belong to the row's place in the run, not to the underlying test case record.", - "Use latest_result_id from a row when you need to fetch or amend that case's recorded result. It is populated even on rows that have never been executed \u2014 the result row is minted when the case is mapped into the run \u2014 so a non-null id does NOT mean the case has run. Detect 'never executed' from latest_status being untested instead.", - "The `urls.self` deep link on these rows is currently emitted without the run segment, so it does not resolve. Build run links from the run identifier you already hold rather than following it." - ], - "returns": [ - "success", - "info", - "test_cases", - "identifier", - "name", - "description", - "latest_status", - "latest_result_id", - "assignee", - "priority", - "status", - "case_type", - "configuration_id", - "folder_path", - "dataset", - "project_id", - "source_project_id", - "source_project_name", - "is_shared", - "execution_id", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "TestRunTestCasesV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "query": [ - { - "name": "minify", - "type": "boolean", - "default": false, - "description": "Return the compact run payload (drops the heavier analytics/detail fields). Pass the literal string 'true'." - } - ], - "intent": "Look up one test run \u2014 its state, owner, tags, configurations, linked plan and per-status progress counts", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Both ids are prefixed strings here (PR-1 / TR-14). The v2 route only matches PR-\\d+ and TR-\\d+, so the integer ids the v1 run capabilities take give a 404 on this path.", - "This returns the run header only \u2014 progress is aggregated counts. To see which cases are in the run, or their latest results, read the run's test cases instead.", - "403 means the caller lacks view permission for this run type (automation runs are gated separately from manual ones).", - "The run is at the top level of the response as `test_run`; there is no `data` envelope here. The v1 run endpoints wrap their payload in `data` \u2014 these v2 ones do not.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "test_cases", - "urls" - ], - "responses": { - "200": { - "$schema": "TestRunV2Response__ListTestRun" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_runs_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/form-fields", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "all", - "type": "boolean", - "default": false, - "description": "Include the project's custom result fields as well as the defaults. PRESENCE-checked, not value-checked: `all=false` and `all=0` behave exactly like `all=true`. The only way to get the compact shape is to omit the parameter entirely." - } - ], - "intent": "Discover the fields available when recording a test RESULT in this project \u2014 the built-in status options and, on request, the project's custom result fields \u2014 so a result can be submitted with valid values", - "returns": [], - "responses": { - "200": { - "$response": "TestRunFormFieldsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These are TEST RESULT fields, not test run fields \u2014 despite the path. Use them to build a valid result submission (status id, custom field ids and their allowed option values).", - "The response SHAPE changes with `all`: omit it and you get {success, default_fields} only; send it and `custom_fields` appears alongside. Code that reads custom_fields must send the parameter.", - "`default_fields` is an object keyed by field name (today just `status`), not an array. Each entry lists its allowed values with their numeric ids \u2014 those ids, not the display labels, are what result writes take.", - "The import-only pseudo-fields (owner_imported, owner_imported_email) are stripped from custom_fields, so a field visible in an exported CSV can be missing here.", - "The response is flat \u2014 {success, default_fields, custom_fields} with no `data` envelope." - ] - }, - { - "name": "list_test_runs_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/selection", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "select_all", - "type": "boolean", - "example": false, - "description": "Select every case in the project. When false, `folders` decides the set.", - "json_path": "/selection/select_all" - }, - { - "name": "unique_test_case_count", - "type": "integer", - "example": 2, - "description": "The caller's own count of the selection. Echoed through; it does not constrain the result.", - "json_path": "/selection/unique_test_case_count" - }, - { - "name": "folders", - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection: {test_case_select_all, selected_test_cases:[ids], deselected_test_cases:[ids]}. A folder with test_case_select_all takes the whole folder minus deselected_test_cases; otherwise only selected_test_cases counts.", - "fields": [ - { - "name": "test_case_select_all", - "type": "boolean" - }, - { - "name": "selected_test_cases", - "type": "array" - }, - { - "name": "deselected_test_cases", - "type": "array" - } - ], - "json_path": "/selection/folders" - }, - { - "name": "show", - "type": "string", - "values": [ - "all", - "filtered" - ], - "default": "filtered", - "description": "`filtered` (the default, and what ANY value other than the literal 'all' means) returns only the cases matching both the selection and `q`. `all` returns the whole selection and adds a `selected` array naming which of those rows also match `q`." - }, - { - "name": "q", - "type": "object", - "description": "Test-case filter/search object, intersected with the selection. Omit it to get the selection as-is. Free-text goes in as a search string matched against case name and identifier." - }, - { - "name": "count", - "type": "integer", - "maximum": 20000, - "default": 30, - "description": "PAGE SIZE, despite the name \u2014 not a count to return. Defaults to 30 when absent or unparseable, and a value above 20000 is refused with a page-size error." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "shared_selection", - "type": "object", - "description": "Accepted by the parameter filter but NOT read by this action \u2014 cases shared in from other projects are not resolved here. Use the count endpoint if you need shared selections accounted for." - } - ], - "intent": "Resolve a folder/case selection into the actual list of test cases it covers \u2014 use it to preview or page through what a run create or edit would pull in before committing it", - "returns": [ - "success", - "info", - "test_cases", - "selected", - "id", - "identifier", - "name", - "description", - "type", - "owner", - "folder_id", - "template", - "template_id", - "project_id", - "group_id", - "is_shared", - "is_project_level", - "review_status", - "estimated_duration_seconds", - "automation_status", - "archived_at", - "trashed_at", - "created_at", - "updated_at", - "preconditions", - "expected_result" - ], - "responses": { - "200": { - "$response": "TestRunsSelectionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This returns TEST CASES, not test runs, despite sitting under the test-runs path and despite the older wording of its name. It is the preview step for a run's case selection.", - "`selection` is effectively mandatory: the backing service reads straight into it and a body without it fails rather than defaulting to everything.", - "Page size is the `count` field, not `per_page`, and it caps at 20000; leaving it out gives 30 rows, which is how a selection preview looks far smaller than the run it will create.", - "`selected` only appears when show='all'. In the default mode the rows already ARE the filtered set, so its absence is not a failure.", - "Folder ids are STRING keys in the `folders` map, while the case ids inside are integers. Mixing those up drops the folder silently.", - "shared_selection is ignored here. A selection that pulls cases from other projects will preview short.", - "The response is flat \u2014 {success, info, test_cases, selected} with no `data` envelope.", - "Needs the test-run write permission even though it only reads \u2014 a viewer-only role is 403'd.", - "The rows here are the RAW case record, not the shared test-case serializer used by every other test-case route. `automation_status` is a plain string, the folder key is `folder_id` (not `test_case_folder_id`), case type arrives as the bare string `type`, and priority, status, case_type, tags, issues, custom_fields and steps are NOT returned at all. Read the case detail route if you need those." - ], - "paginated": true, - "max_page_size": 20000 - }, - { - "name": "list_test_runs_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. There is no page-size parameter: the page is fixed at 30 rows (5000 when filter_mapping is set)." - }, - { - "name": "ignore_run_type", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' to stop the backing service filtering by run type, so manual and automation runs are listed together. Any other value, including boolean true in some encodings, leaves the default filtering in place." - }, - { - "name": "include_test_plan", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' to include each run's linked test plan on the row, so you can tell which runs are already attached to a plan without a second read." - }, - { - "name": "filter_mapping", - "type": "boolean", - "default": false, - "description": "Pass the literal string 'true' for the lightweight picker shape: the page grows to 5000 rows, progress analytics are skipped, and every run is reduced to {id, identifier} ONLY. Every other field disappears \u2014 do not use it when you need run state, owner or counts." - } - ], - "intent": "List a project's active test runs with their progress counts, owner and linked plan \u2014 the starting point for finding a run to read, execute or report on", - "returns": [ - "success", - "test_runs", - "info", - "count", - "id", - "uuid", - "identifier", - "name", - "description", - "owner", - "assignee", - "run_state", - "active_state", - "is_automation", - "is_dynamic", - "tags", - "configurations", - "test_plans", - "test_cases_count", - "overall_progress", - "overall_progress_by_status_id", - "build_meta", - "assignee_imported", - "environment", - "project_id", - "created_at", - "updated_at", - "observability_url", - "links" - ], - "responses": { - "200": { - "$schema": "TestRunsListResponse__ListTestRunsByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Closed runs are NOT in this list. A run count or an 'all our runs' answer built from this endpoint alone silently omits every finished cycle \u2014 read the closed-runs listing too.", - "Page size is fixed at 30 and there is no per_page; only `p` moves. The one exception is filter_mapping=true, which raises it to 5000 and strips every field but id and identifier.", - "Each run's `id` is the TR-NNN STRING, and the integer id is in `uuid`. Take `uuid` whenever a numeric run id leaves this read \u2014 passing the string into a test plan's run list reaches the backing service uncoerced and fails there.", - "The three boolean query params are compared against the literal string 'true'; anything else, including 1 or the word yes, is treated as not set.", - "The response is flat \u2014 {success, test_runs, info, count} with no `data` envelope. `count` is the project-wide per-state totals, not this page's size; the page's size is in `info`.", - "Per-run analytics can be suppressed for large groups, in which case the progress fields come back empty rather than absent \u2014 do not read a zeroed `overall_progress` as 'nothing was executed'.", - "To filter or search runs rather than page through them, use the project entity search with the test-runs entity; this endpoint takes no filter parameters.", - "Needs the test-run view permission, with the automation variant demanded for automation runs." - ], - "paginated": true, - "max_page_size": 30 - }, - { - "name": "import_dataset_csv", - "method": "POST", - "path": "/api/v1/projects/{project_id}/datasets/import_csv", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Parse an uploaded CSV file into column headers and rows \u2014 NOT INVOKABLE from this profile: it takes a multipart file upload, and it does not create a dataset", - "responses": { - "200": { - "description": "Parsed CSV contents, wrapped in a `data` envelope (unlike every other dataset endpoint, which returns at the top level). Nothing was written.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + }, + "Folder__BulkMoveTestCases": { + "type": "object", + "description": "Folder as returned by `bulk_move_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-21T13:26:55.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13482" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:31:46.000Z" + }, + "group_id": { + "type": "string", + "example": "3452" }, - "headers": { - "type": "array", - "description": "The CSV header row, in file order \u2014 the candidate column names.", - "items": { - "type": "string" - } + "id": { + "type": "integer", + "example": 765811 }, - "rows": { - "type": "array", - "description": "Data rows as header-keyed objects, not positional arrays \u2014 they are NOT in the shape the dataset create call expects.", - "items": { - "type": "object" - } + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 6 + }, + "total_cases_count": { + "type": "integer", + "example": 35 } - } } - } - } - }, - "400": { - "description": "{error:'No file uploaded'} \u2014 what every invocation from this profile returns, because no file part can be sent." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, error:...} \u2014 oversized file, non-.csv extension, blank headers, too many rows or columns, or malformed CSV. Also the Team Pro gate, which renders {success:false, message:'Feature not available'}." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "QUARANTINE CANDIDATE \u2014 do not attempt to call this. The only input is a multipart/form-data file part; a byte stream has to cross the boundary and there is no way to declare it here, so this record has no parameter for the one input it needs and any invocation fails with 400 {error:'No file uploaded'}.", - "The name is misleading: this does not import anything. It validates and parses the CSV and returns the parsed headers and rows to the caller. Nothing is written \u2014 no dataset is created or modified by this call.", - "To get the same result without a file upload: parse the CSV yourself and call the dataset create capability with the header row as `variables` and the data rows as `rows`. That is what the UI does with this endpoint's output.", - "If it is ever reachable, the limits enforced are: .csv extension only, 50 MB max (the published spec's 10 MB is wrong), every header non-blank, and at most 40 columns / 100 rows for a standard group (100 / 200 for groups on the raised-limit flag). Failures come back as 422 {success:false, error:...}.", - "Still behind the Team Pro plan gate and the test-case write permission like the rest of the dataset family." - ], - "disabled": true - }, - { - "name": "list_activity_events", - "method": "GET", - "path": "/api/v1/activities", - "mode": "read", - "entity": "activity", - "query": [ - { - "name": "project_id", - "type": "integer", - "description": "Narrow the group-wide feed to one project. A query filter, not a path segment." - }, - { - "name": "cursor", - "type": "string", - "description": "Opaque cursor from a previous page's pagination.next_cursor. There is no page number." - }, - { - "name": "limit", - "type": "integer", - "description": "Page size, forwarded to the backing service which owns the default and ceiling; the empty-starred short-circuit reports 50. Trust pagination.limit for what you actually got." - }, - { - "name": "actor_id", - "type": "integer", - "description": "Filter to one actor, as a BrowserStack user id (the same id events carry in actor_id)." - }, - { - "name": "starred_only", - "type": "boolean", - "description": "Send the literal string 'true' to restrict to the caller's starred projects. With none starred the response is {events: [], no_starred_projects: true} \u2014 that is 'nothing starred', not 'no activity'." - }, - { - "name": "entity_type", - "type": "array", - "description": "Filter by subject entity type. An array parameter \u2014 repeat the key rather than sending a comma-joined string." - }, - { - "name": "origin_channel", - "type": "string", - "values": [ - "ui", - "api" - ], - "description": "Restrict to events raised from the UI or from the API. Any other value is silently DROPPED rather than rejected, so a typo quietly widens the result instead of erroring." - } - ], - "intent": "Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window", - "returns": [ - "success", - "events", - "pagination", - "projects", - "no_starred_projects", - "next_cursor", - "has_next_page", - "limit", - "actor_id", - "actor_name" - ], - "responses": { - "200": { - "description": "A cursor page of activity events.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "events": { - "type": "array", - "items": { - "type": "object" - } - }, - "pagination": { - "type": "object", - "properties": { - "next_cursor": { - "type": "string", - "nullable": true + }, + "TestCase__BulkMoveTestCases": { + "type": "object", + "description": "TestCase as returned by `bulk_move_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "duplicates_tc_count": { + "type": "string", + "nullable": true + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkMoveTestCases" }, - "has_next_page": { - "type": "boolean" + "priority": { + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__BulkMoveTestCases" + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } }, - "limit": { - "type": "integer" + "step_count": { + "type": "integer", + "example": 1 } - } - }, - "projects": { - "type": "object", - "description": "Project id \u2192 metadata, for resolving event project references." - }, - "no_starred_projects": { - "type": "boolean", - "description": "Present and true only when starred_only was set and the user has none starred." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Cursor paging only: follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no page number and no total count, so you cannot state how many events exist or jump to a page.", - "The server clamps every query to the last 15 DAYS regardless of what you ask for. For anything older, say the history is not retrievable through the API and point at the Test Management UI \u2014 reporting 'no activity' for an older period is a false negative.", - "Group-wide, not project-scoped: `project_id` is a query filter, not a path segment. The response's `projects` map (project id to metadata) resolves the project each event refers to without a second read.", - "`starred_only` is compared against the literal string 'true'. When the caller has no starred projects the reply is {events: [], no_starred_projects: true} \u2014 that means 'nothing is starred', NOT 'no activity'. Check that flag before reporting emptiness.", - "Gated by a group feature flag. A 403 {success:false, error:'Activity feed is not enabled'} means the feed is off for the group, not that the caller lacks permission \u2014 say so rather than reporting no activity.", - "Every event is enriched with `actor_name`; an actor who cannot be resolved inside the caller's group renders as 'Unknown User' rather than being dropped, so an unfamiliar name is not necessarily a data gap.", - "`entity_type` is an array filter \u2014 repeat the key rather than sending a comma-joined string. `origin_channel` accepts only 'ui' or 'api'; any other value is dropped silently instead of erroring, which widens the result rather than narrowing it.", - "Read-only by design: activity rows are emitted as a side effect of other writes. There is no create, update or delete, so an audit entry cannot be edited or removed on request.", - "Per-project visibility is applied downstream \u2014 the feed is already scoped to projects the caller can access, so an empty page can mean 'no accessible projects had events' rather than 'nothing happened'." - ], - "disabled": true - }, - { - "name": "list_archived_test_plans", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans/archived_test_plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. Page size is fixed at 30; there is no per_page." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match on the archived plan's name or identifier." - }, - { - "name": "plan_status", - "type": "string", - "description": "Status filter for the archived view, forwarded upstream as given (a comma-joined list such as 'new,started' is accepted). Unlike the active listing, an unrecognised value is not rejected here \u2014 it simply matches nothing." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Forwarded WITHOUT validation on this route (the active listing validates the same parameter), so a typo is passed upstream rather than returning a 400." - } - ], - "intent": "Page through the test plans that have been ARCHIVED in a project \u2014 the place to find a plan to restore; archived plans do not appear in the normal plan listing at all", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "V1ArchivedTestPlanListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Test-plan archiving is not enabled for this group: `{success: false, errors: \"Feature not available\"}`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "errors": { - "type": "string" } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a fixed literal route on the plan collection \u2014 it takes no plan id. It is a different endpoint from the plan detail read that sits on the same path prefix.", - "Gated on the group archive flag: when it is off the call is 404 {success: false, errors: 'Feature not available'}, which means the feature is unavailable, not that nothing is archived.", - "Needs the test-plan DELETE permission, not view \u2014 a user who can list plans normally can still be 403'd here.", - "Archived SUB-plans are not listed; there is no include_sub_plans switch on this route. Only top-level archived plans come back.", - "Rows are serialized exactly like the active listing (same fields, plus record_status: 'archived'), so anything that consumes the normal plan list works unchanged here.", - "Page size is fixed at 30; page with `p` and stop using the `info` envelope. There is no `count` object on this response, unlike the active listing.", - "To put a plan back, pass its integer id to the bulk-retrieve capability." - ], - "returns": [ - "success", - "test_plans", - "info", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "record_status", - "start_date", - "end_date", - "owner", - "assignee", - "tags", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "test_plan_progress_v2", - "project_id", - "created_at", - "updated_at", - "links" - ], - "max_page_size": 30 - }, - { - "name": "list_custom_field_dataset_options", - "method": "GET", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler; a wrong value surfaces as a bodyless 404." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Not validated by this handler; a dataset belonging to another field returns the same bodyless 404." - } - ], - "intent": "List the option values a dataset offers, with the numeric option id each one carries \u2014 the ids you need to rename, re-default or delete an option", - "shape": "discovered", - "responses": { - "200": { - "$response": "CustomFieldAdminDatasetOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is where option ids come from. The dataset read returns options only when asked and without a stable contract; this call always returns them with their ids.", - "Each row is {id, option_value, is_default, colour, parent_option_id}. When any row carries a parent_option_id the whole list is rebuilt as a tree and children appear under a `children` key on their parent instead of at the top level \u2014 a flat scan of `data` then misses them.", - "Exception: if every row in the page has a parent (a single level of a nested_dropdown read on its own), the tree build finds no root and falls back to returning the rows flat.", - "Paging is `p` only; the page size is fixed at 30 and `per_page` is ignored. `info` carries the page metadata. On a nested field the tree is built per page, so a parent and its children can land on different pages.", - "Neither path id is validated here, so a wrong field/dataset pair reaches storage and returns a bare 404 {success:false} with no message." - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control \u2014 page size is fixed at 30 and `per_page` is ignored." - } - ], - "paginated": true, - "max_page_size": 30, - "returns": [ - "success", - "data", - "info", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id", - "children" - ] - }, - { - "name": "list_datasets", - "method": "GET", - "path": "/api/v1/projects/{project_id}/datasets", - "mode": "read", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[name]", - "type": "string", - "description": "Partial, case-insensitive match on dataset name. Use this to find a dataset you only know by name." - }, - { - "name": "q[uuid]", - "type": "string", - "description": "Exact match on dataset uuid." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort order, forwarded unchanged to the backing store; the accepted values are not validated or enumerated here, and an unrecognised one is not rejected by this service. Omit it unless you know the store's vocabulary." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Validated globally: a non-numeric or non-positive value is 400 {success:false, error:'Invalid Params'}; p=0 is treated as page 1. There is no page-size parameter \u2014 see max_page_size." - } - ], - "intent": "Find datasets in a project \u2014 browse them or look one up by name or uuid to get the uuid the other dataset calls need", - "returns": [ - "success", - "datasets", - "info", - "uuid", - "name", - "rows_count", - "test_cases_count", - "created_by", - "created_at", - "updated_at" - ], - "paginated": true, - "responses": { - "200": { - "description": "A page of datasets, with paging metadata under `info`.", - "schema": { - "$schema": "DatasetsListResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric page value." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "{success:false, message:'Feature not available'} \u2014 the group lacks the Team Pro plan." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are the compact form: name, uuid, counts, tags, metadata, creator and timestamps \u2014 but NOT the columns or rows. Read a single dataset by uuid to get its contents.", - "Page size is fixed at 30 and cannot be requested; ask for successive `p` values and stop when `info.next` is null.", - "`test_cases_count` here is the cheapest way to tell whether a dataset is in use before deleting it.", - "A creator no longer in the group resolves to the placeholder {id:0, full_name:'Former User'} rather than null.", - "422 {message:'Feature not available'} means the group lacks the Team Pro plan \u2014 every dataset endpoint answers this way, and it is an entitlement answer, not a payload error.", - "`success`, `datasets` and `info` are all top level; there is no `data` envelope and no second nesting under `datasets`." - ], - "max_page_size": 30 - }, - { - "name": "list_duplicates", - "method": "GET", - "path": "/api/v1/projects/{project_id}/ai-duplicates", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `p` is accepted as an alias and `page` WINS if both are sent. Unlike `p`, `page` is NOT validated: a non-numeric value silently becomes 0 and is forwarded as page 0. Send a positive integer." - }, - { - "name": "entity_type", - "type": "string", - "description": "Accepted and IGNORED \u2014 the handler never reads it. Every pair links test cases; there is no other entity type. Sending it changes nothing." - }, - { - "name": "entity_id", - "type": "integer", - "description": "Integer test-case id. Restricts the listing to pairs involving that case AND strips that case out of each returned pair, so each row's `test_cases` holds only the OTHER case. Use it to answer 'what is this test case a duplicate of'." - }, - { - "name": "duplicates", - "type": "string", - "values": [ - "exact", - "semantic" - ], - "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim to the backing store and not validated here, so an unrecognised value narrows to nothing rather than returning an error. Omit for both types." - }, - { - "name": "owner", - "type": "string", - "description": "The 'My duplicates' tab. ANY non-empty value scopes the listing to pairs involving the CALLER's own test cases \u2014 the value itself is discarded, so this cannot select another user. Omit for all duplicates in the project." - } - ], - "intent": "List the AI-suggested duplicate test-case pairs awaiting review in a project, highest-confidence first \u2014 optionally only those involving one test case, or only the caller's own", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "Suggested duplicate pairs. This exact shape with an empty `duplicates` is also what comes back when the group lacks the dedupe feature flag, so an empty list is not evidence the project has none.", - "schema": { - "$schema": "V1DuplicatesListResponse", - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "last_scan_time": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When the dedupe scan that produced these groups last ran. NOTE: this can be populated while `get_dedupe_status` reports NOT_ENABLED for the same project - the two disagree." - }, - "duplicates": { - "type": "array", - "items": { - "$schema": "DuplicateGroup" - } + }, + "TestCaseFieldValue__BulkMoveTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_move_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "valueDetails": { + "type": "object", + "properties": { + "custom_values_id": { + "type": "integer", + "example": 318605 + }, + "dataset_id": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 117295159 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + }, + "field_name": { + "type": "string", + "example": "status" + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "An empty list is ambiguous. When the group lacks the dedupe feature flag this returns 200 with exactly {success:true, duplicates:[], info:{count:0,total_pages:0}} \u2014 indistinguishable from a genuinely clean project. Read the dedupe status capability to tell 'feature off / never run' from 'nothing found'.", - "Only pairs still in `suggested` status appear. Once a pair is merged, archived or discarded it leaves this listing permanently; there is no filter to bring it back.", - "Page size is fixed at 10 and cannot be requested. Pairs are ordered by confidence (ai_score descending, then id descending), NOT by age \u2014 the oldest suggestion is not on page 1 and a bigger page cannot be asked for.", - "`info.count` can exceed the rows you actually receive: a pair whose test case cannot be resolved is dropped from the page after the count is taken. Do not derive page counts from row arithmetic; use `info.total_pages` / `info.next`.", - "Carry each row's `id` forward \u2014 that integer is the duplicate id the read, resolve and discard calls take.", - "`last_scan_time` at the top level is the project's last dedupe run, not a property of this page; it is fetched separately and can be null when no scan has completed.", - "Duplicate REVIEW is all that is exposed here. There is no way to create a suggestion or trigger a scan through this API." - ], - "returns": [ - "success", - "duplicates", - "info" - ], - "max_page_size": 10 - }, - { - "name": "list_exploratory_session_defects", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. The only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 30." - } - ], - "intent": "List the defects and issues raised during an exploratory session \u2014 the external tracker tickets linked to the session and to its individual log entries \u2014 to review what the exploration found", - "returns": [ - "success", - "defects", - "info", - "issue_id", - "issue_type", - "jira_id", - "created_at" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionDefectsV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ONLY LOG-LEVEL DEFECTS APPEAR HERE. A defect linked on a session LOG shows up immediately; an issue linked on the SESSION itself (the `issues` array on create/update_exploratory_session) NEVER appears, though it persists and is readable from the session's own detail.", - "The row is FOUR fields - {issue_id, issue_type, jira_id, created_at}. It is NOT the full issue-link record: no id, entity_type, entity_id, entity_uuid, issue_status, priority, record_status, project_id, metadata or updated_at, all of which an earlier version of this contract promised.", - "Paging uses `p`, NOT `page`. A `p` that is not a positive integer is rejected as 400 {success:false, error:'Invalid Params'}.", - "`per_page` is accepted and ignored - the page size is fixed at 30. Walk `p` until a page comes back short.", - "No filters: no status, tracker or date narrowing. Filter client-side.", - "There is no project-wide defect listing in this index. 'What defects exist in this project' means listing sessions and issuing one request per session.", - "Everything is at the top level: defects, info. There is no `data` envelope.", - "An unknown session_id returns a bare {success:false} with 404 and no message." - ] - }, - { - "name": "list_exploratory_session_logs", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. The only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 50, - "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 50." - }, - { - "name": "q[status]", - "type": "string", - "example": "fail,bug", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "description": "Comma-separated list of statuses as a single string (e.g. 'retest,block') \u2014 not a repeated array parameter. Every value is allowlisted before the upstream call; anything else, or an empty list, returns 400 'Invalid status value.'. Does not affect status_counts." - } - ], - "intent": "Read the observations recorded during an exploratory session \u2014 the timestamped notes, their pass/fail/bug status, elapsed time, linked defects and embedded screenshots \u2014 optionally narrowed to particular statuses", - "returns": [ - "success", - "session_logs", - "info", - "status_counts", - "id", - "session_id", - "hashed_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "attachment_relations", - "created_at", - "updated_at" - ], - "max_page_size": 50, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionLogListV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Logs are children of one session: session_id in the path scopes the whole listing, and a log id is only meaningful together with the session it belongs to.", - "Paging uses `p`, NOT `page` \u2014 `page` is read by nothing here. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'}.", - "`per_page` is accepted and ignored: the handler overwrites it with the fixed page size of 50. Walk `p` until a page comes back short. Compare against session_log_count on the session to know when you have them all.", - "q[status] is a COMMA-SEPARATED STRING, not an array, and it is allowlisted before the upstream call: any value outside pass, fail, bug, retest, block, note, skipped returns 400 'Invalid status value.'. Note `skipped` \u2014 it is a real status that older documentation of this entity omits.", - "status_counts is meant to be the whole-session per-status totals, deliberately unfiltered by q[status]. Do not rely on it today: the typed upstream response object carries no such field, so this key is currently rendered as an empty object on every call.", - "There is no sort parameter on this endpoint and the upstream default is not ascending \u2014 the internal session-copy path has to ask for ascending order explicitly. Sort by created_at yourself rather than assuming chronological order.", - "`content` is sanitised HTML, not plain text, and embedded images are TM-hosted attachment URLs. What is stored can differ from what was written: links and images pointing outside the project or group are stripped on write.", - "Each log's `defects` are the issue links on that log. The session-level defects listing is a different, broader set \u2014 read that one for everything linked to the session.", - "Everything is at the top level: session_logs, info, status_counts. There is no `data` envelope." - ] - }, - { - "name": "list_exploratory_sessions", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. This is the only page parameter read on this path \u2014 `page` is ignored. A non-positive or non-numeric value is rejected as 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Accepted but NOT honoured \u2014 the handler overwrites it with the fixed page size of 30. There is no way to widen the page from the caller side." - }, - { - "name": "q[session_state]", - "type": "string", - "values": [ - "active", - "closed" - ], - "example": "active", - "default": "active", - "description": "Lifecycle state. Defaults to 'active' when omitted, so closed sessions are invisible unless you ask for them. Not validated locally \u2014 an unrecognised value is forwarded upstream rather than rejected." - }, - { - "name": "q[assignee]", - "type": "integer", - "example": 42, - "description": "TCM user id of the session assignee; translated to a BrowserStack user id before the query is sent. Wins over q[owner] when both are given. An id that resolves to no user is sent as null instead of erroring." - }, - { - "name": "q[owner]", - "type": "integer", - "example": 42, - "description": "Alias for q[assignee], used only when q[assignee] is absent. Same TCM user id semantics." - }, - { - "name": "q[created_by]", - "type": "integer", - "example": 10, - "description": "TCM user id of the session creator; translated to a BrowserStack user id. An unresolvable id is sent as null rather than rejected." - }, - { - "name": "q[created_at_from]", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Created on or after this date. Forwarded verbatim \u2014 not parsed or validated here." - }, - { - "name": "q[created_at_to]", - "type": "string", - "format": "date", - "example": "2026-01-31", - "description": "Created on or before this date. Forwarded verbatim \u2014 not parsed or validated here." - }, - { - "name": "q[tags][]", - "type": "array", - "example": [ - "regression", - "payment" - ], - "description": "Tag names to match. Forwarded verbatim." - }, - { - "name": "q[configuration_ids][]", - "type": "array", - "example": [ - 1, - 2 - ], - "description": "Configuration ids (integers) the session runs against." - }, - { - "name": "q[search]", - "type": "string", - "example": "checkout", - "description": "Free-text match, forwarded upstream as-is." - }, - { - "name": "q[query]", - "type": "string", - "description": "Second free-text key the handler forwards alongside q[search]; both are passed through independently. Undocumented upstream \u2014 prefer q[search]." - }, - { - "name": "q[status]", - "type": "string", - "example": "fail", - "description": "Filter by the log statuses recorded inside the session. Forwarded verbatim and not allowlisted here, unlike the same-named filter on the session logs listing." - }, - { - "name": "q[test_plan_id]", - "type": "integer", - "example": 77, - "description": "Only sessions linked to this test plan. Takes the integer test plan id, not a TP-NNN identifier." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "id-asc", - "id-desc", - "title-asc", - "title-desc" - ], - "description": "Combined field+direction token. There is no separate sort_order parameter. Any other value is rejected with 400 'Invalid sort_by value.' before the upstream call." - } - ], - "intent": "Find exploratory testing sessions in a project \u2014 filter by state, assignee, creator, tags, configurations, linked test plan, date range or free text \u2014 to pick the session whose logs or defects you want to read next", - "returns": [ - "success", - "exploratory_sessions", - "info", - "count", - "id", - "identifier", - "title", - "session_state", - "timebox_duration", - "assignee", - "created_by", - "tags", - "attachments", - "created_at", - "updated_at", - "project_id", - "group_id", - "test_plan_id", - "summary", - "test_plan" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ExploratorySessionListV1Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The default listing hides closed work: when q[session_state] is omitted the handler substitutes 'active'. Pass q[session_state]=closed explicitly to find a finished session, and expect a session to vanish from the default list the moment it is closed.", - "Paging is driven by `p`, NOT `page`. `page` is read by nothing on this path. A `p` that is not a positive integer is rejected globally as 400 {success:false, error:'Invalid Params'} before the action runs.", - "`per_page` is accepted and ignored \u2014 the handler overwrites it with the fixed limit of 30 rows. There is no way to ask for a larger page; walk `p` until a page comes back short.", - "q[assignee] / q[owner] / q[created_by] take TCM user ids (integers) and are translated to BrowserStack user ids before the query is sent. q[owner] is only a fallback for q[assignee] \u2014 if both are present, q[assignee] wins. An id that matches no user in this group is not an error: the filter is sent as null and you get back a result set that silently does not mean what you asked for.", - "sort_by is a single combined token ('id-asc', 'id-desc', 'title-asc', 'title-desc') \u2014 there is no separate sort_order. Anything else is 400 'Invalid sort_by value.'.", - "`count` is an OBJECT of per-state totals ({active, closed}), not the number of rows matching your filter. The row total lives in info.count. Do not use `count` to size a pagination loop.", - "Rows are NOT put through the custom-field normaliser that the single-session read applies, so custom_fields on a list row is the raw upstream shape (or absent). Re-read the session by id if you need normalised custom fields.", - "assignee / created_by / closed_by come back as user objects resolved from the local user table, and resolve to null when the underlying user is not a TM user in this group \u2014 a null assignee here does not prove the session is unassigned.", - "Take `id` (integer) from a row and carry it as session_id into the logs, defects, close, clone, update and delete calls. `identifier` (ES-NNN) is display-only and is not accepted by any route on this entity.", - "Everything is at the top level: exploratory_sessions, info, count. There is no `data` envelope.", - "This listing is a NARROW projection, not the full session. It does not return charter, description, duration, actual_duration, session_log_count, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations \u2014 read a single session for those. It is also the one session endpoint that does NOT normalise custom_fields." - ] - }, - { - "name": "list_filters", - "method": "GET", - "path": "/api/v1/projects/{project_id}/filter", - "mode": "read", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "entity", - "required": true, - "type": "string", - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Required \u2014 which list's saved views to return, one type per call. Omitting it is a 400 'Invalid entity type'; to see every view, call once per type. An unlisted value is rejected with 422 `{success:false, message:\"Invalid payload\"}` \u2014 422 here, not 400." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. This is the only page parameter the handler reads \u2014 a `page` query parameter is ignored and you silently get page 1. A non-positive, non-numeric or array-valued `p` is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - } - ], - "intent": "List the saved filter views available in a project \u2014 yours and the project-wide ones \u2014 to find the filter_id to read, apply, update or delete", - "returns": [ - "success", - "data", - "info", - "id", - "name", - "is_project_level" - ], - "paginated": true, - "responses": { - "200": { - "$response": "FilterListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "max_page_size": 30, - "guidance": [ - "Page with `p`. The previously published `page` parameter is never read \u2014 sending it returns page 1 and looks like a successful call.", - "Page size is fixed at 30 and cannot be changed; there is no per_page or limit here. Walk pages until `info.next` is null.", - "The list mixes your private views with the project-level ones. Each row carries only `id`, `name` and `is_project_level` \u2014 no owner, entity_type or conditions \u2014 and `is_project_level` is the only thing that tells private views from project-wide ones.", - "An invalid `entity` returns 422, not the 400 the other filter endpoints use for validation failures.", - "Unlike reading a single filter, this listing does no custom-field validation and never rewrites anything \u2014 but it does not return a view's conditions either, only its id, name and scope.", - "Not permission-gated \u2014 the filter controller has no RBAC entry." - ] - }, - { - "name": "list_folder_contents", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}", - "mode": "read", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "Open one folder \u2014 its immediate subfolders, the folder's own record, and optionally its ancestor breadcrumb \u2014 to walk the tree a level at a time or work out where a folder sits", - "returns": [ - "success", - "folders", - "self", - "parent_id", - "ancestors", - "test_cases", - "info", - "id", - "name", - "notes", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "contents", - "links" - ], - "responses": { - "200": { - "$response": "FolderDetailsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number over the SUBFOLDERS. A non-positive or non-numeric value returns 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "detail", - "type": "boolean", - "default": false, - "description": "Send the literal string \"true\" to populate `ancestors` with the folder's breadcrumb up to the root. Without it `ancestors` comes back null. Only the exact string \"true\" enables it." - }, - { - "name": "nested_sub_folders", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" asks for the whole descendant subtree rather than one level. It is additionally gated on a group flag, so it silently does nothing when the group is not enabled. When it does take effect, `self.contents` is stripped from the response." - }, - { - "name": "case_count", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" to include per-folder test-case counts. Read only on the nested_sub_folders path; ignored on a plain one-level listing." - }, - { - "name": "total_count", - "type": "boolean", - "default": false, - "description": "Literal string \"true\" to include the total row count. Read only on the nested_sub_folders path; ignored on a plain one-level listing." - }, - { - "name": "folder_sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "created_at-asc", - "created_at-desc" - ], - "description": "Subfolder ordering. Comma-separated combinations accepted; anything else returns 400 {error:'Invalid folder_sort_by parameter'}. Either name- sort gains a secondary created_at-asc tiebreak." - } - ], - "paginated": true, - "max_page_size": 300, - "guidance": [ - "Page size is fixed at 300 subfolders and cannot be changed: `per_page` from the caller is discarded and the ceiling is applied unconditionally. Only `p` moves you through the pages. (A group-level flag can lift the limit to unbounded, which you cannot detect from the response.)", - "`test_cases` is ALWAYS null. The key exists but this endpoint never returns test cases \u2014 fetch them from the folder's test-case listing instead.", - "`folders` holds the immediate children only. The folder you asked for is at `self`, its parent id is duplicated at the top level as `parent_id`, and `ancestors` is null unless you passed detail=true.", - "Folder notes come back HTML-escaped with signed image URLs injected, at every level of the response \u2014 do not treat them as the literal text that was stored.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500. A folder id from another project is a bare 404 {success:false} with no message.", - "This action has no RBAC entry, so it is not permission-gated the way the other folder reads are; it is also the action reachable through a public share link, scoped to the shared folder." - ] - }, - { - "name": "list_folder_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Walk pages until `info.next` is null." - }, - { - "name": "per_page", - "type": "integer", - "description": "Rows per page. Bounded to 1..100; anything outside that range, or non-numeric, silently falls back to the default of 30.", - "minimum": 1, - "maximum": 100, - "default": 30 - }, - { - "name": "required[fields]", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated extra columns to join into each row (e.g. `owner,priority`). If you also send `sort_by`, the sorted column MUST appear in this list or the call is rejected with 'Sort by column X is not present in required fields'." - }, - { - "name": "required[custom_fields]", - "type": "string", - "example": "121,119", - "description": "Comma-separated custom field ids to include on each row." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-ASC", - "description": "Sort token, `-ASC|DESC` or `cf##-`. A malformed token is a 400 listing the allowed columns. `case_type` is translated to `category` for you." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "Send the string `true` to include cases from this folder's subfolders, and add a `folders` block to the response." - }, - { - "name": "shared", - "type": "boolean", - "description": "Scope the listing to cases shared in from other projects." - }, - { - "name": "for_test_run", - "type": "boolean", - "description": "Send the string `true` when building an 'add cases to run' picker: on a project with review gating configured it hides cases whose review status makes them ineligible, so the same folder returns FEWER rows than a plain read." - }, - { - "name": "hasDuplicates", - "type": "boolean", - "description": "When true, fills `duplicates_tc_count` on each row from the dedupe store; otherwise it is null." - } - ], - "intent": "List the test cases in one folder \u2014 the repository browser read, with optional extra columns and custom fields.", - "shape": "discovered", - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "$response": "TestCaseFolderListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Folder-scoped and non-recursive by default: pass `include_subfolders_tc=true` to reach cases in child folders. To read the whole project use the project-level listing with all_folders=true instead.", - "`per_page` is clamped to 1..100 and silently falls back to 30 for anything else \u2014 so trust `info.page_size`, not your request. Walk `p` until `info.next` is null.", - "Combining `sort_by` with `required[fields]` has a trap: the sorted column must be inside `required[fields]` or the call is rejected outright.", - "`for_test_run=true` deliberately returns FEWER rows on a review-gated project \u2014 do not use it for a plain repository read or you will under-report the folder.", - "A backend 404 is swallowed into an empty list with `success: true`, so an empty result is not proof the folder is empty or that the id is right.", - "Each row's `id` is the integer id the v1 write paths take and `identifier` is the TC-NNN the v2 surface takes; `estimate` is always null." - ], - "returns": [ - "success", - "test_cases", - "info", - "folders" - ] - }, - { - "name": "list_projects", - "method": "GET", - "path": "/api/v1/projects", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "q", - "type": "string", - "example": "checkout", - "description": "Free-text match on project name. Must be a flat string \u2014 a nested q[...] value returns 400 'Invalid Params: q should be a String'." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive, non-numeric or array values return 400 'Invalid Params'." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Any other value returns 400 echoing the allowed list." - }, - { - "name": "starred", - "type": "boolean", - "description": "Restrict to starred projects. Forwarded to the backing service verbatim, so sending the string 'false' is still a filter, not 'no filter' \u2014 omit the key instead." - }, - { - "name": "shared", - "type": "boolean", - "description": "Restrict to projects that have shared test cases. Any present value is treated as true; omit the key to not filter." - }, - { - "name": "is_hidden_projects", - "type": "boolean", - "default": false, - "example": true, - "description": "Include projects hidden from the dashboard. Only the exact string 'true' turns this on." - } - ], - "intent": "List the group's projects with their PR-NNN identifiers, case/run counts and duplicate badge \u2014 the listing to use when you need each project's identifier; for a plain name lookup or a count, get_projects_basic is leaner", - "returns": [ - "success", - "projects", - "info", - "id", - "identifier", - "name", - "description", - "created_at", - "import_id", - "links", - "page", - "page_size", - "count", - "prev", - "next" - ], - "paginated": true, - "responses": { - "200": { - "description": "Projects at the top level as `projects` (no `data` envelope), with pagination under `info`. Each row also carries duplicates_tc_count; project_creator is NOT emitted despite being on the shared Project schema.", - "schema": { - "$schema": "ProjectsListResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Page size is fixed at 30 on this route. There is no size parameter \u2014 `count` and `page_size` are ignored here (only the basic and minify project listings honour `count`). Walk pages with `p` and stop when `info.next` is null.", - "`q` must be a flat string on this route, not a nested object: `q=checkout`. Sending q[query]=... returns 400 'Invalid Params: q should be a String'. The nested q[query]/q[identifier] form belongs to get_projects_basic.", - "`sort_by` is validated against a closed set; anything else returns 400 listing the allowed values. Omitting it leaves the backing service's default order.", - "Unlike the other project listings, each row carries `identifier` (PR-NNN) \u2014 this is the cheapest way to resolve project names to the v2 id form in bulk.", - "`duplicates_tc_count` is a decorative badge and is zero whenever the AI duplicate-detection feature is off for the group; a failure computing it is swallowed and also reads as 0, so never treat 0 as 'no duplicates'.", - "`project_creator` is documented on the shared Project schema but is NOT emitted by this endpoint \u2014 it is commented out in the serializer. Do not branch on it.", - "`starred` and `shared` are forwarded to the backing service verbatim, including the string 'false', so send them only when you mean to filter.", - "Requires the project_view permission under RBAC." - ], - "max_page_size": 30 - }, - { - "name": "list_schedules", - "method": "GET", - "path": "/api/v1/projects/{project_id}/reports/schedules", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q[query]", - "type": "string", - "description": "Case-insensitive substring search over report type, title and owner full name. Also enables the `owner_id` and `created_between` filters, which are ignored without it." - }, - { - "name": "q[scheduled]", - "type": "string", - "values": [ - "true", - "false" - ], - "description": "Only the exact string `true` filters (to scheduled reports). `false` and every other value are ignored and return all reports." - }, - { - "name": "owner_id", - "type": "integer", - "description": "Restrict to one owner. Only honoured alongside `q[query]`; supplying it also drops the owner name from the search match." - }, - { - "name": "created_between", - "type": "array", - "description": "Two timestamps, [from, to], bounding created_at. Only honoured alongside `q[query]`, and only when exactly two entries are given \u2014 otherwise ignored." - }, - { - "name": "per_page", - "type": "integer", - "description": "Accepted and then IGNORED \u2014 the page size is fixed at 30 on both code paths." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, 30 per page. A page past the last one returns 400 `page_out_of_bounds`, not an empty list." - } - ], - "intent": "List a project's reports \u2014 scheduled and unscheduled alike, with type, window, cadence and owner \u2014 the way to find a report_id", - "returns": [ - "data", - "scheduled_report_count", - "total_report_count", - "info" - ], - "paginated": true, - "responses": { - "200": { - "description": "A successful response containing a list of schedules.", - "schema": { - "$schema": "ReportScheduleGetResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the discovery call for the report entity: every other report capability needs an integer `report_id`, and this is where it comes from (`data[].id`; `identifier` is the SC-NNN display string and is not accepted as an id anywhere).", - "It lists ALL of the project's reports, not only scheduled ones \u2014 the route name is misleading. `scheduled_report_count` and `total_report_count` are counts over the whole project, not over the returned page.", - "`q[scheduled]` only acts on the exact string `true`. `q[scheduled]=false` is silently ignored and returns everything, so you cannot filter for unscheduled reports \u2014 filter client-side on `frequency == null` instead.", - "Page size is a hard-coded 30 and cannot be changed: `per_page` is read from the request and then never applied. Page with `p`; requesting a page past the end returns 400 `page_out_of_bounds` rather than an empty list.", - "Ordering is always newest first by id. An `order_by` parameter is parsed and then ignored.", - "`q[query]` is a case-insensitive substring match over report type, title, and (unless `owner_id` is also given) the owner's full name. Supplying it switches to a different code path that additionally honours `owner_id` and `created_between` \u2014 those two do nothing without `q[query]`.", - "Requires the report_view permission on the project (403 otherwise)." - ], - "max_page_size": 30 - }, - { - "name": "list_templates", - "method": "GET", - "path": "/api/v2/templates", - "mode": "read", - "entity": "template", - "query": [ - { - "name": "name", - "type": "string", - "description": "Narrow the list to templates whose name contains this value. It is a substring filter, not an exact lookup, so it can return several templates or none." - }, - { - "name": "p", - "type": "integer", - "description": "Page number. This is the page parameter for this endpoint \u2014 `page` is ignored. A value that is not a positive integer is rejected with a 400 before the search runs.", - "minimum": 1, - "default": 1 - }, - { - "name": "count", - "type": "integer", - "description": "Page size. This is the size parameter for this endpoint \u2014 `per_page` is ignored. Above 300 the request is REJECTED with a 400, it is not clamped. A non-numeric value silently falls back to 30.", - "maximum": 300, - "default": 30 - } - ], - "intent": "List the workspace's test-case templates \u2014 start here to find a template's id before creating a test case from it or before editing the template itself.", - "guidance": [ - "Asking for more than 300 per page fails the whole call with a 400 rather than returning a clamped page; page through at 300 or below instead.", - "List items carry only template metadata and counts \u2014 no field detail. Read a single template when you need its description or property fields.", - "Only test-case templates are returned. The entity type is fixed server-side and cannot be widened from the request, so result/plan/exploratory-session templates are not reachable here.", - "project_count and the field counts can come back null: on some workspaces the expensive per-project count is skipped server-side. Treat a null count as 'not computed', not as zero." - ], - "returns": [ - "success", - "templates", - "info", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at" - ], - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "$response": "TemplateListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/tags-v2", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. IGNORED whenever `q` is also sent: the search branch drops the pagination options entirely and always returns the first page. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - }, - { - "name": "q", - "type": "string", - "description": "Free-text match on tag name. Trimmed; an all-whitespace value is treated as absent. Sending it silently disables paging (see `p`)." - } - ], - "intent": "List a project's test-case tag vocabulary with usage counts \u2014 the set of tag names to pick from when tagging a case or filtering a list by tag", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "TagsSearchResponse" - }, - "401": { - "description": "Unauthorized." - } - }, - "guidance": [ - "`tags` is a plain array of tag NAMES; `tag_objects` carries the same tags with `created_at` and `usage_count`. Tags are referenced by name everywhere else in this product, so `tags` is usually the field you want and `usage_count` is what tells you which are actually in use.", - "Combining `q` with `p` does not work: when `q` is present the handler forwards only the query and never the page, so you always get page 1 and a second page request returns the same rows. To walk the whole vocabulary, page with `p` and no `q`; to search, accept the first page and narrow the query.", - "Page size is fixed at 30 and there is no per_page parameter.", - "This is the TEST-CASE tag vocabulary only. Run, plan and shared-step tags are separate vocabularies with separate listings, and a name existing here says nothing about the others.", - "No tag ids are returned here. If you need an id (the tag merge takes integer ids), use the group-level tag search.", - "Not permission-gated \u2014 the tags controller has no RBAC entry." - ], - "returns": [ - "tags", - "tag_objects", - "info", - "name", - "created_at", - "usage_count" - ], - "max_page_size": 30 - }, - { - "name": "list_test_case_templates", - "method": "GET", - "path": "/api/v1/admin-v2/settings/templates", - "mode": "read", - "entity": "template", - "query": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "TestCase", - "TestRunResult" - ], - "example": "TestCase", - "description": "Which kind of template to list. Effectively required \u2014 omitting it errors rather than defaulting. Only these two values are accepted; TestPlan and ExploratorySession templates are not listable here." - }, - { - "name": "project", - "type": "integer", - "description": "Integer project id \u2014 restricts the list to templates linked to that project." - }, - { - "name": "name", - "type": "string", - "description": "Fuzzy match on template name (not an exact match)." - }, - { - "name": "paginated", - "type": "boolean", - "default": false, - "description": "Must be the literal string 'true' to turn paging on. Without it `p` is ignored, every matching template is returned in one array, and `info` is absent from the response." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Only read when paginated=true is also sent; page size is then fixed at 10." - } - ], - "intent": "List the workspace's test-case (or test-result) templates with their ids \u2014 the lookup to run before creating a case from a template or editing a template's field layout", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "`{success, templates: [{id, name, entity, enabled, step_type, is_default, is_system, future_projects_applicable, field_count, linked_to_all_projects, project_count, created_at, updated_at}]}`. `info` is present only when paginated=true was sent.", - "schema": { - "type": "object" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`entity_type` is effectively REQUIRED even though it reads as optional: it is forwarded straight to the backing client, which refuses a missing value outright rather than defaulting to TestCase. Always send it.", - "Only two values are accepted: `TestCase` and `TestRunResult`. `TestResult` is not the accepted spelling, and TestPlan / ExploratorySession templates cannot be listed from here at all \u2014 anything outside the pair errors rather than returning an empty list.", - "Paging is opt-in and easy to miss: `p` is IGNORED unless `paginated=true` is sent alongside it. With paging on, page size is fixed at 10 and `info` appears in the response; with it off you get every matching template in one array and no `info`.", - "`project` narrows to templates linked to that integer project id; `name` is a fuzzy match on the template name, not an exact one.", - "Rows are the summary shape \u2014 id, name, entity, step_type, is_system, is_default, enabled, project_count, field_count, linked_to_all_projects. The field layout itself is not here; read a single template for that.", - "Templates belong to the workspace, not to a project: the same id is valid in every linked project, so do not look for a per-project id.", - "Responses are served from a short-lived per-group cache, so a template created or edited through a different surface can take a moment to show up here.", - "A validation failure from the backing service is passed through raw at its original status rather than being reshaped into the usual {success:false} envelope." - ], - "max_page_size": 10, - "returns": [ - "success", - "templates", - "info", - "id", - "name", - "entity", - "enabled", - "step_type", - "is_default", - "is_system", - "future_projects_applicable", - "field_count", - "linked_to_all_projects", - "project_count", - "created_at", - "updated_at" - ] - }, - { - "name": "list_test_plans_by_integer_id", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is 400 {success: false, error: 'Invalid Params'}. There is no per_page \u2014 the page size is fixed at 30." - }, - { - "name": "include_sub_plans", - "type": "boolean", - "default": false, - "description": "Include sub-plans in the page. Off by default, so a sub-plan the user named looks missing until this is set. Pass the literal string 'true'." - }, - { - "name": "status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "Plan status filter. Anything outside the three values returns HTTP 200 carrying {success: false, error: 'Check query params for test plan status'} and NO plans \u2014 it does not 400." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name-asc", - "name-desc", - "identifier-asc", - "identifier-desc" - ], - "description": "Sort order. Validated: anything else is 400 with the allowed list in the message. Must be a string \u2014 an array form is 400 'Invalid Params: sort_by should be a String'." - }, - { - "name": "minify", - "type": "boolean", - "description": "Ask for the slim plan rows. A group-level flag can force this on regardless of what you send, so rows may be slimmer than requested." - }, - { - "name": "q", - "type": "object", - "description": "Filter object (must be a nested object, not a string, or the call is 400). Recognised keys include query (free text), tags, ids, parent_plan_id, owner, team, created_at / created_between, days_left, overdue_days, tp_custom_fields, issue_type + issue_ids (both needed together), and match. Keys outside that set are ignored silently.", - "fields": [ - { - "name": "query", - "type": "string" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "ids", - "type": "array" - }, - { - "name": "parent_plan_id", - "type": "integer" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "team", - "type": "array" - }, - { - "name": "created_at", - "type": "string" - }, - { - "name": "created_between", - "type": "array" - }, - { - "name": "days_left", - "type": "string" - }, - { - "name": "overdue_days", - "type": "string" - }, - { - "name": "tp_custom_fields", - "type": "object" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "issue_ids", - "type": "array" - }, - { - "name": "match", - "type": "object" - } - ] - }, - { - "name": "ignore_type", - "type": "boolean", - "description": "Drop the implicit plan_status narrowing that the filter path otherwise applies (active plans unless status=completed). Pass the literal string 'true'." - }, - { - "name": "filter_details", - "type": "boolean", - "description": "Add a filter_details object resolving the owner and custom-field filters you sent in `q` back into displayable values. Only populated for q.owner and q.tp_custom_fields." - }, - { - "name": "filter_mapping", - "type": "boolean", - "description": "Id-picker mode: raises the page size to 5000 but returns ONLY {id, identifier} per plan \u2014 no name, no status, no dates. Do not use it to read plan detail." - } - ], - "intent": "Page through a project's test plans \u2014 filter by status, search and sort, and optionally include sub-plans; this is how you resolve a plan name a user mentioned to the integer id every other v1 plan route needs", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "V1TestPlanListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Sub-plans are hidden by default. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", - "An invalid `status` is a 200 trap: the response is HTTP 200 with {success: false, error: 'Check query params for test plan status'} and no plans. Branch on `success`, not on the status code.", - "Page size is fixed at 30 and there is no per_page parameter. Page with `p` and stop using the `info` envelope. filter_mapping=true is the only way to get more rows and it strips every row down to {id, identifier}.", - "`count` is {active, completed} for the whole project (active = every plan not completed), not for the current page \u2014 do not use it as a page total.", - "Each row carries both ids: `id` is the integer every v1 plan route takes, `identifier` is the TP-NNN / STP-NNN for display. Carry the integer forward.", - "Rows are full plan objects, same serializer as the single-plan read, unless a minify/skip-analytics path trims them \u2014 a group-level flag can turn that on without you asking, so check for the fields you need rather than assuming.", - "`q` must be sent as a nested object; a scalar q is 400 'Invalid Params: q should be a ActionController::Parameters'. Custom-field filters inside q.tp_custom_fields are validated and a bad field id fails the whole request.", - "Archived plans are NOT in this listing under any filter \u2014 they have their own archived listing.", - "Needs the test-plan VIEW permission." - ], - "returns": [ - "success", - "test_plans", - "info", - "count", - "filter_details", - "id", - "identifier", - "name", - "description", - "plan_status", - "active_state", - "record_status", - "start_date", - "end_date", - "owner", - "assignee", - "created_by", - "tags", - "issues", - "custom_fields", - "parent_plan_id", - "sub_plans_count", - "test_runs_count", - "total_test_runs_count", - "test_plan_progress_v2", - "project_id", - "created_at", - "updated_at", - "links" - ], - "max_page_size": 30 - }, - { - "name": "list_workspace_fields", - "method": "GET", - "path": "/api/v1/admin-v2/settings/fields", - "mode": "read", - "entity": "custom_field", - "query": [ - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Which entity's fields to list. Any other value is a 400 'Invalid Params'. TestCaseStep returns an empty list when the group's step-custom-fields flag is off." - }, - { - "name": "field_source", - "type": "string", - "values": [ - "system", - "custom", - "all" - ], - "default": "all", - "description": "Case-insensitive. `system` returns every system field on a single page (total_pages 1) and no custom fields; `custom` skips the system fields entirely. Anything else is a 400 naming the valid values." - }, - { - "name": "field_type", - "type": "string", - "description": "Filter custom fields by data type, using the canonical vocabulary (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown). System fields all report as `dropdown`, so any other value silently excludes every one of them." - }, - { - "name": "q", - "type": "string", - "description": "Case-insensitive substring match on the field name. Applied to system fields in this layer and forwarded as a search for custom fields." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. The only pagination control \u2014 page size is fixed at 10 and cannot be raised. Must be a scalar; `p[]=1` is a 400." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "name", - "field_name", - "created_at", - "id" - ], - "description": "Sort column. Also accepts a combined `-` form, e.g. `name-desc`, in which case sort_order is ignored. `name` is an alias for field_name. Any unrecognised column falls back to sorting by id." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "default": "desc", - "description": "Direction, used only when sort_by does not already carry one. Compared case-insensitively; anything that is not `desc` sorts ascending." - }, - { - "name": "selected_ids", - "type": "array", - "description": "Custom field ids to EXCLUDE from the custom-field half of the result. Has no effect on the system fields injected on page 1." - } - ], - "intent": "Browse or search every field configured for the workspace \u2014 built-in system fields and user-defined custom fields together \u2014 to find a field's numeric id, type and how many projects it reaches", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "$response": "WorkspaceFieldsListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Start here. This is the only listing that returns both system and custom fields, and the numeric `id` it gives is what every other custom-field call takes in its path.", - "The page size is fixed at 10 and there is no way to raise it: only `p` is read. Expect to page. `info` carries {count, page, page_size, total_pages}.", - "System fields are injected on page 1 ONLY, ahead of the custom fields, and they eat into that page's 10 slots. Page 2 onward is custom fields only. Row order is therefore not stable across pages unless you also send sort_by.", - "Every system field is reported as field_type `dropdown`, so a field_type filter with any other value silently drops all of them. Set field_source=custom when you only want user-defined fields, or field_source=system to get them all on one page.", - "An unsupported field_source is a 400 naming the three valid values; an unsupported entity_type is a 400 'Invalid Params'. entity_type=TestCaseStep returns an empty list (not an error) when the group lacks the step-custom-fields flag.", - "sort_by also accepts a combined form like `name-desc`; `name` is an alias for field_name. Sorting is applied in this layer AFTER the system fields are merged in, so it reorders only the rows on the page you asked for.", - "`q` matches on field name only, case-insensitively, and is applied to both system and custom fields.", - "Rows carry `dataset_count` and a derived `linked_to_all_projects`, but no option values and no project list \u2014 read the field, then its datasets, for those." - ], - "max_page_size": 10, - "returns": [ - "success", - "data", - "info", - "id", - "field_name", - "field_type", - "entity_type", - "system_name", - "place_holder_text", - "linked_projects_count", - "dataset_count", - "is_system_field", - "type", - "field_values", - "linked_to_all_projects", - "count", - "page", - "page_size", - "total_pages" - ] - }, - { - "name": "merge_tags", - "method": "POST", - "path": "/api/v1/projects/{project_id}/tags/merge", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Needed to match the route, and nothing else \u2014 it is never sent to the backing service. The merge is NOT scoped to this project; it applies across the whole workspace." - } - ], - "body": [ - { - "name": "source_id", - "type": "integer", - "required": true, - "description": "The tag that LOSES. It is absorbed into the target and ceases to exist; every entity carrying it is re-pointed at the target tag. Integer id \u2014 the project tag listings return names only, so resolve the id from the group tag search first." - }, - { - "name": "target_id", - "type": "integer", - "required": true, - "description": "The tag that SURVIVES. It keeps its own name and picks up all of the source tag's usages. Nothing about the target's name or metadata changes." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run", - "test_plan", - "shared_step" - ], - "description": "Which tag vocabulary the two ids belong to. NOT validated here \u2014 only checked for presence \u2014 so an unknown or mismatched value is forwarded and comes back as an opaque 400 from the backing service." - } - ], - "intent": "Merge one tag into another \u2014 everything tagged with the source tag ends up carrying the target tag instead, and the source tag stops existing", - "responses": { - "200": { - "description": "The backing service's merge result, passed through unchanged. No `success` flag and no envelope are added by this API; the exact keys are owned by the tag service.", - "schema": { - "type": "object" - } - }, - "400": { - "description": "Missing source_id/target_id/entity_type, or any failure reported by the backing service (including an unknown tag id), as `{error: \"\"}`." - }, - "401": { - "description": "Unauthorized." - }, - "403": { - "description": "Production only: caller is not an owner or admin \u2014 `{error: \"Only admins can manage tags\"}`." - }, - "500": { - "description": "Unexpected failure \u2014 `{error: \"Internal server error\"}`." - } - }, - "guidance": [ - "CURRENTLY IMPOSSIBLE TO COMPLETE, and not because of the field names here. The server accepts `source_id` exactly as declared, then forwards it to the backing service, which rejects the request asking for a differently-named plural field. So a correctly-formed call is refused downstream and no input from this surface succeeds. Do not work around it by renaming the field \u2014 the server's own check would then reject the call first. This needs a product fix, not a contract change.", - "Which tag loses: `source_id`. After the merge the source tag no longer exists as a tag, and every test case, run, plan or shared step that carried it carries the target's tag instead. `target_id` is unchanged apart from gaining those usages. Getting the two the wrong way round destroys the tag you meant to keep.", - "There is no unmerge. The response does not list what was re-tagged, so nothing here tells you which entities to fix afterwards \u2014 capture the source tag's usage list (search by that tag) BEFORE calling, or the mapping is unrecoverable.", - "Not project-scoped despite the path: `project_id` is used for routing only and never reaches the backing service, so the merge applies across the entire workspace and can touch projects you were not looking at.", - "Both ids are integers. Every project-level tag read in this profile returns names rather than ids \u2014 resolve the ids through the group-level tag search, and never send a tag name here.", - "`entity_type` must match the vocabulary both tags live in. It is not validated locally, so a mismatch is not caught until the backing service rejects it.", - "Tag management is restricted to account owners and admins. Check who is asking before promising a merge, and expect a refusal for anyone else.", - "Every downstream failure \u2014 unknown tag id, mismatched entity_type, permission problem \u2014 is flattened to 400 `{error: \"\"}`. There is no 404 and no `success` flag; the message text is the only signal.", - "On success the body is the backing service's raw merge result passed straight through, with no envelope added by this API." - ], - "disabled": true - }, - { - "name": "move_folder_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/mv", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "new_base_folder_id", - "type": "integer", - "example": 123, - "description": "Destination parent folder. NOT required despite what the name suggests: omit it or send null to move the folder to the project ROOT. It must exist in the destination project." - }, - { - "name": "new_base_project_id", - "type": "integer", - "example": 456, - "description": "Destination project; omit for a same-project move. Send it as a JSON NUMBER \u2014 the cross-project test is a raw inequality against the integer project id, so the string \"456\" against project 456 is misread as a cross-project move and takes the slow async path with all its extra restrictions." - }, - { - "name": "auto_rename", - "type": "boolean", - "default": true, - "description": "Defaults to TRUE here, unlike create and rename. A name clash in the destination is silently renamed rather than rejected; send false explicitly if you would rather the move fail." - }, - { - "name": "prev", - "type": "integer", - "description": "Optional: after the move, place the folder immediately after this sibling id. Must be an integer; sending prev or next triggers a second reorder call whose failure is not reported in this response." - }, - { - "name": "next", - "type": "integer", - "description": "Optional: after the move, place the folder immediately before this sibling id. Must be an integer." - } - ], - "intent": "Move a folder \u2014 with everything under it \u2014 to a different parent, to the project root, or into another project; same-project moves complete immediately, cross-project ones run in the background", - "returns": [ - "data", - "success", - "async", - "unique_id", - "folder", - "source_path_folders", - "destination_path_folders", - "parent_id", - "links" - ], - "responses": { - "200": { - "description": "Folder moved successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder__MoveFolderByIntegerId" + }, + "ReportDetail__CreateReportByIntegerId": { + "type": "object", + "description": "ReportDetail as returned by `create_report_by_integer_id`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:49:30.804Z" }, - "source_path_folders": { - "type": "array", - "nullable": true, - "items": { - "$schema": "Folder" - } + "custom_date_range": { + "type": "string", + "nullable": true }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "PathFolder__MoveFolderByIntegerId" - } + "description": { + "type": "string", + "example": "MCP live probe of create_report_by_integer_id capability" }, - "success": { - "type": "boolean" + "frequency": { + "type": "string", + "nullable": true + }, + "frequency_details": { + "type": "string", + "nullable": true + }, + "grain": { + "type": "string", + "example": "test_case" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 10411 + }, + "identifier": { + "type": "string", + "example": "SC-510" + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + }, + "is_dynamic": { + "type": "boolean", + "example": false + }, + "next_run_at": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "project_ids": { + "type": "integer", + "example": 1234 + }, + "report_creation_mode": { + "type": "string", + "example": "custom_report" + }, + "report_timeframe": { + "type": "string", + "example": "last_one_week" + }, + "report_type": { + "type": "string", + "example": "Test Run Summary" + }, + "slack_notification": { + "type": "string", + "nullable": true }, - "async": { - "type": "boolean" + "slack_notification_supported": { + "type": "boolean", + "example": true }, - "unique_id": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Invalid input or move not allowed", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } - } - } - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Two completely different responses come out of this one route. A same-project move is synchronous and returns {data:{folder, source_path_folders, destination_path_folders, success:true, async:false}}. A cross-project move returns only {data:{success:true, async:true, unique_id}} and has done nothing yet \u2014 branch on `data.async` before reading `data.folder`.", - "For the async path there is no HTTP status endpoint. `unique_id` is the X-Request-ID echoed back and progress goes over a websocket the API does not expose; confirm the move by re-reading the destination folder.", - "Omit new_base_folder_id to move to the project root. That is the only way to promote a nested folder to root level.", - "Moving a folder into one of its own descendants is NOT blocked. The only self-move check compares the folder id to the destination id, so a move into a child is accepted here (the copy endpoint does check for this \u2014 the move endpoint does not).", - "Cross-project moves carry two hard limits: the subtree may contain at most 50 folders counting all nesting, and the folder must contain no shared test cases at all. Either one fails the request with 422 {success:false, error:'...'} before anything moves.", - "The error shape varies by stage: local validation failures are 422 {success:false, error}, upstream rejections are 400 {success:false, error, message} (both keys, same text), and anything unexpected is 500 {success:false, error:'An unexpected error occurred. Please try again.'}.", - "`user_action` appears nowhere in the codebase \u2014 it is accepted and ignored. Do not build anything on it.", - "This action has no RBAC entry, so it is not permission-gated the way rename and reorder are.", - "A move is reversible by moving the folder back, but the async cross-project path recreates folders rather than relocating them, so ids on the far side are new." - ] - }, - { - "name": "rename_folder_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/rename", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/folder/name", - "description": "New folder name. Send it flat \u2014 the `folder` wrapper is added for you from the field's json_path, and sending one yourself is refused before the call leaves. Not strictly required: a notes-only edit is accepted, but the `folder` object itself must be present and non-empty." - }, - { - "name": "notes", - "type": "string", - "json_path": "/folder/notes", - "description": "New description, HTML allowed; unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent. The key is `notes` \u2014 `description` is silently dropped by the permit list on this v1 route." - }, - { - "name": "auto_rename", - "type": "string", - "values": [ - "true" - ], - "description": "Literal string \"true\" to auto-suffix a clashing sibling name instead of failing. Any other value, including the boolean true, reads as false." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "fields": [ - { - "name": "blob_id", - "type": "integer" - } - ], - "description": "Replaces the folder's attachment set with these blob ids. Sending the key at all switches the handler into attachment mode and re-reads the folder's relations; an invalid set returns 422 with the bare string \"attachment is invalid\"." - } - ], - "intent": "Rename a folder or rewrite its notes \u2014 despite the name this is the general folder-edit call, and it changes whichever of the two fields you send", - "returns": [ - "data", - "success", - "folder", - "links" - ], - "responses": { - "200": { - "$schema": "FolderWriteResponse__RenameFolderByIntegerId" - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send `name` flat. The `folder` wrapper is added for you from the field's json_path, and sending your own `folder` object is rejected before the call as an unknown body key. Sending no fields at all fails on the server for an empty wrapper.", - "Sibling-name uniqueness is always checked. A clash relays the entity store's own message and status verbatim rather than the generic masked text most folder endpoints return \u2014 read `message` for the real reason. auto_rename:\"true\" turns the rejection into a silent rename.", - "There is no undo. Folders keep no version history, so an overwritten name or notes value is gone and the only way back is to send the old value again \u2014 capture it with a read first if it matters.", - "The updated folder is at `data.folder`. The `cases_count` and `sub_folders_count` in that echo are copied from the read taken BEFORE the update, not re-derived \u2014 do not treat them as fresh counts.", - "This endpoint does NOT return a request_trace_id; the only keys are `data.folder` and `data.success`.", - "folder_id must be a positive integer; literal 0 raises an unhandled ArgumentError and comes back as a 500.", - "Requires the folder-edit permission." - ] - }, - { - "name": "reorder_folders", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folders/reorder", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "current", - "type": "array", - "required": true, - "example": [ - 21 - ], - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "description": "The folder ids being repositioned. Must be a JSON array of NUMBERS \u2014 the check is a strict integer type test, so [\"21\"] or a form-encoded current[]=21 fails with 422 {success:false, message:'test_case_folders must be an array of integers.'}." - }, - { - "name": "prev", - "type": "integer", - "example": 101, - "description": "Id of the sibling the moved folders should land AFTER. Integer or null only \u2014 a string is 422. Null means 'place at the top', but see next: prev and next cannot both be omitted." - }, - { - "name": "next", - "type": "integer", - "example": 103, - "description": "Id of the sibling the moved folders should land BEFORE. Integer or null only. At least one of prev/next must be a real id." - } - ], - "intent": "Change the display order of sibling folders by dropping one or more of them between two named neighbours \u2014 ordering only, it never changes nesting", - "returns": [ - "data", - "success", - "message" - ], - "responses": { - "200": { - "description": "Folders reordered successfully", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true + "title": { + "type": "string", + "example": "__mcp-probe-report-20260921" }, - "message": { - "type": "string", - "example": "Folders reordered successfully" + "updated_at": { + "type": "string", + "example": "2026-09-22T09:49:30.804Z" } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`current` alone is not enough, whatever the required flags say. Sending neither prev nor next fails with 422 {success:false, message:'Both top_folder_id and bottom_folder_id cannot be nil.'} \u2014 you must anchor the move against at least one neighbour.", - "Every id is type-checked as a Ruby Integer before anything is sent upstream. Numeric strings are rejected, so send a JSON body with real numbers rather than form-encoded parameters.", - "All the validation failures come back as 422, not 400, even though they are plainly bad-request conditions \u2014 the status the code intended is not the status that is sent.", - "The response is only {data:{success:true, message:'Folders reordered successfully'}}. It does not echo the new order and it does NOT contain a request_trace_id. Re-read the folder listing to confirm the result.", - "Nothing about the ids is validated locally \u2014 existence, project ownership and sibling-ness are all the upstream service's problem, so a wrong-but-well-typed id can return success-shaped output without moving what you meant.", - "This reorders siblings within their existing parent. To change a folder's parent, use the move call instead.", - "Requires the folder-edit permission." - ] - }, - { - "name": "reorder_test_cases_by_folder", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "top_test_case", - "type": "string", - "description": "Integer id of the case the moved block should sit BELOW. Omit it to move the block to the top of the page.", - "json_path": "/re_order/top_test_case" - }, - { - "name": "bottom_test_case", - "type": "string", - "description": "Integer id of the case the moved block should sit ABOVE. Omit it to move the block to the bottom of the page.", - "json_path": "/re_order/bottom_test_case" - }, - { - "name": "page", - "type": "integer", - "description": "Page of the folder listing the move happens on. Ordering is resolved against that page's window, so a neighbour that is not on this page cannot anchor the move.", - "json_path": "/re_order/page" - }, - { - "name": "test_cases", - "type": "array", - "required": true, - "description": "Integer ids of the cases to move, in the order they should end up in. These are moved as one block between `top_test_case` and `bottom_test_case`.", - "json_path": "/re_order/test_cases" - } - ], - "intent": "Change the manual order of test cases within a folder by dropping a block of them between two neighbours.", - "returns": [ - "success", - "test_cases", - "info" - ], - "paginated": true, - "responses": { - "200": { - "description": "Test cases reordered successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__ReorderTestCasesByFolder" - } - }, - "info": { - "type": "object", - "description": "Additional metadata about the reorder operation." } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The wrapper applied for you here is `re_order`, NOT `test_case` like every other test-case write on this surface \u2014 that difference is handled by the json_paths, so you do not need to act on it, but it is why a hand-built `test_case` wrapper fails here in a different way than elsewhere.", - "Position is expressed by NEIGHBOURS, not by index: `top_test_case` and `bottom_test_case` are the case ids the moved block lands between. Omit `top_test_case` for the top of the page, `bottom_test_case` for the bottom.", - "Everything is integer case ids \u2014 the anchors and the moved list alike. TC-NNN identifiers are not accepted here.", - "Ordering is resolved within the page named by `page`, so both anchors must be on that page of the folder listing for the move to mean what you intend.", - "A rejection comes back as 400 `{errors: \"\"}` \u2014 not the usual `{success:false}` \u2014 and covers 'ids not in this folder' as well as malformed input.", - "The 200 body is the folder's listing in its NEW order, so you can confirm the result without a second read. Ordering is not versioned: the only way back is another reorder." - ] - }, - { - "name": "replace_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' \u2014 it is not skipped." - }, - { - "name": "run_state", - "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it.", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "json_path": "/test_run/run_state" - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings; the list you send replaces the run's tags." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read. Duplicates are collapsed." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^$|^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^$|^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "The run's COMPLETE intended case list, not a list to add. The partial-update call on this same path REPLACES membership rather than unioning it, and this full-replace call is no gentler: assume a case missing from the list is removed with its results and assignee. Send the run's existing case identifiers plus the new ones \u2014 or, to add cases, use bulk_update_test_run_test_cases with add_test_cases, which is truly additive." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders" - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list \u2014 cases you list elsewhere are still included, with a null-configuration row." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "json_path": "/test_run/filter_scope", - "description": "Accepted by the parameter filter but NOT honoured on either update path \u2014 the selection is always built in global mode here. Only create reads it." - } - ], - "intent": "Rewrite a test run from a complete payload \u2014 every metadata field you leave out is reset, so the run ends up as whatever this one body says. Use the partial update instead when you only mean to change some fields.", - "guidance": [ - "Two different operations share this path. This POST rebuilds the whole run payload; the PATCH applies only the keys you send. If you are renaming a run, closing it, or linking a plan, pick the PATCH.", - "Omission is destructive here: no run_state resets the run to new_run, no test_plan_id/sub_test_plan_id unlinks the plan it was under, and no issues clears its linked defects. Read the run first and re-send its current values for anything you do not mean to change.", - "An invalid run_state is not rejected on this call \u2014 it is silently coerced to new_run. The partial-update call on the same run returns 400 instead, so use that one if you want the error.", - "shared_test_cases and filter_scope are accepted by the parameter filter and then ignored on this path. Cross-project cases only land through the PATCH.", - "Case membership is REPLACED, not unioned: whatever test_cases / folder_ids / include_all resolve to should be treated as the run's whole case list. Always send the existing cases plus the new ones; a case left out loses its results.", - "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "400 'invalid data' also covers an assignee email that cannot be resolved in this group \u2014 the run is left untouched in that case.", - "There is no undo: test runs keep no version history, so a field this call clears cannot be restored.", - "The updated run comes back at the top level as `testrun` \u2014 one word, no underscore, and no `data` envelope.", - "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", - "`urls.self` is a link to the run in the web UI, not an API path \u2014 it is built from the frontend base URL, so calling it returns HTML.", - "`assignee` comes back as a plain email string \u2014 or null when the owner cannot be resolved \u2014 and `configurations` as a list of configuration ids. Neither is a list of objects.", - "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", - "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", - "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", - "The run's case list is not on this reply \u2014 `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", - "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write. Re-read the run when the exact stored values matter.", - "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunWriteV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "resolve_duplicate", - "method": "POST", - "path": "/api/v1/projects/{project_id}/ai-duplicates", - "mode": "write", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "duplicate_id", - "type": "integer", - "required": true, - "description": "The duplicate to resolve, from the `id` of a listing row. Passed in the BODY, not the path \u2014 this endpoint posts to the collection path, unlike discard which puts to the item path." - }, - { - "name": "merge_action", - "type": "string", - "required": true, - "values": [ - "merge", - "archive" - ], - "description": "Exactly `merge` or `archive`. There is no default and no fallback: any other value, including a missing one, is rejected with 400 {error:'Invalid action'} and nothing happens." - }, - { - "name": "source_testcase_id", - "type": "integer", - "description": "Required when merge_action is `merge`. The integer id of the case that will be archived and folded into the other. Must be one of the pair's two test cases." - }, - { - "name": "target_testcase_id", - "type": "integer", - "description": "Required when merge_action is `merge`. The integer id of the case that survives and receives the merge comment. Must be one of the pair's two test cases." - }, - { - "name": "archive_testcase_id", - "type": "integer", - "description": "Required when merge_action is `archive`, and ignored otherwise. The integer id of the single case to archive; must be one of the pair's two test cases. Without it the archive path fails with 'Test case not found in duplicates'." - } - ], - "intent": "Act on a duplicate suggestion \u2014 merge one test case into the other (archiving the source and commenting on the survivor), or archive one of the pair outright", - "responses": { - "200": { - "description": "Duplicate resolved. Body is {success:true} only \u2014 nothing about the affected test cases is returned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "400": { - "description": "{error:'Invalid action'} when merge_action is neither `merge` nor `archive`, or {errors:'Invalid Input'} when the backing store rejects the merge/archive." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "The caller lacks the test-case delete permission (tc_delete)." - }, - "404": { - "description": "{errors:'Duplicate not found.'} \u2014 no such pair in this project, or it is no longer in `suggested` status." - }, - "409": { - "description": "{errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'} \u2014 a dedupe data migration is in progress. Nothing was changed; retry later." - }, - "422": { - "description": "A test case id that is not part of this pair. Body is {errors:'Test case not found in duplicates'} on the merge path and {success:false, message:'Test case not found in duplicates'} on the archive path." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Two different operations behind one field. `merge` archives source_testcase_id, tags it 'Archived as Duplicate', posts a comment on target_testcase_id recording the merge, stores a snapshot of the source, and marks the pair merged. `archive` archives archive_testcase_id only \u2014 no comment, no snapshot \u2014 and still marks the pair merged.", - "Ids are not interchangeable: the SOURCE is the case that disappears. Getting source and target the wrong way round archives the case you meant to keep, and the only route back is unarchiving it manually.", - "Archived is not deleted \u2014 a merged-away or archived case is recoverable from the project's archived cases, findable by the 'Archived as Duplicate' tag on the merge path. Nothing here is a hard delete, but the merge comment, the snapshot and the pair's status change are not undone by unarchiving.", - "Resolving requires the test-case DELETE permission (tc_delete), not write. A caller who can edit test cases may still be refused here with 403.", - "The pair must still be `suggested`. An already-resolved pair is 404 {errors:'Duplicate not found.'}, which usually means someone else handled it, not that the id is wrong.", - "Undeclared until now, and retryable: while a dedupe data migration is in progress the call is refused with 409 {errors:'Duplicate review is paused while this data is being migrated. Please try again shortly.'}. It is deliberately not a 200 \u2014 nothing was merged. Retry later.", - "A test case id that is not part of the pair is rejected AFTER the pair lookup and reports 422, not 404, with two different bodies depending on the action: merge gives {errors:'Test case not found in duplicates'} and archive gives {success:false, message:'Test case not found in duplicates'}.", - "Success is just {success:true} \u2014 the affected test cases are not echoed back. Re-read the surviving case if you need its post-merge state, and note the pair leaves every duplicates listing at this point.", - "After a merge the source case's pre-merge contents are readable only through the merged pair's source-snapshot call. Keep the duplicate id if you might need it; it is no longer discoverable from any listing." - ], - "returns": [ - "success" - ], - "disabled": true - }, - { - "name": "restore_history", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/histories/{history_id}/restore", - "mode": "write", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - }, - { - "name": "history_id", - "type": "integer", - "required": true - } - ], - "intent": "Roll a test case back to an earlier revision \u2014 reapplies that snapshot's name, description, preconditions, steps, status, priority, owner, tags, defects, custom fields, attachments and reviewers to the live case", - "responses": { - "200": { - "description": "Restore applied. Body is only {success: true} \u2014 read the test case back to see the restored content.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Plan gate: not on an Enterprise or Team Pro plan ({success:false, message:'Not Allowed'}); or the entity type is not restorable ({success:false, message:'Not supported'} / 'Not supported for build')." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Paid feature. Without an Enterprise or Team Pro plan this returns 422 {success:false, message:'Not Allowed'} and nothing is changed.", - "`history_id` is the numeric revision id from the listing, not the V1/V2 position label.", - "It is a full OVERWRITE of the snapshotted fields, not a merge. A custom field that has a value today but was empty in the snapshot is CLEARED. Steps are rewritten from the snapshot, so steps added since are removed, and steps whose ids no longer exist come back with new ids. The attachment set is rewritten too, unlinking files added after that revision.", - "There is a way back, but only one: the restore is applied as an ordinary edit of the live case, so the pre-restore state remains in the revision trail and can itself be restored. There is no undo call.", - "Say what will be lost before calling. The caller cannot see the delta from this endpoint \u2014 read the diff (or the two revisions) first and tell the user which fields will change.", - "Only test cases are restorable on this route; the same shape against another entity type answers 422 'Not supported'.", - "Success is only {success: true}. Read the case back to confirm what the restore actually produced \u2014 in particular, custom fields whose definition no longer exists in the project are not recreated and stay absent." - ], - "returns": [ - "success" - ] - }, - { - "name": "search_duplicates", - "method": "POST", - "path": "/api/v1/projects/{project_id}/ai-duplicates/search-v2", - "mode": "read", - "entity": "duplicate", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "q", - "type": "object", - "description": "Test-case filter object, the same grammar the test-case search takes; a pair matches when its test cases match. Sharp edges here: `owner`/`reviewers` take TM user ids and are translated server-side (an id resolving to nobody filters to nothing, never to everything); date filters must use `date_range` or the relative `created_at`/`updated_at` tokens, and `all_time` is dropped rather than applied.", - "fields": [ - { - "name": "query", - "type": "string", - "description": "Free-text match on test case title / identifier." - }, - { - "name": "folder_ids", - "type": "string", - "description": "Folder ids, comma-separated string or array." - }, - { - "name": "owner", - "type": "string", - "description": "TM user ids, comma-separated. Translated to backing-store ids before the search." - }, - { - "name": "reviewers", - "type": "string", - "description": "TM user ids, comma-separated. Translated like owner." - }, - { - "name": "tags", - "type": "string", - "description": "Tag names, comma-separated string or array." - }, - { - "name": "priority", - "type": "string", - "description": "Priority option ids, not display labels." - }, - { - "name": "status", - "type": "string", - "description": "Status option ids, not display labels." - }, - { - "name": "case_type", - "type": "string", - "description": "Case-type option ids." - }, - { - "name": "automation_state", - "type": "string", - "description": "Option ids, or the literals `automated` / `manual` which the server resolves." - }, - { - "name": "issue_ids", - "type": "string", - "description": "Linked issue ids, comma-separated." - }, - { - "name": "custom_fields", - "type": "object", - "description": "Keyed by custom-field id, e.g. {\"179720\": [\"Yes\"]}." - }, - { - "name": "match", - "type": "object", - "description": "Per-field operator map (all / any / none). Values stay beside it under their own key \u2014 a value placed inside `match` applies no filter at all." - }, - { - "name": "empty", + }, + "Project__CreateReportByIntegerId": { "type": "object", - "description": "Is-empty filter: {system_fields:[...], custom_fields:[...]}." - }, - { - "name": "date_range", - "type": "string", - "description": "Absolute created-at range; takes precedence over created_at when both are sent." - }, - { - "name": "created_at", - "type": "string", - "description": "Relative token such as `7_day`. `all_time` is dropped, applying no bound." - }, - { - "name": "updated_at", - "type": "string", - "description": "Relative token, same form as created_at. `all_time` is dropped." - } - ] - }, - { - "name": "match_scope", - "type": "string", - "values": [ - "any", - "all" - ], - "default": "any", - "description": "Whether ONE test case of a pair matching `q` is enough (`any`) or BOTH must match (`all`). Defaults to `any` \u2014 with `all` a filter that matches only one side returns nothing." - }, - { - "name": "duplicates", - "type": "string", - "values": [ - "exact", - "semantic" - ], - "description": "Duplicate-type filter (`near` is a legacy alias for `semantic`). Forwarded verbatim and not validated here; an unrecognised value narrows to nothing." - }, - { - "name": "owner", - "type": "string", - "description": "The 'My duplicates' tab. ANY non-empty value scopes results to the CALLER's own test cases; the value is discarded, so it cannot select another user. This is separate from the `owner` filter inside `q`, which does take user ids." - }, - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `p` is an accepted alias and only `p` is validated (a bad `p` is 400 {success:false, error:'Invalid Params'}); `page` wins when both are sent and a non-numeric `page` silently becomes 0." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Send the literal string 'true' (with a non-empty `q`) to get a `filter_details` object back that resolves the filter's ids to display labels. Any other value, or a missing `q`, omits the key entirely." - } - ], - "intent": "Search the AI-suggested duplicate pairs with the full test-case filter grammar \u2014 narrow the review queue by folder, owner, tags, priority, status, custom fields or free text", - "shape": "discovered", - "paginated": true, - "responses": { - "200": { - "description": "Matching suggested duplicates, plus `filter_details` when it was requested. The same shape with an empty `duplicates` is returned when the dedupe feature flag is off.", - "schema": { - "$schema": "V1DuplicatesListResponse" - } - }, - "400": { - "description": "{success:false, error:'Invalid Params'} \u2014 a non-positive or non-numeric `p`." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "description": "{success:false, message:...} \u2014 the backing store refused the filter combination (usually because it resolves to too many test cases). This is NOT 'no results'." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A POST, but a read \u2014 it changes nothing. It exists because the filter object is too long for a query string.", - "Same feature gate as the plain listing: with the dedupe flag off this returns 200 {success:true, duplicates:[], info:{count:0,total_pages:0}} and never reaches the filter. An empty result is not evidence that nothing matched.", - "'Nothing matched' and 'the filter could not be evaluated' are different answers. A filter the backing store refuses (typically one resolving to too many test case ids) comes back 422 {success:false, message:}, not an empty list \u2014 do not treat that as zero results.", - "Page size is fixed at 10, ordering is by confidence (ai_score descending), and `info.count` can exceed the rows on the page because pairs with an unresolvable test case are dropped after counting.", - "Send only the filter keys you mean. `q` is forwarded to the search engine, which discards keys that would take it off the duplicate-pair scope, so an unexpected key fails silently rather than erroring.", - "Carry each row's `id` forward as the duplicate id for the read, resolve and discard calls.", - "Response adds two keys the plain listing has: `filter_details` (only when requested) and, as there, `last_scan_time` for the project's last dedupe run." - ], - "returns": [ - "success", - "duplicates", - "info" - ], - "max_page_size": 10 - }, - { - "name": "search_group_tags", - "method": "GET", - "path": "/api/v1/group/{entity_type}/tags", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run" - ], - "example": "test_case", - "description": "Which tag vocabulary to search. Hyphens are normalised to underscores, so `test-case` and `test_case` both work. Not validated against a list here \u2014 an unrecognised value is forwarded to the backing service rather than rejected." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search, trimmed. Omit it to get the first page of the whole vocabulary." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`." - } - ], - "intent": "Search the workspace-wide tag vocabulary for one entity type and get each tag's id \u2014 the only tag read here that returns ids, so it is where you resolve the source_id and target_id a tag merge needs", - "returns": [ - "tags", - "tag_objects", - "info", - "name" - ], - "paginated": true, - "responses": { - "200": { - "$schema": "TagsSearchResponse__SearchGroupTags" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Group-scoped: no project is involved and none can be supplied, so tags from every project in the workspace come back together. A name you find here may not be in use in the project you care about.", - "This read returns the tag `id` alongside the name \u2014 that is what makes it the resolution step for a tag merge, which takes integer ids. The project-level tag reads return names only.", - "Page size is fixed at 30 and there is no per_page parameter; page with `p`.", - "`created_at` and `usage_count`, which the project-scoped tag search returns, are not guaranteed on this group-scoped path \u2014 treat `id` and `name` as the fields you can rely on.", - "Not permission-gated \u2014 the search-filter controller has no RBAC entry, and this route deliberately skips project resolution.", - "Neither `info.count` nor `info.next` can be trusted to size a fetch. Confirmed live: a listing that returns 30 rows on page 1 and 30 more distinct rows on page 2 reports a count of 31 on both, and `next` is correct on page 1 and then null on page 2 with 60 rows already returned. Page until a page comes back empty rather than until the count is satisfied or `next` is null, and never report the count to a user as a total." - ], - "max_page_size": 30 - }, - { - "name": "search_project_entities", - "method": "GET", - "path": "/api/v1/projects/{project_id}/{entity}/search", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id. The route segment only matches digits (or the literal 'new'); a PR-NNN identifier is a routing 404." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "test-runs", - "trtc", - "test-plans" - ], - "example": "test-cases", - "description": "What to search. 'trtc' means the test cases inside one test run and needs test_run_id. 'test-plans' matches the route but has no handler branch and always returns an empty list. 'reports' is NOT a valid value \u2014 it fails the route constraint with a 404." - } - ], - "query": [ - { - "name": "q[tags][]", - "type": "array", - "example": [ - "2FA", - "delivery" - ], - "description": "Tag filter. Send it as a REPEATED BRACKETED parameter - `q[tags][]=2FA&q[tags][]=delivery` - one entry per tag name. Multiple tags are OR'd: a case matching any of them is returned. DO NOT use `q[test_case_tags]`: it is accepted, returns 200, and is SILENTLY IGNORED - the full unfiltered set comes back looking like a successful search. A bare `q[tags]` carrying a JSON string matches nothing (0 rows), and `q[tags]` with some other shapes 500s." - }, - { - "name": "q[query]", - "type": "string", - "example": "login", - "description": "Free-text match on title/identifier. Special characters are stripped before the match." - }, - { - "name": "q[folder_ids]", - "type": "string", - "example": "3306878,3669741", - "description": "Comma-separated folder ids to restrict to (test-cases). Add include_subfolders_tc=true to descend into sub-folders." - }, - { - "name": "q[folder_id]", - "type": "integer", - "example": 29529465, - "description": "Single folder id; read on the trtc path only." - }, - { - "name": "q[status]", - "type": "string", - "example": "9319,9321", - "description": "Comma-separated numeric status IDs, not display names \u2014 resolve them first. Renamed to status_id internally." - }, - { - "name": "q[priority]", - "type": "string", - "example": "550376,9261", - "description": "Comma-separated numeric priority IDs, not display names. Renamed to priority_id internally." - }, - { - "name": "q[case_type]", - "type": "string", - "example": "9379,9375", - "description": "Comma-separated numeric case-type IDs, not display names. Renamed to category_id internally." - }, - { - "name": "q[automation_state]", - "type": "string", - "example": "automated", - "description": "Either the literal word 'automated' / 'manual' (resolved to ids for this project) or comma-separated numeric ids. The only filter here that accepts words." - }, - { - "name": "q[owner]", - "type": "string", - "example": "3700,3701", - "description": "Comma-separated TM user ids (test-cases and test-runs). Pass use_bstack_id=1 if you are supplying BrowserStack ids instead." - }, - { - "name": "q[assignee]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids; the trtc equivalent of owner (mapped to owner internally)." - }, - { - "name": "q[reviewers]", - "type": "string", - "example": "3700", - "description": "Comma-separated TM user ids; translated to BrowserStack ids server-side because reviews are stored against the BrowserStack id." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "3_month", - "description": "Relative window as _[_gte], or 'all_time' for no bound. Converted to an absolute range server-side." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "3_month", - "description": "Same token format, on last-updated date." - }, - { - "name": "q[review_status]", - "type": "string", - "example": "in_review", - "description": "Comma-separated review-status tokens (approved, rejected, in_review, pending, changes_requested), test-cases only." - }, - { - "name": "q[configuration_id]", - "type": "string", - "example": "101,102", - "description": "Comma-separated configuration ids; trtc path." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. Non-positive or non-numeric values return 400 'Invalid Params'." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "description": "Rows per page \u2014 this is the size parameter, NOT page_size or count. Above 300 on test-cases the call silently switches to id-only compact rows; on test-runs it is overridden to 30 regardless." - }, - { - "name": "required[fields]", - "type": "string", - "example": "count", - "description": "Comma-separated list of extra columns to hydrate. The single value 'count' switches the endpoint into count-only mode: the response becomes {success, count} with no rows." - }, - { - "name": "sort_by", - "type": "string", - "example": "name-asc", - "description": "Column and direction as -. For test-cases the column must also appear in required[fields] when that is used, or the request fails." - }, - { - "name": "use_bstack_id", - "type": "string", - "values": [ - "0", - "1" - ], - "description": "Set to '1' when the user ids in q[owner]/q[assignee]/q[reviewers]/q[last_executed_by] are BrowserStack ids; they are then translated to TM ids. Without it BrowserStack ids simply match nothing." - }, - { - "name": "test_run_id", - "type": "integer", - "example": 45123, - "description": "Required when entity=trtc \u2014 the run whose test cases you are searching. Omitting it yields an empty page, not an error." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "default": false, - "description": "Descend into sub-folders of q[folder_ids] (test-cases). Only the exact string 'true' enables it." - }, - { - "name": "include_test_plan", - "type": "boolean", - "default": false, - "description": "Decorate each run with its linked test plans (test-runs only). Only the exact string 'true' enables it." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true" - ], - "description": "Also return the filters you sent, resolved into display objects, under `filter_details`. Ignored when q is empty." - }, - { - "name": "compact", - "type": "boolean", - "default": false, - "description": "Force compact mode for test-cases: id-only stub rows grouped by folder, empty filter_details. This is the same mode per_page>300 triggers implicitly." - } - ], - "intent": "Search and filter a project's test cases, test runs, or the test cases inside one run \u2014 the general-purpose finder: free text plus status / priority / type / owner / folder / tag / date filters, with an id-only count mode", - "shape": "discovered", - "max_page_size": 300, - "paginated": true, - "responses": { - "200": { - "description": "Top-level result; the collection key depends on entity (test_cases for test-cases and trtc, test_runs for test-runs) and there is no `data` envelope. Compact mode and count-only mode return reduced shapes \u2014 see guidance.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "description": "Present for entity=test-cases and entity=trtc. In compact mode the rows degrade to {id, test_case_folder_id, project_id} stubs.", - "items": { - "$schema": "V1TestCaseSearchRow__SearchProjectEntities" - } - }, - "test_runs": { - "type": "array", - "description": "Present for entity=test-runs.", - "items": { - "$schema": "V1TestRunListRow" - } - }, - "folders": { - "type": "object", - "description": "KEYED BY FOLDER ID \u2014 {\"764093\": [{id, name, parent_id, ui_position}, \u2026]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." - }, - "count": { - "type": "integer", - "description": "Total matches. Always present for entity=test-runs; the sole payload (with success) in count-only mode." - }, - "filter_details": { - "type": "object", - "description": "The filters you sent, resolved to display objects. Empty {} unless filter_details=true and q was non-empty, and always empty in compact mode." - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "TAG FILTERING USES `q[tags][]`, REPEATED ONCE PER TAG. The previously documented `q[test_case_tags]` does not filter at all - it returns 200 and the complete unfiltered list, which reads as 'no tag filtering available'.", - "Four entity values route here: test-cases, test-runs, trtc (the test cases inside one test run) and test-plans. Anything else \u2014 'reports' included \u2014 does not match the route constraint and is a 404 before the API is reached.", - "entity=test-plans is accepted by the route and by the controller but has no implementation behind it: it always renders {test_plans: [], info: null} with a 200. It is a dead branch, not an empty result \u2014 use the test-plan listing capabilities instead.", - "entity=trtc additionally requires a `test_run_id` query param. Without it the run filter is built from 0 and you get an empty page rather than an error.", - "The page-size parameter is `per_page`, not `page_size` or `count`; `page_size` is ignored and you silently get the 30-row default.", - "Ceiling behaviour differs per entity and this is the sharpest edge here. For test-cases, asking for more than 300 rows (or passing compact=true) flips the call into compact mode: the rows degrade to id-only stubs {id, test_case_folder_id, project_id}, filter_details comes back empty, and the page size jumps to 20000. For test-runs, per_page is overridden to 30 no matter what you ask for. Only trtc forwards per_page unchanged.", - "Count-only mode: send required[fields]=count (exactly that one field) and the response collapses to {success, count} with no rows \u2014 far cheaper than paging for a total.", - "Filter keys are renamed on the way through: q[priority] -> priority_id, q[status] -> status_id, q[case_type] -> category_id. They take numeric IDs, not display labels \u2014 resolve them with the system/custom field value capabilities first. q[automation_state] is the exception: it accepts the literal words 'automated' or 'manual' as well as ids.", - "Any key under q that the backing allow-list does not recognise is dropped silently. A misspelled filter therefore widens the result set to everything instead of erroring \u2014 always sanity-check the count against an unfiltered call.", - "q[owner] / q[assignee] / q[reviewers] take TM user ids by default. Pass use_bstack_id=1 if your ids are BrowserStack ids and they will be translated first; without it, BrowserStack ids match nothing and the response is simply empty.", - "Response shape depends on the entity: test-cases returns {success, test_cases, folders, info, filter_details}; trtc returns {success, info, test_cases, filter_details}; test-runs returns {success, test_runs, info, count} plus filter_details only when filter_details=true and q is non-empty. There is no `data` envelope on any of them.", - "For test-cases, `sort_by` must name a column that is also present in required[fields] when required[fields] is used, or the call fails with 'Sort by column X is not present in required fields'.", - "Binned (recycle-bin) and archived test cases are excluded from this search; they have their own listing capabilities." - ], - "returns": [ - "success", - "test_cases", - "test_runs", - "folders", - "info", - "filter_details", - "count", - "page", - "page_size", - "prev", - "next" - ] - }, - { - "name": "search_project_entities_by_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/{entity}/search-v2", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "test-cases", - "trtc", - "test-runs", - "test-plans" - ] - } - ], - "body": [ - { - "name": "query", - "type": "string", - "example": "tc", - "description": "Search query string", - "json_path": "/q/query" - }, - { - "name": "owner", - "type": "string", - "example": "14", - "json_path": "/q/owner" - }, - { - "name": "reviewers", - "type": "string", - "example": "14,15", - "description": "Filter by reviewer, same form as `owner` \u2014 comma-separated TM user ids as a string, `$none` for none.", - "json_path": "/q/reviewers" - }, - { - "name": "last_executed_by", - "type": "string", - "example": "14", - "description": "Filter by who last executed the case, same form as `owner`. A non-string value raises server-side rather than returning empty.", - "json_path": "/q/last_executed_by" - }, - { - "name": "folder_id", - "type": "string", - "example": 29529465, - "description": "Filter by folder ID (single value or array)", - "json_path": "/q/folder_id" - }, - { - "name": "folder_ids", - "type": "string", - "example": [ - 125382, - 125385, - 125386 - ], - "description": "Filter by multiple folder IDs (comma-separated string or array)", - "json_path": "/q/folder_ids" - }, - { - "name": "priority", - "type": "string", - "example": [ - 1268, - 1270, - 1269, - 1267 - ], - "description": "Filter by priority IDs (comma-separated string or array)", - "json_path": "/q/priority" - }, - { - "name": "status", - "type": "string", - "description": "Filter by status IDs (comma-separated string or array)", - "json_path": "/q/status" - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Filter by issue type (e.g., jira, github)", - "json_path": "/q/issue_type" - }, - { - "name": "issue_ids", - "type": "string", - "description": "Filter by issue IDs (comma-separated string or array)", - "json_path": "/q/issue_ids" - }, - { - "name": "tags", - "type": "string", - "description": "Filter by tags (comma-separated string or array)", - "json_path": "/q/tags" - }, - { - "name": "case_type", - "type": "string", - "description": "Filter by case-type option ids (comma-separated string or array).", - "json_path": "/q/case_type" - }, - { - "name": "automation_state", - "type": "string", - "description": "Option ids, or the literals `automated` / `manual` which the server resolves.", - "json_path": "/q/automation_state" - }, - { - "name": "automation_status", - "type": "string", - "description": "Filter by automation status.", - "json_path": "/q/automation_status" - }, - { - "name": "review_status", - "type": "string", - "description": "Filter by review status (only meaningful where review/approval is configured).", - "json_path": "/q/review_status" - }, - { - "name": "created_at", - "type": "string", - "example": "7_day", - "description": "Relative token `_` with a SINGULAR unit (`day`, `hour`, `week`, `month`,\n`year`) \u2014 e.g. `7_day`, `24_hour`; `_gte` inverts it (`7_day_gte` = older than).\nAlso accepts `all_time` or an absolute `\"YYYY-MM-DD YYYY-MM-DD\"` range. A PLURAL\nunit such as `7_days` is an unhandled server error (500), not a 400.", - "json_path": "/q/created_at" - }, - { - "name": "updated_at", - "type": "string", - "example": "1_month", - "description": "Same form as `created_at`.", - "json_path": "/q/updated_at" - }, - { - "name": "date_range", - "type": "string", - "description": "Absolute created-at range as epoch milliseconds: `\",\"`.", - "json_path": "/q/date_range" - }, - { - "name": "ids", - "type": "string", - "description": "Fetch specific cases by integer id (comma-separated string or array).", - "json_path": "/q/ids" - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Restrict to archived (or non-archived) cases.", - "json_path": "/q/is_archived" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "179720": [ - "Yes" - ] - }, - "json_path": "/q/custom_fields" - }, - { - "name": "match", - "type": "object", - "example": { - "tags": "all", - "custom_fields": { - "179720": "none" - } - }, - "description": "Per-field **operator** map \u2014 how to combine a field's values, NEVER the values\nthemselves. The value stays beside `match` under its own key; `match` only says\nhow to read it. A value placed inside `match` sets a meaningless operator and\napplies NO filter, which is the silent no-op described on `q`.\n\nAny filterable field may appear here, plus `custom_fields` keyed by field id.\nModes: `all`, `any`, ", - "json_path": "/q/match" - }, - { - "name": "empty", - "type": "object", - "example": { - "system_fields": [ - "priority" - ], - "custom_fields": [ - "179720" - ] - }, - "description": "Is-empty / is-unset filter. `system_fields` accepts the payload names `status`,\n`priority`, `case_type`, `automation_state`; `custom_fields` takes field ids.", - "fields": [ - { - "name": "system_fields", - "type": "array" - }, - { - "name": "custom_fields", - "type": "array" - } - ], - "json_path": "/q/empty" - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page number", - "minimum": 1, - "default": 1 - }, - { - "name": "per_page", - "type": "integer", - "example": 5, - "description": "Results per page. Rows are large and vary with the data, so probe rather than assume: send p=1 with a moderate per_page and take the rows that actually come back as your ceiling (for entity=test-cases a row is ~40 keys / 5-9 KB, so start around 5). Then keep per_page FIXED for the whole walk \u2014 the offset is (p-1)*per_page, so changing it part-way shifts the window and SKIPS rows (page 1 at 5 then ", - "minimum": 1, - "maximum": 100, - "default": 25 - }, - { - "name": "fields", - "type": "string", - "example": "owner,priority", - "description": "Comma-separated JOINED columns to hydrate (owner, tags, issues, reviewers, priority, status, case_type, automation_state, defects; unlisted names pass through). It selects joins ONLY \u2014 it can never remove the base fields (name, description, preconditions, steps, test_case_steps, custom_fields, attachments), so naming fewer changes how many rows fit per page, NOT the order of magnitude, and it cann", - "json_path": "/required/fields" - }, - { - "name": "custom_fields", - "type": "string", - "example": "121,119", - "json_path": "/required/custom_fields" - }, - { - "name": "result_custom_fields", - "type": "string", - "description": "Same idea for test-RESULT custom fields, on the result-bearing listings.", - "json_path": "/required/result_custom_fields" - } - ], - "intent": "Search for entities within a project (v2 - POST with body)", - "guidance": [ - "Tag and status filtering WORK here. Send `tags` (and `status`) as ordinary body fields - their json_path maps them under `q`, which is the shape the server wants.", - "The general way to find cases by attribute. Ask for a large page when collecting ids to feed into another call, so the follow-up gets the whole set rather than the first page." - ], - "shape": "discovered", - "max_page_size": 100, - "paginated": true, - "responses": { - "200": { - "description": "Search results. Flat body, no `data` envelope: {success, test_cases, folders, info, filter_details}. The rows are the same shared test-case record the v1 search returns, including `attachments` and the per-value `valueDetails` bindings.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "description": "Matching rows for entity=test-cases.", - "items": { - "$schema": "V1TestCaseSearchRow__SearchProjectEntitiesByFilter" - } - }, - "folders": { - "type": "object", - "description": "KEYED BY FOLDER ID \u2014 {\"764093\": [{id, name, parent_id, ui_position}, \u2026]}. Each value is the breadcrumb from root to that folder. The keys are ids, not field names." - }, - "info": { - "$schema": "PageInfo" - }, - "filter_details": { - "type": "object", - "description": "The filters you sent, resolved to display objects. `{}` unless filter_details was requested and q was non-empty." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "returns": [ - "success", - "test_cases", - "folders", - "info", - "filter_details" - ] - }, - { - "name": "search_project_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users/search", - "mode": "read", - "entity": "user", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Matches FULL NAME only, case-insensitive contains. Email is not searched \u2014 a query that looks like an address returns nothing. Omit it to list everyone in the workspace." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. A non-positive or non-numeric value is rejected globally with 400 `{success:false, error:\"Invalid Params\"}`. Paging overflows to the last page rather than returning an empty one." - }, - { - "name": "sort", - "type": "string", - "values": [ - "created_at", - "id", - "identifier" - ], - "default": "created_at", - "description": "Sort column. The allowlist is shared across entities \u2014 `identifier` is not a user column and has no effect. An unlisted value is ignored rather than rejected, leaving the default order." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "default": "desc", - "description": "Lower-case only; an unlisted value silently falls back to the default ordering." - } - ], - "intent": "Find people by name and read their role, permissions and product access \u2014 the search behind owner and assignee pickers; note it searches the whole workspace, not just this project's members", - "returns": [ - "users", - "info", - "id", - "browserstack_user_id", - "full_name", - "role", - "productRole", - "permissions", - "has_access", - "onboarded", - "group_id", - "reports_and_notification_enabled", - "customisable_dashboard_accessible", - "webhook_accessible", - "is_build_listing_enabled", - "is_delighted_visible", - "is_product_intro_visible", - "is_onboarding_project_header_visible", - "is_lcnc_explore_dismissed", - "is_new_project_settings_visible", - "is_jira_app_project_panel_visible" - ], - "max_page_size": 30, - "paginated": true, - "responses": { - "200": { - "$response": "ProjectUsersSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This is NOT project-scoped, despite the path and the name. The query filters on the caller's group only \u2014 `project_id` resolves the route and is never used in the lookup \u2014 so every user in the workspace comes back, including people with no access to this project.", - "`has_access` on each row is the user's product-access flag, not project membership. Do not read it as 'is on this project'; there is no project-membership field in this response at all.", - "`q` matches full_name only, case-insensitive contains. Searching by email returns an empty list, which looks identical to 'no such person'.", - "`id` is the Test Management user id \u2014 the one owner and assignee fields take. `browserstack_user_id` is the BrowserStack account id for the same person and is a different number.", - "Page size is fixed at 30: the `page_size` parameter that was published here is not read, and asking for 100 still returns 30.", - "Paging overflows to the LAST page \u2014 a `p` beyond the end returns the final page again rather than an empty list. Stop on `info.next == null`, never on 'the page came back empty', or you will loop forever on the last page.", - "Default order is creation date descending, so the newest members appear first; it is not alphabetical.", - "`permissions` is computed per request from the user's product role and this workspace's feature flags, so the same person can show a different permission list in a different workspace, and the list is not a stored attribute.", - "Not permission-gated \u2014 the search-filter controller has no RBAC entry." - ] - }, - { - "name": "search_test_case_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-case/tags/search", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Name search, trimmed; an all-whitespace value is treated as absent and you get the backing service's default first page. This is the ONLY query parameter the handler reads." - } - ], - "intent": "Check whether a test-case tag name exists in a project, or complete a partial one, before tagging a case or building a tag filter", - "returns": [ - "tags", - "tag_objects", - "info", - "name", - "created_at", - "usage_count" - ], - "max_page_size": 100, - "paginated": false, - "responses": { - "200": { - "$response": "TagsSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This search cannot be paged. `p` and `page_size` are accepted by the URL and never read \u2014 no page parameter reaches the backing service, so every call returns its first page and a request for page 2 silently returns the same rows. If a project has more tags than one page, narrow with `q` rather than paging.", - "The response still carries an `info` block; it describes the one page you were given, not a page you can navigate away from.", - "`tags` is an array of names, `tag_objects` the same tags with `created_at` and `usage_count`. No tag ids here \u2014 use the group-level tag search when you need an id.", - "If the project cannot be resolved the handler returns 200 with `{tags: []}`. An empty vocabulary and an unknown/inaccessible project are indistinguishable, so do not read an empty result as 'this project has no tags'.", - "Test-case tags only. Run tags are a separate vocabulary on a separate path, and a name present in one says nothing about the other.", - "Not permission-gated \u2014 the search-filter controller has no RBAC entry." - ] - }, - { - "name": "search_test_cases_by_folder", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/search", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "id", - "type": "integer", - "description": "Exact integer test case id. Blank values are skipped." - }, - { - "name": "name", - "type": "string", - "description": "EXACT case title match \u2014 not a substring search. Blank values are skipped." - }, - { - "name": "identifier", - "type": "string", - "description": "Exact identifier match. Blank values are skipped." - } - ], - "intent": "Legacy id/name/identifier lookup against the deprecated local test-case table \u2014 do NOT use it to find test cases; use the project search or the folder listing instead.", - "returns": [ - "id", - "name", - "identifier" - ], - "responses": { - "200": { - "$response": "LegacyTestCaseSearchResponse" - }, - "400": { - "$response": "BadRequest" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DEPRECATED AND EFFECTIVELY DEAD. It queries the legacy local test-case table, which is no longer the system of record \u2014 for any project whose data lives in the current backend it returns an empty list with 200, whatever you search for. An empty result here is not evidence that a case does not exist.", - "It does NOT accept a free-text `query` parameter. The only filters read are `id`, `name` and `identifier`, each matched for EXACT equality, and a blank value is skipped.", - "The response is a BARE JSON ARRAY of legacy rows \u2014 no `success`, no `info`, no `test_cases` wrapper, and the rows are the old table's columns, not the serialised test case the rest of this surface returns.", - "The folder id in the path is not used to scope the query; only the project and group are.", - "Use the project-wide entity search for finding cases by text, or the folder listing to enumerate a folder." - ], - "disabled": true - }, - { - "name": "send_report_email_now", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/send_email_now", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "emails", - "type": "array", - "required": true, - "example": [ - "qa-leads@company.com" - ], - "description": "Recipient addresses. MUST be an array, even for one address. 1-10 entries, each matching a standard address syntax \u2014 invalid entries fail the whole call 400 and are listed in the message. Any address is accepted; recipients need not be TM users." - }, - { - "name": "file_type", - "type": "array", - "values": [ - "csv", - "pdf", - "xlsx" - ], - "example": [ - "pdf" - ], - "default": [ - "pdf" - ], - "description": "Format to attach. MUST be an array with EXACTLY ONE entry \u2014 two entries, or an explicit empty array, crash the allow-list check and return 500. Omit the field entirely to default to [\"pdf\"]." - } - ], - "intent": "Email an existing report to specific people right now, as a PDF/CSV/XLSX attachment, without changing its schedule", - "responses": { - "200": { - "description": "Generation job enqueued; the mail is sent asynchronously.", - "schema": { - "type": "object", - "required": [ - "success", - "message" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Report generation started. Email will be sent shortly." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the only way to get a report's file out of the product through the API \u2014 the download capability hands back a channel id you cannot resolve. Mail it to yourself to obtain the file.", - "Send a SINGLE entry in `file_type`. The server checks the allow-list with a splat, so an array of two formats (or an explicit empty array) raises an ArgumentError and comes back 500, not 400. Call the endpoint once per format you need.", - "Both fields must be JSON arrays even for one value \u2014 a bare string is 400 `emails should be an array` / `file_type should be an array`.", - "`emails` is effectively required: it defaults to an empty array which then fails the count check with 400 `Please provide between 1 and 10 email addresses.` Between 1 and 10 addresses, each syntactically valid; invalid ones are named in the 400 rather than skipped.", - "Recipients are arbitrary addresses \u2014 they need not be TM users, and the mail goes out immediately with no further confirmation. Treat this as an irreversible external side effect.", - "The 200 only means the job was enqueued (`Report generation started. Email will be sent shortly.`). Generation or delivery can still fail afterwards and there is no job id and no status endpoint \u2014 you cannot confirm the mail was sent.", - "Unlike every other report call, this one is NOT permission-gated: any user with access to the project can share a report. An unknown report id is 404 `Report not found`.", - "The report's own `mail_to` recipients are untouched: this does not add them, and it does not affect the schedule." - ], - "returns": [ - "success", - "message" - ] - }, - { - "name": "delete_folder_and_subtree", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm", - "mode": "destructive", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "DOES NOT restore a deleted folder \u2014 this route currently deletes the folder and its entire subtree, identically to the delete call; there is no folder-restore API", - "returns": [ - "data", - "success", - "folder", - "path_folders", - "id", - "name", - "parent_id", - "project_id", - "group_id", - "is_automation", - "cases_count", - "total_cases_count", - "sub_folders_count", - "links" - ], - "responses": { - "200": { - "description": "Folder successfully restored", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "$schema": "Folder" + "description": "Project as returned by `create_report_by_integer_id`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Project" + } + ], + "properties": { + "access": { + "type": "string", + "nullable": true }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } + "app_percy_build_updated_at": { + "type": "string", + "nullable": true }, - "success": { - "type": "boolean" + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-17T08:01:58.000Z" + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "example": "probe edit_project 2026-09-17T10:47:41Z" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true } - } - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Do not call this to undo a deletion. The handler is a one-line alias that forwards to the delete action, and the two arguments it passes to signal 'restore' are never read by that action \u2014 the request that reaches the backing service is exactly the same DELETE the remove endpoint sends.", - "The effect is therefore identical to deleting the folder: the folder, every subfolder beneath it and every test case in the subtree are removed, with test cases landing in the recycle bin only if the group's Move to Bin flag is on.", - "It is also the cheaper way to cause that damage: this route is gated on the folder-EDIT permission while the real delete route requires folder-DELETE, so an edit-only caller can destroy a folder through it.", - "There is no folder-restore capability anywhere in this API. Binned TEST CASES can be listed and restored from the project bin; folders cannot be brought back at all. If you need the structure again, recreate it.", - "The response is the delete response: {data:{folder, path_folders, success}}, with the echoed folder's counts zeroed." - ], - "disabled": true - }, - { - "name": "unlink_test_runs_from_test_plan", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/unlink", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "test_runs", - "type": "array", - "required": true, - "description": "Run ids to unlink, as the INTEGER `uuid` from a run read \u2014 NOT the TR-NNN string that run endpoints return in `id`. Sent at the TOP LEVEL of the body: this call has no `test_plan` wrapper, unlike plan create/update." - } - ], - "intent": "Detach test runs from a test plan without deleting them \u2014 the runs survive and become plan-less, ready to be linked somewhere else", - "responses": { - "200": { - "description": "`{\"success\": true}` on an unlink. Also returned with `{\"success\": false, \"error\": \"Please check test runs\"}` when `test_runs` is missing or not an array \u2014 a 200 that did nothing.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is FLAT here \u2014 {\"test_runs\": [...]} at the top level. Wrapping it in `test_plan` the way create and update require means the handler sees nothing and the call silently no-ops.", - "A missing test_runs, or one that is not an array, returns HTTP 200 with {success: false, error: 'Please check test runs'} and changes nothing. This is the main failure mode and it does not look like a failure.", - "Use each run's integer `uuid`. Run endpoints put the TR-NNN string in `id`, and passing that fails upstream rather than being rejected here.", - "The 200 is returned without checking what actually happened: ids that were never linked to this plan, or that do not exist, are absorbed silently. Read the plan's run list before and after if you need to report what changed.", - "This only breaks the plan\u2194run link. The runs themselves, and their test cases and results, are untouched \u2014 they just stop belonging to any plan.", - "To re-link runs, send them in the plan update's test_runs list (which REPLACES the whole link set) rather than looking for a link endpoint here." - ], - "returns": [ - "success", - "error" - ], - "disabled": true - }, - { - "name": "update_custom_field_dataset_option", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/options/{option_id}/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Not validated by this handler." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id holding the option. Not validated by this handler." - }, - { - "name": "option_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric option id, from the dataset options listing. Not validated by this handler; a wrong value surfaces as a bodyless 404." - } - ], - "body": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The option label. REQUIRED even when unchanged \u2014 a missing or null value is a 400. Must stay unique within the dataset." - }, - { - "name": "is_default", - "type": "boolean", - "required": true, - "description": "REQUIRED even when unchanged \u2014 a missing or null value is a 400. Setting it true does not clear any other option's default." - } - ], - "intent": "Rename one dropdown choice, or make it the default \u2014 the option id stays the same, so cases already holding this value follow the new label", - "responses": { - "200": { - "$response": "CustomFieldAdminOptionResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A full replace of the option's label and default flag: both option_value and is_default must be sent, even when only one is changing. A missing or null either way is a 400 {success:false, error:'Invalid Params'}.", - "`parent_option_id` cannot be changed here \u2014 the handler does not forward it, so an option cannot be re-parented in a nested_dropdown. Delete and recreate instead, which drops the stored values.", - "The option id is stable across a rename, and stored values reference the id, so renaming does not lose data and can be undone by renaming back.", - "The rename lands in every project the dataset is linked to, with no per-project variant.", - "Making an option the default does not clear the previous default; on a single-select dropdown clear the old one yourself. On a multi_dropdown every option should stay is_default false.", - "Neither path id nor the option id is validated in this layer \u2014 a wrong triple surfaces as a bare 404 {success:false} with no message. A duplicate label is relayed verbatim from upstream; every other failure is masked as 400 'The API request is invalid or improperly formed'." - ], - "returns": [ - "success", - "data", - "id", - "option_value", - "is_default", - "colour", - "parent_option_id" - ] - }, - { - "name": "update_custom_field_dataset_project_mapping", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}/project-mappings/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric custom field id. Validated only on the synchronous path; an async (>300 project) request does not check it at all." - }, - { - "name": "dataset_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Numeric dataset id. Validated only on the synchronous path; an async request does not check it at all." - } - ], - "body": [ - { - "name": "link_projects", - "type": "array", - "description": "Integer project ids to ADD. Note the spelling \u2014 `linked_projects` is silently dropped here. Send [] rather than omitting it: unless select_all is present, a missing array makes the request 500." - }, - { - "name": "unlink_projects", - "type": "array", - "description": "Integer project ids to REMOVE. Destroys those projects' stored values for this field, irreversibly. Send [] rather than omitting it \u2014 unless select_all is present, a missing array makes the request 500." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Auto-link projects created after this call. Forwarded as-is; omitting it sends null." - }, - { - "name": "select_all", - "type": "boolean", - "description": "true = link every project in the workspace except unlink_projects; false = UNLINK every project except link_projects, deleting their stored values. Only literal true/false are recognised \u2014 any other value falls back to the two explicit lists. Over 300 projects the call goes async." - } - ], - "intent": "Change which projects a custom field's dataset applies to \u2014 link projects so they see the field, or unlink them, which deletes the values those projects already stored", - "responses": { - "200": { - "$response": "CustomFieldDatasetProjectMappingUpdateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send BOTH `link_projects` and `unlink_projects`, even when one is empty. Unless `select_all` is present, the handler adds their two lengths together, and a missing array raises an unhandled error \u2014 the call comes back 500, not 400.", - "The spelling is link_projects / unlink_projects here. The create calls use linked_projects / linked_to_all_projects; those names are silently dropped on this path, so the call returns 200 with nothing changed.", - "Unlinking is destructive to data: the values every case in those projects stored for this field are removed, and there is no undo. Name and count the projects before proceeding.", - "`select_all: true` links every project in the workspace except those in unlink_projects. `select_all: false` is the inverse and UNLINKS every project except those in link_projects \u2014 a whole-workspace data deletion from a single boolean. Only the literal strings/values true and false are recognised.", - "Above 300 affected projects the call switches to a background job and returns {success: true, async: true, unique_id: \"\"} with NO dataset body. The unique_id is a job handle only; there is no endpoint here to poll it, so verify by re-reading the dataset.", - "In that async case the path ids are never validated, so a bad field or dataset id is accepted with a 200 and the work fails silently in the worker.", - "A project may belong to only one dataset per field. Linking a project already held by a sibling dataset fails with the upstream message relayed verbatim (e.g. 'Projects already linked to a different dataset for this custom field...', 'invalid project mapping', 'Dataset id is invalid').", - "On the synchronous path the response is the re-read dataset under `data`, with its options included." - ], - "returns": [ - "success", - "data", - "async", - "unique_id", - "id", - "link_to_future_projects", - "linked_project_count", - "linked_projects", - "linked_to_all_projects", - "options" - ] - }, - { - "name": "update_custom_field_definition", - "method": "POST", - "path": "/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/update", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "custom_fields_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "field_name", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Display label. REQUIRED even when unchanged. Stripped before comparison and storage." - }, - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "REQUIRED for validation. Send the field's CURRENT type \u2014 this surface does not compare it to what is stored, it forwards it." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "description": "REQUIRED, literal JSON boolean. Omitting it, or sending the string \"true\", is a 400." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase", - "TestResult", - "TestPlan", - "ExploratorySession", - "TestCaseStep" - ], - "default": "TestCase", - "description": "Used only to select the valid field_type set and the reserved-name list; never sent upstream, so it cannot move the field to a different entity. Send the field's real entity_type." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "Hint text. Omitting it sends null and CLEARS the stored value \u2014 resend it to keep it." - }, - { - "name": "default_value", - "type": "boolean", - "description": "Applied only when field_type is `boolean`. For any other type the handler sends null regardless of what you pass, clearing any stored default." - } - ], - "intent": "Rename a custom field, or change whether it is mandatory, its placeholder or its boolean default \u2014 a full replace of the field's attributes, not a partial edit", - "responses": { - "200": { - "$response": "CustomFieldAdminWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a REPLACE, not a patch. field_name, field_type and is_required must all be present or the call is 400 'Invalid Params' \u2014 read the field first and resend the whole set.", - "Omitting place_holder_text sends null and clears the stored hint text. Likewise, for any non-boolean field the handler always sends default_value as null, so a stored default on a string/int/date field does not survive this call. Resend every value you want to keep.", - "field_type must be sent and must be valid for the entity_type, but this surface does NOT compare it against the stored type \u2014 it is forwarded upstream. Send the field's CURRENT type. (Its sibling on the v2 custom-fields surface rejects a changed type outright with 400 'Field type cannot be changed'.)", - "entity_type is used only to pick the valid field_type list and the reserved-name list; it is never sent upstream, so a field cannot be moved between TestCase / TestResult / TestPlan. Send the field's real entity_type or the validation applies the wrong rules.", - "Datasets, options and project links are NOT touched here \u2014 this call carries no `datasets` key upstream. Change option values through the dataset option calls and project scope through the dataset project-mapping call.", - "Renaming onto a reserved name (Title, Description, Priority, State, Owner, Tags, \u2026) is a 400, except when the field already carries that name \u2014 an import-created field keeps its name and stays editable.", - "There is no version history for a field definition: an overwritten label, placeholder or default cannot be restored. A rename is workspace-wide and lands in every project the field's datasets link to.", - "Upstream duplicate-name errors come back verbatim; every other upstream failure is masked as 400 {success:false, message:'The API request is invalid or improperly formed'}, and a field id that does not exist comes back as a bare 404 {success:false} with no message." - ], - "returns": [ - "success", - "data", - "id", - "field_name", - "field_type", - "is_required", - "default_value", - "place_holder_text" - ] - }, - { - "name": "update_dataset", - "method": "PUT", - "path": "/api/v1/projects/{project_id}/datasets/{uuid}", - "mode": "write", - "entity": "dataset", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "uuid", - "type": "string", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "json_path": "/dataset/name", - "description": "Dataset name. REQUIRED on update despite reading like a partial edit \u2014 update shares the create request schema. A name already used by another dataset in the project returns 400 {success:false, message:'Dataset name already exists.'}." - }, - { - "name": "variables", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 40, - "json_path": "/dataset/variables", - "description": "The complete new column list \u2014 this REPLACES the existing columns, it does not merge. Required. Send each column's existing `uuid` (from a read) for the columns you are keeping; omit it only for genuinely new columns. Blank-named or entirely-empty columns are silently dropped. 40 is the default cap, 100 for groups with the raised-limit flag.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "pattern": "^[\\p{L}][\\p{L}\\p{N}_]*$", - "description": "Letters, digits and underscores only, must start with a letter. Duplicate names (case-insensitive) fail the call." - }, - { - "name": "uuid", - "type": "string", - "description": "The existing column's uuid, taken from a read. Forwarded to the store so the column keeps its identity; leave it out and you are creating a new column." - } - ] - }, - { - "name": "rows", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 100, - "json_path": "/dataset/rows", - "description": "The complete new row set \u2014 REPLACES the existing rows. Required. Same normalisation as create: blank rows dropped, cells trimmed, duplicate rows collapsed, row_number reassigned 1..N. 100 is the default cap, 200 for groups with the raised-limit flag.", - "fields": [ - { - "name": "row_number", - "type": "integer", - "required": true, - "description": "Required by the schema, then overwritten with the row's final position." - }, - { - "name": "row_data", - "type": "array", - "required": true, - "minItems": 1, - "description": "Strings only, positional against `variables`; length must match the surviving column count after empty-column pruning." - }, - { - "name": "uuid", - "type": "string", - "description": "The existing row's uuid from a read, for rows you are keeping." - } - ] - }, - { - "name": "tags", - "type": "array", - "maxItems": 20, - "json_path": "/dataset/tags", - "description": "Replaces the dataset's tags when the key is present; omit the key entirely to leave existing tags alone. Sending [] clears them. Each tag is a string of at most 50 characters, at most 20 tags.", - "fields": [ - { - "name": "tag", - "type": "string", - "maxLength": 50 - } - ] - } - ], - "intent": "Replace a dataset's contents \u2014 rename it, or write a new set of columns and rows over the existing table", - "returns": [ - "success", - "dataset", - "uuid", - "name", - "variables", - "rows", - "rows_count", - "created_at", - "created_by" - ], - "responses": { - "200": { - "description": "Dataset updated; the full stored dataset is returned at the top level as `dataset`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "dataset": { - "$schema": "Dataset__UpdateDataset" } - } - } - }, - "400": { - "description": "Request-schema rejection ({error:'Invalid JSON data', details:[...]}) \u2014 including a body missing name/variables/rows \u2014 or a duplicate dataset name ({success:false, message:'Dataset name already exists.'})." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No dataset with this uuid in the project: {success:false, message:'Dataset not found.'}" - }, - "422": { - "description": "{success:false, message:...}. 'Feature not available' = no Team Pro entitlement; any other message is a dataset-shape validation failure." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a full replace, not a patch, and `name`, `variables` and `rows` are ALL required \u2014 the update reuses the create request schema verbatim. A body with only `name` is rejected with 400 {error:'Invalid JSON data'}; the spec marking these optional is wrong.", - "To rename without losing data: read the dataset first and send its existing `variables` and `rows` (uuids included) back alongside the new name.", - "There is no undo. Datasets keep no version history and there is no restore endpoint, so an overwritten column or row set is gone. Read the current contents before writing.", - "`metadata` passes validation here and is then silently discarded \u2014 the handler forwards only name, columns, rows and tags. Sending metadata returns 200 with metadata unchanged. It can only be set at creation.", - "Send the fields flat. The `dataset` wrapper is added for you from each field's json_path, and sending your own `dataset` object is rejected before the call as an unknown body key. Nothing beyond the declared keys is permitted anywhere in the body (additionalProperties:false at every level) \u2014 an extra or misspelled key fails the whole call with 400.", - "The `uuid` in the path is the dataset's uuid string, resolved by looking it up in the project's dataset list; an unknown one is 404 {success:false, message:'Dataset not found.'}.", - "Rows and columns come back normalised (blank rows and empty columns removed, cells trimmed, duplicate rows collapsed, row_number renumbered) \u2014 trust the response, not your payload.", - "422 {message:'Feature not available'} is the Team Pro entitlement gate, not a bad body; any other 422 message is a real validation failure.", - "Needs the test-case write permission (tc_write)." - ] - }, - { - "name": "update_exploratory_session", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - } - ], - "body": [ - { - "name": "title", - "type": "string", - "example": "Updated checkout exploration", - "description": "New title. `name` is accepted by the request schema and silently dropped, so use this field.", - "json_path": "/exploratory_session/title" - }, - { - "name": "description", - "type": "string", - "description": "Replacement description.", - "json_path": "/exploratory_session/description" - }, - { - "name": "charter", - "type": "string", - "description": "Replacement testing charter.", - "json_path": "/exploratory_session/charter" - }, - { - "name": "timebox_duration", - "type": "integer", - "example": 90, - "description": "Planned duration. Rendered as minutes by TM's own exports but documented upstream as seconds; passed through unconverted.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/timebox_duration" - }, - { - "name": "duration", - "type": "integer", - "example": 45, - "description": "Tracked duration. Same unit ambiguity as timebox_duration.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/duration" - }, - { - "name": "actual_duration", - "type": "integer", - "example": 50, - "description": "Final actual duration. This is the ONLY place it can be set \u2014 the close call ignores any duration you send it \u2014 so write it here before closing the session.", - "minimum": 0, - "maximum": 2147483647, - "json_path": "/exploratory_session/actual_duration" - }, - { - "name": "folder_id", - "type": "integer", - "example": 789, - "description": "Move the session to a different folder; a numeric string is also accepted.", - "json_path": "/exploratory_session/folder_id" - }, - { - "name": "test_plan_id", - "type": "integer", - "example": 77, - "description": "Integer id of the test plan to link (not a TP-NNN identifier). Only reachable through the wrapped body.", - "json_path": "/exploratory_session/test_plan_id" - }, - { - "name": "owner", - "type": "integer", - "example": 55, - "description": "TCM user id of the new owner; translated to the upstream user id. Wins over `assignee` when both are sent. An unresolvable id clears the assignee instead of failing.", - "json_path": "/exploratory_session/owner" - }, - { - "name": "assignee", - "type": "integer", - "example": 55, - "description": "Genuine alias for `owner` on this endpoint \u2014 both are translated here. (On create only `owner` is, so prefer `owner` in code shared between the two.)", - "json_path": "/exploratory_session/assignee" - }, - { - "name": "tags", - "type": "array", - "example": [ - "smoke", - "payment" - ], - "description": "REPLACES the session's tags with this list; an empty array clears them. Non-string entries are rejected by the request schema.", - "json_path": "/exploratory_session/tags" - }, - { - "name": "configurations", - "type": "array", - "example": [ - 2, - 4 - ], - "description": "REPLACES the session's configuration ids (integers or numeric strings). An empty array clears them.", - "json_path": "/exploratory_session/configurations" - }, - { - "name": "attachments", - "type": "array", - "description": "REPLACES the attached blob ids. Every id you leave out is detached and its blob freed for reuse. Ids must be integer blob ids from a prior generic attachment upload; an invalid set returns 422 with the bare string body \"attachment is invalid\".", - "json_path": "/exploratory_session/attachments" - }, - { - "name": "issues", - "type": "array", - "description": "REPLACES the linked issues. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the whole list to empty. Jira ids must match ^[A-Z][A-Z0-9]*-[0-9]+$ or the call fails. A per-entry metadata object is accepted. Applied asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/exploratory_session/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "101": "Regression" - }, - "description": "Custom field values as a map of {custom_field_id: value} for entity type ExploratorySession, converted server-side. Not the array shape returned on read. Only sent when non-empty \u2014 an empty object is ignored rather than clearing values.", - "json_path": "/exploratory_session/custom_fields" - } - ], - "intent": "Change fields on an existing exploratory session \u2014 retitle it, revise the charter, record the time spent, reassign it, move it to another folder, or replace its tags, configurations, attachments and linked issues", - "returns": [ - "success", - "exploratory_session", - "id", - "identifier", - "title", - "description", - "charter", - "session_state", - "timebox_duration", - "duration", - "actual_duration", - "session_log_count", - "test_plan_id", - "assignee", - "created_by", - "tags", - "configurations", - "issues", - "custom_fields", - "attachments", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "description": "Exploratory session updated successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_session": { - "$schema": "ExploratorySession" + }, + "V1TestCaseCreateResponse__CreateTestCaseByIntegerId": { + "type": "object", + "description": "V1TestCaseCreateResponse as returned by `create_test_case_by_integer_id`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "V1TestCaseCreateResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "cases_count": { + "type": "integer", + "example": 47 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } + } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 48 + } + } + }, + "path_folders": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13472" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "cases_count": { + "type": "integer", + "example": 47 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 48 + } + } + }, + "test_case": { + "type": "object", + "properties": { + "comments_count": { + "type": "integer", + "example": 0 + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "automation_state": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 2509329 + }, + "internal_name": { + "type": "string", + "example": "not_automated" + }, + "name": { + "type": "string", + "example": "Not Automated" + }, + "value_category": { + "type": "string", + "example": "manual" + } + } + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "case_type": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318626 + }, + "internal_name": { + "type": "string", + "example": "other" + }, + "name": { + "type": "string", + "example": "Other" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "case_type_imported": { + "type": "string" + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:42.487Z" + }, + "creator_details": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "estimate": { + "type": "string", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1986203 + }, + "identifier": { + "type": "string", + "example": "TC-54559" + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + }, + "links": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203/detail" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203" + } + } + }, + "metadata": { + "type": "object", + "properties": { + "ai_prompt": { + "type": "object", + "properties": { + "status": { + "type": "boolean", + "example": false + } + } + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "profileId": { + "type": "string", + "nullable": true + }, + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + } + } + }, + "name": { + "type": "string", + "example": "__mcp-probe-createcase-20260921" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318618 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "status": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "active" + }, + "id": { + "type": "integer", + "example": 318632 + }, + "internal_name": { + "type": "string", + "example": "active" + }, + "name": { + "type": "string", + "example": "Active" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "mcp-probe" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_name": { + "type": "string", + "example": "Test Case Steps" + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_case_dataset": { + "type": "object" + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_steps": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:42.508Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "id": { + "type": "integer", + "example": 12373418 + }, + "order": { + "type": "integer", + "example": 1 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_case_id": { + "type": "integer", + "example": 1986203 + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:42.508Z" + } + } + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "nullable": true + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Send the fields flat. The `exploratory_session` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key.", - "Scalar fields are patched by presence: only title, description, charter, timebox_duration, duration, actual_duration, folder_id and test_plan_id keys actually present in the body are sent. Omitted keys are left alone.", - "The collection fields are NOT patched, they are replaced. Sending `tags`, `configurations`, `issues` or `attachments` overwrites the whole list; sending an empty array clears it. Omitting the key is the only way to leave a collection untouched.", - "Replacing `attachments` detaches every blob you left out and frees it for reuse. An invalid set is rejected with 422 whose body is the bare JSON string \"attachment is invalid\" \u2014 not the usual {success:false, message} envelope, so do not parse it like one.", - "`owner` and `assignee` are both translated to the upstream user id here (unlike create, where only `owner` is). `owner` wins when both are sent. An id that resolves to no user clears the assignee rather than failing.", - "There is no way to change `session_state` through this call, and no reopen endpoint exists \u2014 closing a session is one-way.", - "`name` is accepted by the request schema and then dropped by the parameter filter; retitle with `title`.", - "Exploratory sessions keep no version history and there is no undo: an overwritten title, description, charter or tag list cannot be restored.", - "Issue linking is applied asynchronously after the 200, so the session echoed back may not yet reflect the new links.", - "Upstream validation failures all arrive as 400 {success:false, message:'The API request is invalid or improperly formed'} \u2014 the same text for a bad configuration id, a folder in another project or a malformed value." - ] - }, - { - "name": "update_exploratory_session_log", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs/{log_id}", - "mode": "write", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "session_id", - "type": "integer", - "required": true, - "example": 123 - }, - { - "name": "log_id", - "type": "integer", - "required": true, - "example": 789 - } - ], - "body": [ - { - "name": "content", - "type": "string", - "example": "

Confirmed the spinner issue on iOS 17 as well.

", - "description": "Replacement rich-text HTML. Sanitised on write. Re-sending it re-derives the log's image attachments from the blob ids in the HTML, so an image removed from the HTML is detached. A null is stripped rather than clearing the field \u2014 send an empty string to blank it.", - "json_path": "/session_log/content" - }, - { - "name": "status", - "type": "string", - "values": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note", - "skipped" - ], - "example": "bug", - "description": "Replacement outcome. `skipped` is valid; anything outside the seven is rejected by the request schema. A null is stripped rather than clearing the status.", - "json_path": "/session_log/status" - }, - { - "name": "elapsed_time", - "type": "integer", - "example": 180, - "description": "Replacement time spent, in seconds. Must be an integer. A null is stripped rather than clearing the value.", - "json_path": "/session_log/elapsed_time" - }, - { - "name": "defects", - "type": "array", - "description": "REPLACES the log's defect links when non-empty; omit the key to leave them untouched. An empty array is treated as 'not supplied' and does NOT clear the links. Bare id strings are coerced to jira issues; entries missing issue_id or issue_type silently reduce the list to empty; a bad jira id fails the whole call. Applied asynchronously after the response.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "required": true - }, - { - "name": "issue_type", - "type": "string", - "required": true - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/session_log/defects" - } - ], - "intent": "Revise an observation already recorded in an exploratory session \u2014 correct the note, change its pass/fail/bug outcome, adjust the time spent or restate which defects it raised", - "returns": [ - "success", - "session_log", - "id", - "session_id", - "content", - "status", - "elapsed_time", - "created_by", - "defects", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "description": "Session log updated successfully.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_log": { - "$schema": "ExploratorySessionLog__UpdateExploratorySessionLog" + }, + "V1TestCaseCreateResponse__CreateTestCases": { + "type": "object", + "description": "V1TestCaseCreateResponse as returned by `create_test_cases`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "V1TestCaseCreateResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "cases_count": { + "type": "integer", + "example": 48 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } + } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 49 + } + } + }, + "path_folders": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "deleted": { + "type": "boolean", + "example": false + }, + "entity_type": { + "type": "string", + "nullable": true + }, + "identifier": { + "type": "string", + "example": "FD-13472" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "parent_id": { + "type": "string", + "nullable": true + }, + "source": { + "type": "string", + "example": "TCM" + }, + "updated_at": { + "type": "string", + "example": "2026-09-17T08:13:35.000Z" + }, + "cases_count": { + "type": "integer", + "example": 48 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 12 + }, + "total_cases_count": { + "type": "integer", + "example": 49 + } + } + }, + "test_case": { + "type": "object", + "properties": { + "comments_count": { + "type": "integer", + "example": 0 + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "automation_state": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 2509329 + }, + "internal_name": { + "type": "string", + "example": "not_automated" + }, + "name": { + "type": "string", + "example": "Not Automated" + }, + "value_category": { + "type": "string", + "example": "manual" + } + } + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "case_type": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318626 + }, + "internal_name": { + "type": "string", + "example": "other" + }, + "name": { + "type": "string", + "example": "Other" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "case_type_imported": { + "type": "string" + }, + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:43.059Z" + }, + "creator_details": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "id": { + "type": "integer", + "example": 2522 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "estimate": { + "type": "string", + "nullable": true + }, + "estimated_duration_seconds": { + "type": "string", + "nullable": true + }, + "expected_result": { + "type": "string", + "nullable": true + }, + "history_count": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 1986204 + }, + "identifier": { + "type": "string", + "example": "TC-54560" + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "issues": { + "type": "string", + "nullable": true + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + }, + "links": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204/detail" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204" + } + } + }, + "metadata": { + "type": "object", + "properties": { + "ai_prompt": { + "type": "object", + "properties": { + "status": { + "type": "boolean", + "example": false + } + } + }, + "lcnc_build_map": { + "type": "object", + "properties": { + "profileId": { + "type": "string", + "nullable": true + }, + "resource_path": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "nullable": true + } + } + } + } + }, + "name": { + "type": "string", + "example": "__mcp-probe-createcases-20260921-1" + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "owner_imported": { + "type": "string" + }, + "preconditions": { + "type": "string", + "nullable": true + }, + "priority": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 318618 + }, + "internal_name": { + "type": "string", + "example": "medium" + }, + "name": { + "type": "string", + "example": "Medium" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "priority_imported": { + "type": "string" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "status": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "active" + }, + "id": { + "type": "integer", + "example": 318632 + }, + "internal_name": { + "type": "string", + "example": "active" + }, + "name": { + "type": "string", + "example": "Active" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "status_imported": { + "type": "string" + }, + "steps": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "string", + "example": "mcp-probe" + }, + "template": { + "type": "string", + "example": "test_case_steps" + }, + "template_id": { + "type": "integer", + "example": 1164 + }, + "template_name": { + "type": "string", + "example": "Test Case Steps" + }, + "template_step_type": { + "type": "string", + "example": "test_case_steps" + }, + "test_case_dataset": { + "type": "object" + }, + "test_case_folder_id": { + "type": "integer", + "example": 764138 + }, + "test_case_steps": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-09-22T09:43:43.080Z" + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "id": { + "type": "integer", + "example": 12373419 + }, + "order": { + "type": "integer", + "example": 1 + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_case_id": { + "type": "integer", + "example": 1986204 + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:43.080Z" + } + } + }, + "test_run_results_count": { + "type": "string", + "nullable": true + }, + "test_run_results_issues_count": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "nullable": true + }, + "updator_details": { + "type": "string", + "nullable": true + } + } + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Both ids matter and both are integers: log_id identifies the entry, session_id in the path identifies its parent session. A log_id that belongs to a different session is not addressable here.", - "Send the fields flat. The `session_log` wrapper is added for you from each field's json_path, and sending your own is rejected before the call as an unknown body key. Only content, status, elapsed_time and defects are read, and only the keys present are forwarded \u2014 everything else is left as it is.", - "You cannot blank a field out: nulls are stripped from the outgoing payload, so sending content:null or status:null leaves the existing value in place. Send an empty string for content instead, and expect status and elapsed_time to be un-clearable.", - "`defects`, when non-empty, REPLACES the log's defect links; omit the key to leave them alone. The same coercion applies as on create \u2014 bare id strings become jira issues, malformed entries silently reduce the list to empty, and a bad jira id fails the whole call. Linking is applied asynchronously after the 200.", - "You cannot unlink every defect from a log: sending defects:[] is treated as 'not supplied' and the existing links survive. This differs from the session update, where an empty tags/configurations/issues array does clear the list.", - "Re-sending `content` re-derives the log's image attachments from the blob ids in the HTML: an image you drop from the HTML loses its attachment link, and if any referenced blob id is not visible to your group or project then ALL of the call's attachment links are silently dropped while the update still succeeds.", - "`content` is sanitised on write, so the stored HTML can differ from what you sent.", - "Session logs keep no version history and there is no undo \u2014 an overwritten note cannot be recovered.", - "`status` must be one of pass, fail, bug, retest, block, note, skipped; anything else is a 400 from the request schema." - ] - }, - { - "name": "update_filter", - "method": "POST", - "path": "/api/v1/projects/{project_id}/filter/{filter_id}", - "mode": "write", - "entity": "filter", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "filter_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "description": "Required on every update, even when you are only changing the conditions. Read the filter first and resend its current name rather than inventing one \u2014 the value you send is what it is renamed to." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "testcase", - "testplan", - "testrun", - "exploratory_session", - "test_plan_test_case" - ], - "description": "Required on every update, but not applied: a saved view's list type cannot be changed, and a different value returns success while changing nothing. Resend the filter's existing entity_type. An unlisted value returns 400 'Invalid JSON data'." - }, - { - "name": "is_project_level", - "type": "boolean", - "description": "Forwarded whenever the key is present, including `false` \u2014 this is how you demote a project-wide view back to private. Omit the key to leave the current scope alone." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement condition set, in the same UI filter-state shape as create_filter's `filters` (systemFields option objects, extraSystemFields.issue_type, matchAllMatchAny) \u2014 send the whole set, not just the change. Omit to leave the stored conditions untouched. `{}` is treated as absent, so conditions cannot be cleared to empty through this call." - } - ], - "intent": "Rename a saved filter view, replace its conditions, or switch it between private and project-wide \u2014 resending name and entity on every call", - "returns": [ - "success", - "data", - "id", - "name", - "entity_type", - "is_project_level", - "filters", - "owner", - "project_id", - "created_at", - "updated_at" - ], - "responses": { - "200": { - "$response": "FilterUpdateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is a POST, not a PATCH or PUT, and it is only half a partial update: `name` and `entity` are required by the request-body schema on every call, while `is_project_level` and `filters` are forwarded only when you send them. Read the filter first and resend name + entity_type unchanged.", - "The body is flat and closed \u2014 an extra key returns 400 'Invalid JSON data' before the handler runs.", - "`filters: {}` is dropped as blank, so there is no way to empty a view's conditions here; delete and recreate instead.", - "Saved filter views keep no version history, so an overwritten name or condition set cannot be restored. Read the filter before you overwrite it if you may need the old value.", - "Changing is_project_level to true publishes a previously private view to the whole project immediately.", - "An unknown filter_id comes back as 400 `{success:false, message:...}`, not 404 \u2014 this action only translates the backing service's 400, and every other failure from it becomes a 500.", - "Not permission-gated: the filter controller has no RBAC entry, so a project-level view can be edited by anyone with product access to the project, not only its owner." - ] - }, - { - "name": "update_latest_test_result_for_test_case", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/test-results", - "mode": "write", - "entity": "result", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$" - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "configuration_id", - "type": "string" - }, - { - "name": "mapping_id", - "type": "string" - } - ], - "body": [ - { - "name": "status_id", - "type": "integer", - "description": "Result status id (resolve via the statuses-and-states concept \u2014 this path takes ids, not names; a `status` name is accepted by the filter and then discarded).", - "json_path": "/test_result/status_id" - }, - { - "name": "description", - "type": "string", - "description": "Rich text note on the result. Links to uploaded attachments are rewritten server-side, so the stored value can differ from what you sent.", - "json_path": "/test_result/description" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom field values keyed by custom field id (not by field name \u2014 that is the v2 contract).", - "json_path": "/test_result/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked defects. Each entry is an OBJECT \u2014 a bare string is silently dropped by the server's parameter filter, so the issue link is never created and no error is returned.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_result/issues" - }, - { - "name": "attachments", - "type": "array", - "example": [ - 10288829 - ], - "description": "Integer blob ids from a prior generic attachment upload \u2014 not filenames and not file bytes.", - "json_path": "/test_result/attachments" - }, - { - "name": "elapsed_time", - "type": "integer", - "description": "Accepted by the parameter filter and then DROPPED before the write on this path \u2014 setting it here does nothing and reports no error.", - "json_path": "/test_result/elapsed_time" - }, - { - "name": "configuration_id", - "type": "integer", - "description": "Which configuration's latest result to patch. TOP level \u2014 the server does not read it from inside `test_result`." - }, - { - "name": "mapping_id", - "type": "integer", - "description": "Target a specific run/case mapping. Top level." - } - ], - "intent": "Amend the most recent result logged for a test case in a run \u2014 correct its status, note, linked defects or custom fields in place, without adding another attempt to the case's history", - "returns": [ - "id", - "status", - "result_status", - "description", - "author", - "created_at", - "updated_at", - "configuration_id", - "custom_fields", - "custom_fields_count", - "test_run_step_result", - "attachments", - "issues", - "links" - ], - "responses": { - "200": { - "description": "Test result successfully updated", - "schema": { - "$schema": "TestResult" - } - }, - "400": { - "description": "Bad request - Test run is closed or invalid parameters", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "status": { - "type": "integer", - "example": 400 - }, - "error": { - "type": "string", - "example": "Cannot Update Closed Test Run" + }, + "TestRunWriteResponse__CreateTestRunByIntegerId": { + "type": "object", + "description": "TestRunWriteResponse as returned by `create_test_run_by_integer_id`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17579088 + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "run_state": { + "type": "string", + "example": "new_run" + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "example": 3 + }, + "test_plans": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T09:43:43.000Z" + }, + "updated_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "uuid": { + "type": "integer", + "example": 17579088 + } + } + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + }, + "success": { + "type": "boolean", + "example": true + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No test results found", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "status": { - "type": "integer", - "example": 404 - }, - "message": { - "type": "string", - "example": "No test results found" + }, + "TestCase__EditTestCase": { + "type": "object", + "description": "TestCase as returned by `edit_test_case`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "comments_count": { + "type": "integer", + "example": 0 + }, + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "test_case_steps": { + "$schema": "TestCaseStep__EditTestCase" + } } - } - } - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This EDITS the latest existing result; it never creates one. With nothing logged yet it answers 404 {success:false, message:'No test results found'} \u2014 log a result first, then amend it.", - "A closed test run is refused with 400 {success:false, error:'Cannot Update Closed Test Run'} before any field is written. A closed run cannot be reopened, so its results are final \u2014 record new results in a clone of the run instead.", - "Send the fields flat. The `test_result` wrapper is added for you from each field's json_path, and sending your own `test_result` object is rejected before the call as an unknown body key. Sending no fields at all is legal and re-saves the current values unchanged.", - "Only description, status_id, custom_fields, attachments and issues survive to the write. `status` (the name) and `elapsed_time` are accepted by the parameter filter and then dropped on this path \u2014 set the status by id here, and use the v2 result surface if you only have a status name.", - "Keys you omit keep their current values (the handler re-reads the result and fills them in), so this is a genuine partial update.", - "configuration_id and mapping_id are read at the TOP level \u2014 query string or body root, never from inside `test_result`. Without one of them 'latest' means latest across all configurations, which on a configuration matrix is usually not the row you meant.", - "Unlike the id-addressed result update, this path applies NO authorship check \u2014 only the standard project permission gate runs, so it will overwrite a result logged by someone else. The edit is in place and the previous values are not recoverable; log a new result instead if you want the earlier outcome preserved.", - "The 200 body is the bare updated result object at the TOP level \u2014 no `data` envelope and no `success` key, unlike the create-result endpoint on the same path." - ], - "disabled": true - }, - { - "name": "update_project_settings", - "method": "POST", - "path": "/api/v1/projects/{project_id}/settings", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Turn a project's configuration flags on or off \u2014 review/approval gating, the custom test-case id prefix, test-case sharing, the manual-execution timer, Part 11 re-authentication, Jira/Azure linking and test-plan review", - "responses": { - "200": { - "description": "Written settings echoed back as one object per setting. A body containing no recognised setting name also returns 200, with success:false and an empty array \u2014 nothing was written.", - "schema": { - "type": "object", - "required": [ - "success", - "project_settings" - ], - "properties": { - "success": { - "type": "boolean", - "description": "False (with HTTP 200) when nothing was written \u2014 including when the body contained no recognised setting name." - }, - "project_settings": { - "type": "array", - "description": "One single-key object per setting written, {settingName: value}. Note this differs from the read, which returns a single flattened object at index 0.", - "items": { - "type": "object" - } - } - } - } - }, - "400": { - "description": "Invalid test_case_id_prefix (format, reserved, or already used elsewhere) or an unknown review-status token." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "description": "Timer settings written without the project-settings write permission." - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "New project settings not enabled for this group/user, or a plan gate (Team Pro / Enterprise) not met." - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Settings are sent as FLAT top-level body keys, one per setting \u2014 there is no wrapper object and no generic {key, value} pair. Only the names listed below are read; everything else is ignored.", - "The silent no-op to watch for: if the body contains none of the known setting names, the request still returns HTTP 200 with {success:false, project_settings:[]} and nothing is written. A 200 here is not confirmation \u2014 check `success` and check that the setting you sent comes back in project_settings.", - "This is a partial update: only the keys you send are touched, and the others keep their stored values. It does NOT reset omitted settings.", - "There is no undo and no history \u2014 the previous value is gone the moment the write lands. Re-read get_project_settings first if you need to be able to restore it.", - "Feature-gated for the whole endpoint: without the new-project-settings flag for the group or user the call fails 422 'Feature not available' before any key is examined.", - "Per-setting gates, all raised before the write: review_approve and the two eligible-review-status settings need a Team Pro plan (422); part_11_reauth and part_11_reauth_send_for_review need an Enterprise plan (422); the four timer settings need the project-settings write permission (403 'Only admins can update Time Tracking settings').", - "test_case_id_prefix is validated twice \u2014 locally against 2-10 uppercase alphanumerics starting with a letter and not equal to 'TC', then against the backing service for reserved prefixes and cross-project collisions. Either failure returns 400 with the reason, and it is checked before anything else in the body is written.", - "The two eligible-review-status settings take a comma-separated list drawn from approved / rejected / in_review / pending / changes_requested. An unknown token fails the whole request with 400; an empty string is valid and means gating is OFF.", - "Values are stored as strings in an EAV table, so send booleans as the strings \"true\"/\"false\" and numbers as numeric strings; that is also how they read back.", - "The response echoes one object PER setting written ([{name: value}, ...]) \u2014 a different shape from the read, which flattens everything into a single object at project_settings[0].", - "Removing a setting entirely is a different operation (a delete on the same path with the attribute name) and is not exposed as a capability here; writing an empty string is not the same as unsetting it." - ], - "body": [ - { - "name": "review_approve", - "type": "string", - "description": "Enable the test-case review/approval workflow. Requires a Team Pro plan \u2014 otherwise 422 'Feature not available'. Send \"true\"/\"false\" as strings." - }, - { - "name": "test_run_eligible_review_statuses", - "type": "string", - "description": "Comma-separated review statuses a test case must be in before it can be added to a run. An unknown token fails the whole request with 400. Empty string = no gating. Requires a Team Pro plan." - }, - { - "name": "test_execution_eligible_review_statuses", - "type": "string", - "description": "Comma-separated review statuses a test case must be in before results can be logged against it. Same validation and plan gate as the run variant." - }, - { - "name": "default_test_case_approval_state", - "type": "string", - "description": "The review state newly created test cases start in." - }, - { - "name": "test_case_id_prefix", - "type": "string", - "description": "Custom identifier prefix for this project's test cases. 2-10 characters, uppercase letters and digits, must start with a letter, and cannot be \"TC\". Also checked against reserved prefixes and other projects' prefixes; either check failing returns 400 with the reason." - }, - { - "name": "tc_sharing", - "type": "string", - "description": "Allow this project's test cases to be shared into other projects." - }, - { - "name": "part_11_reauth", - "type": "string", - "description": "Require re-authentication for regulated (21 CFR Part 11) actions. Requires an Enterprise plan \u2014 otherwise 422." - }, - { - "name": "part_11_reauth_send_for_review", - "type": "string", - "description": "Require re-authentication specifically when sending for review. Requires an Enterprise plan \u2014 otherwise 422." - }, - { - "name": "preferred_jira_host", - "type": "string", - "description": "Default Jira site URL used when linking issues from this project." - }, - { - "name": "preferred_azure_org", - "type": "string", - "description": "Default Azure DevOps organisation used when linking work items from this project." - }, - { - "name": "sync_jira_defect_attachments", - "type": "string", - "description": "Copy attachments to the linked Jira defect when one is raised." - }, - { - "name": "link_jira_requirements", - "type": "string", - "description": "Enable linking Jira requirements from this project." - }, - { - "name": "link_jira_test_case_requirement", - "type": "string", - "description": "Enable linking a Jira requirement to a test case." - }, - { - "name": "link_jira_test_run_requirement", - "type": "string", - "description": "Enable linking a Jira requirement to a test run." - }, - { - "name": "jira_create_allowed_hosts", - "type": "string", - "description": "Allow-list of Jira site URLs that may create or update requirements, as a JSON-ENCODED ARRAY STRING (e.g. the JSON text of an array of site URLs). Omitting the key means all hosts are allowed; the string \"[]\" means none." - }, - { - "name": "time_tracking_enabled", - "type": "string", - "description": "Per-project master switch for the manual-execution timer; the feature is on only when this is exactly \"true\". Requires the project-settings write permission \u2014 403 otherwise." - }, - { - "name": "timer_auto_start", - "type": "string", - "description": "Start the execution timer automatically when a tester opens a case. Requires the project-settings write permission." - }, - { - "name": "timer_elapsed_required", - "type": "string", - "description": "Require an elapsed time before a result can be submitted. Requires the project-settings write permission." - }, - { - "name": "timer_min_threshold_seconds", - "type": "string", - "description": "Minimum elapsed seconds before a result is accepted, as a numeric string. Requires the project-settings write permission." - }, - { - "name": "test_plan_review_enabled", - "type": "string", - "description": "Enable the review workflow for test plans." - }, - { - "name": "test_plan_review_sub_plan_mode", - "type": "string", - "description": "How sub-plans participate in test-plan review." - }, - { - "name": "test_plan_review_default_state", - "type": "string", - "description": "The review state newly created test plans start in." - } - ], - "returns": [ - "success", - "project_settings" - ] - }, - { - "name": "update_report", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/reports/{report_id}", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "example": 10000, - "description": "Integer project id the rewritten filter rows are filed under. Not enforced by the server: omit it and rows are written against project 0 and become invisible, at 200. Send the report's own project id." - }, - { - "name": "project_ids", - "type": "array", - "example": [ - 10000, - 10001 - ], - "description": "Integer project ids for a cross-project report. Only applied when present \u2014 omitting it leaves the stored set alone. Max 60 (5 for User Workload Report), and multi-project only for the seven supported types; the cap is checked against the report_type in THIS body." - }, - { - "name": "report_type", - "type": "string", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary", - "description": "Report type as its DISPLAY NAME. Required, and it also selects which filter-rewrite branch runs \u2014 omit it and the report_type column is nulled AND the filters are silently left untouched. `Defects Summary` / `Defects Detailed Report` have no data branch and no filter branch." - }, - { - "name": "title", - "type": "string", - "example": "Updated Title", - "description": "Written unconditionally \u2014 omit it and the title is set to null." - }, - { - "name": "description", - "type": "string", - "example": "", - "description": "Written unconditionally \u2014 omit it and the description is set to null." - }, - { - "name": "report_timeframe", - "type": "string", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week", - "description": "The window the report covers. `custom` REQUIRES `custom_date_range` (omitting it is an unhandled 500); any other value NULLS the stored custom_date_range. The five entity timeframes carry no dates and each reads one matching `report_filters` key." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "example": "custom_report", - "description": "Written unconditionally. Unlike create, `system_generated` here does NOT re-fetch requirements for a traceability report \u2014 send `report_filters.requirements` yourself." - }, - { - "name": "is_dynamic", - "type": "boolean", - "description": "Written unconditionally from the body \u2014 omit it and the report's dynamic flag is set to null (unlike create, it is not inferred from the filters)." - }, - { - "name": "grain", - "type": "string", - "values": [ - "test_case", - "test_plan" - ], - "description": "Immutable after creation. Send the report's persisted value or omit it; anything different is 400 'grain cannot be changed after report creation'." - }, - { - "name": "test_run", - "type": "object", - "fields": [ - { - "name": "created_at", - "type": "string", - "required": true - }, - { - "name": "status", - "type": "array", - "required": true - }, - { - "name": "owner", - "type": "array", - "required": true - }, - { - "name": "automation_status", - "type": "array", - "required": true - }, - { - "name": "type", - "type": "array", - "required": true - } - ], - "json_path": "/dynamic_filters/test_run", - "description": "Dynamic run selection, accepted under either `dynamic_filters` or `filters`. Only rewritten for a Requirement Traceability Report; on other types it is read but the JQL config is not updated." - }, - { - "name": "status", - "type": "array", - "json_path": "/report_filters/status" - }, - { - "name": "priority", - "type": "array", - "json_path": "/report_filters/priority" - }, - { - "name": "case_type", - "type": "array", - "json_path": "/report_filters/case_type" - }, - { - "name": "assignee", - "type": "array", - "json_path": "/report_filters/assignee" - }, - { - "name": "automation_status", - "type": "array", - "json_path": "/report_filters/automation_status" - }, - { - "name": "automation_state", - "type": "array", - "json_path": "/report_filters/automation_state" - }, - { - "name": "test_runs", - "type": "array", - "description": "Run ids as INTEGERS \u2014 a quoted id or TR-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored run selection.", - "json_path": "/report_filters/test_runs" - }, - { - "name": "test_plans", - "type": "array", - "description": "Plan ids as INTEGERS \u2014 a quoted id or TP-NNN fails the request 400 'Invalid report filters'. Omitting the key DELETES the stored plan selection.", - "json_path": "/report_filters/test_plans" - }, - { - "name": "test_cases", - "type": "object", - "description": "Case selection \u2014 must be an OBJECT (an array is 400), folder-keyed like SelectionData: folder ids are STRING keys, case ids INTEGERS. Read the current value from get_selected_report_testcases and send it back; omitting the key deletes the saved selection.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "required": true - }, - { - "name": "folders", + }, + "UsersV2Response__ListGroupUsers": { "type": "object", - "required": true - }, - { - "name": "unique_test_case_count", - "type": "integer", - "required": true - } - ], - "json_path": "/report_filters/test_cases" - }, - { - "name": "shared_selection", - "type": "object", - "description": "Cross-project case selection: { \"\": } for non-primary projects. A project id outside the report's project set is dropped silently.", - "json_path": "/report_filters/shared_selection" - }, - { - "name": "session_ids", - "type": "array", - "description": "Exploratory session ids \u2014 pairs with report_timeframe=specific_sessions.", - "json_path": "/report_filters/session_ids" - }, - { - "name": "requirements", - "type": "object", - "description": "{ issue_type: [issue_id, ...] } \u2014 must be an object, not an array. Pairs with report_timeframe=requirements and is NOT auto-populated on edit even for system_generated reports.", - "json_path": "/report_filters/requirements" - }, - { - "name": "test_plan_selection", - "type": "object", - "description": "Per-plan sub-plan selection: { plan_id: { select_all, selected_sub_plan_ids, deselected_sub_plan_ids } }. A malformed entry is discarded silently rather than rejected.", - "json_path": "/report_filters/test_plan_selection" - }, - { - "name": "test_runs_by_project", - "type": "object", - "description": "Cross-project run selection: { \"\": [run_id, ...] }. Numeric-string keys and numeric-id arrays only, else 400. Replaces the flat `test_runs` key when present.", - "json_path": "/report_filters/test_runs_by_project" - }, - { - "name": "test_plans_by_project", - "type": "object", - "description": "Cross-project plan selection: { \"\": [plan_id, ...] }. Replaces the flat `test_plans` and `test_plan_selection` keys when present.", - "json_path": "/report_filters/test_plans_by_project" - }, - { - "name": "builds", - "type": "array", - "json_path": "/report_filters/builds", - "description": "Internal build ids paired with `test_runs`. The report read returns them as `build_ids`; send them back unchanged or the paired rows are wiped on the next write." - }, - { - "name": "projects", - "type": "array", - "json_path": "/report_filters/projects" - }, - { - "name": "folder", - "type": "array", - "json_path": "/report_filters/folder" - }, - { - "name": "test_case_tags", - "type": "array", - "json_path": "/report_filters/test_case_tags" - }, - { - "name": "tc_custom_fields", - "type": "object", - "description": "Keyed by custom-field id (an OBJECT, not an array). ONLY consumed for `Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity`; elsewhere it is dropped silently. Persisted and read back under the name `custom_fields`.", - "json_path": "/report_filters/tc_custom_fields" - }, - { - "name": "custom_date_range", - "type": "string", - "example": "2025-04-16 2025-04-24", - "description": "\"YYYY-MM-DD YYYY-MM-DD\", space separated. REQUIRED when report_timeframe is `custom` (omitting it is a 500); NULLED on the record for every other timeframe." - }, - { - "name": "users", - "type": "array", - "description": "TM user ids to mail the report to. Every id must be in your group or the request is 400. Only applied when the report is already scheduled.", - "json_path": "/mail_to/users" - }, - { - "name": "external_mails", - "type": "array", - "description": "Email addresses outside the group. Only applied when the report is already scheduled.", - "json_path": "/mail_to/external_mails" - }, - { - "name": "frequency", - "type": "string", - "values": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly", - "description": "Send with `frequency_details` to keep or change the schedule. Omitting EITHER unschedules the report \u2014 both columns are nulled and it stops emailing. `once` is not a value: it passes validation and then raises a 500." - }, - { - "name": "time", - "type": "string", - "example": "08:00", - "description": "24-hour HH:MM, UTC. Required whenever frequency is sent.", - "json_path": "/frequency_details/time" - }, - { - "name": "day", - "type": "string", - "example": "monday", - "description": "Required for weekly (lowercase English day name) and monthly (integer 1-28). Omit for daily.", - "json_path": "/frequency_details/day" - } - ], - "intent": "Rewrite a saved report \u2014 its type, window, filters, schedule or recipients \u2014 replacing the whole definition in one call", - "returns": [ - "data", - "report_data", - "report_filters", - "dynamic_filters" - ], - "responses": { - "200": { - "$response": "UpdateReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This PATCH is a FULL REPLACE, not a merge. `title`, `description`, `report_type`, `report_timeframe`, `report_creation_mode` and `is_dynamic` are written from the body unconditionally, so any one you omit is set to NULL. Read the report first and send the complete object back with your change applied.", - "Omitting `frequency` / `frequency_details` UNSCHEDULES the report: both columns are nulled and it stops emailing. Resend the persisted values to keep the schedule. Likewise `custom_date_range` is nulled whenever `report_timeframe` is not `custom`.", - "`projectId` in the body is effectively required even though it is not marked so: it is the project every rewritten filter row is filed under. Omit it and the rows are written against project 0, which no read ever finds \u2014 the call still returns 200 with a report whose filters have silently vanished.", - "`report_filters` is authoritative: a key you omit has its stored rows deleted. Send the full filter set, not a delta. Use the report read and get_selected_report_testcases to recover the current values first.", - "Filters are only rewritten for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Plan Detailed Report, Test Case Activity, User Workload Report, Requirement Traceability Report and Exploratory Session Summary. On `Defects Summary` / `Defects Detailed Report`, or when `report_type` is omitted, the filter update is skipped entirely and `report_filters` comes back null \u2014 at 200.", - "There is no undo and no version history. The previous definition and its filter rows are gone once this returns.", - "`grain` cannot be changed after creation \u2014 a differing value is 400. Resending the persisted value is accepted.", - "`mail_to` is only applied when the report is already scheduled AND `mail_to` is present in the body; on an unscheduled report recipients are ignored.", - "The response is NOT the create envelope: there is no `success` key. The report is at `data`, the computed figures at `report_data`, the resolved filters at `report_filters`. `report_data` is computed inline here, so a large report makes this call slow.", - "The report is resolved by id within your group \u2014 the project in the path does not scope the lookup, so a report belonging to a sibling project still resolves (404 `Schedule not found` only when no such report exists in the group). Requires the report_update permission.", - "The same cross-project caps as create apply, and they are re-checked against the incoming `report_type`: 60 projects overall, 5 for User Workload Report, and multi-project only for the seven supported types." - ] - }, - { - "name": "update_shared_step", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/shared-steps/{shared_step_id}", - "mode": "write", - "entity": "shared_step", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "shared_step_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Required on every call even when you are only changing steps. Read the shared step first and resend its current title; whatever you send becomes the new title." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Shared-field folder to move the block into. Forwarded only when the key is present, so omitting it leaves the block where it is; send null to move it to the root (Unassigned)." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "description": "Plain tag name strings; the list you send replaces the block's tags. The key is always forwarded, so omitting it sends a null tags value rather than preserving the current tags \u2014 resend the existing tags if you want to keep them." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The FULL replacement step list, not a patch. Rows you leave out are removed from the block. Required \u2014 omitting the key fails with a flat 400 'Validation Error'.", - "fields": [ - { - "name": "id", - "type": "integer", - "description": "Existing step-row id, from a prior read. Rows sent with an id are updated in place and keep their identity; rows sent without one are created fresh, which breaks the shared_step_detail_id that referencing test-case steps already hold." - }, - { - "name": "order", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Required here too, not optional: local validation demands order >= 1 on every row, so a row sent without an order fails the whole update." - }, - { - "name": "step", - "type": "string", - "description": "Step body, HTML. Each row must carry at least one of step or result." - }, - { - "name": "result", - "type": "string", - "description": "Expected result, HTML. Each row must carry at least one of step or result." - }, - { - "name": "test_data", - "type": "string" - } - ] - } - ], - "intent": "Replace a shared step's title, folder and its whole ordered step list \u2014 the edit lands at once in every test case that embeds the block", - "returns": [ - "success", - "data", - "id", - "title", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$schema": "SharedStepsIDResponse__UpdateSharedStep" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Blast radius first: a shared step is embedded by reference, so this edit changes the steps rendered by every test case that embeds the block \u2014 immediately, without touching or versioning those cases. Read the block (or find it in the listing) and check `test_case_count` before you write: that is how many test cases you are about to change.", - "shared_step_details is a full replacement, not a merge. Any existing row you omit is deleted, and deleting a row orphans the `shared_step_detail_id` that referencing test-case steps point at. Read the current rows, edit that array, send it back whole.", - "Rows carrying their existing `id` are updated in place; rows without one are created new. Dropping the ids and resending the same text rebuilds every row with fresh ids and breaks every reference into the block.", - "It is a PATCH but not a partial one: title and shared_step_details are both required on every call, and an omitted `tags` is sent as null rather than left alone.", - "Shared steps keep no version history \u2014 only test cases, test plans and builds do \u2014 so there is no undo for an overwritten title or step list, and no restore-previous-version call to fall back on.", - "The handler reads the block before writing, so a wrong or deleted shared_step_id returns 404 `{data:null, success:false}` before anything changes.", - "Every local validation failure is the same opaque 400 `{data:null, success:false, message:\"Validation Error\"}` \u2014 no field name. The usual causes are a missing shared_step_details, a row with no order (or order 0), or a row with neither step nor result.", - "Requires tc_write." - ] - }, - { - "name": "update_template", - "method": "PATCH", - "path": "/api/v2/templates/{id}", - "mode": "write", - "entity": "template", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "Template id \u2014 a plain integer with no prefix, discovered from the template list.", - "pattern": "^[0-9]+$" - } - ], - "body": [ - { - "name": "name", - "type": "string", - "description": "New template name. Only applied when the key is present; omit it to leave the name alone.", - "minLength": 1 - }, - { - "name": "enabled", - "type": "boolean", - "description": "true re-activates the template, false disables it. Read as truthiness: send a real JSON boolean, because the STRING \"false\" is truthy and ENABLES the template. Disabling is reversible and is the alternative to deleting." - }, - { - "name": "is_default", - "type": "boolean", - "description": "Make this the workspace default template for its entity type. Setting a new default displaces the current one; the system default template cannot be disabled or deleted." - }, - { - "name": "description_fields", - "type": "array", - "description": "REPLACES the description section wholesale \u2014 it is not merged. Anything you leave out is removed from the template. Read the template first and resend every field you want to keep.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - }, - { - "name": "properties_fields", - "type": "array", - "description": "REPLACES the properties section wholesale. Sending this key also silently re-adds the Review & Approve system fields (reviewers, review status), so the stored set is a superset of what you sent and those two cannot be removed by omission.", - "fields": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The system field id when is_system is true, otherwise the custom field id." - }, - { - "name": "is_system", - "type": "boolean" - }, - { - "name": "is_mandatory", - "type": "boolean" - } - ] - } - ], - "intent": "Rename a template, enable or disable it, make it the workspace default, or replace its description and property field layout.", - "guidance": [ - "A name-only PATCH works and returns a bare {success:true} - unlike the create, the update does NOT echo the template back, so re-read it if you need the result.", - "Sparse update: only the keys actually present in the body are touched, everything else keeps its current value. Sending an empty body is accepted and changes nothing.", - "Field arrays REPLACE, they do not merge. Templates have no version history in this product, so the previous layout cannot be restored \u2014 read the template, edit the returned arrays, and send them back whole.", - "Editing the field layout changes the fields shown on every test case using this template, in every linked project. Establish how many projects are affected before writing.", - "Sending properties_fields re-injects the Review & Approve system fields, and reads filter those same fields out, so a read-modify-write round-trip neither loses them nor can remove them. Do not report them as deleted.", - "enabled is evaluated for truthiness, so the string \"false\" enables rather than disables. Use a JSON boolean.", - "Paid plans only. On a free plan the response is HTTP 200 with success:false and the paid-feature message, and nothing is changed \u2014 check the flag, not the status code.", - "Success returns only success:true with no template body. Re-read the template to confirm the new layout.", - "Project links are not changed here; linking and unlinking projects is a separate operation on the template." - ], - "returns": [ - "success" - ], - "responses": { - "200": { - "$response": "TemplateWriteAckResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_test_plan_by_integer_id", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/update", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "test_plan_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/test_plan/name", - "description": "Plan name. EFFECTIVELY REQUIRED on any content edit: the server's edit branch is gated on name being present, so omitting it rejects the whole call with 400 'Invalid Update Params' however valid the rest is. Read the plan first and echo the current name back unchanged." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text; rewritten server-side, so the stored value can differ from what you sent. Dropped entirely when the same request also changes plan_status." - }, - { - "name": "start_date", - "type": "string", - "description": "ISO-8601 date. Only settable while the plan is still `new` \u2014 once started, the stored start date is used and this field is ignored. REQUIRED in the same request when moving plan_status to 'started'.", - "format": "date", - "json_path": "/test_plan/start_date" - }, - { - "name": "end_date", - "type": "string", - "description": "ISO-8601 date. Must not be earlier than the start date (400 'Invalid end_date, should be greater than start_date'). REQUIRED in the same request when moving plan_status to 'completed'.", - "format": "date", - "json_path": "/test_plan/end_date" - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "description": "Target status. Only moves forward one step: new -> started -> completed. Sending a value that differs from the current one switches the call into transition mode, in which EVERY other content field in the body is discarded.", - "json_path": "/test_plan/plan_status" - }, - { - "name": "owner", - "type": "integer", - "description": "Owner as the internal TM user id (the `id` from the project users read \u2014 not a BrowserStack user id or email). An id that does not resolve inside the caller's group clears the owner instead of erroring. Only applied on a content edit, never alongside a status transition.", - "json_path": "/test_plan/owner" - }, - { - "name": "parent_plan_id", - "type": "integer", - "description": "Re-parent a SUB-plan under a different parent plan (integer id). It CANNOT convert a top-level plan into a sub-plan (400 'Cannot update a parent test plan to a sub plan') nor promote a sub-plan to top level (400 'Cannot update a sub test plan to a parent plan'), and the move is only applied while the plan is still `new`.", - "json_path": "/test_plan/parent_plan_id" - }, - { - "name": "tags", - "type": "array", - "description": "Plain tag strings; the list you send REPLACES the plan's tags. Discarded when the same request changes plan_status.", - "json_path": "/test_plan/tags" - }, - { - "name": "reviewers", - "type": "array", - "description": "Reviewer user ids; the list REPLACES the current reviewers. Omit the key to leave reviewers untouched, send [] to clear them. A reviewers-only body is the one edit that does not need `name`.", - "json_path": "/test_plan/reviewers" - }, - { - "name": "attachments", - "type": "array", - "description": "Attachment ids already uploaded via the attachments surface. An attachment that does not verify returns 422 with the verifier's own body rather than the usual envelope.", - "json_path": "/test_plan/attachments" - }, - { - "name": "custom_fields", - "type": "object", - "description": "Custom-field values keyed as the test-plan form-fields read returns them. Unlike the other content fields, these ARE applied even alongside a plan_status transition. A bad key or value is 400 {success: false, error: }.", - "json_path": "/test_plan/custom_fields" - }, - { - "name": "issues", - "type": "array", - "description": "Linked tracker issues. Structured \u2014 NOT a flat array of keys. Only url and ticket_id survive inside metadata. Linking is processed on a background job, so the plan echoed back may not show them yet.", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - }, - { - "name": "metadata", - "type": "object" - } - ], - "json_path": "/test_plan/issues" - }, - { - "name": "test_runs", - "type": "array", - "description": "The plan's linked test runs, as run INTEGER `uuid`s. This REPLACES the whole link set rather than appending; [] unlinks everything; omitting the key leaves the linkage alone. Applied even alongside a status transition.", - "json_path": "/test_plan/test_runs" - } - ], - "intent": "Edit a test plan or sub-plan \u2014 rename it, change its dates, owner, tags, custom fields or reviewers, move its status forward, or replace the set of test runs it groups", - "responses": { - "200": { - "description": "The updated plan, re-read and returned under a top-level `test_plan` key (no `data` wrapper) \u2014 same shape as the plan detail read.\n", - "schema": { - "$schema": "V1TestPlanDetail__UpdateTestPlanByIntegerId" - } - }, - "400": { - "description": "A local rule violation: `{success: false, error: ...}` where error is one of 'Invalid Update Params', 'Cannot update completed test plan', 'Cannot revert started test plan', 'Cannot directly complete test plan', 'Cannot update a parent test plan to a sub plan', 'Cannot update a sub test plan to a parent plan', 'Invalid start_date', 'Invalid end_date', 'Invalid end_date, should be greater than start_date', 'Invalid field plan_status', 'Invalid Test Plans Params'. Also a rejected custom field.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" + "description": "UsersV2Response as returned by `list_group_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "UsersV2Response" + } + ], + "properties": { + "info": { + "$schema": "PageInfo__ListGroupUsers" + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Upstream rejected the update (for example the review gate on completing an unapproved plan): `{success: false, error_code, error}`. Attachment verification failures also land on 422 with the verifier's own body.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error_code": { - "type": "string" - }, - "error": { - "type": "string" + }, + "PageInfo__ListGroupUsers": { + "type": "object", + "description": "PageInfo as returned by `list_group_users`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "count": { + "type": "integer", + "example": 10 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Everything goes inside a `test_plan` object. A flat body is rejected before any field is read.", - "name IS REQUIRED on every content edit, including one that only touches test_runs or tags. Omit it and the call is 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt.", - "A status transition and a content edit are mutually exclusive branches, not one merged write. When plan_status differs from the plan's current status, ONLY the dates and the new status are applied \u2014 name, description, tags, issues, owner and parent_plan_id in the same body are silently discarded, with a 200 and no warning. Rename first, then transition.", - "Moving to 'started' requires a valid start_date in the SAME request (400 'Invalid start_date'); moving to 'completed' requires end_date (400 'Invalid end_date'). The plan's stored dates are not used as a fallback.", - "Status only moves forward one step: new -> started -> completed. new -> completed is 400 'Cannot directly complete test plan' and started -> new is 400 'Cannot revert started test plan'.", - "A completed plan is FROZEN \u2014 even a rename is refused with 400 'Cannot update completed test plan'. Complete it last.", - "Run ids in test_runs are the INTEGER `uuid` from a run read, NOT the TR-NNN string that every run endpoint puts in `id`. And linking MOVES a run: a run belongs to at most one plan, so linking silently unlinks it from whichever plan held it, with nothing in the 200 to say so. Read the runs' current membership first.", - "test_runs REPLACES the link set \u2014 send the full list you want, not just the additions. An empty array unlinks every run.", - "parent_plan_id cannot promote or demote: a top-level plan can never become a sub-plan and a sub-plan can never become top-level. Moving a sub-plan between parents only takes effect while the plan is still `new`.", - "Plans keep no version history. An overwritten name, description, date or tag list is gone for good \u2014 there is no prior revision to restore.", - "Upstream rejections (for example the review gate that blocks completing an unapproved plan) come back as 422 {success: false, error_code, error}, while the local rule violations above are 400 {success: false, error}.", - "The success body is the freshly re-read plan under a top-level `test_plan` key \u2014 the same shape as the plan detail read, with no `data` wrapper." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "assignee", - "created_by", - "test_runs_count", - "total_test_runs_count", - "links" - ] - }, - { - "name": "update_test_run", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-runs/{test_run_id}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^TR-\\d+$", - "description": "Run identifier in TR-NNN form. The v2 routes constrain this segment to TR-\\d+ \u2014 unlike the v1 run endpoints, a bare integer is NOT accepted here and returns a routing 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Only applied when non-empty; sending an empty string leaves the existing name." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is escaped and sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, given as an email (or a TM user id). An address that does not resolve to a user in this group fails the whole request with 400 'invalid data' \u2014 it is not skipped." - }, - { - "name": "run_state", - "description": "`closed` is one-way: a closed run cannot be reopened, and this call does not do it. The partial update: on a closed run it answers 404 'Test Run ID is invalid' rather than a clear refusal.", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "json_path": "/test_run/run_state" - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings; the list you send replaces the run's tags." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read. Duplicates are collapsed." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^$|^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link. Send null or \"\" to unlink the current plan. Mutually exclusive with sub_test_plan_id." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^$|^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link. Send null or \"\" to unlink. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "The run's COMPLETE intended case list \u2014 NOT a list to add. Any case already in the run and missing from this list is removed, with its run row, assignee and results; the run's own fields (owner, tags, description) are kept. To ADD cases, use bulk_update_test_run_test_cases with add_test_cases instead \u2014 it is the only truly additive call. If you must use this field, send the run's current case identifiers (list_test_run_test_cases) plus the new ones: retained cases then keep their run row id and assignee, and only the new cases are inserted." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Select every test case in the project. Overrides folder_ids and filter_test_cases when true." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs whose cases go into the run's selection. Assume the same replace semantics as test_cases, so combine it with the existing case identifiers rather than sending folders alone." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders" - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Filter criteria used to resolve cases, and the only place is_dynamic is read. is_dynamic:true needs at least one other key here or the call 400s; is_dynamic:false turns a dynamic run static. Omitting is_dynamic leaves the run's current dynamic flag alone." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment overlay: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Unresolvable case or configuration IDs return 422. It is an overlay, not the case list \u2014 cases you list elsewhere are still included, with a null-configuration row." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "json_path": "/test_run/filter_scope", - "description": "Accepted by the parameter filter but NOT honoured on either update path \u2014 the selection is always built in global mode here. Only create reads it." - }, - { - "name": "shared_test_cases", - "type": "array", - "fields": [ - { - "name": "project_id", - "type": "integer" - }, - { - "name": "test_case_ids", - "type": "array" - } - ], - "json_path": "/test_run/shared_test_cases", - "description": "Cases shared in from other projects, as [{project_id, test_case_ids: [...]}]. Only this partial-update call resolves them; the full-replace call on the same run accepts the key and ignores it." - }, - { - "name": "is_automation", - "type": "boolean", - "json_path": "/test_run/is_automation", - "description": "Only honoured on this partial-update call (and only for the literal values true/false); the full-replace call on the same run ignores it. It turns an existing manual run into an automation run \u2014 this is the only way to get one, since both run creates ignore the flag. The reply carries no is_automation, so confirm with the v1 run read." - } - ], - "intent": "Change just the fields you name on a test run \u2014 rename it, move its state on, link or unlink a test plan, attach configurations or a dynamic filter \u2014 leaving everything you omit untouched", - "guidance": [ - "Two different operations share this path. This PATCH applies only the keys present in `test_run`; the POST rebuilds the whole payload and resets what you omit. This is the safe default for a targeted edit.", - "Unlinking is explicit: send test_plan_id (or sub_test_plan_id) as null or \"\" to detach the plan. Omitting the key keeps the current link.", - "run_state is validated here: an unknown value returns 400 echoing it. Both 'new_run' and 'new' are accepted. Omit the key to leave the state alone.", - "is_dynamic lives inside filter_test_cases and nowhere else. true needs at least one other criterion in the same object or the call 400s; false flips a dynamic run back to static; omitting it leaves the flag as it is.", - "Composition keys interact: sending only configurations re-reads the run's existing case selection so the case list survives, and sending selection keys without configurations re-reads the existing configuration list. Send neither and the run's composition is left completely untouched.", - "Case membership is REPLACED, not unioned: the selection you send becomes the run's whole case list, and any case missing from it is removed with its run row, assignee and results \u2014 with a 200. Only the run's own fields follow the partial rule (omitted owner, tags and description are kept). To add cases use bulk_update_test_run_test_cases (add_test_cases); if you use this call anyway, read the current cases and send existing + new, then re-read to confirm the count.", - "Send the fields flat. The `test_run` wrapper is added for you from each field's json_path, and sending your own `test_run` object is rejected before the call as an unknown body key.", - "There is no undo: test runs keep no version history, so an overwritten name, description or tag list cannot be restored.", - "The updated run comes back at the top level as `testrun` \u2014 one word, no underscore, and no `data` envelope. This differs from the read endpoints, which use `test_run`.", - "The updated run comes back under `testrun` (no underscore) and is the NARROW v2 projection: no `id`, no `uuid`, no `owner`, no `is_automation`, no `test_cases_count`. Its identity is the TR-NNN string in `identifier`.", - "`urls.self` is a link to the run in the web UI, not an API path \u2014 it is built from the frontend base URL, so calling it returns HTML.", - "`assignee` comes back as a plain email string \u2014 or null when the owner cannot be resolved \u2014 and `configurations` as a list of configuration ids. Neither is a list of objects.", - "`overall_progress` is sparse and keyed by the capitalised status display name, so a run whose cases are all untested returns a single Untested count rather than a seven-key object of zeros. It is omitted altogether for a run with no cases.", - "`test_plan` is singular and appears only when the run is linked to a plan. Its `id` field holds the TP-NNN identifier rather than a number, and there is no `test_plans` array on this reply.", - "`issues` and `issue_tracker` are both conditional: `issues` only when the run carries linked defects, `issue_tracker` only when at least one of them names a tracker. An absent key here does not mean an empty list.", - "The run's case list is not on this reply \u2014 `test_cases` appears only on the run read. Fetch the run's test cases separately if you need the rows.", - "Treat this reply as an acknowledgement, not a read. `updated_at` and `overall_progress` are echoed from the write itself rather than re-read, and both have been observed disagreeing with what was actually stored; `assignee` can echo the value that was in place before the write, and `tags` can come back [] while the stored tags are unchanged. Re-read the run when the exact stored values matter.", - "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunWriteV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "upload_ai_attachments", - "method": "POST", - "path": "/api/v1/projects/{project_id}/generic/attachments/ai_uploads", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Upload a document as AI context for a project \u2014 the same store as a generic upload but with the restricted type and size rules that the test-case generation flow requires", - "returns": [ - "id", - "name", - "content_type", - "download_url", - "size", - "url" - ], - "responses": { - "200": { - "$response": "GenericAttachmentsResponse" - }, - "400": { - "description": "A file failed the AI type allowlist or its per-type size cap. All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "NOT INVOCABLE through this interface. Identical handler to the generic upload: it reads `attachments` as an array of uploaded FILES from a multipart form, so the one input it needs cannot be expressed as a JSON argument and this record carries no body parameter.", - "With no files it returns 200 and `generic_attachment: []` rather than an error \u2014 a call from here looks successful and uploads nothing.", - "Only the path differs from the generic upload, and the difference is validation. Allowed types: pdf, txt, csv, json, rtf, doc, docx, xls, xlsx, pptx, jpeg, png. Anything else is rejected with 'Invalid file type for AI uploads'.", - "Size caps are PER TYPE, not the generic 50 MB: 500 KB for csv/txt/json/xls/xlsx, 5 MB for jpeg/png, 10 MB for pptx, 25 MB for pdf/doc/docx/rtf. Passing linked_jira_data=true replaces all of them with a flat 3 MB cap and files under a separate storage prefix.", - "Any text/* file that is byte-sniffed as something outside the allowlist (markdown read as text/html, for example) is silently normalised to text/plain and accepted.", - "On a real multipart upload the status is 200, not the 201 the spec claims. `id` is the blob id the test-case creation flow consumes; `url`/`download_url` are pre-signed for 30 minutes and `size` is a human-readable string." - ], - "disabled": true - }, - { - "name": "upload_generic_attachments", - "method": "POST", - "path": "/api/v1/projects/{project_id}/generic/attachments", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "intent": "Upload a file to a project and get back its blob id plus pre-signed view/download URLs \u2014 the first half of the attach flow, needed before a file can be linked to a test case, result or rich-text field", - "returns": [ - "id", - "name", - "content_type", - "download_url", - "size", - "url" - ], - "responses": { - "200": { - "$response": "GenericAttachmentsResponse" - }, - "400": { - "description": "At least one file failed validation (size). All blobs from the request are deleted; the body is {errors: {message: [...] }} and names only the last failing file's errors." - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "NOT INVOCABLE through this interface. The handler reads `attachments` as an array of uploaded FILES from a multipart form and streams their bytes to storage; a file cannot be expressed as a JSON argument, which is why this record carries no body parameter at all.", - "Worse than an error: with no files the handler takes the empty path and answers 200 with `generic_attachment: []`. A call from here looks like it succeeded and uploads nothing \u2014 do not report a successful upload on that response.", - "If you need a blob id, the file has to be uploaded out of band (the product UI, or a direct multipart request built by the caller). Once it exists, the blob id is what the test-case, test-result and step attach capabilities take.", - "On a real multipart upload the status is 200, not the 201 the spec claims, and the body is {generic_attachment: [...]}: `id` is the blob id to carry forward, `url` and `download_url` are pre-signed and expire after 30 minutes, and `size` is a HUMAN-READABLE string ('300 KB') rather than a byte count.", - "No type allowlist is enforced on this generic path; the only limit is 50 MB per file. SVG/HTML/XML uploads are served with a forced-download disposition rather than inline.", - "A single invalid file fails the whole request: every blob already written is deleted, and the 400 body reports only the LAST failing file's errors, under errors.message." - ], - "disabled": true - }, - { - "name": "link_attachments_to_test_case", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/attachments", - "mode": "write", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - }, - { - "name": "test_case_id", - "type": "integer", - "required": true - } - ], - "intent": "Link already-uploaded files to a test case by their blob ids \u2014 the second half of the two-step attach flow; it does not carry file content", - "returns": [ - "success", - "attachments", - "id", - "filename", - "content_type", - "byte_size" - ], - "responses": { - "200": { - "description": "Attachments uploaded successfully", - "schema": { - "type": "object", - "properties": { - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentResponse" - } - }, - "success": { - "type": "boolean" + }, + "V1TestPlanDetail__GetTestPlanByIntegerId": { + "type": "object", + "description": "V1TestPlanDetail as returned by `get_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "active_state": { + "type": "string", + "example": "active" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-18T10:40:45.000Z" + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "example": "2026-10-18T00:00:00.000Z" + }, + "id": { + "type": "integer", + "example": 48300 + }, + "issues": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "example": "2026-09-18T00:00:00.000Z" + }, + "sub_plans_count": { + "type": "integer", + "example": 0 + }, + "tags": { + "type": "string", + "example": "disposable" + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-18T10:40:45.000Z" + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "DOES NOT WORK. A well-formed request returns a bare 500. The handler calls an attach method that the object it holds does not have, so the call raises before anything is stored. There is no request that makes it succeed \u2014 do not retry with a different body.", - "Despite the name and the published spec this is NOT a multipart file upload. The body is JSON and `attachments` is a list of INTEGER blob ids returned by a prior generic attachment upload. No file bytes cross this boundary.", - "Send the list flat. The `test_case` wrapper is added for you from the field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key.", - "Each blob must belong to the caller's group or to this project, and must not already be attached to anything. Reusing a blob that is attached elsewhere fails the WHOLE call with 422 'the attachment has been referenced elsewhere' \u2014 upload a fresh copy for each attachment point.", - "An id that resolves to no blob gives 422 'attachment not found'. An EMPTY array is accepted, does nothing, and still answers 200 with the case's existing attachments \u2014 it does not clear them.", - "The write is additive: the ids you list are added to whatever the case already has. Removal is the delete-attachment capability, one id at a time.", - "`folder_id` and `test_case_id` must both be integers or the route does not match; folder_id itself is not used to locate the case.", - "Success returns the case's attachments (id, filename, content_type, byte_size) at the top level alongside `success` \u2014 no `data` envelope." - ], - "body": [ - { - "name": "attachments", - "type": "array", - "required": true, - "json_path": "/test_case/attachments", - "example": [ - 10288829, - 10288830 - ], - "description": "Integer blob ids from a prior generic attachment upload \u2014 NOT filenames and NOT file content. Each blob must be unused; one already attached elsewhere fails the whole request with 422. An empty array is a silent no-op, not a clear." - } - ], - "disabled": true - }, - { - "name": "get_user_workload_drilldown", - "method": "POST", - "path": "/api/v1/projects/{project_id}/reports/{report_id}/drilldown", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "report_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "user_id", - "type": "integer", - "required": true, - "example": 12345, - "description": "BrowserStack user id to break down \u2014 the id carried by the report's `user_table` rows, NOT the TM user id. Missing or <= 0 is 422. A Member-role caller may only pass their own id (403 otherwise)." - } - ], - "intent": "Break one person's workload out of a User Workload Report \u2014 the test cases they executed, the runs they took part in, and their day-by-day activity", - "paginated": false, - "responses": { - "200": { - "$response": "UserWorkloadDrilldownResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only works on a report whose stored `report_type` is `User Workload Report`. Any other report is 400 `Drill-down is only available for User Workload Reports`; an id not in your group is 404.", - "`user_id` is the BrowserStack user id, the same id the report's `user_table` rows carry \u2014 not the TM-internal user id. A missing or non-positive value is 422 (not 400), with `user_id is required for drill-down`.", - "A caller whose role is Member may only drill into themselves: any other `user_id` is 403 `Forbidden: cannot drill into another user`. Admins and above can drill into anyone.", - "It is a POST but it changes nothing \u2014 safe to call repeatedly. The window and run scope are taken from the REPORT's saved timeframe and filters; you cannot override them from the body.", - "Response is the analytics payload verbatim \u2014 there is no `success` flag and no envelope. Expect `tcs` (cases executed), `trs` (runs participated in) and `days` (daily timeline). An upstream failure surfaces as 422 `User Workload drill-down failed: ...`, so do not read an empty body as 'this person did nothing'.", - "NOT paginated: page and per-page values in the body are ignored \u2014 the full breakdown comes back in one response. Do not loop pages.", - "A report saved on a relative or custom timeframe that resolves to no dates falls back to a five-year window when it has specific runs selected, and is 422 otherwise.", - "Requires the report_view permission." - ], - "returns": [ - "tcs", - "trs", - "days" - ], - "disabled": true - }, - { - "name": "update_test_case", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id the v1 test-case endpoints take does NOT resolve here and comes back 404 'Test case not found'." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Checking the login functionality", - "json_path": "/test_case/name", - "description": "Case title. Omit to leave it alone; sending an empty or whitespace-only string is rejected 422 'Test case name cannot be empty' rather than clearing it. Note the response calls this field `title`, not `name`." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_case/description", - "description": "Rich-text field. Links are stripped server-side, so the stored value can differ from what you sent." - }, - { - "name": "preconditions", - "type": "string", - "json_path": "/test_case/preconditions", - "description": "Rich-text field, same server-side link stripping as description." - }, - { - "name": "test_case_folder_id", - "type": "integer", - "example": 21, - "json_path": "/test_case/test_case_folder_id", - "description": "Move the case to this folder as part of the edit. This is the name the parameter filter accepts \u2014 `folder_id`, the name the response and the published schema use, is DROPPED here and the case silently stays put. Unlike the v1 edit path, a value equal to the current folder is not stripped before the write." - }, - { - "name": "priority", - "type": "string", - "example": "Medium", - "json_path": "/test_case/priority", - "description": "The project's display value for priority (e.g. 'Low', 'Medium', 'High') or that value's numeric field id. Matched on the display value first, then on the id; anything else fails the WHOLE call with 400 'Invalid default field value priority'. Resolve the allowed set from the project's system field values first \u2014 it is per project, not a fixed list." - }, - { - "name": "status", - "type": "string", - "example": "Active", - "json_path": "/test_case/status", - "description": "The project's display value for test-case status, or its numeric field id. Same matching and same 400 'Invalid default field value status' as priority. Not the run/result status \u2014 this is the case's own lifecycle field." - }, - { - "name": "case_type", - "type": "string", - "example": "Smoke & Sanity", - "json_path": "/test_case/case_type", - "description": "The project's display value for case type, or its numeric field id. Stored internally as the category, and a wrong value fails the whole call with 400 'Invalid default field value case_type'." - }, - { - "name": "automation_status", - "type": "string", - "values": [ - "not_automated", - "automated", - "automation_not_required", - "cannot_be_automated", - "obsolete" - ], - "example": "automated", - "json_path": "/test_case/automation_status", - "description": "Internal name, not a display label. Validated against this closed list before the request leaves the API with 400 'Invalid automation status', and then matched against the project's own automation-state options." - }, - { - "name": "owner", - "type": "string", - "example": "jane@acme.com", - "json_path": "/test_case/owner", - "description": "Owner as an email address, or a TM user id. DANGER: an address that does not resolve to a user in this group is NOT an error \u2014 it is written as null, clearing the owner, and the call still returns 200. Verify the user before sending." - }, - { - "name": "tags", - "type": "array", - "example": [ - "regression", - "smoke" - ], - "json_path": "/test_case/tags", - "description": "Plain tag-name strings. The list you send REPLACES the case's tags, so read the current tags and send the union if you mean to add one; `[]` clears them all; omit the key to leave them untouched." - }, - { - "name": "issues", - "type": "array", - "example": [ - "TES-204" - ], - "json_path": "/test_case/issues", - "description": "Linked issue/defect ids as plain STRINGS on this path \u2014 the parameter filter here permits only scalars, so the {issue_id, issue_type} objects the v1 edit body takes are dropped and the links never happen. The list replaces the case's linked issues." - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "Sprint": "24", - "Component": [ - "auth", - "billing" - ] - }, - "json_path": "/test_case/custom_fields", - "description": "Keyed by the custom field's NAME here \u2014 not by its numeric id, which is what the v1 edit and bulk paths use. A key the project does not define is skipped SILENTLY and the call still returns 200; a defined field with an unusable value fails the whole call with 400 'Custom field validation failed for '. Dropdown values are the option label; a user-type field takes an email." - }, - { - "name": "test_case_steps", - "type": "array", - "example": [ - { - "step": "Open the login page", - "result": "The login form is shown" - } - ], - "fields": [ - { - "name": "step", - "type": "string" - }, - { - "name": "result", - "type": "string" - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer" - }, - { - "name": "order", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps", - "description": "The case's ordered step list. `order` is overwritten by array position, so the array order is what counts. Omit the key to keep the existing steps. Rejected with 422 for a BDD-template case \u2014 send feature/background/scenario instead. This is the only surface that writes steps: the bulk edit refuses them outright." - }, - { - "name": "template", - "type": "string", - "example": "test_case_steps", - "json_path": "/test_case/template", - "description": "Template NAME, not an id. Omit it and the case's current template is kept \u2014 the action fills it in from the stored case before the write." - }, - { - "name": "template_id", - "type": "integer", - "example": 42, - "json_path": "/test_case/template_id", - "description": "Custom-template id. Must be a TestCase-entity template visible to the caller's group; an unknown or cross-group id is rejected 400 rather than quietly falling back to the system default. Explicit null is a no-op, not a clear." - }, - { - "name": "feature", - "type": "string", - "json_path": "/test_case/feature", - "description": "BDD-template cases only; 422 on a non-BDD case. Omit to keep the stored value \u2014 sending an EMPTY string is rejected 400 'Cannot clear feature and scenario for BDD test cases.'" - }, - { - "name": "background", - "type": "string", - "json_path": "/test_case/background", - "description": "BDD-template cases only; 422 on a non-BDD case. Omitted or blank keeps the stored background." - }, - { - "name": "scenario", - "type": "string", - "json_path": "/test_case/scenario", - "description": "BDD-template cases only; 422 on a non-BDD case. Like feature, an empty string is rejected 400 rather than clearing it." - }, - { - "name": "reviewers", - "type": "array", - "example": [ - "jane@acme.com" - ], - "json_path": "/test_case/reviewers", - "description": "Reviewer EMAILS. An array replaces the whole reviewer set ([] unassigns everyone). An object form {value: [...], operation: replace|add|remove} is also accepted for an additive or subtractive change. A bare string or null is dropped by the parameter filter and can never become a reviewer write. Revoked reviewers are emailed. Naming the case owner is rejected 422." - }, - { - "name": "send_for_review", - "type": "boolean", - "json_path": "/test_case/send_for_review", - "description": "Moves the case into review and mails the reviewers, independently of `reviewers`. String booleans ('true'/'false'/'1'/'0'/'on'/'off') are cast; an ambiguous string such as 'yes' is rejected 422 rather than guessed." - }, - { - "name": "review_status", - "type": "string", - "values": [ - "pending", - "in_review" - ], - "json_path": "/test_case/review_status", - "description": "Assignment states only \u2014 a verdict (approved / rejected / changes_requested) is one reviewer's decision and is rejected 422. OMIT this on a metadata-only edit: sending `pending` on an approved case invalidates the approval and writes an audit row. The pending/in_review narrowing only applies once the reviewer feature flag is on; with it off the value is passed through unvalidated." - }, - { - "name": "identifier", - "type": "string", - "json_path": "/test_case/identifier", - "description": "External id for the case, used only when the workspace is configured for project-level ids; it is stored as the case's third-party identifier. On any other workspace it is ignored. `third_party_identifier` is accepted as a synonym." - }, - { - "name": "expected_result", - "type": "string", - "json_path": "/test_case/expected_result", - "description": "ACCEPTED BUT NOT WRITTEN on this path \u2014 the parameter filter permits it and the request builder never copies it, so it is dropped before the write and you still get 200. The v1 edit paths do persist it. Never report an expected result as saved from here." - }, - { - "name": "estimate", - "type": "string", - "json_path": "/test_case/estimate", - "description": "ACCEPTED BUT NOT WRITTEN \u2014 passes validation and is then dropped before the write, exactly as on the v1 create/edit paths. Never report an estimate as saved." - }, - { - "name": "test_case_dataset", - "type": "object", - "json_path": "/test_case/test_case_dataset", - "description": "ACCEPTED BUT NEVER APPLIED on this path: the builder only regenerates dataset mappings when the modified-dataset flag is set, and that flag is not accepted by this action, so the existing mappings are always kept. Returns 200 with the dataset unchanged." - }, - { - "name": "old_id", - "type": "string", - "required": false, - "description": "Legacy id carried over from an import; free text, not validated against anything.", - "json_path": "/test_case/old_id" - }, - { - "name": "expectation", - "type": "string", - "required": false, - "description": "Expected outcome, the counterpart to `preconditions`. Distinct from `expected_result`, which belongs to a step.", - "json_path": "/test_case/expectation" - }, - { - "name": "attachment_id", - "type": "integer", - "required": false, - "description": "Id of a previously stored attachment to associate with the case.", - "json_path": "/test_case/attachment_id" - }, - { - "name": "ids", - "type": "array", - "required": false, - "description": "Ids of other cases this write applies to, for the shared-write path.", - "json_path": "/test_case/ids" - }, - { - "name": "issue_tracker", - "type": "object", - "required": false, - "description": "Tracker context for `issues` \u2014 which external system the ids belong to. Validated together with `issues`.", - "json_path": "/test_case/issue_tracker" - }, - { - "name": "created_at", - "type": "string", - "required": false, - "description": "Explicit creation timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", - "json_path": "/test_case/created_at" - }, - { - "name": "updated_at", - "type": "string", - "required": false, - "description": "Explicit update timestamp. Only honoured for workspaces with external-API timestamps enabled; silently dropped otherwise.", - "json_path": "/test_case/updated_at" - } - ], - "intent": "Change one existing test case \u2014 rename it, rewrite its steps, preconditions or description, set priority/status/type/owner, retag it, relink issues or put it into review \u2014 sending only the fields you want changed", - "guidance": [ - "This is the SINGLE-case update, addressed by one identifier. The bulk edits take a selection (ids or a filter) instead, refuse step lists, and key custom fields by numeric id \u2014 so when you are changing one case, and always when you are changing its steps, this is the one to use.", - "Both ids in the path are prefixed strings (PR-1 / TC-42). The case id is resolved by an identifier lookup inside the project, so the internal integer id that the v1 edit endpoints take returns 404 'Test case not found' here.", - "Send the fields flat. The `test_case` wrapper is added for you from each field's json_path, and sending your own `test_case` object is rejected before the call as an unknown body key. Send at least one field: a 400 saying 'Invalid request format: test_case parameter is missing or malformed' means the body reached the server empty, not that you must nest it.", - "Only the keys you send are written \u2014 omitted fields keep their stored values. There is no undo, but the case keeps version history, so a bad edit can be recovered by restoring the previous version.", - "Accepted and then silently discarded on this path, each returning 200: expected_result, estimate, is_shared, the dataset payload, and `folder_id` (use test_case_folder_id to move). Re-read the case before telling anyone these landed.", - "priority, status and case_type take the PROJECT's display value or that value's numeric id; one bad value fails the whole call with 400 'Invalid default field value '. Resolve the allowed values from the project's system field values first rather than guessing 'P1' or 'Open'.", - "custom_fields is keyed by field NAME here (the v1 paths key by numeric id). An unknown name is skipped without error, so a typo means a 200 that changed nothing \u2014 confirm by reading the case back.", - "An owner that does not resolve to a user in this group is written as null and CLEARS the owner instead of erroring.", - "BDD and step-based cases are mutually exclusive: steps on a BDD case are 422, feature/background/scenario on a non-BDD case are 422, and clearing feature or scenario to an empty string is 400.", - "reviewers, send_for_review and review_status run a gate ladder (Team-Pro plan, the project's Review & Approve setting, 21 CFR Part 11) and are applied AFTER the case update has committed. A 502 with error_code reviewer_write_failed and test_case_updated true means the edit landed but the reviewer change did not \u2014 retry with only the reviewer field, never with the whole body.", - "Changing anything here can move the case out of an approved review state, and reviewer removals send email immediately.", - "Sharing cannot be changed here. `is_shared` is checked before the write \u2014 sending it on a project without test-case sharing enabled returns 400 \u2014 and is then discarded, so the value never takes effect. Use the bulk edit to change sharing.", - "Four fields are entitlement-gated and are dropped without error when the workspace lacks the entitlement: `created_at` and `updated_at` need external-API timestamps, and `identifier` applies only to project-level-id workspaces. Read the case back if you need to confirm one landed.", - "Parts of the echoed case are conditional, so test for the key rather than for an empty value: `feature`, `background` and `scenario` come back only on a Gherkin case (where `steps` is then an empty array), and `reviewers` and `review_status` appear only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps`, `custom_fields` and `attachments` come back as empty arrays when nothing is set \u2014 note that `attachments` is empty here, not missing, unlike the create response, and that editing a case never adds one: files are attached through the attachment upload capability." - ], - "returns": [ - "success", - "test_case", - "identifier", - "title", - "folder_id", - "priority", - "status", - "case_type", - "automation_status", - "owner", - "tags", - "template", - "description", - "preconditions", - "steps", - "custom_fields", - "issues", - "attachments", - "is_shared", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "responses": { - "200": { - "description": "The updated test case, in the v2 public shape (title, not name; no integer id).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + }, + "PageInfo__GetTestResultsCustomFields": { + "type": "object", + "description": "PageInfo as returned by `get_test_results_custom_fields`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" + } + ], + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "TestRunResponse__ListTestRunCases": { + "type": "object", + "description": "TestRunResponse as returned by `get_test_run_detail`. Composes TestRunResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "test_run": { + "type": "object", + "properties": { + "build_normalised_name": { + "type": "string", + "example": "__mcp-probe-run-2026-09-18T06-35-00Z" + }, + "build_serial_id": { + "type": "integer", + "example": 1 + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "is_read_from_tra_enabled": { + "type": "boolean", + "example": false + }, + "tc_sharing_enabled": { + "type": "boolean", + "example": true + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "updated_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "dynamic_filter": { + "type": "string", + "nullable": true + }, + "filters": { + "type": "string", + "nullable": true + }, + "issues": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17575382 + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "trtc_folder_progress_enabled": { + "type": "boolean", + "example": false + } + } + } + } + } + } + }, + "Filter__UpdateFilter": { + "type": "object", + "description": "Filter as returned by `update_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 }, - "test_case": { - "$schema": "V2PublicTestCase" + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions", + "properties": { + "customFields": { + "type": "object" + }, + "priority": { + "type": "string", + "example": "P1" + } + } } - } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "The test case WAS updated but the reviewer change could not be applied (error_code reviewer_write_failed, test_case_updated true). Also returned with error_code review_approve_unknown, in which case nothing was written at all." - } - } - }, - { - "name": "move_test_case", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/move", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API. It is also the only project involved \u2014 the move cannot leave it." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case IDENTIFIER, normally TC-NNN. The segment carries no route constraint, but the action resolves it through an identifier lookup scoped to this project, so only an identifier resolves: TC-NNN, or the case's third-party identifier in workspaces that issue project-level ids. The internal integer id returns 404 'Test case not found'." - } - ], - "body": [ - { - "name": "destination_folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 5, - "description": "Target folder, as a plain positive INTEGER folder id \u2014 folders are not identified by a prefixed string. Sent at the TOP LEVEL of the body, not inside a `test_case` object (the bulk move is the opposite and nests it). Missing, non-numeric, zero or negative is refused before anything is read. A numeric string is accepted and coerced." - } - ], - "intent": "File one test case in a different folder \u2014 use this to reorganise the tree, move a case out of an inbox/triage folder, or put a case where a run or plan expects to find it", - "guidance": [ - "Within ONE project only: there is no destination-project field, and the case is looked up in the same project it is moved inside. A cross-project move is not available here.", - "One case per call. To move many, use the bulk move, which takes a selection under a `test_case` object and runs asynchronously past ~30 cases; this one is always synchronous and is the cleaner choice for a single case.", - "The destination is a plain integer folder id and is NOT guessable \u2014 resolve it from the project's folder tree first. Nothing in this layer checks that the folder belongs to this project, so a stray id from another project is only caught (or not) downstream.", - "Moving a case into the folder it is already in is accepted and returns 200 with the case unchanged. Unlike the v1 edit path, the same-folder value is not stripped before the write, so the upstream write still happens.", - "Capture the case's current folder before you call if you may want to undo: the response reports only the NEW folder, and moving back means calling this again with the old id.", - "Nothing but the folder changes \u2014 the identifier, steps, tags, owner and review state are untouched \u2014 and the response carries the whole case in the v2 public shape.", - "The same folder change can be made as part of a single-case update by sending test_case_folder_id in its body; use this endpoint when the move is all you want, because it needs no other field and cannot accidentally rewrite one." - ], - "returns": [ - "success", - "test_case", - "message", - "identifier", - "title", - "folder_id", - "priority", - "status", - "case_type", - "automation_status", - "owner", - "tags", - "template", - "description", - "preconditions", - "steps", - "custom_fields", - "issues", - "created_at", - "last_updated_at", - "created_by", - "updated_by", - "urls" - ], - "responses": { - "200": { - "description": "The moved test case in the v2 public shape, with folder_id now the destination, plus a top-level message 'Test case moved successfully'.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + }, + "V1TestPlanDetail__UpdateTestPlanByIntegerId": { + "type": "object", + "description": "V1TestPlanDetail as returned by `update_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "TP-NNN identifier — the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", + "example": "TP-12345" + }, + "name": { + "type": "string", + "example": "Release 2.0 plan" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + }, + "id": { + "type": "integer", + "example": 2522 + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "active_state": { + "type": "string", + "example": "active" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-21T13:44:07.000Z" + }, + "custom_fields": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "example": 48319 + }, + "issues": { + "type": "string", + "nullable": true + }, + "owner": { + "type": "integer", + "example": 2522 + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string", + "example": "new" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "record_status": { + "type": "string", + "example": "active" + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer", + "example": 0 + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "example": "2026-09-22T05:11:26.000Z" + } + } + } + } + }, + "TestPlanV2Response__CreateTestPlan": { + "type": "object", + "description": "TestPlanV2Response as returned by `create_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestPlanV2Response" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "urls": { + "type": "object", + "properties": { + "sub_test_plans": { + "type": "string" + }, + "self": { + "type": "string", + "example": "https://test-management.browserstack.com/projects/1234/test-plans/49442" + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 0 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + } + } + } + } + }, + "V1TestCaseListRow__ListTestCases": { + "type": "object", + "description": "V1TestCaseListRow as returned by `list_test_cases`. Composes V1TestCaseListRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseListRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } }, - "test_case": { - "$schema": "V2PublicTestCase" + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListTestCases" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListTestCases" } - } - }, - "message": { - "type": "string" } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case_histories", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}/history", - "mode": "read", - "entity": "version", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Test case identifier: TC-NNN, or the third-party identifier set on the case. The route deliberately leaves this segment unconstrained, but it is resolved through identifier lookup before anything is read \u2014 a bare internal numeric case id is not a match and comes back 404 'Test case not found'." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "example": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read \u2014 you silently get page 1 again. Page size is fixed at 30 and is not caller-controllable here: `per_page` and `page_size` are accepted by the URL but ignored. A `p` that is not a positive integer (blank, zero, negative, non-numeric, repeated) is refused with 400 'Invalid Params'." - } - ], - "intent": "See what changed on a test case, when, and who changed it \u2014 the revision trail, newest first, naming the fields touched in each edit; the read to answer 'what did this case look like before that edit?' or 'which revision should we roll back to?'", - "guidance": [ - "Rows come newest first. Each row's `version_name` is a positional label \u2014 V1 is the oldest revision still visible, and the highest number is the newest edit on page 1.", - "`version_name` is the only handle a row gives you: the v2 rows carry no history record id. Strip the leading V and pass the number as the version number to the single-version read to pull that revision's full test case snapshot.", - "Only the newest row on each page is enriched with the per-field old/new `modified` map \u2014 the rest carry `modified_fields` (the field names only), so a per-field before/after for an older edit needs the single-version read for that version. The creating revision never carries `modified` at all, even when it is the only row on the page, because a create has nothing to diff against \u2014 so a single-revision history is expected to come back with `modified_fields` null and no `modified` map.", - "Without an Enterprise or Team Pro plan the caller sees only the last 30 days of history. Older revisions are simply absent and nothing in the response flags that the window was applied, so a short list is not proof that the case was rarely edited. Version numbers count only what is visible, so they renumber as old revisions age out.", - "Listing degrades gracefully across plans, but comparing two versions and restoring one are paid-plan only \u2014 fall back to listing plus single-version reads when those are refused.", - "Each record carries `source` ('create' on the first revision, 'update' for an edit, 'restore' after a rollback), `created_at`, `modified_fields`, and `updated_by` \u2014 the editor's email, null when that user cannot be resolved in this group. The raw `user_id` is the BrowserStack user id, not a Test Management one.", - "Page with `p`; the page size is fixed at 30, so a long-lived test case is walked 30 revisions at a time and the total is in `info.count`.", - "Every row also carries `origin_channel`, recording how that edit arrived: ui, api, jira_app, azure, import, automation or ai. Anything written through this API is recorded as api, so the field only discriminates once a case has also been touched in the web app, through an issue-tracker app, or by an import." - ], - "returns": [ - "success", - "history", - "info", - "version_id", - "version_name", - "source", - "modified_fields", - "modified", - "comment", - "created_at", - "updated_by", - "user_id", - "testcase_id", - "origin_channel" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "V2HistoryListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_runs", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-runs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (50, 100, 301) is refused with a 400 'parameter_error' rather than clamped, so ask for 300 directly when you want a large project in few calls." - }, - { - "name": "include_closed", - "type": "boolean", - "default": false, - "description": "Include closed runs. Closed runs are EXCLUDED from this listing by default, so counts and 'all runs' answers are silently short without this. Pass the literal string 'true'." - }, - { - "name": "run_state", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "example": "in_progress", - "description": "Filter by lifecycle state. Supplying it also lifts the default closed-run exclusion on its own, so run_state=closed works without include_closed." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com,lead@acme.com", - "description": "Comma-separated owner emails, resolved against this group. This filter FAILS OPEN: an address that matches no user in the group is silently dropped from the filter instead of erroring, so a typo widens the result set and still returns 200. Verify the emails resolve before trusting a filtered count." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^TP-\\d+$", - "example": "TP-1", - "description": "Restrict to runs linked to this test plan. An identifier that matches no plan in the project is a 404 ('Test Plan ID is invalid'), not an empty list." - }, - { - "name": "created_after", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Lower bound on creation date, YYYY-MM-DD. May be sent on its own. A value in a different format is a 400; a date later than tomorrow is a 400." - }, - { - "name": "created_before", - "type": "string", - "format": "date", - "example": "2026-03-31", - "description": "Upper bound on creation date, YYYY-MM-DD. May be sent on its own. Sending a created_after later than this is a 400 naming the start date." - }, - { - "name": "closed_after", - "type": "string", - "format": "date", - "example": "2026-01-01", - "description": "Lower bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." - }, - { - "name": "closed_before", - "type": "string", - "format": "date", - "example": "2026-03-31", - "description": "Upper bound on close date, YYYY-MM-DD. Sending either closed bound also forces the result to CLOSED runs only, whatever include_closed says." - } - ], - "intent": "Find test runs in a project \u2014 filter by owner, lifecycle state, linked plan or date to locate a run, count how many there are, or collect the run ids you need before linking runs to a test plan", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 \u2014 any other number is a 400 'parameter_error', not a clamp \u2014 so pass 300 when you want the project in as few calls as possible.", - "Closed runs are excluded by default. Without include_closed=true (or a run_state filter, which lifts the exclusion by itself) a 'how many runs do we have?' answer quietly omits every closed run and still comes back 200.", - "Rows carry the run's `identifier` (TR-14) and nothing numeric \u2014 this listing's serializer emits no `id` and no `uuid` field at all. TR-NNN is exactly what the newer single-run and run-test-case capabilities want in their paths, so for reading a run onward this is all you need.", - "Linking runs to a test plan is the exception: a plan's linked-run array takes the INTEGER run id. The TR-NNN string reaches the backing service uncoerced and 502s there, and this listing does not give you the integer. Resolve it first \u2014 the v1 project run listing (list_test_runs_by_integer_id) carries the integer as `uuid` on each run \u2014 and send that, never the TR-NNN string.", - "Rows are a summary projection, not the single-run read. They never carry `issues` or `issue_tracker`, however many issues a run actually has linked \u2014 read the run itself when you need those.", - "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none', and read the run itself to see them.", - "`test_plan` appears only on runs that are linked to a plan, and the key is omitted entirely rather than sent as null when there is no link. `overall_progress` behaves the same way \u2014 omitted, not zeroed, for a run with nothing executed yet.", - "The assignee filter fails open while the plan and date filters fail closed: an unresolvable email is dropped silently, but an unknown TP-NNN is a 404 and a malformed or out-of-order date is a 400. Only the assignee filter can quietly widen your result.", - "closed_after / closed_before are not just a date window \u2014 either one restricts the whole result to closed runs.", - "Deep paging is capped by rows, not pages: once p x page_size passes roughly 30,000 the call returns 429 with error_code 'request_limit_exceeded'. Narrow with a filter rather than walking a large project to the end.", - "Rows are run headers only \u2014 progress is aggregated counts. To see which cases are in a run, read that run's test cases." - ], - "returns": [ - "success", - "test_runs", - "info", - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "is_dynamic", - "overall_progress", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "TestRunsListV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "429": { - "description": "Deep-paging guard. Returned when page x page_size exceeds the account's row ceiling (30,000 by default).", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "message": { - "type": "string", - "example": "Page count exceeds limit. Requests with more than 1000 pages are temporarily disabled. Please reduce the page count and try again." - }, - "error_code": { - "type": "string", - "example": "request_limit_exceeded" + }, + "TestCaseFieldValue__ListTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `list_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } } - } - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-runs", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Regression - Sprint 24", - "json_path": "/test_run/name", - "description": "Run name. Optional in practice despite the published schema marking it required: omitting it names the run 'Test Run DD/MM/YYYY' from today's date rather than failing." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_run/description", - "description": "HTML is unescaped, sanitised and re-escaped server-side, so the stored value can differ from what you sent." - }, - { - "name": "assignee", - "type": "string", - "example": "qa@acme.com", - "json_path": "/test_run/assignee", - "description": "Run owner, as an email resolved inside this group. Unlike the run edit paths, an address that does not resolve is NOT an error here \u2014 it is dropped and the run is created owned by the calling user, so a typo produces a wrongly-owned run with a 200." - }, - { - "name": "run_state", - "type": "string", - "values": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "default": "new_run", - "example": "new_run", - "json_path": "/test_run/run_state", - "description": "Lifecycle state, checked against this fixed list before any case selection is built. On create an absent or unrecognised value is not rejected \u2014 it is silently stored as new_run \u2014 so send the state you want explicitly rather than relying on a validation error." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "sprint-24" - ], - "json_path": "/test_run/tags", - "description": "Plain tag strings." - }, - { - "name": "configurations", - "type": "array", - "fields": [ - { - "name": "configuration_id", - "type": "integer" - } - ], - "example": [ - 101, - 102 - ], - "json_path": "/test_run/configurations", - "description": "Configuration IDs (integers) the run executes against \u2014 not the configuration objects returned on read, and not the display names the published schema's example shows. Duplicates are collapsed. Overwritten entirely when configuration_map is also sent." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - }, - { - "name": "issue_type", - "type": "string" - } - ], - "json_path": "/test_run/issues", - "description": "Linked defects. Each entry is an object; a bare string entry is coerced to {issue_id: ...} but loses the tracker type." - }, - { - "name": "test_plan_id", - "type": "string", - "pattern": "^TP-\\d+$", - "example": "TP-1", - "json_path": "/test_run/test_plan_id", - "description": "Parent test plan to link at creation. Resolved by identifier \u2014 a TP-NNN that matches no plan in this project fails the whole create with 404, the run is not created. Mutually exclusive with sub_test_plan_id; when both are sent test_plan_id wins." - }, - { - "name": "sub_test_plan_id", - "type": "string", - "pattern": "^STP-\\d+$", - "example": "STP-1", - "json_path": "/test_run/sub_test_plan_id", - "description": "Sub test plan to link at creation. Mutually exclusive with test_plan_id." - }, - { - "name": "test_cases", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - } - ], - "example": [ - "TC-1", - "TC-2" - ], - "json_path": "/test_run/test_cases", - "description": "Test case identifiers to put in the run. This, shared_test_cases, folder_ids and include_all are the ONLY inputs that actually place cases in a run \u2014 filter_test_cases alone does not." - }, - { - "name": "shared_test_cases", - "type": "array", - "fields": [ - { - "name": "project_id", - "type": "integer" - }, - { - "name": "test_case_ids", - "type": "array" - } - ], - "json_path": "/test_run/shared_test_cases", - "description": "Cases shared in from other projects: a list of {project_id, test_case_ids: [...]} objects. The flat/scalar form is stripped by the parameter filter and silently yields zero mapped cases, so always send the object shape. Not present in the published schema." - }, - { - "name": "include_all", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_all", - "description": "Put every test case in the project into the run." - }, - { - "name": "folder_ids", - "type": "array", - "fields": [ - { - "name": "folder_id", - "type": "integer" - } - ], - "example": [ - 1, - 2 - ], - "json_path": "/test_run/folder_ids", - "description": "Folder IDs (integers) whose cases go into the run." - }, - { - "name": "include_sub_folders", - "type": "boolean", - "default": false, - "json_path": "/test_run/include_sub_folders", - "description": "Descend into sub-folders of folder_ids. Sending this key with folder_ids absent or empty is a hard 400 ('Cannot process nested folders of empty folder array'), so only send it alongside a non-empty folder_ids." - }, - { - "name": "filter_test_cases", - "type": "object", - "fields": [ - { - "name": "is_dynamic", - "type": "boolean" - }, - { - "name": "priority", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "case_type", - "type": "array" - }, - { - "name": "owner", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "custom_fields", - "type": "object" - }, - { - "name": "match", - "type": "object" - } - ], - "json_path": "/test_run/filter_test_cases", - "description": "Forward-matching rule for the run, and the only place is_dynamic is read. Sending it ALONE does not select cases: the run is created empty and marked dynamic. Labels ('High', 'Draft') are resolved to ids; an owner email that does not resolve in this group fails the create with 400. is_dynamic:true needs at least one other key here or it is a 400." - }, - { - "name": "filter_scope", - "type": "string", - "values": [ - "global", - "within_folders" - ], - "default": "global", - "json_path": "/test_run/filter_scope", - "description": "How filter_test_cases combines with folder_ids: 'global' unions them across the project, 'within_folders' intersects the filter with the listed folders. Honoured on create only \u2014 the run update paths accept it and ignore it." - }, - { - "name": "configuration_map", - "type": "array", - "fields": [ - { - "name": "test_case_id", - "type": "string" - }, - { - "name": "configuration_ids", - "type": "array" - } - ], - "json_path": "/test_run/configuration_map", - "description": "Per-case configuration assignment: each entry is {test_case_id: \"TC-1\" or [\"TC-1\",\"TC-2\"], configuration_ids: [101,102]}. Sending it REPLACES the top-level configurations list with the ids used here. It is an overlay, not the case list \u2014 cases selected elsewhere still come in, with a null-configuration row." - }, - { - "name": "test_case_assignee", - "type": "string", - "example": "tester@acme.com", - "json_path": "/test_run/test_case_assignee", - "description": "Default assignee applied to the cases placed in the run, as an email. Applied by the asynchronous population step, not at create time. Not present in the published schema." - }, - { - "name": "is_automation", - "type": "boolean", - "default": false, - "json_path": "/test_run/is_automation", - "description": "Accepted and then DROPPED \u2014 it does not make the run an automation run. It is still read to decide which write permission is demanded, so sending true can 403 a caller who could have created a manual run. To get an automation run, create it without this flag and then call update_test_run with is_automation: true on the returned identifier; confirm with the v1 run read." - } - ], - "intent": "Create a test run in a project and load it with the test cases to be executed \u2014 the starting point for recording results, and what you make before assigning testers or linking the run to a plan", - "guidance": [ - "The run comes back keyed by its TR-NNN string in `identifier`. There is NO `id` and NO `uuid` field on this v2 payload \u2014 the integer run id is not exposed here at all, so anything downstream that needs it has to come from a v1 read. Carry `identifier` forward; the other v2 run routes take the TR-NNN form.", - "Every field goes inside a `test_run` object. A flat body is rejected before a single field is read.", - "Only test_cases, shared_test_cases, folder_ids or include_all actually put cases in the run. filter_test_cases is a forward-sync rule, NOT a selection: a create carrying only filter_test_cases returns 200 with an EMPTY run and silently marks it dynamic even though you did not ask. If the user named cases, list them.", - "Create runs STATIC unless the user explicitly asked for one that keeps picking up new cases. A dynamic run matches on nine fields only \u2014 priority, status, category, owner, automation_status, automation_state, folder, tags, issues \u2014 so a 'cases created in the last 7 days' filter is stored, echoed back on read, and never matched, and the run over-collects forever.", - "is_dynamic belongs inside filter_test_cases and nowhere else; a top-level is_dynamic is stripped by the parameter filter and has no effect. is_dynamic:true with no other key inside filter_test_cases is a 400 saying filter criteria are required.", - "A bare 400 'invalid data' is about the run's own fields \u2014 it is raised while converting the run header, before any case selection is built \u2014 most often an owner or reviewer inside filter_test_cases that resolves to no user in this group. It is never a complaint about the shape of your selection.", - "Populating the run is asynchronous. A 200 means the run exists, not that its cases are attached yet; re-read the run's test cases to confirm. A 202 with build_pending:true means the run was created but is not readable back yet \u2014 poll for it, never retry the create, which would make a second run.", - "include_sub_folders is only meaningful next to a non-empty folder_ids; on its own it is a hard 400.", - "A run created here keeps no version history. If the wrong cases land in it, adding is easy but removing is a separate operation, and the run's own metadata cannot be rolled back \u2014 only overwritten or the run deleted.", - "The new run comes back at the top level as `test_run`, not under `data` \u2014 unlike the v1 create, which wraps its payload in `data`.", - "This payload is narrower than the v1 run reads. `owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`, `closed_at` and `closed_by` are v1-only \u2014 if you need any of them, read the run through v1 instead of assuming a missing key means an unset value.", - "`urls.self` is a link to the run in the web UI, merged in by a shared helper rather than built by the run serializer \u2014 it is a browser link for a human, not an API path, so do not call it.", - "`configurations` comes back empty on the older run backend whether or not the run has any \u2014 that read fails silently there. Treat a non-empty list as reliable and an empty one as unknown." - ], - "returns": [ - "identifier", - "name", - "description", - "assignee", - "run_state", - "active_state", - "tags", - "configurations", - "filter_test_cases", - "test_plan", - "overall_progress", - "issues", - "issue_tracker", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestRunV2Response" - }, - "202": { - "description": "The run was created but is not yet readable back \u2014 its build is still being provisioned and case population is in progress. Poll for the run rather than retrying the create.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "build_pending": { - "type": "boolean", - "example": true - }, - "message": { - "type": "string", - "example": "Test run was created; its build is still being created and population is in progress." - }, - "test_run": { - "$schema": "TestRun" + }, + "TestCase__SearchAllProjects": { + "type": "object", + "description": "TestCase as returned by `search_all_projects`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "display_meta": { + "type": "object", + "properties": { + "created_by": { + "type": "string", + "example": "6013" + }, + "description": { + "type": "string", + "example": "-" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 2522 + }, + "name": { + "type": "string", + "example": "ing" + } + } + }, + "template": { + "type": "string", + "example": "test_case_text" + }, + "template_id": { + "type": "string", + "example": "1164" + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 415798 + }, + "name": { + "type": "string", + "example": "Reports Sanity Test Temp" + } + } + } + } + }, + "user_id": { + "type": "integer", + "example": 6013 + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_plans", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again." - }, - { - "name": "count", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page; read first when both this and page_size are sent. Defaults to 30. Above 300 the call is refused with 400 'Page limit exceeds the maximum limit of 300' rather than clamped." - }, - { - "name": "page_size", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Alias for count, read only when count is absent. Same 300 ceiling and the same 400 above it." - } - ], - "intent": "List the test plans in a project \u2014 the read that turns a plan name the user said into the TP-NNN id every other plan call needs, and answers 'what plans do we have?'", - "guidance": [ - "Page with `p` (NOT `page`) and size with `count` (or `page_size`). Default 30, hard ceiling 300 \u2014 asking for more errors instead of clamping, so ask for 300 in one go rather than walking 30 at a time.", - "Deep paging is capped on rows, not pages: once p x page_size passes the account's limit (30,000 by default) the call returns 429 'request_limit_exceeded'.", - "There is NO filtering on this route \u2014 no name search, status, owner, tag or sort parameter is read here. Page through and match client-side, or use the v1 plan listing, which does take status/sort/include_sub_plans.", - "Whether sub-plans (STP-NNN) appear among these rows is decided by the backing service and is not steerable from here. Read each row's parent_plan_id rather than assuming, and list a parent's children through the sub-plans listing.", - "Rows are the same plan shape the single-plan read returns, minus the embedded runs. Carry `identifier` (TP-NNN / STP-NNN) forward \u2014 it is what the v2 plan paths take, not the numeric id the v1 plan capabilities use.", - "Page against the `info` envelope (page, page_size, count, next, prev) instead of probing for an empty page." - ], - "returns": [ - "success", - "test_plans", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls", - "info" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "TestPlanV2ListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Release 2.0 plan", - "json_path": "/test_plan/name", - "description": "Plan name, and the only required field. Blank or missing fails the whole call with 400 'Name field cannot be blank' before anything is created." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/test_plan/start_date", - "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. An unparseable value is dropped silently rather than refused." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-01-31", - "json_path": "/test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "release-2.0" - ], - "json_path": "/test_plan/tags", - "description": "Plain tag strings. A non-string entry makes the whole list fall back to empty \u2014 no error, just an untagged plan." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123", - "JIRA-456" - ], - "json_path": "/test_plan/issues", - "description": "Plain issue keys, not objects: the v1 structured {issue_id, issue_type, metadata} form is filtered out here. Each key is stored as a jira-type link unless issue_tracker says otherwise. Linking is queued asynchronously, so the issues may not yet appear on an immediate re-read." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker, adding ticket metadata. Needs BOTH name ('jira') and host; a host that matches no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'. Omit it and issues are stored as bare jira references." - }, - { - "name": "created_at", - "type": "string", - "format": "date-time", - "json_path": "/created_at", - "description": "Backdated creation time, for migrating plans in from another tool. Sits at the TOP level of the body, not inside test_plan, and is only honoured for groups with the external-API timestamp feature enabled \u2014 otherwise it is ignored silently. Unparseable is 400." - }, - { - "name": "updated_at", - "type": "string", - "format": "date-time", - "json_path": "/updated_at", - "description": "Backdated last-update time. Top level like created_at, same feature gate. Must be >= created_at (or >= now when created_at is absent), else 400." - } - ], - "intent": "Create a test plan to group the test runs for a release, sprint or milestone \u2014 the TP-NNN it returns is what runs and sub-plans then hang off", - "guidance": [ - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path, and sending your own wrapper \u2014 `test_plan`, `plan` or `sub_test_plan` \u2014 is rejected before the call as an unknown body key.", - "The plan is always created in the `new` state: a plan_status in the create body is not read at all. Move it to started/completed afterwards with the update capability.", - "Owner is always the calling user \u2014 there is no owner field on this route, unlike the v1 create.", - "This route always creates a TOP-LEVEL plan: a parent_plan_id you send is filtered out before the plan is built, so the created plan has no parent and the response never carries a parent_plan_id key at all - not even as null. To create a child, use the sub-plan create under its parent; parentage cannot be changed afterwards on v2.", - "Reviewers, attachments and custom fields are v1-only on plans; sending them here changes nothing and is not reported.", - "Recoverable: a plan created by mistake can be archived or deleted afterwards, so nothing here is one-way.", - "The response is the created plan under `test_plan`; take `identifier` (TP-NNN) from it. There is no numeric id field, but the numeric plan id and project id are both embedded in the web-app link under urls.self - that is where to recover the numeric id the v1 plan capabilities take." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$schema": "TestPlanV2Response__CreateTestPlan" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_plan", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan identifier in TP-NNN form, or STP-NNN for a sub-plan \u2014 both match this route. The integer plan id the v1 plan capabilities take does NOT match here and gives a routing 404." - } - ], - "intent": "Read one test plan or sub-plan \u2014 dates, status, owner-facing description, tags, linked issues, how many runs and sub-plans it holds, and a first glimpse of the runs inside it", - "guidance": [ - "Pass an STP-NNN to read a sub-plan: the same route serves both, and the plan still comes back under the `test_plan` key (never `sub_test_plan`).", - "The embedded `test_runs` array is a preview, not the membership list: only identifier and name, and only the first 30 runs of the plan. Use the plan's test-runs read to page through them or to see run state and progress.", - "If the upstream run fetch fails, the call still returns 200 with the `test_runs` and `links` keys ABSENT. Absent is not the same as an empty plan \u2014 check test_runs_count before concluding a plan has no runs.", - "test_runs_count splits active/closed and counts this plan's own runs only. parent_plan_id appears ONLY when the plan you read is a sub-plan, and carries the parent's TP-NNN, not its numeric id; on a top-level plan the key is omitted from the payload entirely rather than returned as null, so test for the key's presence, not for a null value. sub_plans_count comes back on both - it is the number of children, and reads 0 on a sub-plan.", - "urls.self is a web-app deep link built from numeric ids, and is the only place those numeric ids appear; urls.sub_test_plans, the address of this plan's children listing, is added only when the plan has no parent. links.self and links.test_runs are relative API paths and come and go with the test_runs preview.", - "active_state is derived: 'completed' once the plan is completed, otherwise 'active'. It does not distinguish a new plan from a started one.", - "A wrong, mistyped or other-project id is a 404 'Test Plan ID is invalid. Please enter valid Test plan ID and try again' \u2014 the same message for every not-found case.", - "Whether an archived plan is readable here is not settled by this route; treat a 404 on a plan the user insists exists as a possible archive and check the archived listing." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "test_runs", - "sub_plans_count", - "parent_plan_id", - "start_date", - "end_date", - "created_at", - "project_id", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "TestPlanV2DetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_sub_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/sub-test-plans", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "The PARENT plan, as TP-NNN. This is where the new child hangs; it is not the id of the plan being created. An unknown parent is a 404 before anything is created." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Payments \u2014 phase 1", - "json_path": "/sub_test_plan/name", - "description": "Sub-plan name, and the only required field. Blank or missing fails the call with 400 'Name field cannot be blank'." - }, - { - "name": "description", - "type": "string", - "json_path": "/sub_test_plan/description", - "description": "Rich text. Sanitised server-side against the HTML allowlist and links are rewritten, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/sub_test_plan/start_date", - "description": "Plain date (YYYY-MM-DD); stored as midnight UTC. Not validated against the parent plan's dates \u2014 a child may sit entirely outside its parent's window." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-01-15", - "json_path": "/sub_test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Earlier than start_date fails the call with 400 'Please check date params'." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression" - ], - "json_path": "/sub_test_plan/tags", - "description": "Plain tag strings. Tags are not inherited from the parent plan. A non-string entry makes the whole list fall back to empty, with no error." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123" - ], - "json_path": "/sub_test_plan/issues", - "description": "Plain issue keys, not objects. Stored as jira-type links unless issue_tracker says otherwise, and queued asynchronously so they may lag an immediate re-read." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/sub_test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." - } - ], - "intent": "Break a test plan into a child plan \u2014 use this when a release plan needs per-team, per-phase or per-platform slices that each hold their own runs", - "guidance": [ - "Send the fields flat. The wrapper applied for you here is `sub_test_plan`, NOT `test_plan`; that difference is handled by the json_paths, and sending either wrapper yourself is rejected before the call as an unknown body key.", - "The parent comes from the path; there is no parent_plan_id field, and a sub-plan cannot be re-parented or promoted afterwards on v2.", - "Every response from this route carries parent_plan_id - the parent's TP-NNN, not its numeric id - because the route only ever creates children. It also carries sub_plans_count, which is 0 on a plan that was just created; that field is not suppressed for sub-plans. urls.self is a web-app deep link and the only place the new plan's numeric id appears; the sub_test_plans link is added to top-level plans only, so a sub-plan does not get one.", - "The created plan comes back under `sub_test_plan` (not test_plan) and its identifier is STP-NNN. That STP id is accepted wherever a plan id is taken on these v2 plan routes \u2014 read it, edit it, list its runs.", - "Runs are not moved here: a new sub-plan starts empty, and a run is attached by setting its plan on the run itself, which unlinks it from whichever plan held it before.", - "Recoverable: an unwanted sub-plan can be deleted or archived afterwards.", - "Completed sub-plans are hidden from the sub-plan listing, so a child you create and later complete stops appearing there even though it still exists." - ], - "returns": [ - "success", - "sub_test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$response": "SubTestPlanV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_plan_test_runs", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/test-runs", - "mode": "read", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan identifier in TP-NNN form, or STP-NNN to list a sub-plan's own runs. The integer plan id the v1 capabilities take does not match this route." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. This is the ONLY paging control here \u2014 the page size is fixed." - } - ], - "intent": "List the test runs that belong to a test plan, with each run's state, assignee and pass/fail progress \u2014 the read behind 'how is this release going?' and 'which runs are in this plan?'", - "guidance": [ - "Page with `p` only. The page size is FIXED at 30 and cannot be raised: `count` and `page_size` are documented on this route but not read, so asking for 100 still returns 30. Page against the `info` envelope.", - "Rows carry `identifier` (TR-NNN) but NO numeric run id \u2014 the integer uuid some plan-linking calls need is not in this payload, so read the run itself when you need it.", - "Rows here omit `test_plan` entirely \u2014 the plan is already the scope you asked for. Do NOT read its absence as the run being unlinked; the project-wide run listing does carry the plan link for the same run.", - "`configurations` comes back as an empty array on every row today, even for runs that genuinely have configurations assigned. Read it as 'not reported here' rather than 'none'.", - "Each row also carries a `urls` object whose `self` is a deep link to the run in the web app, alongside the `links` object of API references.", - "There is no include-sub-plan-runs switch here (v1 has one). To cover a parent and its children, list each sub-plan's runs separately.", - "When the upstream run service fails, the body STILL says success: true with test_runs null while the HTTP status carries the real failure. Check that test_runs is an array before reading 'no runs' as an empty plan.", - "Progress arrives as overall_progress, a map of status name to count for the run's cases; a run with nothing executed may omit the key entirely rather than report zeros.", - "This lists runs, not results or cases \u2014 drill into a run's test cases for case-level state." - ], - "returns": [ - "success", - "test_runs", - "info", - "identifier", - "name", - "description", - "run_state", - "active_state", - "assignee", - "tags", - "configurations", - "filter_test_cases", - "is_dynamic", - "overall_progress", - "project_id", - "created_at", - "updated_at", - "links", - "urls" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestRunsListV2Response" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_test_plan", - "method": "POST", - "path": "/api/v2/projects/{project_id}/test-plans/{test_plan_id}/update", - "mode": "write", - "entity": "test_plan", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "test_plan_id", - "type": "string", - "required": true, - "example": "TP-12", - "pattern": "^(TP|STP)-\\d+$", - "description": "Plan to edit, as TP-NNN \u2014 or STP-NNN to edit a sub-plan through this same route. The integer plan id the v1 capabilities take does not match here." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "example": "Release 2.0 plan", - "json_path": "/test_plan/name", - "description": "New name. Optional here, unlike the v1 plan edit which rejects any body without it. An empty string is ignored and the current name is kept \u2014 the name cannot be cleared." - }, - { - "name": "description", - "type": "string", - "json_path": "/test_plan/description", - "description": "New description. An empty string is ignored and the stored description is kept, so a description cannot be cleared here. Sanitised server-side, so the stored value can differ from what you sent." - }, - { - "name": "start_date", - "type": "string", - "format": "date", - "example": "2026-01-01", - "json_path": "/test_plan/start_date", - "description": "Plain date (YYYY-MM-DD). Applied only when non-empty; omitted leaves the current date. Once the plan is started this is fixed and further values for it are ignored." - }, - { - "name": "end_date", - "type": "string", - "format": "date", - "example": "2026-02-28", - "json_path": "/test_plan/end_date", - "description": "Plain date (YYYY-MM-DD). Applied only when non-empty. Earlier than the plan's start date is refused with 400 'Invalid end_date, should be greater than start_date' and nothing is saved." - }, - { - "name": "plan_status", - "type": "string", - "values": [ - "new", - "started", - "completed" - ], - "example": "started", - "json_path": "/test_plan/plan_status", - "description": "Lifecycle move. Only forward, one step at a time: new -> started -> completed. 'started' stamps start_date (today unless you send one) and 'completed' stamps end_date the same way." - }, - { - "name": "tags", - "type": "array", - "fields": [ - { - "name": "tag", - "type": "string" - } - ], - "example": [ - "regression", - "release-2.0" - ], - "json_path": "/test_plan/tags", - "description": "REPLACES the plan's tags with this list. An empty array is treated as 'not sent' and leaves the existing tags in place, so tags cannot be cleared through this call." - }, - { - "name": "issues", - "type": "array", - "fields": [ - { - "name": "issue_id", - "type": "string" - } - ], - "example": [ - "JIRA-123" - ], - "json_path": "/test_plan/issues", - "description": "Plain issue keys, not the v1 structured objects. Sent non-empty they replace the linked set; an empty array is ignored, so links cannot be removed here. Processing is queued, so an immediate re-read may lag." - }, - { - "name": "issue_tracker", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "host", - "type": "string" - } - ], - "example": { - "name": "jira", - "host": "https://example.com/jira" - }, - "json_path": "/test_plan/issue_tracker", - "description": "Resolves the keys in `issues` against a connected tracker. Needs BOTH name ('jira') and host; a host matching no JIRA site connected to the caller's account fails the call with 'Invalid issue tracker'." - } - ], - "intent": "Change an existing test plan or sub-plan \u2014 rename it, move its dates, retag it, relink issues, or start/complete it; note that attaching or detaching a test run is NOT done from the plan on v2, it is set on the run", - "guidance": [ - "Merge, not replace: only the fields you send change and anything omitted keeps its current value, so a one-field edit is safe. (The v2 run 'update' on the same path shape behaves the opposite way \u2014 do not carry that habit here.)", - "Linking runs is the trap. A `test_runs` list in this body is SILENTLY DISCARDED \u2014 200, plan unchanged, nothing linked, no warning. It is a v1-only field. On v2 you set the run's plan from the run side (its test_plan_id / sub_test_plan_id), and that MOVES the run: a run belongs to at most one plan, so the plan that held it loses it with nothing in the response to say so. Check the runs' current plans first and tell the user which plans will lose runs.", - "Unlike the v1 plan edit, name is NOT required \u2014 an update carrying only dates, tags or a status move is accepted.", - "Lifecycle is one-way and one step at a time: new -> completed is 400 'Cannot directly complete test plan', started -> new is 400 'Cannot revert started test plan', and once completed the plan is FROZEN \u2014 every later edit, including a rename, is 400 'Cannot update completed test plan'. Complete a plan last.", - "No undo: plans keep no version history, so a name, description or date this call overwrites cannot be restored and there is nothing to roll back to. Read the plan first if the old value matters.", - "Clearing is not possible through this call: empty strings and empty arrays are read as 'not sent' for name, description, tags and issues.", - "Send the fields flat. The `test_plan` wrapper is added for you from each field's json_path \u2014 including when the target is a sub-plan (STP-NNN), where `sub_test_plan` is NOT the key this route reads. Sending your own wrapper is rejected before the call as an unknown body key.", - "parent_plan_id comes back only when the plan you edited is a sub-plan, carrying the parent's TP-NNN; a top-level plan's response omits the key entirely rather than returning null. This call cannot change parentage, so the field never appears or disappears as a result of the edit. sub_plans_count is always present, and test_runs and links are not - they belong to the single-plan read.", - "Owner, reviewers, attachments, custom fields and re-parenting are v1-only on plans; sending them here changes nothing and is not reported.", - "A rejected edit returns 400 with {success: false, error: ...} and saves nothing \u2014 the plan is left exactly as it was." - ], - "returns": [ - "success", - "test_plan", - "identifier", - "name", - "description", - "active_state", - "tags", - "issues", - "test_runs_count", - "sub_plans_count", - "start_date", - "end_date", - "created_at", - "project_id", - "urls" - ], - "responses": { - "200": { - "$schema": "TestPlanV2Response__UpdateTestPlan" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_custom_fields", - "method": "GET", - "path": "/api/v2/custom-fields", - "mode": "read", - "entity": "custom_field", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page` is not read on this listing: send it and you silently get page 1 again. A zero, negative, non-numeric or repeated value is refused with 400 'Invalid Params' rather than coerced." - }, - { - "name": "count", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Rows per page. Defaults to 30 and the real ceiling is 300 \u2014 a larger value is REFUSED with 400 'Page limit exceeds the maximum limit of 300', not clamped, so ask for 300 directly on a large workspace. The published spec claims a maximum of 100; the code enforces 300." - } - ], - "intent": "Discover the test-case custom fields defined in this workspace \u2014 list each field with its name, type, required flag and linked-project count, and pick up the numeric field id needed before reading, editing or filling in a field", - "guidance": [ - "Page with `p` and `count` \u2014 not `page` / `per_page`, which this listing ignores. `count` above 300 is a 400 naming the limit, so request 300 when you want the workspace in as few calls as possible.", - "Only TestCase fields are listed. The entity type is fixed server-side, so test-result, test-plan and exploratory-session fields never appear here whatever you send \u2014 the workspace field listing on the admin-settings surface is the one that covers those.", - "Rows are field metadata only: no dropdown options, no dataset and no list of linked projects, just a linked_projects_count. Read the field's datasets when you need the option values or the actual project links.", - "This is the newer public custom-fields surface. The admin-settings custom-field capabilities read the same stored definitions but answer in a different envelope and cover every entity type \u2014 prefer this one for test-case fields, and fall back to those for result/plan fields and for dataset or option editing.", - "Carry the numeric `id` from each row forward: that is exactly what the single-field read and the field edit take in their path.", - "Needs the field-view permission when role-based access is on. Without it the answer is 403 'This action is not permitted for the user', not an empty list." - ], - "returns": [ - "success", - "custom_fields", - "info", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "placeholder", - "system_name", - "is_bulk_editable", - "is_filterable", - "linked_projects_count", - "group_id" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "CustomFieldsV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_custom_field", - "method": "POST", - "path": "/api/v2/custom-fields", - "mode": "write", - "entity": "custom_field", - "body": [ - { - "name": "field_type", - "type": "string", - "required": true, - "values": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "example": "dropdown", - "description": "The kind of field. Mandatory in practice even though the body schema marks nothing required \u2014 anything outside this list is a flat 400 'Invalid Params'. It is fixed for the life of the field: the edit capability will not change it.", - "minLength": 1 - }, - { - "name": "field_name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 256, - "example": "Test Environment", - "description": "Label shown on the test case. Mandatory; trimmed before storage, so ' Priority' is stored as 'Priority'. Over 256 characters or blank is a 400 'Invalid Params'. Names that collide with a system field are refused separately (see guidance)." - }, - { - "name": "is_required", - "type": "boolean", - "required": true, - "example": false, - "description": "Whether a test case must fill this field. Mandatory, and it must be a real JSON boolean: the string \"true\" fails the parameter check with a flat 400 'Invalid Params' that names no field." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "example": "Select an environment", - "description": "Hint text shown in the empty field. Trimmed before storage; over 256 characters is a 400 'Invalid Params'." - }, - { - "name": "default_value", - "type": "string", - "description": "Accepted by the schema but NOT settable here: for every non-boolean type it is discarded before the write, and for boolean the checks contradict each other (a JSON boolean fails the body schema, the string 'true' fails the parameter check). Set it afterwards with the field edit capability." - }, - { - "name": "entity_type", - "type": "string", - "values": [ - "TestCase" - ], - "default": "TestCase", - "description": "What the field attaches to. Only TestCase exists on this route \u2014 any other value is 400 'Only TestCase entity type is supported'. Result, plan and exploratory-session fields are created on the admin-settings surface instead." - }, - { - "name": "options", - "type": "array", - "description": "Choices for dropdown / multi_dropdown. Persisted ONLY when the request also carries applies_to_all_projects or assigned_projects \u2014 sent alone they are silently dropped while still being echoed in the response. For multi_dropdown every is_default is forced to false; for dropdown only the first is_default:true survives.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The choice as the user sees it. Must be non-empty.", - "minLength": 1 - }, - { - "name": "is_default", - "type": "boolean", - "description": "Pre-selected choice. At most one per dropdown; ignored for multi_dropdown." - } - ] - }, - { - "name": "applies_to_all_projects", - "type": "boolean", - "default": false, - "description": "Link the new field to every project that exists today. Heavy: the server enumerates the whole workspace (stopping around 3000 projects) and links each one. NOTE this is not the same flag as on the edit capability, where it only toggles future-project linking." - }, - { - "name": "assigned_projects", - "type": "array", - "example": [ - "PR-12345", - "PR-67890" - ], - "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ], - "items": { - "type": "string" - } - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "default": false, - "description": "Automatically offer this field in projects created later. Only meaningful alongside applies_to_all_projects or assigned_projects, because it is stored on the dataset those build." - }, - { - "name": "levels", - "type": "array", - "minItems": 2, - "description": "Hierarchy tiers, REQUIRED for field_type nested_dropdown and ignored for every other type. At least two entries, each with name and level_type; the schema refuses a nested_dropdown create without them.", - "fields": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "Tier label, e.g. COUNTRY." - }, - { - "name": "level_type", - "type": "string", - "required": true, - "description": "Widget for the tier, e.g. dropdown." - }, - { - "name": "index", - "type": "integer", - "description": "Position of the tier, top tier first." - } - ] - }, - { - "name": "parent_custom_field_id", - "type": "integer", - "minimum": 1, - "description": "For nested_dropdown the KEY must be present even when the value is null \u2014 the upstream nested-create path is chosen on the key's presence. Set to an existing nested field's id to add one more tier to it, in which case only field_name is validated and the rest is copied from the parent." - }, - { - "name": "datasets", - "type": "array", - "description": "Advanced, forwarded upstream as sent. Lets one call define option values and project links together, including a recursive children tree for nested_dropdown. Prefer options + assigned_projects / applies_to_all_projects; reach for this only when you need nested option trees or explicit unlinks.", - "fields": [ - { - "name": "linked_to_all_projects", - "type": "boolean", - "description": "Expand to every project in the workspace before sending." - }, - { - "name": "linked_projects", - "type": "array", - "description": "Numeric project ids to link on this dataset (this path takes ids, not PR-NNN identifiers)." - }, - { - "name": "unlinked_projects", - "type": "array", - "description": "Numeric project ids to unlink on this dataset." - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Link projects created later." - }, - { - "name": "options", - "type": "array", - "description": "Option rows; each may carry a children array one tier down for nested_dropdown." - } - ] - } - ], - "intent": "Define a new test-case custom field \u2014 choose its type, name it, say whether filling it is mandatory, and optionally give a dropdown its choices and link it to the projects that should offer it", - "guidance": [ - "field_type, field_name and is_required are all mandatory although the body schema marks nothing required, and is_required must be a real JSON boolean. Every failure of that validator comes back as the same 400 'Invalid Params' with no field named, so check all three before retrying.", - "options are only stored when the same request also carries applies_to_all_projects or assigned_projects \u2014 the choices live on a dataset, and no dataset is built without one of those. Sent alone they are dropped, and the response still echoes them, so a dropdown can look configured while holding no values. Re-read the field's datasets to confirm.", - "The returned custom_field is partly an echo of the request: options, assigned_projects, applies_to_all_projects and link_to_future_projects are replayed from what you sent (or defaulted to false / empty), not read back from storage. Only id, field_name, field_type, entity_type and is_required are authoritative.", - "assigned_projects takes PR-NNN identifiers, not numeric project ids, and one unresolvable identifier fails the whole create with 400 \u2014 resolve the projects first. applies_to_all_projects:true instead walks every project in the workspace (up to about 3000) and links them all, which is a slow call on a large account.", - "default_value cannot be set at create time for any type. Create the field, then set it through the field edit capability, where a boolean default is sent as the string 'true' or 'false'.", - "These names are reserved and rejected with 400 'Field with same name already exists. Use a unique name.': Title, Description, Preconditions, Steps, Result, Attachments, State, Owner, Priority, Type of Test Case, Automation Status, Tags, Jira Issues, Asana Issues, Review Status.", - "Choose the type carefully \u2014 it can never be edited. Changing a field's type in practice means deleting it and creating it again, which destroys every value already stored on test cases.", - "nested_dropdown needs levels with at least two entries (name + level_type each) and the parent_custom_field_id key present even when null. To append one more tier to an existing nested field, send parent_custom_field_id plus field_name and nothing else.", - "Same stored fields as the admin-settings create capability, different contract: this route is TestCase-only, takes PR-NNN project identifiers rather than numeric ids, and answers with a serialized custom_field object instead of the raw upstream record.", - "Needs the field-write permission; 403 otherwise. The new field can be removed again, but any values captured on test cases in the meantime go with it.", - "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null \u2014 and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "options", - "applies_to_all_projects", - "assigned_projects", - "link_to_future_projects", - "system_name", - "is_bulk_editable", - "is_filterable", - "placeholder", - "group_id", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2DetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_custom_field", - "method": "GET", - "path": "/api/v2/custom-fields/{id}", - "mode": "read", - "entity": "custom_field", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The field's numeric id, as the custom-fields listing returns it. It is validated as a positive integer before anything else runs: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer'. The published spec showing a prefixed 'cf-123' string is wrong." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page of the datasets fetched behind this field, reflected in dataset_ids / dataset_urls. `p` is accepted as a legacy alias for the same thing. Zero or non-numeric falls back to 1 instead of erroring." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 20, - "description": "Datasets per page behind this field. Defaults to 20 here (not 30) and is clamped at 300 rather than refused. Most fields have a single dataset, so this rarely matters." - } - ], - "intent": "Read one custom field by its numeric id \u2014 confirm its name, type, placeholder, default and whether it is mandatory, and pick up the ids of the datasets that hold its option values", - "guidance": [ - "applies_to_all_projects and link_to_future_projects come back hardcoded false on this read. They are shape fillers, not the field's real project configuration \u2014 never report project scope from this response; read the field's datasets for that.", - "No option values are included. What you get is dataset_ids plus ready-made dataset_urls and datasets_url; follow one of those to see the choices a dropdown offers and which projects it is linked to.", - "An id that exists nowhere is 404 'Custom field not found', while an id that is not a positive integer is 400 \u2014 the two are distinguishable, so do not treat a 400 as 'no such field'.", - "The `project` query parameter documented in the spec is not read by this route. Filtering a field's option values by project is done on the dataset listing, not here.", - "Needs the field-view permission when role-based access is on; 403 otherwise.", - "Same stored definition as the admin-settings single-field read, but a different body: that one returns the field with its datasets and options inline, this one returns metadata plus dataset references.", - "Built by the same serializer as the create and update replies, which omits any field without a value \u2014 so several keys are absent rather than null, and `is_bulk_editable` and `is_filterable` are omitted when FALSE as well as when unset, which makes those two indistinguishable from unset in the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "placeholder", - "system_name", - "is_bulk_editable", - "is_filterable", - "group_id", - "applies_to_all_projects", - "link_to_future_projects", - "dataset_ids", - "datasets_url", - "dataset_urls", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2WithDatasetsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_custom_field", - "method": "PATCH", - "path": "/api/v2/custom-fields/{id}", - "mode": "write", - "entity": "custom_field", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The field's numeric id. Validated as a positive integer before the field is loaded: a non-numeric or non-positive value is 400 'Invalid Params: id must be a positive integer', and an id with no field behind it is 404 'Custom field not found'." - } - ], - "body": [ - { - "name": "field_name", - "type": "string", - "minLength": 1, - "maxLength": 256, - "example": "Test Environment", - "description": "New label. Omit it to keep the current one \u2014 this is a partial update. An empty string does NOT clear the name, it leaves the stored value untouched. Renaming onto a reserved system-field name is refused with 400." - }, - { - "name": "is_required", - "type": "boolean", - "description": "Whether the field must be filled. Omit to keep the current setting; false is honoured (unlike the text attributes, this one is applied even when falsy). Must be a real JSON boolean." - }, - { - "name": "place_holder_text", - "type": "string", - "maxLength": 256, - "description": "New hint text, trimmed before storage. Omit to keep the current one. An empty string does not clear it \u2014 the stored value survives." - }, - { - "name": "default_value", - "type": "string", - "description": "New default. Unlike the create call this one is applied, for every field type. On a boolean field send the STRING \"true\" or \"false\" \u2014 it is coerced to \"1\"/\"0\", a real JSON boolean is rejected by the body schema, and an empty or falsy value leaves the stored default in place." - }, - { - "name": "options", - "type": "array", - "description": "Replaces the dropdown's option list WHOLE on the field's existing dataset \u2014 options you leave out are removed and the values test cases hold for them are gone, in every linked project, with no undo. Send the full intended list. Ignored on non-dropdown field types.", - "fields": [ - { - "name": "option_value", - "type": "string", - "required": true, - "description": "The choice as the user sees it.", - "minLength": 1 - }, - { - "name": "is_default", - "type": "boolean", - "description": "Pre-selected choice." - } - ] - }, - { - "name": "assigned_projects", - "type": "array", - "example": [ - "PR-12345", - "PR-67890" - ], - "description": "Projects to link the field to, each as its PR-NNN identifier STRING. An integer is rejected by the request schema as a type error; an integer sent as a string gets 'Not all project identifiers could be found', which reads like the project is missing when the real problem is the form of the id. Neither message names the expected form.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ], - "items": { - "type": "string" - } - }, - { - "name": "linked_projects", - "type": "array", - "example": [ - "PR-12345" - ], - "description": "Projects to ADD, as PR-NNN identifiers. Sending this (or unlinked_projects) switches off the assigned_projects diff entirely, so existing links you do not mention are left alone. An identifier that resolves to no project fails the call with 400.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ] - }, - { - "name": "unlinked_projects", - "type": "array", - "example": [ - "PR-99999" - ], - "description": "Projects to REMOVE, as PR-NNN identifiers. Unlinking discards the values those projects' test cases hold for this field and cannot be undone. An unresolvable identifier fails the call with 400.", - "fields": [ - { - "name": "identifier", - "type": "string", - "description": "A project identifier such as PR-12345.", - "pattern": "^PR-\\d+$" - } - ] - }, - { - "name": "link_to_future_projects", - "type": "boolean", - "description": "Whether projects created later automatically offer this field. Applied to the field's dataset; sending it is itself enough to trigger the dataset write." - }, - { - "name": "applies_to_all_projects", - "type": "boolean", - "description": "Legacy alias that here ONLY sets the future-projects flag \u2014 it does not link the projects that already exist, unlike the same flag at create time. An explicit link_to_future_projects wins over it. Use linked_projects to reach existing projects." - }, - { - "name": "levels", - "type": "array", - "description": "Nested-dropdown tiers, forwarded upstream but currently ignored there for renames. Rename or retype a tier by editing that tier's own field id instead.", - "fields": [ - { - "name": "id", - "type": "integer", - "description": "Existing tier field id." - }, - { - "name": "name", - "type": "string", - "description": "Tier label." - }, - { - "name": "level_type", - "type": "string", - "description": "Widget for the tier." - }, - { - "name": "index", - "type": "integer", - "description": "Position of the tier." - } - ] - } - ], - "intent": "Change an existing test-case custom field \u2014 rename it, make it mandatory or optional, adjust its placeholder or default value, replace a dropdown's choices, or change which projects offer it", - "guidance": [ - "This is a PARTIAL update: every attribute you leave out keeps its stored value, so sending field_name alone is a valid rename. It differs from the admin-settings update capability, which is a full replace demanding field_name, field_type and is_required together.", - "None of it can be undone. Option lists and project links are rewritten in place, there is no bin and no prior revision: an option dropped from `options` is removed from the dataset and every value test cases hold for it disappears across all linked projects.", - "assigned_projects is read as the complete desired set, not an addition \u2014 any currently linked project you omit is unlinked, and the values stored there go with it. Count and name the projects you are about to drop before sending, or use linked_projects / unlinked_projects to change links one at a time.", - "field_type is not editable here. A field_type in the body is ignored, the stored type is kept and the call still answers 200, so never report a type change as done. A guard meant to refuse it with 400 exists but cannot fire, because the stored field is loaded after that check runs.", - "Empty strings do not clear anything: field_name, place_holder_text and default_value are applied only when non-empty, so the stored value survives an attempt to blank it. is_required is the exception \u2014 false is written.", - "Dataset-side changes (options, assigned_projects, linked_projects, unlinked_projects, link_to_future_projects, applies_to_all_projects) only take effect on dropdown, multi_dropdown and nested_dropdown fields. On any other type they are accepted, answered 200 and quietly ignored. If the field has no dataset yet, one is created.", - "applies_to_all_projects means something different here than at create time: it only toggles the future-projects flag and never links existing projects.", - "The two halves are not atomic. Field attributes are written first, the dataset second, so a failure on the dataset call leaves the rename or required-flag change already applied. Re-read the field before retrying.", - "The response replays options, assigned_projects, applies_to_all_projects and link_to_future_projects from your request rather than from storage \u2014 only the field attributes in it are read back. Confirm project links and option values by reading the field's datasets.", - "Needs the field-write permission; 403 otherwise. Unknown id is 404, a non-integer id is 400.", - "The reply is built by one shared serializer that omits any field without a value, so several keys are simply absent rather than null \u2014 and two booleans, `is_bulk_editable` and `is_filterable`, are omitted when FALSE as well as when unset, so you cannot tell those apart from the response." - ], - "returns": [ - "success", - "custom_field", - "id", - "field_type", - "field_name", - "entity_type", - "is_required", - "default_value", - "place_holder_text", - "options", - "applies_to_all_projects", - "assigned_projects", - "link_to_future_projects", - "system_name", - "is_bulk_editable", - "is_filterable", - "placeholder", - "group_id", - "linked_projects_count" - ], - "responses": { - "200": { - "$response": "CustomFieldV2DetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_reports", - "method": "GET", - "path": "/api/v2/projects/{project_id}/reports", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "example": 2, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again with a 200. Rows come back newest-created first." - }, - { - "name": "page_size", - "type": "integer", - "values": [ - 30, - 300 - ], - "default": 30, - "description": "Rows per page, and NOT a free range: only the literal values 30 and 300 are accepted. Anything else (5, 50, 100, 301) is refused with a 400 naming the two allowed values rather than clamped, so ask for 300 directly when you want a whole project in few calls." - }, - { - "name": "is_scheduled", - "type": "boolean", - "example": true, - "description": "Narrows to recurring (scheduled) reports only, and it is a one-way switch: the filter is applied only when the value is exactly the string 'true'. is_scheduled=false applies NO filter and returns both kinds, so it cannot be used to find one-off reports \u2014 read the is_scheduled flag on each row for that." - }, - { - "name": "report_type", - "type": "string", - "example": "Test Run Summary", - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Plan Detailed Report", - "Test Case Activity", - "Requirement Traceability Report", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "description": "Exact-match filter on the stored report type DISPLAY NAME, including spaces and capitals. A near-miss ('test run summary', 'Test Runs Summary') is not an error \u2014 it returns 200 with an empty list, which reads exactly like 'this project has no such reports'." - }, - { - "name": "q[query]", - "type": "string", - "example": "regression", - "description": "Case-insensitive substring search across report title AND report type, matched together. The parameter is nested and must go on the wire as q[query]=term. A flat q=term is not an error and not a search: the filter is skipped and you get the full unfiltered list with a 200." - } - ], - "intent": "Find a project's reports \u2014 search saved and scheduled reports by title or type, see which ones run on a recurring schedule and when each next fires, or pick up the SC-NNN identifier of a report you then want to read or download", - "guidance": [ - "Page with `p` (NOT `page`) and `page_size`. page_size is an allow-list of exactly 30 and 300 \u2014 any other number is a 400, not a clamp \u2014 so pass 300 when you want the project in as few calls as possible.", - "Both one-off and scheduled reports are listed by default; is_scheduled=true narrows to scheduled ones, and nothing narrows to one-off ones.", - "scheduled_report_count and total_report_count describe the WHOLE filtered result set, not the page you are holding \u2014 use them to answer 'how many?' without paging, but remember any report_type / q / is_scheduled filter you sent is already baked into both numbers.", - "Each row's `id` is the SC-NNN identifier, and that is what the v2 single-report read and the v2 download take. It is NOT the integer report id the v1 report capabilities (report detail, report sections, drilldown, send-email-now) require: SC-NNN is a per-group sequence number, so stripping the prefix gives the wrong id. Get the integer from the v1 schedules listing, which returns `id` and `identifier` side by side on the same records.", - "Rows carry the report's CONFIGURATION only \u2014 type, timeframe, filters, schedule, recipients. No figures, no report data. Reading the numbers a report contains is a separate call.", - "`latest_attachment` is derived from the newest generated file in the whole PROJECT, not from this report \u2014 a row can advertise an attachment produced by a different report. Treat it as 'something was generated here recently', not as this report's output.", - "`dynamic_filters` is always null on every row, even for reports created with dynamic filters; only `report_filters` echoes back what was configured.", - "Cross-project reports appear under every project they cover, so the same SC-NNN can show up in more than one project's listing." - ], - "returns": [ - "success", - "reports", - "info", - "scheduled_report_count", - "total_report_count", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "V2ReportListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_report", - "method": "POST", - "path": "/api/v2/projects/{project_id}/reports", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project the report is filed under, in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. For a multi-project report this project is still the owner; the full set goes in project_ids." - } - ], - "body": [ - { - "name": "report_type", - "required": true, - "values": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Test Case Activity", - "Requirement Traceability Report", - "Exploratory Session Summary" - ], - "description": "What the report reports on, as a DISPLAY NAME - the set is closed and matched exactly, spaces and capitals included. Only these six can be created here. Other report types exist in the product and appear on reports created elsewhere - Test Plan Detailed Report, User Workload Report, Defects Summary, Defects Detailed Report - but every one of them is refused by this create. Never substitute a neighbour: an activity question is 'Test Case Activity', not 'Test Run Summary'." - }, - { - "name": "report_timeframe", - "required": true, - "values": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_sessions" - ], - "description": "What the report covers: either a rolling window, or a mode that says the scope comes from report_filters (specific_test_runs / specific_test_plans / specific_sessions). 'custom' pairs with custom_date_range. Two further values exist in the stored data and can be seen on reports created elsewhere - a requirements scope and a specific-test-cases scope - but neither is accepted by this create, and both are refused by body validation before any report-specific message is reached." - }, - { - "name": "report_filters", - "required": true, - "description": "Required - a create without this key is refused by body validation before anything else is checked, even when the report needs no scoping. Send an empty object for a rolling-window report. When you do scope it, this is a STRICT ALLOW-LIST of exactly four keys - test_runs, test_plans, session_ids, projects. Any other key, including a typo or an advanced-filter name such as status, priority, assignee, requirements, folder or per-case custom fields, is refused by name. Nothing is silently dropped." - }, - { - "name": "mail_to", - "required": true, - "description": "Required - a create without this key is refused by body validation, even for a report nobody will be emailed. Send both inner lists empty for that case. Recipients are wired up only when the report is genuinely recurring; on a one-off the value is accepted and then ignored, so nobody is emailed." - }, - { - "name": "frequency", - "required": true, - "values": [ - "once", - "daily", - "weekly", - "monthly" - ], - "description": "Required - a create without this key is refused by body validation. 'once' means a one-off, unscheduled report and is what you send when nothing should recur. A recurring value only takes effect when frequency_details is sent with it - frequency alone is silently ignored and you get a one-off back, created successfully." - }, - { - "name": "title", - "description": "Display name. Genuinely optional: omit it and the server fills in the report type followed by today's date, which is easy to mistake later for a report someone else made. Titles are not unique, and this is the field the list search matches on." - }, - { - "name": "report_creation_mode", - "type": "string", - "required": true, - "values": [ - "custom_report", - "system_generated" - ], - "description": "Whether the report is one the caller defines or one the product generates. REQUIRED \u2014 the request schema rejects a body without it, so a create that omits it fails before anything is stored. It was missing from this contract entirely, which made the capability impossible to call successfully from here." - } - ], - "intent": "Set up a new report over a project's test data \u2014 either a one-off you will download, or a recurring daily/weekly/monthly schedule that emails chosen people; this saves the report's definition (type, window, scope, recipients) and hands back its SC-NNN identifier, it does not produce any figures", - "guidance": [ - "`report_creation_mode` is required and takes `custom_report` or `system_generated`. Sending `system_generated` with a traceability report type is treated specially by the server, so use `custom_report` unless you specifically want the generated form.", - "This returns a report DEFINITION, not report content. The created body is the saved configuration echoed back - identifier, type, timeframe, filters, schedule, recipients - and contains no counts, rows or figures at all. Never present it as the report's findings.", - "Getting the content afterwards is a separate, ASYNCHRONOUS step: request a download for the new report identifier and the server answers with a job id and a status URL, having queued the generation; then poll the matching download-status read until status flips from 'processing' to 'ready' and a file URL appears (pdf or csv only). Nothing in this create waits for that.", - "The alternative route to content is the v1 report reads (report detail and its section/widget reads), but those take the INTEGER report id and this API never exposes it - the public identifier is a per-group sequence, so you cannot derive it. Resolve it through the v1 schedules listing, which carries both ids on the same record.", - "Six fields are required, not three: report_type, report_timeframe and report_creation_mode, plus report_filters, mail_to and frequency. The last three are required even when they carry nothing meaningful, and omitting any of them is refused up front by body validation with a message naming the missing property. An empty object for report_filters, empty lists for both parts of mail_to, and 'once' for frequency are all accepted and are exactly what an unscheduled, unmailed report needs.", - "Scheduling needs frequency AND frequency_details together, with frequency one of daily/weekly/monthly. Send frequency alone, or frequency 'once', and you get an unscheduled one-off back with no warning - check the scheduled flag and the next-run time in the response before telling anyone a schedule exists.", - "Reading a report back always shows a frequency of 'once' when no schedule is stored - that is a stand-in for an empty value, not evidence that a one-time run was scheduled. The scheduled flag and the next-run time are the fields that actually distinguish the two.", - "Recipients are wired up only for a recurring report: recipients on a one-off are accepted and then ignored, so nobody is emailed. When you do send them, include BOTH the in-group users and the external addresses (empty lists are fine, and there is a cap of 50 external addresses) - a missing inner key fails after the record is written, and the server deletes the half-built report and answers with an unprocessable-entity error.", - "Validation happens in two stages with two different error shapes. A missing required key, a value outside a published set, or a wrong type is caught first by body validation, which answers with a generic 'invalid JSON data' message plus a details list naming each problem. Everything after that - unknown emails, unresolvable identifiers, unsupported filter keys - is answered as a single message string prefixed 'Validation error.' Branch on the status, not on the body's shape.", - "Every identifier in the body is resolved before anything is written: an unknown email or an unknown run, plan or project identifier comes back as a 400 with field-level errors, and nothing is persisted.", - "Cross-project: more than one project is allowed only for Test Run Summary, Test Run Detailed Report, Test Plan Summary, Test Case Activity and Requirement Traceability Report, is capped at 60 projects, and for every one of those except Test Case Activity also needs the cross-project feature enabled for the group - without it the answer is a forbidden error. The product's own multi-project list also names two report types this create will not accept at all, so ignore those here.", - "The only way back from this call is deleting the report, which is itself irreversible - there is no archive or trash for reports. Re-running a create with the same title does not replace anything: titles are not unique and you get a second report.", - "The attachment summary on the response is not this report's: it is the newest generated file anywhere in the project, and its ready flag is set whenever any such file exists. A brand-new report can therefore appear to already have output. Do not use it to decide whether this report has been generated." - ], - "returns": [ - "success", - "data", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "responses": { - "201": { - "$response": "V2ReportDetailResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report", - "method": "GET", - "path": "/api/v2/projects/{project_id}/reports/{report_id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all. It is authoritative: the report must belong to this project or the read is a 404, even inside the same group." - }, - { - "name": "report_id", - "type": "string", - "required": true, - "example": "SC-42", - "pattern": "^SC-\\d+$", - "description": "Report identifier in SC-NNN form \u2014 the `id` field of the v2 report listing. The route is constrained to SC-\\d+, so the integer report id used by the v1 report capabilities gives a routing 404 here, and SC-NNN is a per-group sequence number that cannot be converted to that integer by stripping the prefix." - } - ], - "intent": "Read one report's setup by its SC-NNN identifier \u2014 what it covers (type, time window, the runs/plans/sessions it is scoped to), whether it is on a schedule and when it next runs, who receives it, and whether a generated file exists; configuration only, no figures", - "guidance": [ - "This returns the report's CONFIGURATION, never its data \u2014 there is no report_data, no counts and no rows in the response. For the numbers inside a report, either fetch the generated file (request a download for this SC-NNN, then poll the download status until it is ready) or use the v1 report detail / section reads, which take the INTEGER report id this API never exposes; the v1 schedules listing is where SC-NNN and that integer appear together.", - "Every miss is the same 404 'Report not found': an identifier that does not exist, one from another group, and one that exists but belongs to a different project. A 404 therefore does not mean the report is gone \u2014 retry under the project that actually owns it, and for a cross-project report any of its projects will serve it.", - "`latest_attachment` is NOT this report's attachment. It is the newest generated file anywhere in the project, so it can belong to a different report, and it reports ready:true whenever it is present. Its file_type is a MIME type such as application/pdf, not the 'pdf'/'csv' the spec advertises. Only a download job you started yourself tells you about this report's file.", - "`dynamic_filters` is hard-coded to null on this read even when the report was created with them; only report_filters reflects configured scope.", - "report_filters echoes public identifiers rather than the internal ids stored (TR-NNN, TP-NNN, assignee emails). Two caveats: a sub-plan filter comes back as TP-NNN because the sub-plan marker is not kept, and if the upstream identifier lookup fails the values degrade to prefix+raw-id which may not be a real identifier.", - "`frequency` reads 'once' for an unscheduled report \u2014 that is a substitution for a stored null, not something the report was created with. Trust `is_scheduled` and `next_run_at` for whether anything will actually fire.", - "`project_ids` lists every project a cross-project report covers, and `project_id` is only the owning one." - ], - "returns": [ - "success", - "data", - "id", - "project_id", - "project_ids", - "report_type", - "title", - "description", - "report_timeframe", - "report_creation_mode", - "custom_date_range", - "frequency", - "frequency_details", - "next_run_at", - "is_scheduled", - "created_at", - "owner", - "mail_to", - "report_filters", - "dynamic_filters", - "latest_attachment" - ], - "responses": { - "200": { - "$response": "V2ReportDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_entity_attachments", - "method": "GET", - "path": "/api/v2/projects/{project_id}/{entity_type}/{entity_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "example": "PR-1", - "pattern": "^PR-\\d+$", - "description": "Project identifier in PR-NNN form. The v2 routes constrain this segment to PR-\\d+, so a bare integer project id does not match the route at all and never reaches the API." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "example": "test-results", - "values": [ - "test-cases", - "test-results", - "test-plans" - ], - "description": "Which kind of parent item to read attachments from. A CLOSED set of exactly these three hyphenated plurals, enforced in the route. Singular or snake_case spellings (test-case, test_case, testcase, test_results) do not match the route and come back as a routing 404 with no JSON body, not a validation error naming the field. Test runs and exploratory sessions are not attachable here." - }, - { - "name": "entity_id", - "type": "string", - "required": true, - "example": "TC-42", - "description": "Form depends on entity_type: TC-NNN identifier for test-cases (resolved by identifier lookup; the internal integer case id gives 404 'Test case not found'), the plain INTEGER result id for test-results (no TR- prefix - that is a run), TP-NNN or STP-NNN for test-plans. The route leaves this segment unconstrained, so a wrong form is a 404 from the resolver rather than a 400." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "PAGE NUMBER, and the parameter really is named `p`. `page=2` is not read: you silently get page 1 again. 0 is treated as 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "minimum": 1, - "maximum": 300, - "description": "Rows per page. Unlike the v2 run and result listings this is a real range, not an allow-list of 30/300 - any value up to 300 is honoured. Above 300 the call is refused with 400 'parameter_error' ('Page limit exceeds the maximum limit of 300'). A non-numeric or absent value is NOT an error: it silently falls back to 30, so a typo quietly shortens your page." - }, - { - "name": "file_type", - "type": "string", - "example": "image", - "description": "Filter by the attachment's content type, matched as a prefix - `image` keeps every image/* row, `application/pdf` only PDFs. Applied upstream; an unmatched value returns an empty list rather than an error." - }, - { - "name": "uploaded_by", - "type": "integer", - "example": 1234, - "description": "Filter by uploader, as the INTERNAL NUMERIC user id - not an email and not a username. The value is coerced with to_i, so a non-numeric value such as an email address becomes 0 and matches nothing while still returning 200. Resolve the user id from a project users read before filtering." - } - ], - "intent": "List the files attached to one test case, test result or test plan - use this to see what screenshots, logs or evidence are on the item and to get the URL for reading each file", - "guidance": [ - "One route serves all three parent types; entity_type is a closed set of exactly `test-cases`, `test-results` and `test-plans`, spelled hyphenated and plural. Anything else, including the singular or snake_case form, fails to match the route and returns a bare 404 rather than telling you the value was wrong.", - "entity_id is the trap: it is an IDENTIFIER for two of the three types and an integer for the other. test-cases wants TC-NNN (the internal integer id 404s 'Test case not found'), test-plans wants TP-NNN or STP-NNN, and test-results wants the plain integer result id - the one a v2 test-results listing returns as `id`. There is no route constraint on the segment, so the wrong form always surfaces as a 404 from the resolver, never a 400.", - "A test-result id that belongs to another project is rejected with 404 'Test result not found' rather than an empty list, so a 404 here means wrong id or wrong project, not an item with no attachments.", - "Page with `p` (NOT `page`) and `per_page`. per_page defaults to 30 and the real ceiling is 300; ask for 300 directly instead of walking pages of 30. Over 300 is a 400 'parameter_error'; a non-numeric value is silently treated as 30.", - "Rows are deliberately thin: id, name, size in bytes, created_at and url. Despite what the reference spec shows, this listing carries no content_type, byte_size, checksum or download_url. Read the file straight from `url` - there is no separate download capability for these attachments.", - "Inline attachments are always included. Images embedded in a description through the rich-text editor come back alongside real file attachments and carry an extra `data_id`, so this count can exceed what the item's attachment panel shows.", - "Prefer this over the v1 test-case attachments read whenever you hold a TC-/TP- identifier, need a test result's or plan's files, or want paging and file-type / uploader filtering. The v1 read handles test cases only, needs the integer project, folder and case ids, and returns every row in one unpaginated response.", - "`info` carries page, page_size, count, prev and next - use `count` to decide whether another page is worth fetching instead of paging until you get an empty array.", - "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments." - ], - "returns": [ - "success", - "attachments", - "info", - "id", - "name", - "size", - "created_at", - "url", - "data_id" - ], - "paginated": true, - "max_page_size": 300, - "responses": { - "200": { - "$response": "AttachmentListV2Response" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_results", - "method": "GET", - "path": "/api/v1/test-results", - "mode": "read", - "entity": "result", - "query": [ - { - "name": "issue_id", - "type": "string", - "example": "BUG-123", - "description": "The tracker issue whose linked results you want \u2014 the issue KEY as the tracker shows it (Jira 'BUG-123', not a numeric id). Only filters when issue_type is sent with it; on its own it is ignored and you get the unfiltered account-wide list back. Forwarded with no validation, so a wrong or unknown key comes back as an empty list with success:true, never an error. This is the filter the Jira/ADO issue panel uses." - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Which tracker issue_id belongs to. REQUIRED for the issue filter to apply at all \u2014 the search only builds the issue predicate when both issue_id and issue_type are present, so sending issue_id alone silently returns every recent result in the account rather than that issue's results. Trackers the product stores links for: jira, azure, asana, linear, clickup, confluence, figma, devrev, gitlab, github, trello, onedrive, google-drive, sharepoint, zoho, custom. Not validated." - }, - { - "name": "priority", - "type": "string", - "example": "high", - "description": "ACCEPTED AND IGNORED. It is forwarded to the search service but is not a filterable field for results, and the underlying query never joins the test case, so there is nothing to match a case priority against. Sending it changes nothing: you get the same rows you would have got without it, not an empty list. Filter by priority yourself from the test_case object you get with compact=false." - }, - { - "name": "compact", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "true", - "example": "false", - "description": "Shape switch, compared as a literal STRING against 'false'. Only compact=false expands each row with its test_run and test_case objects; every other value (including omitting it, and including boolean false serialised oddly) gives the lean shape with issues and configuration only. Rows always carry issues and configuration whatever you send." - }, - { - "name": "include_relations", - "type": "string", - "description": "Read and then thrown away: the action overwrites it unconditionally from the compact switch before the search runs, on BOTH branches. Asking for a relation here has no effect at all and no error. Use compact=false to get test_run and test_case; there is no way to request any other relation on this endpoint." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 2, - "description": "PAGE NUMBER, and the parameter is named `p` \u2014 `page=2` is not read and silently returns page 1. A value that is not a strictly positive integer (0, -1, 'abc', or a repeated/array form) is rejected before the action with HTTP 400 {success:false, error:'Invalid Params'}." - }, - { - "name": "per_page", - "type": "integer", - "description": "NOT a page size \u2014 a presence flag, and its value is discarded. Sending per_page at ALL (per_page=100, per_page=1, anything non-blank) SHRINKS the page to 10 rows; omitting it entirely gives 30 rows, the maximum this endpoint can return. Omit it whenever you want the fewest round trips." - }, - { - "name": "q", - "type": "object", - "description": "Optional wrapper for the same filters, e.g. q[issue_id]=BUG-123&q[issue_type]=jira. When q is present the flat top-level filters are IGNORED and only q's contents are read, and they reach the search service in a different (query_object) shape. It must be a nested param hash \u2014 a JSON-encoded STRING here raises and 500s. p and per_page are still read from the top level. Prefer the flat form.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "example": "BUG-123", - "description": "Same as the top-level issue_id, and it still needs issue_type beside it to filter anything." - }, - { - "name": "issue_type", - "type": "string", - "example": "jira", - "description": "Same as the top-level issue_type, and equally required for the issue filter to apply." - }, - { - "name": "priority", - "type": "string", - "example": "high", - "description": "Same as the top-level priority: accepted and ignored." - }, - { - "name": "compact", - "type": "string", - "example": "false", - "description": "Same string-literal 'false' switch as the top-level compact." - }, - { - "name": "include_relations", - "type": "string", - "description": "Overwritten from compact exactly as at the top level; it does nothing." - } - ] - } - ], - "intent": "Find test results anywhere in the account without knowing the project, run or case \u2014 above all, list every result linked to a tracker issue (a Jira/ADO ticket key) to answer 'what testing covers this bug?', optionally narrowed by case priority", - "guidance": [ - "This is NOT a project read and it takes no project. The action replaces whatever project context arrived with a placeholder before calling the search service, so the search spans every project in the authenticated user's group (account) and is scoped by nothing but that group and your filters. That placeholder also skips the per-project access check, so rows from projects you would not otherwise be able to open can appear here. Passing a project_id query param cannot narrow it \u2014 a numeric one you lack access to only makes the call 404 before the search runs, and a PR-NNN one is ignored outright.", - "The ONLY filter that works is issue_id AND issue_type sent together. Either one alone is dropped, and `priority` is accepted but never applied \u2014 none of them produce an error, they just widen the call back to an account-wide dump of recent results. If a filtered call returns suspiciously many rows spanning many projects, that is what happened.", - "Use this when the only handle you have is an issue key, or when you need results across projects and runs. When you already know project + run + case and want that case's result history, use the per-case reads (list_test_results_for_test_case_by_integer_id / list_test_results_for_test_case) instead \u2014 they are scoped, richer, and cannot silently span the account.", - "Failures are swallowed. An upstream search error is logged and the request STILL answers HTTP 200 with success:true, an empty test_results array and info:null. An empty list is therefore not evidence that nothing matched \u2014 treat info:null as 'the search did not run' and retry rather than reporting zero results.", - "Page with `p`, never `page`, and do NOT send per_page: its value is discarded and its mere presence cuts the page from 30 rows to 10. 30 is the hard ceiling here; there is no way to ask for more, so a large issue's results must be walked page by page.", - "Row shape changes with compact. With compact=false you get test_run and test_case objects, and `test_run_id` is then overwritten with the run's TR-NNN identifier string. Without it there are no test_run/test_case objects and test_run_id / test_case_id come from the result mapping as integers \u2014 so the same field name carries a different type depending on one query flag. Never assume test_run_id is numeric.", - "Read the outcome from result_status.field_value, which is display case ('Passed'/'Failed'/'Blocked'). The flat `status` beside it is the lowercase internal token and is null on almost every row \u2014 it is the more obvious field name and the wrong one to reason over.", - "Several declared fields are structurally empty on this endpoint because the account-wide search does not load them: attachments is ALWAYS null (never an array - guard before iterating), updated_by is ALWAYS null, and started_at, finished_at, time_elapsed and created_by_imported are always null. The per-case and single-result reads do populate them.", - "Rows carry no custom fields and no step results, and `links.self` appears only for rows that came back with a project_id. To read a result's custom fields, follow up with the project-scoped single-result reads using the row's project_id and id.", - "Rows here are NOT the same shape as the project-scoped result reads \u2014 a different shape entirely, not a subset. This one carries author, backtrace, links and a full result_status object, plus updated_by and attachments keys that are always null here; the v2 per-case read carries created_by, custom_fields, dataset, step_result, urls and a result_status cut down to field_value alone. Do not write one parser for both.", - "issue_id and issue_type are always forwarded even when absent, so calling with no filters at all is a legal account-wide dump of recent results \u2014 expensive and rarely what you want." - ], - "returns": [ - "success", - "test_results", - "info", - "id", - "author", - "updated_by", - "status", - "result_status", - "description", - "error_description", - "backtrace", - "failure_type", - "log_type", - "expanded", - "started_at", - "finished_at", - "time_elapsed", - "created_at", - "updated_at", - "created_by_imported", - "attachments", - "issues", - "configuration", - "test_case", - "test_run", - "test_case_id", - "test_run_id", - "test_run_results_mapping_id", - "project_id", - "links" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestResultSearchResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_test_case_comments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", - "mode": "read", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "pattern": "^(TR-)?[0-9]+$", - "example": "TR-14", - "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The test case's NUMERIC id, not its TC-NNN identifier \u2014 the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." - } - ], - "query": [ - { - "name": "mappingId", - "type": "integer", - "minimum": 1, - "example": 90731, - "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "example": 1, - "description": "PAGE NUMBER \u2014 the parameter really is named `p`; `page=2` is ignored and you get page 1 again. Page size is fixed at 30 and cannot be changed. A page past the end does NOT error or come back empty: the server serves the LAST page again, so stop when info.next is null rather than when a page is empty. 0 or a non-number is a 400." - } - ], - "intent": "List the comments left on a test case inside a specific test run \u2014 use this to read the discussion or review feedback on that execution, find who said what and when, or discover a comment's id", - "guidance": [ - "Needs the test-case VIEW permission (a 403 'This action is not permitted for the user' otherwise) AND a paid plan: on a free plan the call still returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} \u2014 check `success` in the body, not the status code.", - "Three ids in the path, two conventions: project_id and test_case_id are plain INTEGERS (a TC-NNN in the case slot never matches the route), while test_run_id takes the TR-NNN identifier or the bare integer. Get the numeric case id from the `id` field of a v1 test case read or of the v1 run test-case listing.", - "A run/case pair that does not resolve is a 400 with message 'TRTC not found for given testrun_id:\u2026' \u2014 not a 404 \u2014 and an automation (Automate/Observability) run is a 400 'Can't comment on automation test'. A test case that does not exist in your group is a bare {success:false} 404.", - "Comments are threaded per run/case/configuration cell. If the case is mapped to more than one configuration in the run, pass mappingId to pick the cell; without it the server resolves a cell itself and you may read a different thread than the UI shows.", - "Newest first (by id), 30 per page, no page-size control. `info.count` is the total across pages; walk `p` until `info.next` is null \u2014 asking for a page beyond the end silently returns the last page again, so an 'empty page' loop never terminates.", - "Each row's `comment` is HTML with @mentions already rendered to display names; `user_id` is an OBJECT {id, full_name, email} whose `id` is the internal TM user id (null for imported comments, which show the original author's name). `edited` is true when the comment was changed after posting.", - "This is the only way to discover comment ids. Reading comments still works on an archived test case; posting does not.", - "Comments on the same test case outside a run are a separate thread with its own capability - nothing posted there appears in this listing, and nothing posted here appears there. Test-plan comments are a further separate surface.", - "Rows also carry group_id, project_id, parent_id, entity_id and entity_type. On this surface entity_type is always TRTC and entity_id identifies the run/case mapping cell, not the test case and not the run; parent_id is always null because replies are not threaded. review_status never appears on this surface - only comments recorded as part of a review verdict carry it, and nothing writes one against a run." - ], - "returns": [ - "comments", - "success", - "info", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "comment", - "edited", - "is_editable" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "CommentsListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_run_test_case_comment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/test-cases/{test_case_id}/comments", - "mode": "write", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id (the v1 route accepts only digits here, not PR-NNN). Resolve it from the project listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "pattern": "^(TR-)?[0-9]+$", - "example": "TR-14", - "description": "Test run identifier as TR-NNN (preferred), or the run's bare integer id. A pair that does not resolve to a case in this run is a 400 'TRTC not found', not a 404." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 4821, - "description": "The test case's NUMERIC id, not its TC-NNN identifier \u2014 the route only matches digits, so TC-12 never reaches the API. Take it from the `id` field of a v1 test case read or of a row in the v1 run test-case listing (the TC-NNN is that row's `identifier`)." - } - ], - "query": [ - { - "name": "mappingId", - "type": "integer", - "minimum": 1, - "example": 90731, - "description": "Id of the test-case-in-run mapping row (one per configuration the case is mapped to). The spec marks it REQUIRED; the server accepts the call without it and resolves the pair itself, but when a case is mapped to several configurations only this picks the exact cell, and threads differ per cell. Send it when you have it. A value that does not match this run/case pair is a 400." - } - ], - "body": [ - { - "name": "comment", - "type": "string", - "required": true, - "json_path": "/comment/comment", - "example": "

Failed on Safari 17 only \u2014 retest after the fix for TM-1234 lands.

", - "description": "Comment body as HTML (rich text), nested as {\"comment\": {\"comment\": \"...\"}}. Whitespace-only or tag-only content is a 400 'Please enter valid comment' (an counts). Any other key inside the `comment` object is a 400 'Invalid JSON data'. To @mention: Display Name with the BrowserStack user id \u2014 all three attributes, as the product's editor writes them; without data-mention the post succeeds but the name shows as plain text, not a tag. Someone outside the group is a 403." - } - ], - "intent": "Post a comment on a test case within a test run \u2014 use this to leave review feedback on an execution, ask the run owner a question, record why a result was marked as it was, or @mention a colleague", - "guidance": [ - "Send the body as application/json with the nested shape {\"comment\": {\"comment\": \"

text

\"}} \u2014 the field is named comment inside an object also named comment. Without a JSON content type the schema check is skipped and the call fails later with a less helpful 400.", - "Needs the test-case VIEW permission (not a write permission) and a paid plan. Below a paid plan the call returns HTTP 200 with {success:false, error:'This Feature is only for paid customers'} and nothing is posted \u2014 check `success` in the body.", - "SIDE EFFECT: the run owner is notified of every new comment, and every @mentioned user gets a mention alert, immediately and outside this API. The author never alerts themselves. Confirm wording with the user before posting on their behalf.", - "Cannot post on an automation run (400 'Can't comment on automation test') or on an ARCHIVED test case (bare {success:false} 400 \u2014 reading still works). A run/case pair that does not resolve is a 400 'TRTC not found for given testrun_id:\u2026', not a 404.", - "If the case is mapped to several configurations in the run, pass mappingId so the comment lands on the intended cell; each cell has its own thread and the listing for a different mappingId will not show it.", - "@mentions must be the span markup carrying data-lexical-mention, data-mention (the display name) and data-id (the person's BrowserStack user id). Leaving out data-mention is NOT refused: the post returns 200 and the span is stored, but it shows as plain text and no tag appears. A mention with no id is a 403 'Missing User Id in comment', one for a user not in this group is a 403 'User does not belong to this project', and either fails the whole post. Plain '@name' text mentions nobody.", - "Link markup in the HTML is normalised server-side and mention spans are rewritten, so the stored `comment` returned in the response can differ from what you sent; use the returned `id` for any follow-up.", - "Success is HTTP 200 (not 201) with the new comment under data.comment. Recoverable: the author can edit or delete the comment afterwards in the product (delete hides it rather than destroying it) \u2014 those operations are not part of this pass.", - "A comment posted here attaches to the run/case cell, not to the test case itself: entity_type comes back as TRTC and entity_id is the mapping cell's id, never the test case id or the run id. It is visible only in the in-run comment listing and never in the test case's own comment thread, which is a separate capability. Test-plan comments are a further separate surface.", - "The returned comment never carries review_status on this surface - nothing writes a review verdict onto a comment attached to a run. On a freshly created comment edited is always false, is_editable is always true, and parent_id is always null because replies are not threaded." - ], - "returns": [ - "data", - "success", - "comment", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "edited", - "is_editable" - ], - "responses": { - "200": { - "$response": "CommentResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "export_test_run_csv", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/download", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." - } - ], - "body": [ - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv" - ], - "example": "csv", - "description": "Export format. Only 'csv' is accepted; any other value is a 422 'Only CSV file type is supported.' before anything is queued." - }, - { - "name": "required", - "type": "object", - "description": "Optional column selection keyed by entity: {test_run_test_case:{fields:[..]}, test_run:{fields:[..]}, test_result:{fields:[..]}}. Omit to export EVERY supported column. `fields` may be an array or comma-separated string; 'all' expands to every supported column. Unknown column names are NOT rejected: the call still returns 200 and the export fails later on the realtime channel.", - "fields": [ - { - "name": "test_run_test_case", + }, + "TestPlanV2__ListTestPlans": { "type": "object", - "description": "{fields:[...]} \u2014 supported: id, title, priority, test_case_status, owner, folder_id, folder_path, case_type, automation_status, description, preconditions, template, steps, issues, tags, created_at, created_by, updated_at, updated_by, attachments, result_status, assignee, configurations, dataset_values, project, estimated_duration_seconds." - }, - { - "name": "test_run", + "description": "TestPlanV2 as returned by `list_test_plans`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestPlanV2" + } + ], + "properties": { + "urls": { + "type": "object", + "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", + "properties": { + "self": { + "type": "string", + "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." + }, + "sub_test_plans": { + "type": "string" + } + } + } + } + }, + "ResultStatus__SearchTestResults": { "type": "object", - "description": "{fields:[...]} \u2014 supported: id, title, created_at, created_by, state, tags, owner, type, updated_by, updated_at, requirements." - }, - { - "name": "test_result", + "description": "ResultStatus as returned by `search_test_results`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "valueDetails": { + "type": "string", + "nullable": true + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "TestPlanV2Response__UpdateTestPlan": { "type": "object", - "description": "{fields:[...]} \u2014 supported: attachments, defects, updated_at, updated_by, description, time_elapsed." - } - ] - } - ], - "intent": "Start a CSV export of a test run's cases and their latest results \u2014 use this when the user wants the run's contents as a downloadable spreadsheet file rather than data returned inline", - "guidance": [ - "The response carries no file and no download link. It only confirms that a background job was queued and returns a `channel` uuid. The CSV is built asynchronously, stored, and its time-limited download link is pushed to the requesting user's web session over a realtime channel; that push carries the same uuid as `id` so the UI can match it. Nothing in this HTTP API returns the link afterwards, so tell the user the file will appear in their Test Management browser session (download notification) rather than promising to hand it back.", - "A 200 means 'queued', not 'exported'. Failures inside the job (bad column names, upstream errors) surface only as a failure message on that realtime channel, never as an HTTP error, so this call cannot be polled for completion.", - "The whole run is exported: no filter or search criteria are honoured, and the export is capped at 20,000 rows fetched 300 at a time \u2014 a larger run is silently truncated at 20,000 rows.", - "Column selection defaults to ALL supported columns when `required` is omitted. Each test-case step after the first becomes its own extra row under the case, so row counts in the file exceed the case count.", - "This is a write only in the sense that it creates a stored export artifact; it does not change the run. It carries no run-specific permission check in this service (the action is absent from the permissions map), so any user who can reach the project can trigger it; plan or product-access gates still apply as a 403.", - "Works for automation runs as well as manual ones (the run lookup deliberately ignores run type). Closed runs export too.", - "Unlike the newer run endpoints, project_id must be the plain integer; the run may be given as TR-14 or its integer id." - ], - "returns": [ - "channel", - "success" - ], - "responses": { - "200": { - "$response": "DownloadReportResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Project not found, or the test run identifier resolves to no run in this project ('Test Run ID is invalid. Please enter valid Test Run ID and try again' / 'Test run not found.').", - "schema": { - "$schema": "ErrorResponse" - } - }, - "422": { - "description": "file_type was not 'csv': {success:false, message:'Only CSV file type is supported.'}", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "get_test_run_progress", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-runs/{test_run_id}/progress", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 1, - "description": "Numeric project id. This older route does not accept the PR-NNN form: a PR- prefixed value skips project resolution and the call fails with a 500 rather than a clear 404. Take the integer id from the projects listing." - }, - { - "name": "test_run_id", - "type": "string", - "required": true, - "example": "TR-14", - "pattern": "^(TR-)?[0-9]+$", - "description": "Run identifier TR-NNN, or the plain integer run id. Anything else does not match the route. An identifier that resolves to no run in this project is a 404 'Test Run ID is invalid'." - } - ], - "query": [ - { - "name": "configuration_id", - "type": "integer", - "minimum": 1, - "example": 101, - "description": "Narrow to ONE configuration of the run. Changes the response shape: with it you get a single `overall_progress_by_status_id` breakdown (an empty object when there is no data); without it you get a paginated `configurations` array, each row carrying its own breakdown. Configuration ids come from the run detail's `configurations` list." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number for the configurations list, and the parameter really is named `p` (`page` is ignored). Only meaningful without configuration_id. 0, negative or non-numeric is a 400 'Invalid Params'. Page size is fixed at 30 and cannot be changed \u2014 there is no per-page parameter on this endpoint." - } - ], - "intent": "Get pass/fail/untested progress of a test run broken down by result status \u2014 per configuration (browser/device) across the whole run, or for one configuration \u2014 to answer 'how far along is this run on each configuration?'", - "guidance": [ - "Two response shapes from one endpoint, chosen by configuration_id. Without it: {success, configurations:[{id, name, ..., overall_progress_by_status_id}], info}. With it: {success, overall_progress_by_status_id}. Read the shape you asked for.", - "overall_progress_by_status_id is a map keyed by result-status id (as a string), each value {count, result_status:{id, field_value, internal_name, field_name, colour}}. Status ids are project-specific \u2014 use result_status.internal_name (e.g. 'passed') to compare across projects, not the numeric key.", - "A configuration row without any executed results may carry no overall_progress_by_status_id key at all, and the single-configuration form returns {} when there is no breakdown. Treat a missing or empty breakdown as 'nothing recorded', not as an error.", - "Paging: pass `p`; the page is always 30 configurations and there is no way to ask for more per page. When `p` is omitted no paging hints are sent upstream and the default page size is decided by the backing service, so send p=1 explicitly when you intend to walk pages. `info` is the backing service's paging block, passed through unchanged.", - "For a run-wide total (not per configuration) use the single-run detail instead \u2014 its overall_progress already aggregates across configurations.", - "Any upstream failure inside the calculation is reported as a 500 {success:false, message:'Failed to calculate configuration progress: ...'}, not as a 404 or 400. An unknown run identifier is still a 404 because the run is resolved before the calculation.", - "No run-specific permission is checked in this service (the action is absent from the permissions map); it is also one of the endpoints reachable with a public share-link token for a run." - ], - "returns": [ - "success", - "configurations", - "info", - "overall_progress_by_status_id" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "TestRunConfigurationProgressResponse" - }, - "400": { - "description": "`p` was 0, negative or non-numeric: {success:false, error:'Invalid Params'}", - "schema": { - "$schema": "ErrorResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "Project not found, or the test run identifier resolves to no run in this project.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "description": "Progress calculation failed (including upstream errors): {success:false, message:'Failed to calculate configuration progress: '}", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "generate_test_case_automation", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/{test_case_id}/lcnc", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer test case id (not TC-NNN). The case is loaded from the entity store first, so an unknown id is a 404 before anything is submitted." - } - ], - "body": [ - { - "name": "version", - "type": "string", - "values": [ - "v1", - "v2" - ], - "default": "v1", - "description": "Payload flavour forwarded to the automation service. Anything other than 'v2' takes the v1 path and forwards `steps`; 'v2' forwards `test_case_details` + `tags` (+ preTestConfig) instead and REQUIRES test_case_details \u2014 with version=v2 and no test_case_details the request 500s." - }, - { - "name": "test_name", - "type": "string", - "example": "Login with valid credentials", - "description": "Display name for the generated automation test. Forwarded verbatim; not validated here." - }, - { - "name": "base_url", - "type": "string", - "example": "https://staging.example.com", - "description": "URL the generated automation starts from. Forwarded verbatim (also lifted into batch defaults on the bulk-pipeline path); not validated here." - }, - { - "name": "credentials", - "type": "string", - "description": "Login credentials the automation may use, forwarded verbatim to the automation service. Not stored in Test Management." - }, - { - "name": "local_enabled", - "type": "boolean", - "default": false, - "description": "Run against a BrowserStack Local tunnel. Defaults to false when omitted." - }, - { - "name": "lcnc_link", - "type": "string", - "description": "Existing automation build link (the case's metadata.lcnc_build_map link) when RE-automating. Its mere presence switches the submission from mode 'new' to 'reautomate'; omit it for a first-time automation." - }, - { - "name": "steps", - "type": "array", - "description": "v1 flow only: the test steps as plain strings. Ignored when version=v2." - }, - { - "name": "test_case_details", - "type": "object", - "fields": [ - { - "name": "name", - "type": "string" - }, - { - "name": "identifier", - "type": "string" - }, - { - "name": "description", - "type": "string" - }, - { - "name": "preconditions", - "type": "string" - }, - { - "name": "test_case_steps", - "type": "array" - } - ], - "description": "v2 flow only: {name, identifier, description, preconditions, test_case_steps[]} of the case, echoed to the automation service (it does not re-read the case). Required when version=v2 (missing \u2192 500); ignored otherwise." - }, - { - "name": "feedback", - "type": "string", - "description": "v2 flow only: free-text feedback on a previous generation, forwarded inside test_case_details." - }, - { - "name": "score", - "type": "number", - "description": "v2 flow only: rating of a previous generation, forwarded inside test_case_details." - }, - { - "name": "tags", - "type": "array", - "description": "v2 flow only: tags forwarded with the payload. Elements are strings." - }, - { - "name": "preTestConfig", - "type": "object", - "description": "v2 flow only: pre-test configuration object (setup steps/profile) forwarded verbatim when present." - }, - { - "name": "test_case_folder_id", - "type": "integer", - "description": "Folder id of the case. Only read on the bulk-pipeline submission path (environment flag); harmless otherwise." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder id of the case; when sent the folder is loaded for an ownership check and passed to the case lookup. Optional; an unknown folder fails the call." - } - ], - "intent": "Kick off no-code (LCNC / Low Code Automation) generation of an automated test for ONE test case \u2014 use this when the user wants a manual case automated or re-automated; it queues work in the automation service and returns immediately", - "guidance": [ - "Submission is handed to a separate automation service, and that service's own failures are not translated before they reach you: they arrive as 422 with the service's raw message copied into the `message` field as text. A 422 here does not mean your request was invalid, and the text in it is not addressed to you \u2014 do not retry against it field by field.", - "422 is the status used for that relayed failure regardless of what actually went wrong upstream, so it carries no information about whether the problem is transient. Treat it as 'submission did not happen' and check the case before retrying.", - "Nothing is recorded when submission fails: the case's automation status and its timestamps are untouched, so a failed attempt leaves the case immediately resubmittable rather than locked. Re-reading the case is how you tell a failed submission from a queued one.", - "A queued submission cannot be followed to completion through this index. Re-reading the case exposes its automation status, but no capability here exposes the resulting build link, so completion can only be confirmed in the product." - ], - "returns": [ - "status", - "body" - ], - "responses": { - "422": { - "description": "The automation service refused or failed the submission and its response was relayed as-is: `{\"success\": false, \"message\": }`. The string is frequently itself structured text rather than a sentence. This status is used for every upstream failure, not only for invalid input, and it is not a validation result for the body you sent. Nothing is queued and the case is unchanged.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Always false." - }, - "message": { - "type": "string", - "description": "The automation service's own response, relayed verbatim as text. Not a caller-facing message; do not parse or display it." + "description": "TestPlanV2Response as returned by `update_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestPlanV2Response" + } + ], + "properties": { + "test_plan": { + "type": "object", + "properties": { + "urls": { + "type": "object", + "properties": { + "sub_test_plans": { + "type": "string" + }, + "self": { + "type": "string", + "example": "https://test-management.browserstack.com/projects/1234/test-plans/48283" + } + } + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 1 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + } + } + } } - } - } - } - }, - "disabled": true - }, - { - "name": "list_binned_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/bin", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number. `page` is accepted as an alias and WINS if both are sent. Only `p` is validated (non-positive / non-numeric \u2192 400 'Invalid Params'); a bad `page` is forwarded as-is." - }, - { - "name": "limit", - "type": "integer", - "minimum": 1, - "description": "Rows per page, forwarded unchanged to the backing service, which owns the default and the ceiling (its client docs say default 25, max 100; not enforced in this codebase \u2014 see open questions). Ask for what you need and trust metadata.page_size for what you got." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match on test case title or identifier." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Only cases that lived in this folder when they were binned." - }, - { - "name": "moved_to_bin_by", - "type": "string", - "description": "Filter by who moved the case to the bin, as BrowserStack user id(s), comma-separated. `updated_by` is an accepted alias (moved_to_bin_by wins). These are browserstack_user_ids, NOT the internal user ids the review endpoints take." - }, - { - "name": "owner", - "type": "string", - "description": "Filter by case owner as BrowserStack user id(s), comma-separated." - }, - { - "name": "date_from", - "type": "string", - "example": "2026-09-01", - "description": "Binned on or after this ISO 8601 date." - }, - { - "name": "date_to", - "type": "string", - "example": "2026-09-16", - "description": "Binned on or before this ISO 8601 date." - }, - { - "name": "sort_by", - "type": "string", - "values": [ - "trashed_at", - "updated_at", - "name", - "identifier" - ], - "default": "trashed_at", - "description": "Sort column; default is when the case was binned." - }, - { - "name": "sort_order", - "type": "string", - "values": [ - "ASC", - "DESC" - ], - "default": "DESC", - "description": "Sort direction; upper-case only." - }, - { - "name": "status", - "type": "string", - "description": "Filter by status as numeric status ID(s), comma-separated \u2014 despite the UI-style name this takes ids, not display values (resolve them via get_system_field_values). `status_id` is an accepted alias (status wins)." - }, - { - "name": "priority", - "type": "string", - "description": "Filter by priority as numeric priority ID(s), comma-separated, not display values. `priority_id` is an accepted alias (priority wins)." - }, - { - "name": "type", - "type": "string", - "description": "Filter by case type as numeric category ID(s), comma-separated, not display values. `category_id` is an accepted alias (type wins)." - }, - { - "name": "automation_state", - "type": "string", - "description": "Filter by automation state as numeric ID(s), comma-separated. `automation_state_id` is an accepted alias (automation_state wins)." - }, - { - "name": "tags", - "type": "string", - "description": "Filter by tag name(s), comma-separated." - } - ], - "intent": "List the test cases sitting in a project's recycle bin (moved to bin / soft-deleted, awaiting purge) with who binned them and when \u2014 use this to find a case to restore or inspect; for just 'how many are in the bin' use count_binned_test_cases instead", - "guidance": [ - "Rows here are the WIDE v1 case shape, not the trimmed v2 one: `priority` and `status` arrive as objects carrying `field_value`, `colour`, `field_name` and `value_category` rather than as plain strings. Code written against a v2 case listing will read them as objects and compare wrongly.", - "Feature-gated: when the project/user lacks the Move to Bin or New Project Settings flags the call is 403 {success:false, error:'... is not enabled for this user'} \u2014 that is a feature gap, not a permission problem. Needs the tc_view permission under RBAC.", - "Paging metadata is under `metadata` {total, page, page_size, total_pages}, NOT the `info` {count, next, prev} envelope other v1 listings use \u2014 there is no `next`; stop when page >= total_pages.", - "Rows carry the integer `id`, the TC-NNN `identifier`, `trashed_at`, `days_remaining` until automatic purge, and `folder_name`/`folder_path` of where the case was. `updated_by` and `owner` are raw BrowserStack user ids; the resolved people are in `updator_details` (who binned it) and `owner_details` {id, browserstack_user_id, full_name, email}.", - "priority and status come back as small objects {id, field_value, internal_name} (status also has colour), not strings.", - "Only the filters listed exist. Binned cases are excluded from every other test case listing and search, so this is the only way to see them.", - "A backing-service failure is rendered as {success:false} with that service's status and no message." - ], - "returns": [ - "success", - "test_cases", - "metadata", - "id", - "identifier", - "name", - "priority", - "status", - "owner", - "owner_details", - "tags", - "issues", - "folder_name", - "folder_path", - "updated_by", - "updator_details", - "trashed_at", - "days_remaining", - "total", - "page", - "page_size", - "total_pages", - "template", - "created_by", - "creator_details" - ], - "paginated": true, - "responses": { - "200": { - "$response": "BinnedTestCasesListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "count_binned_test_cases", - "method": "GET", - "path": "/api/v1/projects/{project_id}/test-cases/bin-count", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "intent": "Return just the number of test cases currently in a project's recycle bin \u2014 the cheap way to answer 'how many binned/deleted cases are there?' without pulling any rows into context (use list_binned_test_cases when you need the cases themselves)", - "guidance": [ - "Same feature gate as the bin listing: 403 {success:false, error:'... is not enabled for this user'} when Move to Bin / New Project Settings are off for this user. Needs tc_view under RBAC.", - "Counts the whole bin; it takes no filters. For a filtered count, call the listing with the filter and read metadata.total.", - "A backing-service failure is {success:false} with that service's status and no count." - ], - "returns": [ - "success", - "count" - ], - "responses": { - "200": { - "description": "Number of test cases in the bin.", - "schema": { - "type": "object", - "required": [ - "success", - "count" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "count": { - "type": "integer", - "example": 12 + }, + "PathFolder__CreateBulkTestCases": { + "type": "object", + "description": "PathFolder as returned by `create_bulk_test_cases`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PathFolder" + } + ], + "properties": { + "cases_count": { + "type": "integer", + "example": 51 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 13 + }, + "total_cases_count": { + "type": "integer", + "example": 52 + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "submit_test_cases_for_review", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-review", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the test cases to send for review. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", + }, + "TestCase__CreateBulkTestCases": { "type": "object", - "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." - } - ], - "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." - }, - { - "name": "q", - "type": "object", - "description": "SCOPE for select_all \u2014 the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project \u2014 verify the count before sending." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." - }, - { - "name": "reviewers", - "type": "array", - "json_path": "/test_case/reviewers", - "example": [ - 4821 - ], - "description": "INTERNAL Test Management user ids of the reviewers (the `id` from search_project_users / list_project_users \u2014 not browserstack_user_id, not email). Replaces the current reviewer set. Each id must belong to the caller's account (else 422 reviewer_not_found) and, when the project keeps an allowed-reviewer list, be on it (else 422). Omit to change status only. Elements are integers." - }, - { - "name": "review_status", - "type": "string", - "json_path": "/test_case/review_status", - "values": [ - "in_review" - ], - "example": "in_review", - "description": "The review state to move the selected cases into. Send 'in_review' to submit for review; it is forwarded to the backing store as a replace. Verdicts (approved/rejected/changes_requested) are NOT applied here \u2014 use apply_test_case_review_verdict." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Note to the reviewers. It is only carried in the reviewer notification when reviewers are given \u2014 it is NOT stored as a comment on the cases." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "format": "uuid", - "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use \u2014 a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." - } - ], - "intent": "Send one or many test cases for review \u2014 set them to 'in review' and assign the reviewers who must approve them; use it for a single case (ids with one element) or a whole selection/folder at once", - "guidance": [ - "Needs the project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 {success:false, message:'Feature not available'}). No RBAC permission entry exists for this action, so any authenticated project member with those two passes.", - "Part 11 e-signature: when the project setting part_11_reauth_send_for_review is 'true', the caller must first re-authenticate (email + password at the reauth endpoint, not in this index) and pass the returned reauth_token; missing/expired/reused \u2192 401 {success:false, error_code:'reauth_required'} and the block is written to the audit log.", - "Selections over 100 cases run asynchronously: the response is {success:true, async:true, unique_id} with NO testCases \u2014 the change lands in the background and you re-read the cases to confirm. Under the threshold the updated cases are returned inline.", - "Reviewer ids are the internal Test Management user id; the same people are addressed by browserstack_user_id in the bin listing filters, so do not carry ids across. A reviewer who is not on the project's allowed-reviewer list is refused (422) for the whole call \u2014 nothing is partially applied.", - "Cases locked by another bulk operation are refused with 400 {success:false, message:'TestCases in progress with another operation'}; retry after it finishes.", - "Every other failure collapses to 400 {errors:'An error occurred during bulk review'} \u2014 including backing-service errors \u2014 so a 400 is not necessarily a bad request.", - "Recoverable: review state is a tracked status; a reviewer verdict or a later edit moves the case on, and each case keeps version history.", - "Sending reviewers notifies them (in-app alert / email) immediately." - ], - "returns": [ - "success", - "testCases", - "async", - "unique_id", - "id", - "identifier", - "name", - "review_status", - "reviewers", - "review_sent_date", - "owner", - "status", - "priority", - "test_case_folder_id" - ], - "responses": { - "200": { - "$response": "TestCaseReviewOutcomeResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "ReauthRequired" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "apply_test_case_review_verdict", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/bulk-approve", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the test cases to give a verdict on. A single case is just a one-element array. Ignored when select_all is true (the set then comes from q/folder_id). Send ONE of ids, select_all or folders; a selection that resolves to zero cases is a bare 400 {success:false} with no message." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Apply to every case in scope (the top-level q filter plus folder_id, or the whole project when neither is sent) minus de_selected_ids. Compared as the string 'true'. Scope is resolved by a count first, and a count above 100 flips the call to the async path." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude. With select_all it is subtracted from the resolved scope; with an explicit ids list it is subtracted from ids." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", + "description": "TestCase as returned by `create_bulk_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "folder": { + "type": "object", + "properties": { + "cases_count": { + "type": "integer", + "example": 51 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 764138 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764138" + } + } + }, + "name": { + "type": "string", + "example": "__scratch__" + }, + "notes": { + "type": "string", + "example": "<p>write probes create their own objects in here</p>" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 13 + }, + "total_cases_count": { + "type": "integer", + "example": 52 + } + } + }, + "test_case_steps": { + "$schema": "TestCaseStep__CreateBulkTestCases" + } + } + }, + "TestCaseStep__CreateBulkTestCases": { "type": "object", - "description": "One entry per folder id: {select_all: bool, ids: [int], de_selected_ids: [int]}. Unknown keys inside an entry are dropped." - } - ], - "description": "Cross-folder selection keyed by folder id, each value {select_all, ids[], de_selected_ids[]}; the union of all entries is the target. Takes precedence over ids/select_all when present. An empty map selects nothing (400), never the whole project." - }, - { - "name": "q", - "type": "object", - "description": "SCOPE for select_all \u2014 the same filter object the test-case search takes (see bulk_archive_test_cases_by_project / search_test_cases_by_folder), sent as a SIBLING of test_case, not inside it. Only read when select_all is true. An unrecognised key is SILENTLY DROPPED, which widens the scope to the whole folder/project \u2014 verify the count before sending." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all selection to (merged into q as folder_id, overriding any q.folder_id). With explicit ids it only makes the folder be loaded for an ownership check; an unknown folder id fails the call." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "description": "Extend a folder-scoped select_all into subfolders of folder_id. Compared as the string 'true'." - }, - { - "name": "review_status", - "type": "string", - "required": true, - "json_path": "/test_case/review_status", - "values": [ - "approved", - "rejected", - "changes_requested" - ], - "example": "approved", - "description": "The verdict: 'approved' passes the case, 'rejected' fails it, 'changes_requested' sends it back to the author for edits. Applied to every selected case; there is no per-case verdict in one call." - }, - { - "name": "comment", - "type": "string", - "json_path": "/test_case/comment", - "description": "Review comment. Unlike the submit-for-review comment this one IS stored: one comment is created on each selected case, tagged with the verdict, and echoed back under `comment`. Strongly recommended for rejected/changes_requested." - }, - { - "name": "notify", - "type": "boolean", - "json_path": "/test_case/notify", - "default": false, - "description": "Compared as the string 'true'. Also email the case owners the verdict (in-app personal alerts go out regardless)." - }, - { - "name": "reauth_token", - "type": "string", - "json_path": "/test_case/reauth_token", - "format": "uuid", - "description": "Single-use e-signature token from the Part 11 re-authentication step (the caller re-enters email + password at the reauth endpoint, which is not in this index, and gets back reauth_token). Valid 5 minutes, consumed atomically on first use \u2014 a retry needs a fresh token. Only checked when the project's Part 11 setting for this action is on." - } - ], - "intent": "Record a reviewer's verdict on test cases that are under review \u2014 approve them, reject them, or request changes \u2014 for one case or a whole selection in a single call", - "guidance": [ - "Every selected case must currently be 'in_review'. If any is not (the author edited it and it dropped back to pending, or it was already decided) the WHOLE call is refused with 422 {success:false, error_code:'invalid_review_state'} and nothing is applied \u2014 re-read the cases and resubmit only the ones still under review.", - "Needs project setting review_approve ON (else 400 {errors:'Please enable review approve feature ...'}) and a Team or Pro plan (else 422 'Feature not available'). No RBAC permission entry exists for this action beyond project access.", - "Part 11 e-signature: when project setting part_11_reauth is 'true', every verdict value requires a fresh reauth_token from the re-authentication step (not in this index); missing/expired/reused \u2192 401 {success:false, error_code:'reauth_required'}.", - "Selections over 100 cases are applied asynchronously: {success:true, async:true, unique_id} with no testCases; re-read to confirm. Below that the updated cases come back inline, plus `comment` (the created comment objects) when a comment was sent.", - "Cases locked by another bulk operation \u2192 400 {success:false, message:'TestCases in progress with another operation'}.", - "When the backing store rejects the verdict (e.g. the audit-log write fails) its status is passed through with {success:false, error_code, errors}; the verdict was not applied. Any other failure is a generic 400 {success:false, errors:'An error occurred during bulk approval'}.", - "Recoverable: a verdict is a tracked review status \u2014 the author can edit and resubmit, a reviewer can decide again, and every case keeps version history. Nothing is deleted.", - "Side effects outside the data: owners get an in-app alert for every verdict, an email too when notify is 'true', and Part 11 projects write an audit-log row." - ], - "returns": [ - "success", - "testCases", - "comment", - "async", - "unique_id", - "id", - "identifier", - "name", - "review_status", - "reviewers", - "owner", - "status", - "priority", - "test_case_folder_id" - ], - "responses": { - "200": { - "$response": "TestCaseReviewOutcomeResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "ReauthRequired" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "Verdict refused: a selected case is no longer under review (error_code invalid_review_state), or the account plan lacks the feature.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error_code": { - "type": "string", - "example": "invalid_review_state" - }, - "error": { - "type": "string", - "example": "Test case in Pending state cannot be approved. It was modified and moved back to Pending \u2014 refresh to review the latest changes." - }, - "errors": { - "type": "string" - }, - "message": { - "type": "string", - "description": "Present instead of error/errors on the plan gate ('Feature not available')." + "description": "TestCaseStep as returned by `create_bulk_test_cases`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "hashed_id": { + "type": "string", + "example": "dcf9955bb4c3de3da47a04212b67ee4281dbcfa5" + }, + "record_status": { + "type": "string", + "example": "active" + } } - } - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "validate_bdd_export_selection", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-cases/exports/bdd/validate", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "minimum": 1, - "description": "Integer project id (the v1 route only matches digits; a PR-NNN identifier never reaches the API)." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "json_path": "/test_case/ids", - "example": [ - 1201, - 1202 - ], - "description": "Integer ids of the cases you intend to export. Non-BDD cases among them are silently skipped by the validator. Send ONE of ids, select_all or folders." - }, - { - "name": "select_all", - "type": "boolean", - "json_path": "/test_case/select_all", - "default": false, - "description": "Validate every BDD case in scope (q + folder_id, or the whole project) minus de_selected_ids. Compared as the string 'true'." - }, - { - "name": "de_selected_ids", - "type": "array", - "json_path": "/test_case/de_selected_ids", - "description": "Integer ids to exclude from a select_all scope." - }, - { - "name": "folders", - "type": "object", - "json_path": "/test_case/folders", - "fields": [ - { - "name": "", + }, + "TestResult__CreateStepResult": { "type": "object", - "description": "{select_all: bool, ids: [int], de_selected_ids: [int]} for that folder." - } - ], - "description": "Cross-folder selection keyed by folder id; the union is validated. Takes precedence over ids/select_all. An empty map validates nothing (400 'No BDD Test Case(s) selected.')." - }, - { - "name": "q", - "type": "object", - "description": "Filter scope for select_all \u2014 same filter object as the test-case search, sent as a sibling of test_case. Only read when select_all is true; an unknown key is silently dropped (scope widens)." - }, - { - "name": "folder_id", - "type": "integer", - "description": "Folder to scope a select_all validation to. Ignored for explicit ids." - } - ], - "intent": "Check, before exporting test cases to a BDD feature file, that every selected BDD case shares the same Feature and Background text so they can be merged into one file \u2014 returns a pass/fail verdict and message, nothing is exported or changed", - "guidance": [ - "Pure check despite the POST: it only reads the selected cases' steps and compares them; no export job, file or record is created. Run it before the real export when the user wants a single merged feature file.", - "Pass and fail share ONE body shape {success, message}: HTTP 200 + success:true on pass, HTTP 400 + success:false on any failure. Read `message` to tell the failures apart \u2014 'No BDD Test Case(s) selected.' means the selection contained no BDD-template cases at all.", - "Hard cap: a selection resolving to more than 300 BDD cases is refused with 400, but with the MISLEADING message 'Validation failed. Selected test cases belong to different features or backgrounds.' \u2014 the same text as a genuine mismatch. Check the selection size yourself before trusting that message.", - "Only BDD-template cases are considered: text/step cases in the selection are filtered out silently and a pass says nothing about them.", - "Comparison is exact after trimming whitespace, on the Feature and Background of each case's FIRST step only.", - "No RBAC permission is attached to this action; project access and authentication suffice." - ], - "returns": [ - "success", - "message" - ], - "responses": { - "200": { - "$response": "BddExportValidationResponse" - }, - "400": { - "$response": "BddExportValidationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "create_test_case_comment", - "method": "POST", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", - "mode": "write", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." - } - ], - "body": [ - { - "name": "comment", - "type": "string", - "required": true, - "json_path": "/comment/comment", - "example": "Checked against the latest build - step 3 still fails on Safari.", - "description": "The comment text, as rich text (HTML); plain text is accepted and kept as sent. Content that is only whitespace or empty tags is rejected as invalid, though an image on its own counts as content. To mention a colleague, send the span exactly as the product's own editor writes it: Display Name, with data-id set to their BrowserStack user id. All three attributes are needed: without data-mention the post still succeeds and the span is stored, but it shows as plain text rather than a tag. Mentioning someone outside the account is refused." - } - ], - "intent": "Post a comment on a test case.", - "guidance": [ - "The comment is attributed to the calling user and the case owner is notified the moment it lands; anyone mentioned in the text is notified separately. Those messages leave the product and cannot be recalled, so confirm the wording before sending.", - "The author can edit or delete their own comment afterwards - each is a separate operation - which is what makes this recoverable; an edit replaces the earlier text and keeps no previous version, and a delete leaves no self-serve way back.", - "Only the author can change a comment, so posting on someone's behalf leaves them unable to correct it.", - "Commenting on an archived test case is refused; restore the case first.", - "This is a paid-plan feature. On a plan without it the call still succeeds at the transport level and reports in the payload that the feature is unavailable, so check the success flag rather than assuming the comment landed.", - "Comments are flat: every one is posted against the case itself, and there is no way to reply beneath an existing comment.", - "Success is HTTP 200, not 201, and the new comment comes back one level down, inside a data envelope that holds a success flag alongside the comment object. Take the comment's id from there for any later edit or delete.", - "The returned comment carries id, user_id, group_id, project_id, parent_id, entity_id, entity_type, created_at, comment, edited and is_editable. user_id is the AUTHOR as an object with id, full_name and email rather than a bare id, and that id is the internal user id, not the BrowserStack one. parent_id is always null because replies are not threaded.", - "On this surface entity_type is always TestCase and entity_id is the test case's own id, so this thread is distinct from the one on the same case inside a test run: a comment posted here never appears in the in-run listing, and one posted there never appears here.", - "Two fields are fixed on a freshly created comment: edited is always false, and is_editable is always true. review_status is never present on a comment posted this way - it only ever appears on comments recorded as part of a review verdict.", - "Link markup in the body is normalised and mention markup is rewritten before the comment is stored, so the text returned can differ from the text sent; treat the returned text as authoritative." - ], - "returns": [ - "data", - "success", - "comment", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "edited", - "is_editable" - ], - "responses": { - "200": { - "$response": "CommentCreateResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_folder", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "description": "Only the literal value true has any effect. When true, a name that clashes with a sibling (case-insensitive, trimmed) is created as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/folder/name", - "example": "Checkout", - "description": "Folder name. Trimmed; must not be empty after trimming and must be unique among siblings ignoring case, otherwise 400 'Folder with same name exists in this location' (unless auto_rename)." - }, - { - "name": "description", - "type": "string", - "json_path": "/folder/description", - "example": "Payment and cart flows", - "description": "Free-text notes shown on the folder. Use this key, not `notes` (the internal-API key): `notes` and any other unknown key inside `folder` are rejected with 400 'Invalid JSON data'. HTML is sanitised on save." - }, - { - "name": "parent_id", - "type": "integer", - "json_path": "/folder/parent_id", - "example": 12, - "description": "Integer id of the existing folder to nest under. Omit it to create a root folder (null cannot be sent through this transport). An id not in this project returns 404. 0 is not an alias for root." - }, - { - "name": "attachments", - "type": "array", - "json_path": "/folder/attachments", - "description": "Array of integer ids of already-uploaded attachment blobs to attach to the folder. Any invalid id fails the whole create with 422." - } - ], - "intent": "Create a folder, or a sub folder under a parent.", - "guidance": [ - "Resolve the project's PR- identifier first via the projects list; the numeric project id used by v1 capabilities is not accepted here.", - "Send the description as `description`. The `notes` key used by the internal API is rejected, and so is any key this capability does not declare: the body is schema-validated with unknown keys forbidden and the request fails with 400 'Invalid JSON data'.", - "Sibling names must be unique ignoring case and surrounding whitespace; a clash returns 400. auto_rename=true creates ' (1)' with the name lowercased instead, so read the returned name rather than assuming the one you sent.", - "parent_id 0 is not root: omit the field for a root folder. An unknown parent_id returns 404.", - "The response carries the new folder's integer id \u2014 carry it forward as parent_id for sub folders, as folder_id when creating test cases, or as the move destination.", - "cases_count and sub_folders_count come back null on creation rather than 0, even though a folder is empty the moment it is made. Read them as 0; every other folder capability reports the same brand-new folder as 0, and the rename and move capabilities return real integers.", - "A folder whose name already exists in the same parent is refused with a 400 and `{success: false, message: ...}` naming the collision \u2014 the only body on that path, and the reason a repeated create with a fixed name fails after the first. Vary the name or read the parent's children first." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_test_case", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}/test-cases", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-12", - "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 105, - "description": "Integer id of the folder the case is created in. This is authoritative: a different folder id in the body is ignored. Discover it from the folder listing." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "example": "Login with valid credentials", - "description": "Title of the test case. The only always-required field; comes back as `title` in the response.", - "json_path": "/test_case/name" - }, - { - "name": "description", - "type": "string", - "example": "Verifies the happy-path login flow.", - "description": "Rich-text body. Send plain text or HTML, not a JSON document.", - "json_path": "/test_case/description" - }, - { - "name": "preconditions", - "type": "string", - "example": "User account exists and is verified.", - "json_path": "/test_case/preconditions" - }, - { - "name": "template", - "type": "string", - "values": [ - "test_case_steps", - "test_case_bdd" - ], - "default": "test_case_steps", - "example": "test_case_steps", - "description": "Step style. `test_case_steps` takes `test_case_steps` rows; `test_case_bdd` takes Gherkin in `feature` / `background` / `scenario` and refuses step rows (422). Mixing the two is refused with 422.", - "json_path": "/test_case/template" - }, - { - "name": "template_id", - "type": "integer", - "example": 42, - "description": "Id of a test-case template from list_templates. Omit or send null for the project's default template. An unknown id, or one from another workspace, is refused with 400 rather than falling back to the default. Choose one whose step_type matches `template`.", - "json_path": "/test_case/template_id" - }, - { - "name": "test_case_steps", - "type": "array", - "description": "Ordered step rows for a `test_case_steps` case. `order` is assigned by array position, so any `order` you send is overwritten. Not allowed for `test_case_bdd` (422). A differently named key such as `steps` is dropped and the case is created with NO steps, so verify against `steps` in the response.", - "fields": [ - { - "name": "step", - "type": "string", - "description": "Action the tester performs." - }, - { - "name": "result", - "type": "string", - "description": "Expected result of this step." - }, - { - "name": "test_data", - "type": "string" - }, - { - "name": "shared_step_id", - "type": "integer", - "description": "Reuse a shared step instead of inline text." - }, - { - "name": "shared_step_detail_id", - "type": "integer" - } - ], - "json_path": "/test_case/test_case_steps" - }, - { - "name": "feature", - "type": "string", - "description": "Gherkin Feature text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", - "json_path": "/test_case/feature" - }, - { - "name": "background", - "type": "string", - "description": "Gherkin Background text; optional, BDD cases only (422 otherwise).", - "json_path": "/test_case/background" - }, - { - "name": "scenario", - "type": "string", - "description": "Gherkin Scenario text. REQUIRED when template is `test_case_bdd` (422 if blank); refused with 422 on a non-BDD case.", - "json_path": "/test_case/scenario" - }, - { - "name": "owner", - "type": "string", - "example": "jane@acme.com", - "description": "Owner as a workspace user's email (a user id is also accepted). An owner that cannot be resolved silently falls back to the caller \u2014 check `owner` in the response.", - "json_path": "/test_case/owner" - }, - { - "name": "status", - "type": "string", - "example": "Active", - "description": "Matched against the project's configured status display values (defaults: Active, Draft, In Review, Rejected, Outdated; a numeric option id also matches). Unknown value \u2192 400 'Invalid default field value'. Omit for the project default. Resolve the live set with get_system_field_values.", - "json_path": "/test_case/status" - }, - { - "name": "priority", - "type": "string", - "example": "High", - "description": "Matched against the project's configured priority display values (defaults: Low, Medium, High, Critical; a numeric option id also matches). Unknown value \u2192 400. Omit for the project default.", - "json_path": "/test_case/priority" - }, - { - "name": "case_type", - "type": "string", - "example": "Functional", - "description": "Matched against the project's configured type display values (defaults include Functional, Regression, Smoke & Sanity, Acceptance, Security, Performance, Usability, Accessibility, Compatibility, Destructive, Automated, Manual, Other). Unknown value \u2192 400. Omit for the project default.", - "json_path": "/test_case/case_type" - }, - { - "name": "automation_status", - "type": "string", - "default": "not_automated", - "example": "not_automated", - "description": "Built-in values: not_automated, automated, automation_not_required, cannot_be_automated, obsolete (matched case-insensitively by internal name or display label). Workspaces that manage their own automation statuses accept their custom labels too. Unknown value \u2192 400.", - "json_path": "/test_case/automation_status" - }, - { - "name": "tags", - "type": "array", - "example": [ - "login", - "smoke" - ], - "description": "Free-text tag names; created if new.", - "json_path": "/test_case/tags" - }, - { - "name": "issues", - "type": "array", - "example": [ - "TES-204" - ], - "description": "Linked issues. Either bare issue keys (treated as Jira) or objects {issue_id, issue_type}. Linking is processed asynchronously after the create returns.", - "fields": [ - { - "name": "issue_id", - "type": "string", - "description": "Issue key, e.g. TES-204." - }, - { - "name": "issue_type", - "type": "string", - "description": "Tracker name, e.g. jira." - } - ], - "json_path": "/test_case/issues" - }, - { - "name": "custom_fields", - "type": "object", - "example": { - "Component": "Checkout", - "Regression candidate": true - }, - "description": "Map of custom-field DISPLAY NAME \u2192 value. Dropdown: option label; multi-dropdown: array of labels; user: email; boolean: true/false; date, int, url, text as typed. A name that is not a custom field on this project is SILENTLY SKIPPED (200, value absent); a reserved system-field name (e.g. 'Automation Status') is refused; a value failing its type check fails the whole call with 400.", - "json_path": "/test_case/custom_fields" - }, - { - "name": "identifier", - "type": "string", - "example": "LOGIN-001", - "description": "Custom identifier for the case. Honoured ONLY when the workspace uses project-level custom ids; otherwise ignored. A duplicate is refused with 400 'Test Case ID already exists'.", - "json_path": "/test_case/identifier" - }, - { - "name": "review_status", - "type": "string", - "description": "Review assignment state. Reviewer write: gated on a Team Pro plan and the project's Review & Approve setting when the reviewer API is enabled for the workspace. Omit unless the user asked to put the case in review. Known states include pending and in_review; the accepted set is not enforced here, so an unknown value is refused by the server.", - "json_path": "/test_case/review_status" - }, - { - "name": "reviewers", - "type": "array", - "example": [ - "jane@acme.com" - ], - "description": "Reviewer emails. Requires Team Pro plan and Review & Approve enabled on the project (422/400 otherwise). The owner cannot be a reviewer (422). A non-array value is silently ignored.", - "json_path": "/test_case/reviewers" - }, - { - "name": "send_for_review", - "type": "boolean", - "description": "Moves the new case into review and emails reviewers. Same plan/project gates as `reviewers`. Ambiguous strings like 'yes'/'no' are refused with 422.", - "json_path": "/test_case/send_for_review" - }, - { - "name": "created_at", - "type": "string", - "format": "date-time", - "description": "Backdated creation timestamp for migrations. Honoured only when the workspace has the external-API timestamp feature enabled; otherwise dropped silently.", - "json_path": "/test_case/created_at" - }, - { - "name": "updated_at", - "type": "string", - "format": "date-time", - "description": "Backdated update timestamp for migrations. Same feature gate as `created_at`; must not be earlier than `created_at` (400).", - "json_path": "/test_case/updated_at" - } - ], - "intent": "Create a test case in a folder.", - "guidance": [ - "Resolve the project's PR-NNN identifier and the destination folder's integer id first; the folder in the path is authoritative and a folder id in the body is ignored.", - "Decide the step style before writing: a Gherkin case needs template=test_case_bdd plus feature and scenario and must carry no test_case_steps; a classic case must carry no feature/background/scenario. Either mix is refused with 422, and a template_id whose step_type disagrees is the usual cause.", - "status, priority and case_type take the project's DISPLAY values (e.g. 'Active', 'High', 'Functional'), which a workspace can rename or extend \u2014 resolve them with get_system_field_values rather than guessing; unknown values are refused with 400.", - "custom_fields is keyed by display name and an unknown name is dropped without error, so confirm each field exists on the project (get_project_form_fields) and verify the response's custom_fields before reporting them saved.", - "The response echoes the case with `title` (not name), `steps` as [{step, result}] and `owner` as an email, and carries NO integer id \u2014 carry data.test_case.identifier forward for later reads, updates and moves.", - "Recoverable: the new case can be deleted or archived afterwards, so this is an ordinary write.", - "The echoed case omits keys rather than returning them empty. A freshly created case has no `attachments` key at all \u2014 not an empty array \u2014 because nothing can be attached yet; `feature`, `background` and `scenario` are present only on a Gherkin case; `reviewers` and `review_status` only when the project has Review & Approve enabled on a Team-Pro plan. `issues`, `steps` and `custom_fields` are always present, empty when nothing is set, so only those three are safe to iterate unguarded." - ], - "returns": [ - "identifier", - "title", - "description", - "preconditions", - "template", - "folder_id", - "status", - "priority", - "case_type", - "automation_status", - "owner", - "tags", - "issues", - "steps", - "feature", - "background", - "scenario", - "custom_fields", - "attachments", - "is_shared", - "review_status", - "reviewers", - "created_at", - "created_by", - "last_updated_at", - "updated_by", - "urls" - ], - "responses": { - "200": { - "$response": "V2TestCaseResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "error_code=review_approve_unknown \u2014 the project's Review & Approve setting could not be read, so nothing was written. Retry.", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "edit_project", - "method": "POST", - "path": "/api/v1/projects/{project_id}/edit", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "example": 11046, - "description": "Integer project id (the numeric `id`, not the PR-NNN identifier). Discover it through the projects list. A project the caller cannot see returns 404, not 403." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/project/name", - "example": "Checkout Regression", - "description": "New project name. Leading/trailing spaces are trimmed. Must be unique within the account and must not contain | ; > < ` + ? % \" / or backslash; violations return 400 with the reason in `error`. Omit to keep the current name." - }, - { - "name": "description", - "type": "string", - "json_path": "/project/description", - "example": "Owns all checkout-flow test cases", - "description": "New description. HTML is escaped on the way in and anything past 16 KB is silently truncated. Send an empty string to clear it; omit to keep the current one." - }, - { - "name": "access_type", - "type": "string", - "json_path": "/project/access_type", - "values": [ - "private", - "restricted", - "org_wide" - ], - "example": "org_wide", - "description": "Project visibility: `private` (owner only), `restricted` (owner plus named users), `org_wide` (everyone in the account). Other values return 400 'Invalid access type'. Honoured only on accounts with project-level access control; elsewhere silently ignored and the response still says success. Server may adjust it: private with named users becomes restricted, restricted with none becomes private." - }, - { - "name": "users", - "type": "array", - "json_path": "/project/users", - "fields": [ - { - "name": "user", - "type": "string", - "required": true, - "description": "Email address of a user in the same account." - }, - { - "name": "role", - "type": "string", - "required": true, - "description": "Product role name to grant on this project (account-configured, e.g. Product Admin, Tester, Viewer)." - } - ], - "example": [ - { - "user": "qa.lead@example.com", - "role": "Tester" - } - ], - "description": "Per-user access grants. Processed only when `access_type` is also sent, otherwise ignored. Each entry is added or has its role changed; users not listed keep their existing access, so this does not replace the list. Entries whose email is not in the account or whose role is not recognised are skipped silently, not rejected." - } - ], - "intent": "Rename a project, or change its description or visibility.", - "guidance": [ - "There is no undo. The previous name and description are overwritten; the change is logged in the project's activity feed and version history, so the old value can be looked up and re-entered by hand, but nothing restores it.", - "Send at least one of name, description or access_type inside the `project` object; an empty `project` object is rejected 400 before anything is looked up.", - "Only account owners/admins or the project owner may edit; anyone else gets 403 with the reason in `error`. A project the caller cannot see comes back 404, not 403.", - "Visibility is silently ignored on accounts without project-level access control: the call returns 200 and the unchanged value. Read `access_type` back from the response to confirm the change took.", - "The `users` list is only read alongside `access_type`. To change one user's role without touching visibility, resend the current access_type with the users entry.", - "Name conflicts and blocked characters are enforced by the backend and surface as 400 with a human-readable `error`; validation runs after the permission check, so a 403 means permissions, not the name." - ], - "returns": [ - "id", - "thProjectId", - "identifier", - "name", - "description", - "created_at", - "import_id", - "test_cases_count", - "test_runs_count", - "test_plans_count", - "links", - "normalisedName", - "starred", - "jira_mapped", - "projectVisibilityBanner" - ], - "responses": { - "200": { - "description": "Project edited. Envelope is data.success and data.project, same as the single-project read; project also carries access_type, normalisedName and projectVisibilityBanner, and the counts are carried over from before the edit rather than recomputed.", - "schema": { - "$schema": "V1ProjectDetail" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_folder_attachments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/attachments", - "mode": "read", - "entity": "attachment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "folder_id", - "type": "integer", - "required": true - } - ], - "intent": "List the files attached to a folder itself.", - "guidance": [ - "This is the folder's own shelf of files. Files attached to the test cases in the folder are a separate list and none of them appear here.", - "It does not look inside subfolders, so walk the folder tree and ask for each folder you care about.", - "Each entry carries links to the stored file; read the file from there, as there is no separate download step.", - "A folder with nothing attached comes back as an ordinary success with an empty list, so an empty list is an answer and not a failure.", - "Alongside the exact byte count each entry also carries a ready-formatted size for display; use the byte count when you need to compare or add sizes up.", - "Attachments on a test case are created outside this surface, so this list reflects work done elsewhere. An empty result is not evidence the entity has no attachments.", - "This lists attachments held on the FOLDER itself, which is a different collection from the attachments on the cases inside it \u2014 attaching a file to a case never makes a row appear here." - ], - "returns": [ - "success", - "attachments", - "id", - "name", - "content_type", - "byte_size", - "key", - "checksum", - "url", - "download_url", - "product_id" - ], - "responses": { - "200": { - "$response": "FolderAttachmentsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_exploratory_session_form_fields", - "method": "GET", - "path": "/api/v1/projects/{project_id}/exploratory-sessions/form-fields", - "mode": "read", - "entity": "exploratory_session", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "values", - "type": "boolean", - "default": false, - "description": "Set to true to get each field's configured choices alongside its definition. Left off, the reply is a compact list of the field definitions with the choice lists omitted, which is enough to learn the field names but not the values they accept.", - "example": true - } - ], - "intent": "List the form fields for authoring an exploratory session.", - "guidance": [ - "Asking for the configured choices does not currently work: any truthy value for the choices flag is answered with a not-found, on every project, while leaving it off succeeds. Call it without that flag and expect field names and types but no choice lists, and resolve a dropdown's accepted values another way.", - "The choices flag is matched against the exact lowercase word true; anything else, including a capitalised spelling, is treated as off.", - "Only the fields your team added are listed. The built-in session details such as name, charter and status are never described here - the built-in list is always returned empty - so an empty reply means nothing extra was configured, not that the session has no fields.", - "Both lists can be empty on a perfectly valid project. Do not read that as a failure.", - "Configuration is per project, so fetch it again for each project - a field set up in one project need not exist in another.", - "Each field carries both an internal name and the display name your team sees; when a person names a field in plain words, match it against the display name.", - "A field describing a dependent choice points at the field it hangs off, so resolve the parent's value first when one is present.", - "Errors here do not carry the enveloped error object described elsewhere in this registry: a not-found comes back as nothing more than a success flag set to false, with no code, message or details to branch on." - ], - "returns": [ - "success", - "custom_fields", - "default_fields", - "id", - "field_name", - "field_user_name", - "field_type", - "entity_type", - "is_required", - "optional", - "option_values", - "field_values", - "default_value", - "placeholder", - "is_bulk_editable", - "is_filterable", - "parent_custom_field_id", - "links" - ], - "responses": { - "200": { - "$response": "ExploratorySessionFormFieldsResponse" - }, - "404": { - "description": "Returned for a project that cannot be read, and - currently - for every request that asks for the configured choices. The body carries only a false success flag: no error object, no code, no message.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - } - } - } - } - } - }, - { - "name": "get_lcnc_user_test_config", - "method": "GET", - "path": "/api/v1/projects/{project_id}/lcnc/user-test-config", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project the setup is read for." - } - ], - "intent": "Get the caller's saved low-code test setup for a project.", - "guidance": [ - "The setup itself belongs to BrowserStack's low-code automation product and is handed back exactly as that product produced it. Test Management does not define, validate or reshape those fields, so they are not guaranteed here and may change without a change in Test Management - read them each time rather than relying on a remembered shape.", - "The status reported inside the payload is the one the other product returned, echoed as the transport status as well, so its failure surfaces as that same code here with the detail in the payload.", - "The setup is per person: it is the calling user's own default, so two people on the same project can correctly get different answers, and it is not a project-wide setting you can read on someone else's behalf." - ], - "returns": [ - "status", - "body" - ], - "shape": "discovered", - "responses": { - "200": { - "description": "The low-code automation service's own response, passed through unchanged. `status` echoes that service's status code and `body` is its parsed payload, whose fields are owned by that service and are not guaranteed by Test Management.", - "schema": { - "type": "object", - "properties": { - "status": { - "type": "integer", - "description": "Status code echoed from the upstream service." - }, - "body": { - "type": "object", - "description": "Upstream payload, passed through unchanged. Field set may vary; see the `discovered` shape." + "description": "TestResult as returned by `create_step_result`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestResult" + } + ], + "properties": { + "author": { + "$schema": "User__CreateStepResult" + }, + "result_status": { + "$schema": "ResultStatus__CreateStepResult" + }, + "test_run_step_result": { + "type": "object", + "nullable": true, + "properties": { + "btcer_id": { + "type": "integer", + "example": 2150428297 + }, + "colour": { + "type": "string", + "example": "#059669" + }, + "field_value": { + "type": "string", + "example": "Passed" + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "App opens successfully" + }, + "status_id": { + "type": "integer", + "example": 318610 + }, + "step": { + "type": "string", + "example": "Open the app" + }, + "step_mapping_id": { + "type": "integer", + "example": 12372581 + }, + "test_data": { + "type": "string", + "nullable": true + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_shared_components", - "method": "GET", - "path": "/api/v1/projects/{project_id}/shared-components", - "mode": "read", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project whose shared fields are listed." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, starting at 1. The page size is fixed by the service and cannot be raised from here; read the returned page details to see the total and whether another page exists." - }, - { - "name": "q[query]", - "type": "string", - "description": "Text matched against the shared field's title only - its step content is not searched. Send it in this bracketed form; a bare q value is ignored and the unfiltered list comes back looking like a successful search." - }, - { - "name": "q[tags]", - "type": "string", - "example": "smoke,regression", - "description": "Comma-separated tag names. Only shared fields carrying at least one of them are listed." - }, - { - "name": "q[empty][system_fields][]", - "type": "array", - "values": [ - "tags" - ], - "example": [ - "tags" - ], - "fields": [ - { - "name": "system_field", - "type": "string" - } - ], - "description": "Lists shared fields that have nothing set for a system field. Only tags is understood; any other name is dropped without an error, which silently widens the result. Combines with a tag filter as an either/or rather than narrowing it." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Restrict the listing to one kind of shared field. Omit to list every kind." - }, - { - "name": "folder_id", - "type": "string", - "example": "null", - "description": "Collection to list from: the collection's numeric id, or the literal four-character text null to list the Unassigned root where uncollected fields sit. That is the word null as a value, not an absent one. Omit the parameter entirely to list the project's shared fields regardless of collection." - }, - { - "name": "include_sub_collections", - "type": "boolean", - "default": false, - "description": "Set true to include the chosen collection's sub-collections as well as the collection itself. Only the exact value true turns it on, it does nothing without a collection, and it never applies to the Unassigned root, which has no children." - }, - { - "name": "sort_by", - "type": "string", - "description": "Sort order for the listing, passed straight through to the service that stores shared fields. Omit it for the default order; an unrecognised value is not reported as an error here." - }, - { - "name": "pre_fetch", - "type": "boolean", - "default": false, - "description": "When true the response also carries the steps of the first shared field on the page, saving a follow-up call. Honoured only on page 1 and only when the page has at least one row." - } - ], - "intent": "List or search a project's shared fields.", - "guidance": [ - "Shared fields and shared steps are different objects with separate listings and separate id sets; an id taken from one will not resolve in the other, so confirm which the user means before acting.", - "The listing carries each field's title, type and usage count but not its step content - fetch a single shared field when the actual steps are needed.", - "The usage count is what tells you how wide the blast radius of an edit would be; read it before suggesting a change to a field.", - "Filtering by collection is the only way to reproduce what the user sees in a collection view - an unfiltered list spans the whole project and will look like the filter was ignored." - ], - "returns": [ - "data", - "id", - "title", - "field_type", - "step_count", - "test_case_count", - "tags", - "created_by", - "updated_by", - "info", - "pre_fetched_shared_step", - "success" - ], - "paginated": true, - "responses": { - "200": { - "$response": "SharedComponentsGetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_case_comments", - "method": "GET", - "path": "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}/comments", - "mode": "read", - "entity": "comment", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project. Not the PR-NNN identifier shown in the UI." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "description": "Numeric id of the folder the test case sits in. The comment thread is resolved from the test case id itself, so this segment only has to be a real folder id in the project." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Numeric id of the test case whose comments these are. Not the TC-NNN identifier." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, starting at 1. Each page holds at most 30 comments and the size cannot be raised; the response reports the total and the next page number." - } - ], - "intent": "Read the comments on a test case.", - "guidance": [ - "The newest comment is first on page 1, so a 'what was said last' question is answered without paging; page on only when the reported total exceeds what you already hold.", - "Comments deleted by their author are gone from every page and from the total, so a thread can be shorter than a screenshot or an email suggests.", - "Bodies are rich text (HTML). A mention of a colleague arrives as markup whose visible text is that person's display name, so strip tags before showing the text plainly.", - "An archived test case still returns its comments, even though nothing new can be added to it.", - "Needs the test-case view permission and a paid plan. Below a paid plan the call still comes back as HTTP 200 with a false success flag and a message saying the feature is for paid customers, so read the success flag in the body rather than trusting the status code.", - "The page parameter is named p, and page size is fixed at 30 with no way to raise it. Asking for a page past the end neither errors nor returns an empty list - it serves the last page again - so stop when the reported next page is null, never when a page comes back empty.", - "Each row's user_id is the AUTHOR as an object with id, full_name and email rather than a bare id; that id is the internal user id and is null for comments brought in by an import, where full_name carries the original author's name instead. Rows also carry group_id, project_id, parent_id, entity_id and entity_type, and parent_id is always null.", - "On this surface entity_type is always TestCase and entity_id is the test case's own id. Comments left on the same case inside a test run are a separate thread with its own capability - nothing posted there shows up here.", - "review_status appears on a row only when that comment was recorded as part of a review verdict, which is how the bulk review flow on test cases writes it; a comment posted through the ordinary comment endpoint never carries it. Treat the field as optional and absent by default.", - "This listing is the only way to discover a comment's id, which every later edit or delete needs." - ], - "returns": [ - "comments", - "success", - "info", - "id", - "user_id", - "group_id", - "project_id", - "parent_id", - "entity_id", - "entity_type", - "created_at", - "comment", - "edited", - "is_editable" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case", - "method": "GET", - "path": "/api/v2/projects/{project_id}/test-cases/{test_case_id}", - "mode": "read", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-12", - "description": "The project's PR-NNN identifier, as v2 addresses projects. This is NOT the integer project id that every v1 capability takes; read it as `identifier` from the projects list." - }, - { - "name": "test_case_id", - "type": "string", - "required": true, - "example": "TC-1024", - "description": "The case's TC-NNN identifier, or its customer-defined identifier when the workspace uses project-level custom ids; matched exactly within the project. A bare integer id is NOT accepted here (404) \u2014 use get_test_case_by_integer_id for integer ids." - } - ], - "intent": "Get one test case in full by its TC-NNN identifier.", - "guidance": [ - "Returns `title` rather than name, `steps` as [{step, result}], custom_fields as [{name, value|values}] and owner as an email; there is no integer id in the body.", - "feature/background/scenario are present only for a Gherkin (test_case_bdd) case and `steps` is then empty; reviewers/review_status appear only when the project has Review & Approve enabled.", - "A 404 means the identifier does not resolve in THIS project; a case moved to another project keeps its identifier but must be read there." - ], - "returns": [ - "identifier", - "title", - "description", - "preconditions", - "template", - "folder_id", - "status", - "priority", - "case_type", - "automation_status", - "owner", - "tags", - "issues", - "steps", - "feature", - "background", - "scenario", - "custom_fields", - "attachments", - "is_shared", - "review_status", - "reviewers", - "created_at", - "created_by", - "last_updated_at", - "updated_by", - "urls" - ], - "responses": { - "200": { - "$response": "V2TestCaseResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - }, - "502": { - "description": "Upstream read failed with an unknown status; retry.", - "schema": { - "$schema": "ErrorResponse" - } - } - } - }, - { - "name": "search_all_projects", - "method": "GET", - "path": "/api/v1/global/search", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "entity", - "type": "string", - "required": true, - "example": "test_case,test_run,project", - "description": "Comma-separated list of the entity types to search. Allowed tokens: test_case, test_run, test_plan, project, shared_step, report. They are snake_case and SINGULAR \u2014 the hyphenated plurals used by the project-scoped search (test-cases, test-runs) are rejected here with a 400 naming the invalid token. Omitting this is a 400, not a search of everything." - }, - { - "name": "q[query]", - "type": "string", - "required": true, - "example": "login", - "description": "The text to search for. Required \u2014 a blank or missing value is a 400. %, _ and \\ are literal characters here and are not escaped or treated as wildcards." - }, - { - "name": "q[project_ids]", - "type": "string", - "example": "101,102", - "description": "Comma-separated NUMERIC project ids to restrict the search to. These are integer ids, not PR-NNN identifiers. Omit to search every project the caller can see." - }, - { - "name": "q[owner]", - "type": "string", - "example": "14,15", - "description": "Comma-separated Test Management user ids; the server converts them to the underlying user ids. Applied to test cases, runs and plans, and to reports. Deliberately NOT applied to project or shared_step hits, whose stored user is a creator rather than an owner, so an owner filter does not narrow those two at all." - }, - { - "name": "q[tags]", - "type": "string", - "example": "regression,smoke", - "description": "Comma-separated tag ids applied to every entity type being searched. A per-entity tag filter overrides it for that entity." - }, - { - "name": "q[issue_ids]", - "type": "string", - "example": "JIRA-1,JIRA-2", - "description": "Comma-separated linked-issue ids. Applied to test cases only at the shared level; use the per-entity form for runs." - }, - { - "name": "q[created_at]", - "type": "string", - "example": "7_day", - "description": "Relative token `_` with a SINGULAR unit (day, hour, week, month, year) \u2014 e.g. 7_day, 24_hour; a `_gte` suffix inverts it (7_day_gte = older than). Also accepts `all_time` (resolved as one year) or an absolute \"YYYY-MM-DD YYYY-MM-DD\" range. A plural unit such as 7_days raises server-side rather than returning a 400." - }, - { - "name": "q[updated_at]", - "type": "string", - "example": "1_month", - "description": "Same form as q[created_at]." - }, - { - "name": "q[created_between]", - "type": "array", - "example": [ - "2026-04-05T00:00:00.000Z", - "2026-05-05T23:59:59.999Z" - ], - "minItems": 2, - "maxItems": 2, - "fields": [ - { - "name": "0", - "type": "string" - }, - { - "name": "1", - "type": "string" - } - ], - "description": "Absolute creation range as a positional 2-element array: index 0 is the start, index 1 the end, ISO 8601. Takes precedence over q[created_at] when both are sent." - }, - { - "name": "q[updated_between]", - "type": "array", - "example": [ - "2026-04-05T00:00:00.000Z", - "2026-05-05T23:59:59.999Z" - ], - "minItems": 2, - "maxItems": 2, - "fields": [ - { - "name": "0", - "type": "string" - }, - { - "name": "1", - "type": "string" - } - ], - "description": "Absolute last-updated range, same positional 2-element form as q[created_between]. Takes precedence over q[updated_at]." - }, - { - "name": "q[test_case][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, test cases only. Overrides the shared q[tags] for this entity." - }, - { - "name": "q[test_case][priority_id]", - "type": "string", - "example": "1268,1270", - "description": "Comma-separated priority option ids. Ids, not labels \u2014 resolve them from the project's system field values first." - }, - { - "name": "q[test_case][status_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated status option ids." - }, - { - "name": "q[test_case][automation_state_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated automation-state option ids." - }, - { - "name": "q[test_case][category_id]", - "type": "string", - "example": "1,2", - "description": "Comma-separated case-type (category) option ids." - }, - { - "name": "q[test_case][issue_ids]", - "type": "string", - "example": "JIRA-1,JIRA-2", - "description": "Comma-separated linked-issue ids, test cases only." - }, - { - "name": "q[test_run][tags]", - "type": "string", - "example": "sprint-1", - "description": "Comma-separated tag ids, test runs only." - }, - { - "name": "q[test_run][issue_ids]", - "type": "string", - "example": "JIRA-3", - "description": "Comma-separated linked-issue ids, test runs only." - }, - { - "name": "q[test_plan][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, test plans only." - }, - { - "name": "q[project][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, project hits only. Projects and shared steps accept tags and the date ranges only \u2014 no owner, priority or status filter reaches them." - }, - { - "name": "q[shared_step][tags]", - "type": "string", - "example": "12,13", - "description": "Comma-separated tag ids, shared-step hits only." - }, - { - "name": "q[match][tags]", - "type": "string", - "example": "all", - "description": "How to combine the tag values supplied elsewhere \u2014 an OPERATOR, never the values themselves: all (has every tag), any, none. A value placed here instead of beside it sets a meaningless operator and applies NO filter, silently widening the result. The operator is broadcast to every entity being searched." - }, - { - "name": "combine", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "true merges all entity types into one list sorted newest-first with an entity_type on each row. Case-insensitive. It forces page 1 and caps the merged list at 5 rows while info.count still reports the real total, so it is a preview, not a way to read results." - }, - { - "name": "typeahead", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "true takes a fast path returning only entity_type, id, identifier, name, project_id and owner per hit, with no folder path, status or description enrichment. It also DROPS reports from the search entirely even when they are listed in `entity`. Case-insensitive." - }, - { - "name": "filter_details", - "type": "string", - "values": [ - "true", - "false" - ], - "default": "false", - "description": "Exactly the lowercase string `true` adds a filter_details block resolving the filter ids you sent back to names. Any other spelling, including True, is ignored. If resolution fails the block is simply absent \u2014 the search itself still succeeds." - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number, applied to every entity type at once. A value below 1 becomes 1. `page` is accepted as an alias. Ignored when combine is true." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 30, - "default": 30, - "example": 30, - "description": "Rows per entity type per page, ceiling 30. A larger value is silently reduced to 30 rather than refused. `count` is accepted as an alias." - } - ], - "intent": "Search every entity type across the account at once.", - "guidance": [ - "Account-wide by default. There is no project path segment; narrow it with the numeric project-ids filter when the user did name a project, otherwise every project the caller can see is searched.", - "Entity tokens here are snake_case and singular (test_case, test_run, test_plan, project, shared_step, report). This is a different vocabulary from the project-scoped searches, which take hyphenated plurals - passing those gets a 400 that names the invalid token.", - "Both the entity list and the query text are mandatory, and each missing one comes back as HTTP 400 whose body is a single bare error string and nothing else - no success flag, and no code, message or details sub-fields. Detect the failure from the status, not from the body's shape.", - "Pagination is per entity type, not overall: page 2 advances every type together, so a type with 3 hits contributes nothing to page 2 while another is still producing rows. Read each type's own info block to know when that type is exhausted.", - "Reports are the exception to the page-size setting: that bucket is fixed at 30 rows per page no matter what you ask for, and its own info block reports 30 back. Page through reports rather than trying to widen the page.", - "Combining results is a preview only - it pins you to page 1 and returns at most 5 rows across all types while still reporting the true total in the count, so a count of 200 beside 5 rows is expected and not a bug. Leave it off and read the per-type results when you actually need the matches.", - "Combining also replaces the reply: the per-entity buckets are gone and you get one merged list with a single info block, plus the project lookup and the test-case folder lookup when there is anything in them. That info block's page size is the per-type page sizes added together, so a three-type search reports 90 - it is not a row count and not a limit you can use.", - "Priority, status, automation-state and case-type filters take option ids, not the labels a user says. Resolve them first; a wrong-form value matches nothing and still returns a successful empty result.", - "Projects and shared steps only honour tags and the date ranges. An owner filter does not narrow them at all, so a query scoped by owner still returns every matching project.", - "Freshly created records are not findable here immediately - indexing lags creation by minutes. If you have just made something and need to confirm it exists, read it back directly instead of searching for it." - ], - "returns": [ - "test_case", - "test_run", - "test_plan", - "project", - "shared_step", - "report", - "project_details", - "data", - "info", - "test_case_folders", - "filter_details" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "GlobalSearchResponse" - }, - "400": { - "description": "Missing entity list, missing query text, or an unrecognised entity token. The body is a single bare error string - there is no success flag and no nested error object.", - "schema": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string", - "description": "The reason, as plain text.", - "example": "No entities provided for search" - } - } - } - } - } - }, - { - "name": "initiate_export", - "method": "POST", - "path": "/api/v1/projects/{project_id}/{entity_type}/exports/{file_type}", - "mode": "write", - "entity": "test_case", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric project id, not the PR-NNN identifier." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-cases" - ], - "example": "test-cases", - "description": "Only test cases can be exported through this capability. Dashboard analytics has its own export capability; asking for it here is accepted, records an export and returns an id, then the background job fails." - }, - { - "name": "file_type", - "type": "string", - "required": true, - "values": [ - "csv", - "bdd" - ], - "example": "csv", - "description": "csv produces a spreadsheet of every field, custom field and step. bdd produces Gherkin .feature output and silently restricts the export to BDD-template cases only." - } - ], - "body": [ - { - "name": "ids", - "type": "array", - "example": [ - 101, - 102, - 103 - ], - "fields": [ - { - "name": "id", - "type": "integer" - } - ], - "json_path": "/test_case/ids", - "description": "The exact test cases to export, by NUMERIC id. A TC-NNN identifier or a customer-defined id is not matched here. Ignored when select_all is true or when a folder selection map is sent. Omit the whole test_case object at your peril: with no selection form at all the export job is started against an empty id set." - }, - { - "name": "select_all", - "type": "boolean", - "example": true, - "json_path": "/test_case/select_all", - "description": "true exports everything matching the q filters within folder_id instead of an explicit id list, minus de_selected_ids. Sent as false (or omitted) the explicit id list is used instead." - }, - { - "name": "de_selected_ids", - "type": "array", - "example": [ - 104 - ], - "fields": [ - { - "name": "id", - "type": "integer" - } - ], - "json_path": "/test_case/de_selected_ids", - "description": "Numeric ids to exclude from a select_all export. Has no effect unless select_all is true." - }, - { - "name": "folders", - "type": "object", - "example": { - "125382": { - "select_all": true - }, - "125385": { - "ids": [ - 9001 - ] - } - }, - "json_path": "/test_case/folders", - "description": "Cross-folder selection: an object KEYED BY FOLDER ID, each value carrying that folder's own select_all / ids / de_selected_ids choice; the server resolves it to a union of case ids. Present, it WINS over both select_all and ids at the top level. An empty map resolves to no cases, never to the whole project.", - "fields": [ - { - "name": "select_all", - "type": "boolean" - }, - { - "name": "ids", - "type": "array" - }, - { - "name": "de_selected_ids", - "type": "array" - } - ] - }, - { - "name": "folder_id", - "type": "integer", - "example": 29529465, - "description": "Folder to scope a select_all export to. Only the folder itself unless subfolders are opted into." - }, - { - "name": "include_subfolders_tc", - "type": "boolean", - "example": true, - "description": "true also pulls cases from the folder's descendants. Off by default, so a folder export silently returns only the cases sitting directly in that folder." - }, - { - "name": "q", - "type": "object", - "example": { - "priority": "1268", - "tags": "12,13" - }, - "fields": [ - { - "name": "priority", - "type": "string" - }, - { - "name": "status", - "type": "string" - }, - { - "name": "tags", - "type": "string" - }, - { - "name": "owner", - "type": "string" - }, - { - "name": "custom_fields", - "type": "object" - } - ], - "description": "Filters selecting which cases a select_all export covers. Same vocabulary and same id-based values as the project test-case search, so resolve option ids first. An unrecognised key is dropped rather than refused, which widens the export instead of erroring." - }, - { - "name": "merge_into_single_file", - "type": "boolean", - "example": true, - "json_path": "/metadata/merge_into_single_file", - "description": "BDD exports only: merge every selected case into one .feature file instead of one file per case. The merge needs a single shared feature and background across the selection." - }, - { - "name": "common_feature", - "type": "string", - "json_path": "/metadata/common_feature", - "description": "BDD merge only: the Feature block to write at the top of the merged file. Omit it and the first exported case's own feature is used." - }, - { - "name": "common_background", - "type": "string", - "json_path": "/metadata/common_background", - "description": "BDD merge only: the Background block for the merged file. Omit it and the first exported case's background is used." - } - ], - "intent": "Start a background export of test cases to CSV or Gherkin.", - "guidance": [ - "THE FILE NEVER COMES BACK THROUGH HERE. This only queues the job and hands you an export id. Downloading the finished file is deliberately not part of this catalogue \u2014 a file download cannot be served over this transport \u2014 and there is no status or polling capability either. So after a successful call, tell the user plainly that the export is being prepared and that they must collect it from the exports list in the Test Management UI. Do not promise to fetch, attach or summarise the file.", - "A 200 with an export id means the job was accepted, not that it succeeded. The work runs afterwards and can still fail, in which case the id stays valid and no file ever appears \u2014 which the caller cannot observe from here.", - "Selection has three forms and the server picks one in a fixed order: a folder-keyed selection map beats everything; otherwise select_all exports everything matching the filters within the folder minus the de-selected ids; otherwise only the listed ids. Send exactly one form \u2014 the losing ones are silently ignored, not merged.", - "Explicit ids are matched on the numeric primary key only. Feeding TC-NNN identifiers there exports nothing and still returns a successful export id.", - "Choosing BDD restricts the selection to BDD-template cases behind your back, so exporting a folder of step-template cases as BDD produces an empty file with no error. Validate a BDD selection for a shared feature and background before merging into one file.", - "This creates an export record against the caller's account each time it is invoked; repeating it produces a new id and a new job rather than reusing the previous one." - ], - "returns": [ - "export_id" - ], - "responses": { - "200": { - "$response": "DashboardExportInitiatedResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "link_entities_to_jira_issue", - "method": "POST", - "path": "/api/v1/integration/jira-app/jira-ticket/link-entities", - "mode": "write", - "entity": "issue", - "body": [ - { - "name": "issue_id", - "type": "string", - "required": true, - "example": "TES-121", - "description": "The external tracker's own ticket key, e.g. the Jira key TES-121. It is not a Test Management id and is not minted here \u2014 take it from the tracker. A missing value is refused with 400 'Missing issue_id'; a key that does not exist in the tracker is NOT validated and the link is stored anyway." - }, - { - "name": "project_id", - "type": "string", - "required": true, - "example": "10001", - "description": "Integer id of the Test Management project that owns the entities being linked, sent as a string. A missing value is refused with 400 'Missing project_id'. This endpoint does not check it is numeric, so a non-numeric value fails deeper as a 500 rather than a clean 400." - }, - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test-case", - "test-run" - ], - "example": "test-case", - "description": "What is being linked. Matched case-insensitively. Anything outside the list is refused with 400 'Unsupported entity_type'. This also decides which other body field is required: 'test-case' requires `selection`, 'test-run' requires `entity_ids`." - }, - { - "name": "entity_ids", - "type": "array", - "example": [ - 27912, - 27399, - 27398 - ], - "description": "Integer ids of the test runs to link. Required ONLY when entity_type is 'test-run' (400 'Missing entity_ids for testrun' otherwise). For 'test-case' this field is read but never used \u2014 cases are resolved from `selection`, so ids sent here are silently discarded." - }, - { - "name": "selection", - "type": "object", - "example": { - "select_all": false, - "unique_test_case_count": 12, - "folders": {} - }, - "description": "Which test cases to link, in the same folder/filter selection shape the selection preview returns. Required ONLY when entity_type is 'test-case' (400 'Missing selection for testcase' otherwise). Ignored for 'test-run'.", - "fields": [ - { - "name": "select_all", - "type": "boolean", - "description": "Select every case matching the selection rather than an explicit list." - }, - { - "name": "unique_test_case_count", - "type": "integer", - "description": "Caller-supplied count of cases this selection resolves to. The server trusts it: it is the only input to the >200 async decision and is never recomputed against the selection." - }, - { - "name": "folders", + }, + "User__CreateStepResult": { + "type": "object", + "description": "User as returned by `create_step_result`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__CreateStepResult": { "type": "object", - "description": "Folder-keyed selection map, taken verbatim from the selection preview." - } - ] - }, - { - "name": "shared_selection", - "type": "object", - "description": "Additional selection covering test cases shared in from other projects. Only read on the synchronous test-case path; the queued path above 200 cases drops it, so shared cases are not linked in an async batch." - }, - { - "name": "build_ids", - "type": "array", - "description": "Build ids matching `entity_ids` one-for-one, for accounts on build-based run ingestion. Omit the field entirely to let the server resolve runs the legacy way. If you do send it, it must hold exactly one positive integer per entry in `entity_ids` with no duplicates, or the call is refused with 400." - } - ], - "intent": "Link test cases or runs to a tracker ticket.", - "guidance": [ - "Which body field carries the entities depends on what you are linking: cases are selected by a selection object, runs by an explicit list of ids. Sending ids alongside a case selection links nothing extra.", - "Above 200 entities the work is queued to a background worker and the response comes back immediately with async true and no per-entity outcome. Nothing has been linked yet at that point, and a worker failure is never reported back \u2014 re-read the ticket's linked test cases to confirm.", - "The 200 threshold for a case selection is read from the count you supplied, not from the selection itself. Understate it and the call takes the synchronous path, which resolves at most 200 cases and links only those \u2014 reporting success for the whole selection.", - "Build the case selection from the selection preview for the same ticket and project rather than assembling it by hand; the preview returns exactly the object this field expects.", - "The ticket key is not verified against the tracker. A typo produces a link to a ticket that does not exist, with a 200 and a success message.", - "Linking is additive and idempotent per entity; relinking an already-linked entity does not duplicate it.", - "Every failure on the argument-validation path comes back as 400 with a plain message, including cases that are really 'not found'." - ], - "returns": [ - "success", - "message", - "async" - ], - "responses": { - "200": { - "description": "The linking was accepted. Nothing about WHICH entities were linked comes back - no ids, no per-entity results, no failure list - so the only way to know what the ticket now holds is to read its linked entities back.\n", - "schema": { - "type": "object", - "required": [ - "success", - "message", - "async" - ], - "properties": { - "success": { - "type": "boolean", - "description": "True on this response. It reports that the request was accepted, not that every entity was linked - when `async` is true nothing has been linked yet. A refusal comes back as a 4xx or 5xx with success false and the reason in `message`.", - "example": true - }, - "message": { - "type": "string", - "description": "Human-readable outcome: 's linked successfully' when the work was done in-request, 's successfully queued for linking' when it was handed to a background worker. The entity type is echoed from your request verbatim, so it is your own wording plus an 's' - do not parse it, read `async` instead.", - "example": "test-runs linked successfully" - }, - "async": { - "type": "boolean", - "description": "False means the entities were linked before this response was sent, and reading the ticket's linked entities now will show them. True means NOTHING has been linked yet: the batch was over 200 entities and was handed to a background worker, and this response carries no job id and no way to poll it. Treat true as 'accepted, not done' - an empty read straight afterwards is expected and is NOT a failure. Do not resend the same request on the strength of an empty read: there is no de-duplication and the resend simply queues the same linking work again. Wait and re-read instead. Note also that for a test-case batch the 200-entity threshold is measured from the count YOU supplied inside the selection, not from a count the server recomputes, so a wrong count there decides this flag." + "description": "ResultStatus as returned by `create_step_result`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "value_category": { + "type": "string", + "example": "completed" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_linked_test_case_selection", - "method": "GET", - "path": "/api/v1/integrations/{issue_type}/test-cases/selection", - "mode": "read", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the caller is acting from. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." - } - ], - "query": [ - { - "name": "issue_id", - "type": "string", - "required": true, - "example": "TES-121", - "description": "The external tracker's ticket key whose current test-case selection is wanted. Not validated here: a blank or unknown key returns an empty selection rather than an error." - }, - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer id of the Test Management project the selection is scoped to. This endpoint does not check it is present or numeric, so a missing or non-numeric value fails deeper as a 5xx instead of a clean 400 \u2014 always send a real project id." - } - ], - "intent": "Preview the test cases a tracker ticket would link.", - "guidance": [ - "This is the step before linking, not the list of what is already linked. The selection object it returns is the exact object the link call expects, folder structure and all \u2014 pass it straight through rather than rebuilding it.", - "The count the link call uses to decide between the immediate and the queued path is the one you carry from this selection. Change the selection by hand and that count stops matching what it resolves to.", - "Unlike its Jira-specific twin, this route validates neither the ticket key nor the project id, so a missing argument surfaces as a server error rather than a 400.", - "During a platform downtime window this returns an empty test-case list rather than a selection object." - ], - "returns": [ - "success", - "selection" - ], - "responses": { - "200": { - "description": "The ticket's linked test cases expressed as a reusable selection - hand this object straight back as the case selection when linking. While the service is unavailable an empty test-case collection is returned in its place: `{success: true, test_cases: []}`, with no `selection` key at all, so check for `selection` before using it.\n", - "schema": { - "type": "object", - "required": [ - "success", - "selection" - ], - "properties": { - "success": { - "type": "boolean", - "description": "True on this response. A fixed acknowledgement, not a report on the selection - an empty selection still comes back as true.", - "example": true - }, - "selection": { - "$schema": "SelectionData", - "description": "The selection as passed through from the linking service. `folders` is an empty map and `unique_test_case_count` is 0 when the ticket has no linked cases in this project." + }, + "User__CreateTestResultForTestCase": { + "type": "object", + "description": "User as returned by `create_test_result_for_test_case`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_configuration_groups", - "method": "GET", - "path": "/api/v1/configurations/groups", - "mode": "read", - "entity": "configuration", - "query": [ - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "example": 1, - "description": "Page number. Page 0 is treated as page 1." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "default": 30, - "example": 30, - "description": "Rows per page, ceiling 30. Anything above 30 \u2014 and 0, a negative, or a non-numeric value \u2014 is silently replaced with 30 rather than refused, so a big page never arrives and you must walk pages. Above 300 the call is refused outright with a page-size error instead." - } - ], - "intent": "List the account's configuration groups and their ids.", - "guidance": [ - "A group carries only the dimension's name \u2014 the selectable values live under it as its options and are fetched separately using the group id returned here, so this is step one of two whenever you need a concrete value.", - "Nothing here is project-scoped: the same groups come back whichever project the user is working in, and they cannot be filtered to one project. Do not offer this as a way to see what a particular project can run against.", - "Page number travels as `p`; a `page` parameter is not read on this call and is ignored, which silently pins you to page 1.", - "There is no name search or sort on this listing \u2014 walk the pages and match names yourself." - ], - "returns": [ - "id", - "name", - "is_system", - "group_id", - "created_at" - ], - "paginated": true, - "max_page_size": 30, - "responses": { - "200": { - "$response": "ConfigurationGroupListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_tags", - "method": "GET", - "path": "/api/v1/projects/{project_id}/tags/v3", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "query": [ - { - "name": "entity_type", - "type": "string", - "required": true, - "values": [ - "test_case", - "test_run", - "test_plan", - "test_recording", - "shared_step" - ], - "description": "Which kind of record's labels to list. There is no option meaning 'all kinds'. Leaving it out or sending anything outside the listed set is refused outright rather than answered with an empty list.", - "example": "test_case" - }, - { - "name": "p", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number, counting from 1. Anything that is not a positive whole number is refused before the labels are read.", - "example": 1 - }, - { - "name": "page", - "type": "integer", - "minimum": 1, - "description": "Accepted as an alternative spelling of the page number, and used only when the page number was not supplied under the shorter name. Prefer the shorter name, which every other listing here uses." - }, - { - "name": "per_page", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "How many labels to return per page. The default is small, so ask for the largest page you need. A value above 100, zero, a negative number or anything non-numeric is refused rather than quietly clamped.", - "example": 100 - }, - { - "name": "search", - "type": "string", - "description": "Narrows the list to labels matching this text. Surrounding spaces are trimmed and an all-blank value counts as no filter at all. 'query' and 'q' are accepted as equivalents; whichever of the three is non-blank first is the one used." - } - ], - "intent": "List or search a project's labels, with their ids.", - "guidance": [ - "This is the only label listing that reaches records other than test cases, and the only one that hands back the id a rename or a merge needs. The test-case-only listings return names and usage counts, so start here whenever an id is required downstream.", - "Name the kind of record explicitly every time. There is no way to ask for every kind at once, and a misspelling is refused rather than silently widened.", - "Labels are scoped to a project and to a kind of record, so the same word can exist as separate labels for test cases and for test runs, each with its own id.", - "Ask for a large page in one go rather than walking small pages; the ceiling is 100 per page." - ], - "returns": [ - "tags", - "info", - "id", - "name" - ], - "shape": "discovered", - "paginated": true, - "max_page_size": 100, - "responses": { - "200": { - "description": "A page of labels for the requested kind of record. The body is passed through from the labels service unchanged, so it carries NO success flag - the absence of `success` here is not an error signal. A failure is a 4xx or 5xx with a plain `error` string instead.\n", - "schema": { - "type": "object", - "required": [ - "tags", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "The labels in use on the requested kind of record in this project, one entry per label, narrowed by the search text when one was given. An empty list means the project has no labels of that kind yet - it is not an error.", - "items": { - "type": "object", - "description": "A label. `id` is the handle to use wherever a call takes label ids; `name` is what people see. The entry is passed through from the labels service and may carry extra bookkeeping keys such as a usage count and timestamps - read id and name, and treat anything else as informational.", - "properties": { - "id": { + }, + "ResultStatus__CreateTestResultForTestCase": { + "type": "object", + "description": "ResultStatus as returned by `create_test_result_for_test_case`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "value_category": { + "type": "string", + "example": "completed" + } + } + }, + "TestCaseStep__EditTestCase": { + "type": "object", + "description": "TestCaseStep as returned by `edit_test_case`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "group_id": { "type": "integer", - "description": "The label's id - the value to pass wherever a call takes label ids." - }, - "name": { + "example": 3452 + }, + "record_status": { "type": "string", - "description": "The label's text, as people see it." - } + "example": "active" } - } - }, - "info": { - "$schema": "PageInfo", - "description": "Pagination for the label list. Page size is capped at 100 and defaults to 30." } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "list_users", - "method": "GET", - "path": "/api/v1/projects/{project_id}/users", - "mode": "read", - "entity": "user", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "example": 11046, - "description": "Integer project id. It gates access only; the returned list is not filtered to this project's members." - } - ], - "intent": "List the account's active users, for assigning a case or run.", - "guidance": [ - "Returns all active users of the caller's account, alphabetical by full name, in one unpaginated response. Appearing here does not mean the user has access to the project.", - "No role field. For project membership with roles use search_project_users; for a paged, name-filterable list of account users use list_project_users.", - "Assign owners and assignees with `id` (the TM user id), not `browserstack_user_id`.", - "`myself` is the caller, reduced to id, full_name and reports_and_notification_enabled; look the caller up in `users` for the full record.", - "Deactivated users and users with no name are omitted, so a colleague missing from the list may simply not have completed onboarding." - ], - "returns": [ - "success", - "users", - "myself", - "id", - "browserstack_user_id", - "full_name", - "email", - "group_id", - "onboarded", - "reports_and_notification_enabled" - ], - "responses": { - "200": { - "$response": "UsersListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "move_folder", - "method": "POST", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}/move", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 54, - "description": "Integer id of the folder to move; everything nested inside it moves with it. Never an FL- string." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "default": true, - "description": "Defaults to true: if a folder with the same name (case-insensitive, trimmed) already exists under the destination, the moved folder is silently renamed to ' (1)' with the name lowercased. Send false to get a 400 on a clash instead." - } - ], - "body": [ - { - "name": "parent_id", - "type": "integer", - "example": 12, - "description": "Integer id of the new parent folder in the SAME project. To move the folder to the project root, omit this field entirely: the server treats a missing key as root, and null cannot be sent through this transport. 0 is not root and is refused with 422. Must differ from folder_id." - } - ], - "intent": "Move a folder and its contents under a different parent.", - "guidance": [ - "Only moves within the same project are possible here; a destination in another project is refused with 422. Cross-project moves exist only in the internal API.", - "To move to root, omit parent_id; the server treats the missing key as root. A body that accidentally lost the key therefore also returns 200 and re-parents the folder to root, so double-check before sending without it. 0 is refused (422) rather than treated as root.", - "Name clashes at the destination are resolved by renaming the moved folder to ' (1)', lowercased, unless auto_rename=false is sent. Read the returned name either way.", - "Every pre-flight failure \u2014 same source and destination, unknown destination, unknown source folder, other-project destination \u2014 is a 422 whose text sits under `error`, not `message`. Only a failure inside the storage update comes back as 400.", - "Reversible by moving the folder back to its previous parent_id, so read the folder before moving; a name changed by auto-rename is not restored by moving back.", - "Completes synchronously and returns the moved folder; there is no async job id to poll, unlike the internal cross-project move." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "description": "The storage update was refused (e.g. an invalid destination such as the folder's own subtree).", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" + }, + "TestRunsListResponse__ListClosedTestRuns": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_closed_test_runs`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17578623" + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "updated_by": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 60 + }, + "closed": { + "type": "integer", + "example": 1 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "description": "A pre-flight validation failed: cross-project destination, destination equal to source, destination not in this project, or source folder not found. The reason is under `error`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "error": { - "type": "string" + }, + "Dataset__GetDataset": { + "type": "object", + "description": "Dataset as returned by `get_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__GetDataset" + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } } - } - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "rename_folder", - "method": "PATCH", - "path": "/api/v2/projects/{project_id}/folders/{folder_id}", - "mode": "write", - "entity": "folder", - "path_params": [ - { - "name": "project_id", - "type": "string", - "required": true, - "pattern": "^PR-\\d+$", - "example": "PR-1234", - "description": "The project's PR-NNN identifier string as shown in the UI and returned by the projects list. This is NOT the numeric project id that v1 capabilities take; a numeric id does not match this path and returns 404." - }, - { - "name": "folder_id", - "type": "integer", - "required": true, - "minimum": 1, - "example": 54, - "description": "Integer id of the folder to update, as returned by the folder list or create. Never an FL- string. 0 is rejected." - } - ], - "query": [ - { - "name": "auto_rename", - "type": "boolean", - "description": "Only the literal value true has any effect. When true, a new name that clashes with a sibling (case-insensitive, trimmed) is saved as ' (1)' with the name lowercased, instead of failing with 400. Check the returned name." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "json_path": "/folder/name", - "example": "Checkout v2", - "description": "New folder name. Optional server-side despite the spec marking it required \u2014 a description-only update is valid. Must be unique among siblings ignoring case, otherwise 400 (unless auto_rename)." - }, - { - "name": "description", - "type": "string", - "json_path": "/folder/description", - "example": "Updated notes", - "description": "New notes for the folder; replaces the existing text. HTML is sanitised on save, so tags may come back stripped or escaped." - } - ], - "intent": "Rename a folder or change its description.", - "guidance": [ - "There is no undo and folders keep no version history: read the folder first and keep the old name and description if you may need to put them back.", - "Neither name nor description is required: send only the one you want to change, and a description-only update is valid.", - "Keys this capability does not declare are silently dropped rather than rejected, so a misspelt field returns 200 with nothing changed. Compare the returned folder to what you sent.", - "A new name matching a sibling's (case-insensitive, trimmed) returns 400 'Folder with same name exists in this location'. auto_rename=true makes the server lowercase the name and append ' (1)' instead \u2014 that is a different result from the rename you asked for.", - "Renaming does not move the folder; use the move capability to change its parent." - ], - "returns": [ - "id", - "name", - "description", - "parent_id", - "cases_count", - "sub_folders_count", - "links", - "urls" - ], - "responses": { - "200": { - "$response": "FolderV2WriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_case_linked_issues", - "method": "GET", - "path": "/api/v1/integrations/{issue_type}/test-case/results/{test_case_id}", - "mode": "read", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the caller is acting from. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally." - }, - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Integer id of the test case whose details and execution results are wanted. Must belong to the caller's own group." - } - ], - "query": [ - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of execution results. Sending it also switches the read to include every result rather than the server default set, so send p=1 explicitly when you want the full result history \u2014 omitting it quietly narrows what comes back." - }, - { - "name": "per_page", - "type": "integer", - "example": 10, - "description": "Results per page. Only read when `p` is also sent; on its own it has no effect." - } - ], - "intent": "Get a test case's linked tracker tickets.", - "guidance": [ - "Everything is nested one level under a `data` wrapper, with the case itself under `test_case` inside it; reading the top level finds nothing.", - "The linked tickets are on the `issues` field of the returned case, each carrying the tracker's own ticket key. That is the list to read before unlinking, because unlinking does not let you choose between several linked tickets.", - "Ownership is checked against the caller's own group, so a case id from another account comes back as a body saying access was denied while the transport status is still 200 \u2014 check the success flag rather than the status.", - "During a platform downtime window this returns a success body with a null case instead of failing; treat a null case as 'unknown', not as 'no such case'." - ], - "returns": [ - "id", - "identifier", - "name", - "description", - "preconditions", - "expected_result", - "project_id", - "test_case_folder_id", - "issues", - "status", - "priority", - "case_type", - "automation_state", - "tags", - "custom_fields", - "steps", - "test_case_steps", - "template", - "assignee", - "creator_details", - "created_at", - "updator_details", - "updated_at", - "links", - "test_run_results", - "test_run_results_count", - "test_run_results_issues_count", - "metadata" - ], - "responses": { - "200": { - "description": "`{\"data\": {\"success\": true, \"test_case\": }}`. The case object also carries `test_run_results`, the page of execution results, and `issues`, the tracker tickets it is linked to. Also returned with `test_case` null during a downtime window.\n", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + }, + "DatasetUserRef__GetDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `get_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } + } + }, + "ResultStatus__ListEntityFilterDetails": { + "type": "object", + "description": "ResultStatus as returned by `list_entity_filter_details`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "entity_type": { + "type": "string", + "example": "TestCase" + } + } + }, + "User__ListEntityFilterDetails": { + "type": "object", + "description": "User as returned by `list_entity_filter_details`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" }, - "test_case": { - "$schema": "TestCase" + "reports_and_notification_enabled": { + "type": "boolean", + "example": true } - } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "unlink_test_case", - "method": "POST", - "path": "/api/v1/integrations/{issue_type}/unlink-test-case", - "mode": "write", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the link belongs to. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." - } - ], - "body": [ - { - "name": "test_case_id", - "type": "integer", - "required": true, - "description": "Integer id of the test case to unlink. This, the caller's group and the tracker type are the whole match key \u2014 the case must belong to the caller's own group or the call is refused." - }, - { - "name": "issue_id", - "type": "string", - "example": "TES-121", - "description": "The tracker ticket key the case should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the case, the group and the tracker type only. Send it for the refreshed list that comes back, and see the guidance if the case has more than one linked ticket." - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of the refreshed linked-test-case list returned on success. Pages are fixed at 10 per page. Must be a plain scalar \u2014 an array or hash value is ignored and the first page is returned." - } - ], - "intent": "Unlink a test case from a tracker ticket.", - "guidance": [ - "On the failure path the body is just `success` and `status`, with no collection \u2014 identical to the test-run unlink, which shares this render. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", - "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. Always read the success flag \u2014 checking the status alone reports every miss as a win.", - "Which link is removed is decided by the test case, the caller's group and the tracker type, not by the ticket you name. If the case is linked to several tickets in the same tracker, the first matching link is removed and it may not be the one you meant \u2014 read the case's linked tickets first and expect to verify afterwards.", - "On success the refreshed list of test cases still linked to the ticket is returned, so no follow-up read is needed to confirm.", - "Nothing is deleted: the case, the run and the ticket all survive and the same link can be created again.", - "Nothing is written back to the external tracker \u2014 the ticket itself is not modified.", - "During a platform downtime window an empty list with a success flag is returned and nothing is unlinked." - ], - "returns": [ - "success", - "test_cases", - "info", - "recent_folder", - "recent_project" - ], - "responses": { - "200": { - "description": "On success, the refreshed list of test cases still linked to the ticket: `{\"success\": true, \"test_cases\": [...], \"info\": {...}, \"recent_folder\": ..., \"recent_project\": ...}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found, and as `{\"success\": false, \"error\": \"Invalid Access\", \"status\": 401}` when the test case is not in the caller's group \u2014 the numbers in these bodies are not the transport status.\nrecent_folder and recent_project are screen state carried alongside the result rather than part of it - see their own descriptions. During service downtime the whole body degrades to `{success: true, test_cases: []}`, with no info and no recent_* keys.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "LinkedIssueTestCase" - } - }, - "info": { - "$schema": "PageInfo" - }, - "recent_folder": { - "type": "object", - "nullable": true, - "description": "The folder this person most recently linked a case from, so a linking panel can reopen where they left off. A convenience for whoever is driving the screen, not part of the unlink result - nothing about it reflects the case you just unlinked, and it is unchanged by the unlink. Null when there is no such history. Returned only alongside the first page of the refreshed list; asking for a later page omits it." - }, - "recent_project": { - "type": "object", - "nullable": true, - "description": "The project this person most recently linked a case from. Paired with recent_folder and carrying the same caveats: screen state, null when there is no history, and returned only alongside the first page." - }, - "status": { - "type": "integer", - "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen \u2014 a successful unlink omits it. 404 means no matching link was found, which is also what a run or case that was already unlinked returns." + }, + "ReportData__GetReportDetail": { + "type": "object", + "description": "ReportData as returned by `get_report_detail`. Composes ReportData and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportData" + } + ], + "properties": { + "time_tracking_enabled": { + "type": "boolean", + "example": false + }, + "test_cases_status": { + "type": "array", + "items": { + "type": "object", + "required": [ + "count", + "result_status" + ], + "properties": { + "count": { + "type": "integer" + }, + "result_status": { + "$schema": "ResultStatus__GetReportDetail" + } + } + } + }, + "test_run_status": { + "type": "object", + "properties": { + "Closed": { + "type": "integer", + "example": 1 + }, + "Done": { + "type": "integer", + "example": 0 + }, + "In_Progress": { + "type": "integer", + "example": 0 + }, + "New": { + "type": "integer", + "example": 59 + }, + "Rejected": { + "type": "integer", + "example": 0 + }, + "Under_Review": { + "type": "integer", + "example": 1 + } + } + }, + "time_tracking": { + "type": "object", + "properties": { + "cases_in_scope": { + "type": "integer", + "example": 166 + }, + "cases_with_elapsed": { + "type": "integer", + "example": 0 + }, + "total_elapsed_seconds": { + "type": "integer", + "example": 0 + } + } + }, + "user_result_stats": { + "type": "array", + "items": { + "type": "object", + "required": [ + "user_id", + "test_results", + "user_name" + ], + "properties": { + "user_id": { + "type": "integer" + }, + "test_results": { + "type": "integer" + }, + "user_name": { + "type": "string" + }, + "local_user_id": { + "type": "integer", + "example": 3741 + }, + "tc_count": { + "type": "integer", + "example": 2 + } + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "unlink_test_run", - "method": "POST", - "path": "/api/v1/integrations/{issue_type}/unlink-test-run", - "mode": "write", - "entity": "issue", - "path_params": [ - { - "name": "issue_type", - "type": "string", - "required": true, - "values": [ - "jira-app", - "azure", - "asana", - "linear", - "clickup", - "confluence", - "figma", - "devrev", - "gitlab", - "github", - "custom", - "trello" - ], - "example": "jira-app", - "description": "Which external tracker the link belongs to. Only these values are routable \u2014 anything else does not match the route at all and comes back as a 404 rather than a validation error. Use 'jira-app' for Jira; the server normalises it to 'jira' internally. The link is matched on this tracker type, so the wrong value finds nothing to unlink." - } - ], - "body": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer id of the test run to unlink. Must belong to the caller's own group, and the run is looked up inside `project_id`, which therefore becomes mandatory as soon as this is sent." - }, - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Integer id of the project that owns the run. Required whenever `test_run_id` is sent; omitting it is refused with a body saying the project id was not provided." - }, - { - "name": "issue_id", - "type": "string", - "example": "TES-121", - "description": "The tracker ticket key the run should be unlinked from. Accepted and echoed through, but NOT used to select which link is removed: the server matches on the run, the group and the tracker type only. Send it for the refreshed list that comes back." - }, - { - "name": "build_id", - "type": "integer", - "description": "Build id for the run, for accounts on build-based run ingestion. Omit the field entirely and the server resolves the run itself \u2014 that is the normal call. Sending a value that is not a positive integer is refused with 400." - }, - { - "name": "p", - "type": "integer", - "example": 1, - "description": "Page of the refreshed linked-test-run list returned on success. Pages are fixed at 10 per page." - } - ], - "intent": "Unlink a test run from a tracker ticket.", - "guidance": [ - "On the failure path the whole body is just `success` and `status` \u2014 there is no `test_runs` array. A caller reading the documented success shape finds nothing and cannot tell 'no such link' from 'wrong call' without checking `success` first.", - "A failure does not change the transport status: the refusal arrives as a 200 carrying a success flag set to false and a 404 inside the body. The same is true when the project id is missing or the run belongs to another group. Always read the success flag.", - "Which link is removed is decided by the test run, the caller's group and the tracker type, not by the ticket you name. If the run is linked to several tickets in the same tracker, the first matching link is removed \u2014 verify afterwards.", - "The run is resolved inside a project, so the project id must accompany the run id even though the ticket is the subject of the call.", - "On success the refreshed list of test runs still linked to the ticket is returned.", - "Nothing is deleted: the run and the ticket both survive and the same link can be created again. Nothing is written back to the external tracker.", - "Read the link first and look at its entity_type. When it is 'Build', this project stores runs as builds and the call needs build_id set to the run's uuid \u2014 omitting it returns a 200 with success false and a 404 body that looks exactly like 'no such link'. Confirmed by invocation: two identical calls without build_id were refused, the same call with it succeeded.", - "During a platform downtime window this refuses with a success flag set to false and a 404 in the body, on a 200 \u2014 indistinguishable from 'no such link' without checking platform status." - ], - "returns": [ - "success", - "test_runs", - "info" - ], - "responses": { - "200": { - "description": "On success, the refreshed list of test runs still linked to the ticket: `{\"success\": true, \"test_runs\": [...], \"info\": {...}}`, 10 per page. ALSO returned, with this same 200 status, as `{\"success\": false, \"status\": 404}` when no matching link was found or during downtime, and as `{\"success\": false, \"error\": ..., \"status\": 400|401|404}` for a missing project id, a run outside the caller's group, or a run that does not exist \u2014 the numbers in these bodies are not the transport status.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_runs": { - "type": "array", - "items": { - "$schema": "TestRun" - } - }, - "info": { - "$schema": "PageInfo" - }, - "status": { - "type": "integer", - "description": "The real outcome code, carried in the body because the HTTP status stays 200. Its PRESENCE means the unlink did not happen \u2014 a successful unlink omits it entirely. 404 means no matching link was found, which is also what you get for a run that was already unlinked." + }, + "ResultStatus__GetReportDetail": { + "type": "object", + "description": "ResultStatus as returned by `get_report_detail`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { + "type": "string", + "example": "#059669" + } + } + }, + "Folder__ListRootFolders": { + "type": "object", + "description": "Folder as returned by `list_root_folders`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/764093" + } + } + } + } + }, + "SharedStepsIDResponse__GetSharedStep": { + "type": "object", + "description": "SharedStepsIDResponse as returned by `get_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "SharedStepsIDResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "page_info": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 1 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 150 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "created_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } + } + }, + "shared_step_details": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "probe result" + }, + "step": { + "type": "string", + "example": "probe step" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } + } + }, + "tags": { + "type": "string", + "nullable": true + } + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_tag", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/tags/{id}", - "mode": "write", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - }, - { - "name": "id", - "type": "integer", - "required": true, - "description": "The label's own id, which only the listing that returns ids gives you. The label's name will not work in its place, so look the id up first.", - "example": 1 - } - ], - "body": [ - { - "name": "new_name", - "type": "string", - "required": true, - "description": "The label's new wording. Surrounding spaces are trimmed, and a blank or spaces-only value is refused. This is the only thing a rename changes - nothing else about the label can be edited here.", - "example": "smoke-critical" - } - ], - "intent": "Rename a label across a project.", - "guidance": [ - "The new wording replaces the old one everywhere the label is already applied, in one step, with no per-record confirmation.", - "There is no undo and no saved history for labels, so the only way back is to rename it a second time - note the old wording before you start, because nothing else records it.", - "Only account owners and admins may do this; anyone else is refused, so check who is asking before promising it.", - "This changes wording only. Folding two labels into one is a different operation, and renaming one to match another does not combine them.", - "Look up the id first: this addresses a single label by id, and a project can hold same-named labels for different kinds of record, each needing its own rename.", - "The reply is the whole label record, not just what changed: its id and new wording, the workspace it belongs to, its colour palette, and when it was created and last updated. Nothing else about the label can be edited through this call, but everything about it comes back.", - "A value that was never set is left out of the reply rather than returned as empty, so read a missing colour palette or timestamp as 'not configured', not as a failure." - ], - "returns": [ - "id", - "name", - "group_id", - "color_preset", - "created_at", - "updated_at" - ], - "shape": "discovered", - "responses": { - "200": { - "description": "The renamed label, as a complete label record.", - "schema": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer", - "description": "The label's own id, unchanged by the rename." - }, - "name": { - "type": "string", - "description": "The label's wording after the rename, with surrounding spaces trimmed." - }, - "group_id": { - "type": "integer", - "description": "The workspace the label belongs to. Labels are workspace-wide, not per project." - }, - "color_preset": { - "type": "string", - "description": "The label's colour palette, as one comma-separated list of hex colours rather than a single colour. Preserved by a rename. Omitted when the label has no palette set.", - "example": "#FBF6FE,#F7EAFF,#792395,#2C023F,#5C007A,#EFD4FF" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the label was first created. Omitted when not recorded." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "When the label was last changed, which this call advances. Omitted when not recorded." - }, - "deleted_at": { - "type": "string", - "nullable": true - }, - "last_used_at": { - "type": "string", - "nullable": true - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "record_status": { - "type": "string", - "example": "active" - }, - "restore_blocked_reason": { - "type": "string", - "nullable": true - }, - "updated_by": { - "type": "integer", - "example": 6013 + }, + "V1TestCaseRecordRow__GetTestCaseByIntegerId": { + "type": "object", + "description": "V1TestCaseRecordRow as returned by `get_test_case_by_integer_id`. Composes V1TestCaseRecordRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseRecordRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "case_type": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "priority": { + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "status": { + "nullable": true, + "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "download_url": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" + }, + "url": { + "type": "string" + } + } + } + } } - } - } - } - } - }, - { - "name": "verify_test_case_tags", - "method": "POST", - "path": "/api/v1/projects/{project_id}/test-case/tags/verify_tag", - "mode": "read", - "entity": "tag", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "tags", - "type": "array", - "required": true, - "description": "The label wordings to check, as a list of plain strings. Omit the whole list and the request is refused. An empty list is accepted and simply reports nothing new.", - "example": [ - "smoke", - "regression" - ] - } - ], - "intent": "Check which label names a project already uses.", - "guidance": [ - "Nothing is saved by this check, despite how it reads. A label only comes into being when it is actually applied to a test case, so follow this with the step that puts the label on the case.", - "Two lists come back: one holds the wordings not yet used on the project's test cases, the other holds the ones already in use. Neither is an error - being already in use is usually the answer you wanted.", - "The accompanying message says to verify the labels whenever any wording is already in use. It reads like a failure and is not one; go by the two lists.", - "Comparison is exact, so a difference in capitalisation or spacing counts as a brand new label and will leave you with two near-identical ones if you apply it.", - "Only labels on test cases are considered. A wording already used on test runs or plans still comes back as new here.", - "Every outcome, including the one where no wording is new, is returned as an ordinary success - read the flag and the lists rather than the status." - ], - "returns": [ - "success", - "tags", - "error_tags", - "error" - ], - "responses": { - "200": { - "description": "The verdict on the submitted wordings. 'tags' holds those not yet used on the project's test cases, 'error_tags' those already in use, and 'error' carries an advisory message whenever any wording was already in use.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "description": "False only when none of the submitted wordings were new." - }, - "tags": { - "type": "array", - "description": "Wordings not yet used on this project's test cases.", - "items": { - "type": "string" - } - }, - "error_tags": { - "type": "array", - "description": "Wordings already in use on this project's test cases. Present only when at least one was.", - "items": { - "type": "string" - } - }, - "error": { - "type": "string", - "description": "Advisory message shown when some or all wordings were already in use. Not a transport failure." + }, + "TestCaseFieldValue__GetTestCaseByIntegerId": { + "type": "object", + "description": "TestCaseFieldValue as returned by `get_test_case_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "disabled": true - }, - { - "name": "create_shared_component", - "method": "POST", - "path": "/api/v1/projects/{project_id}/shared-components", - "mode": "write", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project the shared field belongs to." - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the block, shown in the Shared Fields list." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "example": "background", - "description": "Which kind to create. OMITTING IT SILENTLY CREATES A `step` - it is not rejected, so a caller meaning to add a background or a precondition and forgetting this key gets a shared step instead, with no error to notice." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The ordered rows making up the block. REQUIRED. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "description": "1-based position. Required on every row." - }, - { - "name": "step", - "type": "string", - "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." - }, - { - "name": "result", - "type": "string", - "description": "Expected result; send an empty string when the kind has no result." - }, - { - "name": "test_data", - "type": "string", - "description": "Additional data for the row." - } - ] - }, - { - "name": "tags", - "type": "array", - "items": { - "type": "string" - }, - "description": "Plain tag name strings on the block itself." - }, - { - "name": "folder_id", - "type": "integer", - "nullable": true, - "description": "Collection to file it under. Omit or send null for the Unassigned root." - } - ], - "intent": "Create a reusable shared field - a step sequence, a Gherkin background block, or a precondition - that many test cases can then reference.", - "returns": [ - "success", - "data", - "id", - "title", - "field_type", - "test_case_count", - "step_count", - "tags", - "folder_id", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedComponentWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ONE CAPABILITY, THREE WORKFLOWS. `field_type` is the only difference between creating a shared step, a Gherkin background and a precondition - they are the same record and the same endpoint, not three features.", - "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "`field_type` defaults to `step`. There is no error for omitting it, so a create meant as a background or precondition succeeds as the wrong kind. Send it explicitly every time.", - "Sibling of create_shared_step on /shared-steps, which creates the same record but cannot set `field_type`. Use this route when the kind matters.", - "The response echoes `shared_step_details` with each row's assigned id; the LISTING returns only `step_count`. Read the echo if you need row ids.", - "All three field_type values can be created, and each lists back under the matching field_type filter." - ] - }, - { - "name": "update_shared_component", - "method": "PATCH", - "path": "/api/v1/projects/{project_id}/shared-components/{shared_component_id}", - "mode": "write", - "entity": "shared_field", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric id of the project." - }, - { - "name": "shared_component_id", - "type": "integer", - "required": true, - "description": "Id of the shared field to change, from list_shared_components." - } - ], - "body": [ - { - "name": "title", - "type": "string", - "required": true, - "description": "Name of the block. REQUIRED ON EVERY CALL even when you are only changing rows - sending rows alone is a 400." - }, - { - "name": "field_type", - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Which kind the block is. Send its existing value unless you intend to change it." - }, - { - "name": "shared_step_details", - "type": "array", - "required": true, - "description": "The FULL replacement row list, not a patch - rows you leave out are removed. REQUIRED ON EVERY CALL even when you are only renaming: sending `title` alone is a 400. Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "fields": [ - { - "name": "order", - "type": "integer", - "required": true, - "description": "1-based position. Required on every row." - }, - { - "name": "step", - "type": "string", - "description": "The step text, the Gherkin background line, or the precondition text depending on field_type." - }, - { - "name": "result", - "type": "string", - "description": "Expected result; send an empty string when the kind has no result." - }, - { - "name": "test_data", - "type": "string", - "description": "Additional data for the row." - } - ] - }, - { - "name": "tags", - "type": "array", - "items": { - "type": "string" - }, - "description": "Plain tag name strings; the list you send replaces the block's tags." - }, - { - "name": "folder_id", - "type": "integer", - "nullable": true, - "description": "Collection to move it into." - } - ], - "intent": "Change an existing shared field - its title, rows, tags or collection - with the edit propagating to every test case that references it.", - "returns": [ - "success", - "data", - "id", - "title", - "field_type", - "test_case_count", - "step_count", - "tags", - "folder_id", - "created_by", - "updated_by", - "shared_step_details" - ], - "responses": { - "200": { - "$response": "SharedComponentWriteResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "422": { - "$response": "UnprocessableEntity" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "BOTH `title` AND `shared_step_details` ARE REQUIRED ON EVERY CALL. Sending only one of them is a 400. There is no partial update here despite the PATCH verb.", - "`shared_step_details` REPLACES the row list. Read the block first and send back every row you want to keep, each with its `order`.", - "Each row MUST carry `order`. Omitting it is the single easiest way to fail this call, and the failure is opaque: every malformed body - missing order, unknown keys, an empty object, no body at all - comes back as the same `400 {success:false, message:\"Validation Error\"}` with no indication of which field was wrong.", - "The edit propagates to every test case referencing the block - `test_case_count` tells you how many that is before you change it.", - "Sibling of update_shared_step on /shared-steps, which edits the same record but cannot set `field_type`." - ] - } - ], - "schemas": { - "TestRun": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "TR-17" - }, - "uuid": { - "type": "integer", - "example": 17 - }, - "identifier": { - "type": "string", - "example": "TR-17" - }, - "name": { - "type": "string", - "example": "Test Run1-20/05/2025" - }, - "description": { - "type": "string", - "example": "" - }, - "owner": { - "type": "integer", - "example": 2 - }, - "assignee": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "environment": { - "type": "string", - "nullable": true - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "detail": { - "type": "string" - } - } - }, - "test_cases_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ] - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ] - }, - "is_automation": { - "type": "boolean" - }, - "observability_url": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "string" - }, - "configurations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" + }, + "TestCaseFieldValue__GetTestCaseDetail": { + "type": "object", + "description": "TestCaseFieldValue as returned by `get_test_case_detail`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - }, - "metadata": { - "type": "object", - "nullable": true, - "properties": { - "test_plan_error": { - "type": "string", - "nullable": true, - "description": "Why the run could not be linked to a plan, e.g. `PLAN_LINKED`. Present only when a plan link was attempted and refused.", - "example": "PLAN_LINKED" - } - } - }, - "assignee_imported": { - "type": "boolean" - }, - "is_dynamic": { - "type": "boolean" - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "archived_at": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-18T05:00:22.000Z" - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "example": "2026-10-18T00:00:00.000Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner": { - "type": "integer", - "example": 6013 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_sent_date": { - "type": "string", - "nullable": true - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "example": "2026-09-18T00:00:00.000Z" - }, - "sub_plans_count": { - "type": "integer", - "example": 14 - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T12:53:05.000Z" - }, - "user_id": { - "type": "integer", - "example": 6013 + }, + "TestCaseStep__GetTestCaseDetail": { + "type": "object", + "description": "TestCaseStep as returned by `get_test_case_detail`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseStep" + } + ], + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "hashed_id": { + "type": "string", + "nullable": true + }, + "scenario": { + "type": "string", + "nullable": true + }, + "sharedStep": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "test_data": { + "type": "string", + "nullable": true + }, + "test_recording_id": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + } } - } - } - }, - "attachments": { - "type": "array", - "description": "Files attached to the run. Normally returned as `[]` \u2014 present as a key but not populated.", - "items": { - "type": "object" - } - }, - "created_by": { - "$schema": "TestRunUserRef", - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." - }, - "updated_by": { - "$schema": "TestRunUserRef", - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated." - }, - "closed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Usually null, even on runs whose `run_state` is `done` \u2014 a closed run does not necessarily carry a close timestamp. Do not infer closure from this field; read `run_state`." - }, - "closed_by": { - "type": "integer", - "nullable": true, - "description": "Usually null, even on runs in `run_state: done`. Same caveat as closed_at." - } - } - }, - "Project": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the project." - }, - "id": { - "type": "integer", - "description": "Unique ID for the project." - }, - "description": { - "type": "string", - "description": "A brief description of the project." - }, - "identifier": { - "type": "string", - "description": "A short unique identifier (e.g., PR-674)." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp indicating when the project was created." - }, - "import_id": { - "type": "string", - "description": "An optional import identifier. May be null.", - "example": null, - "nullable": true - } - } - }, - "ErrorResponse": { - "type": "object", - "description": "Standard error response format.", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Application-specific error code.", - "example": "ERR_INVALID_PARAMS" - }, - "message": { - "type": "string", - "description": "Human-readable error message.", - "example": "The provided parameters are invalid." - }, - "details": { + }, + "History__ListTestCaseHistories": { "type": "object", - "description": "Optional field with additional error details." - } - } - } - } - }, - "V1TestPlanBulkResponse": { - "type": "object", - "description": "Dual-shape response. Below the bulk threshold the work is done and you get\n`plans` + `count`. Above it the work was handed to a background job and you get\n`async: true` + `unique_id` \u2014 the plans are NOT changed yet at that point; re-read to\nconfirm (see the async-jobs concept).\n", - "properties": { - "success": { - "type": "boolean" - }, - "async": { - "type": "boolean", - "description": "Present and true only when the operation was dispatched asynchronously." - }, - "unique_id": { - "type": "string", - "description": "Correlation id of the async job. Present only when async is true." - }, - "plans": { - "type": "array", - "description": "Affected plans. Present only on the synchronous path.", - "items": { - "type": "object" - } - }, - "count": { - "type": "integer", - "description": "Number of affected plans. Present only on the synchronous path." - } - } - }, - "User": { - "type": "object", - "description": "The full user reference used by most TM serializers.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - } - } - }, - "TestCaseStep": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "integer" - }, - "order": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "result": { - "type": "string" - }, - "step": { - "type": "string" - }, - "test_case_id": { - "type": "integer" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "TestCase": { - "type": "object", - "description": "The project-level test-case record: TestCaseCore plus the three fields every project-level route adds. The run-mapping rows of list_test_run_test_cases_by_integer_id do NOT return these three and compose on TestCaseCore instead.\n", - "allOf": [ - { - "$schema": "TestCaseCore" - } - ], - "properties": { - "estimated_duration_seconds": { - "type": "integer", - "nullable": true, - "description": "Estimated duration in seconds. THIS is the real value \u2014 `estimate` is always null. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - }, - "template_name": { - "type": "string", - "nullable": true, - "description": "Display name of that template; null for cases on the plain-text template. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - }, - "test_case_dataset": { - "type": "object", - "description": "Dataset bound to this case; `{}` when none. ABSENT from the run-mapping rows of list_test_run_test_cases_by_integer_id." - } - } - }, - "PageInfo": { - "type": "object", - "description": "Pagination metadata returned with paged API responses.", - "required": [ - "page", - "page_size", - "count", - "prev", - "next" - ], - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "type": "integer", - "description": "Total number of items." - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true - } - } - }, - "TestCaseListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "description": "One row per case-x-configuration mapping in the run.", - "items": { - "$schema": "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId" - } - }, - "info": { - "$schema": "PageInfo" - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level, not inside a row: case ids the review gate excluded from this page. Present only when the gate excluded something.", - "items": { - "type": "integer" - } - } - } - }, - "Folder": { - "type": "object", - "properties": {} - }, - "Configuration": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the configuration." - }, - "group_id": { - "type": "integer", - "description": "ID of the group associated with the configuration." - }, - "name": { - "type": "string", - "description": "Name of the configuration." - }, - "record_status": { - "type": "string", - "description": "Status of the record (e.g., active)." - }, - "is_suggested": { - "type": "boolean", - "description": "Indicates if the configuration is suggested." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp." - }, - "is_valid_on_bstack": { - "type": "boolean", - "example": false - } - } - }, - "Dataset": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "variables": { - "type": "array", - "description": "List of dataset variables/columns.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the variable/column." - }, - "uuid": { - "type": "string", - "description": "Unique identifier for the variable/column." + "description": "History as returned by `list_test_case_histories`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "History" + } + ], + "properties": { + "user": { + "$schema": "User__ListTestCaseHistories" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + }, + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "new": { + "type": "object", + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + }, + "old": { + "type": "object", + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } } - } - } - }, - "rows": { - "type": "array", - "description": "List of dataset rows.", - "items": { - "type": "object", - "properties": { - "row_data": { - "type": "array", - "description": "Array of values for the row.", - "items": { - "type": "string" - } - }, - "row_number": { - "type": "integer", - "description": "Row number in the dataset." - }, - "uuid": { - "type": "string", - "description": "Unique identifier for the row." + }, + "User__ListTestCaseHistories": { + "type": "object", + "description": "User as returned by `list_test_case_histories`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } } - } - } - }, - "rows_count": { - "type": "integer", - "description": "Total number of rows in the dataset." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was created." - }, - "created_by": { - "$schema": "DatasetUserRef" - } - } - }, - "ExploratorySessionUserRef": { - "type": "object", - "description": "A user reference object returned in exploratory session responses.", - "nullable": true, - "properties": { - "id": { - "type": "integer", - "description": "TCM user ID.", - "example": 1 - }, - "email": { - "type": "string", - "format": "email", - "description": "User email address.", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "description": "User full name.", - "example": "Jane Doe" - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to.", - "example": 10 - } - } - }, - "ExploratorySessionIssue": { - "type": "object", - "description": "A linked issue / defect associated with an exploratory session or session log.", - "required": [ - "issue_id", - "issue_type" - ], - "properties": { - "issue_id": { - "type": "string", - "description": "Identifier of the issue in the external issue tracker (e.g. Jira key).", - "example": "PROJ-123" - }, - "issue_type": { - "type": "string", - "description": "Type of the issue in the external tracker.", - "example": "Bug" - } - } - }, - "ExploratorySessionTimelineEntry": { - "type": "object", - "description": "A single entry on an exploratory session's timeline: {status, elapsed_time, created_by}. It carries no `action` or `timestamp`.", - "properties": { - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "elapsed_time": { - "type": "integer", - "example": 120 - }, - "status": { - "type": "string", - "example": "bug" - } - } - }, - "ExploratorySession": { - "type": "object", - "description": "A full exploratory testing session, as returned by the single-session read, create, update and clone. The LIST route returns a narrower projection \u2014 see ExploratorySessionListRow.\n", - "allOf": [ - { - "$schema": "ExploratorySessionCore" - } - ], - "properties": { - "description": { - "type": "string", - "description": "Optional description of the session.", - "example": "Exploring the checkout flow for edge cases.", - "nullable": true - }, - "charter": { - "type": "string", - "description": "Testing charter describing the scope and goals of this session.", - "example": "Explore payment gateway error handling", - "nullable": true - }, - "duration": { - "type": "integer", - "description": "Actual duration tracked during the session in minutes.", - "example": 45, - "nullable": true - }, - "actual_duration": { - "type": "integer", - "description": "Final recorded duration of the session in minutes (set on close).", - "example": 50, - "nullable": true - }, - "session_log_count": { - "type": "integer", - "description": "Number of logs recorded in this session.", - "example": 12 - }, - "closed_by": { - "$schema": "ExploratorySessionUserRef" - }, - "configurations": { - "type": "array", - "description": "Configurations associated with the session.", - "items": { - "type": "object" - } - }, - "issues": { - "type": "array", - "description": "Issues linked to this session.", - "items": { - "$schema": "ExploratorySessionIssue" - } - }, - "timeline": { - "type": "array", - "description": "Ordered list of lifecycle events for this session.", - "items": { - "$schema": "ExploratorySessionTimelineEntry" - } - }, - "owner": { - "type": "integer", - "nullable": true - }, - "record_status": { - "type": "string", - "description": "Lifecycle state of the record, e.g. `active`.", - "example": "active" - }, - "closed_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "attachment_relations": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Join rows linking the session to its attachments; parallel to `attachments`." - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - } - } - }, - "ExploratorySessionLog": { - "type": "object", - "description": "A log entry recorded within an exploratory testing session.", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the session log.", - "example": 789 - }, - "session_id": { - "type": "integer", - "description": "ID of the exploratory session this log belongs to.", - "example": 123 - }, - "content": { - "type": "string", - "description": "Rich-text HTML content of the log entry.", - "example": "

Found an issue with the payment button on mobile.

", - "nullable": true - }, - "status": { - "type": "string", - "description": "Test result status for this log entry.", - "enum": [ - "pass", - "fail", - "bug", - "retest", - "block", - "note" - ], - "example": "fail", - "nullable": true - }, - "elapsed_time": { - "type": "integer", - "description": "Time elapsed for this step in seconds.", - "example": 120, - "nullable": true - }, - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "defects": { - "type": "array", - "description": "Defects / issues linked to this log entry.", - "items": { - "$schema": "ExploratorySessionIssue" - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the log was created.", - "example": "2026-01-16T10:35:00Z" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the log was last updated.", - "example": "2026-01-16T10:40:00Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "attachment_relations": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Join rows linking the log to its attachments; parallel to `attachments`." - } - } - }, - "Filter": { - "type": "object", - "description": "Filter object representing a saved filter view", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the filter" - }, - "name": { - "type": "string", - "description": "Name of the filter" - }, - "entity_type": { - "type": "string", - "description": "Entity type the filter applies to", - "enum": [ - "testcase", - "testplan", - "testrun" - ] - }, - "is_project_level": { - "type": "boolean", - "description": "Whether this filter is available at project level" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the filter was created" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the filter was last updated" - }, - "owner": { - "type": "integer", - "description": "User ID of the creator" - }, - "project_id": { - "type": "integer", - "description": "Project ID this filter belongs to" - } - } - }, - "ProjectCreateRequest": { - "type": "object", - "required": [ - "project" - ], - "properties": { - "project": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - } - } - } - }, - "ReportType": { - "type": "string", - "description": "Report type, sent as its DISPLAY NAME (not a machine slug).\n\nSeven types produce data. `Defects Summary` and `Defects Detailed Report` are\naccepted on create/update but have no data branch on the server, so such a report\nalways reads back with `report_data: null` \u2014 never offer them as a way to report\non defects (use `Test Run Summary`, whose sections include `issues_by_priority` /\n`issues_by_status`).\n\nPick by what the user asked about:\n - test cases created / updated / deleted, churn, top creators \u2192 `Test Case Activity`\n - run execution + pass rates \u2192 `Test Run Summary`\n - per-case results within runs \u2192 `Test Run Detailed Report`\n - plan-level rollup \u2192 `Test Plan Summary`\n - requirement/issue coverage \u2192 `Requirement Traceability Report`\n - exploratory sessions \u2192 `Exploratory Session Summary`\n - per-user workload \u2192 `User Workload Report`\n", - "enum": [ - "Test Run Summary", - "Test Run Detailed Report", - "Test Plan Summary", - "Requirement Traceability Report", - "Test Case Activity", - "Exploratory Session Summary", - "User Workload Report", - "Defects Summary", - "Defects Detailed Report" - ], - "example": "Test Run Summary" - }, - "ReportFrequency": { - "type": "string", - "description": "Scheduling cadence. Send `frequency` and `frequency_details` TOGETHER, or omit\nBOTH \u2014 omitting both creates a valid unscheduled, on-demand report.\n\nTwo consequences of omitting them: `mail_to` is only persisted for scheduled\nreports (recipients on an unscheduled report are silently dropped), and\n`next_run_at` stays null.\n\nThere is no `once` value \u2014 the server's cadence enum is daily/weekly/monthly only.\n", - "enum": [ - "daily", - "weekly", - "monthly" - ], - "example": "weekly" - }, - "ReportTimeframe": { - "type": "string", - "description": "The window a report covers. Also the value of the `reportTimeRange` query param\non the report-detail read.\n\nThe four relative windows compute a date range from \"now\". `custom` computes it\nfrom `custom_date_range` and **requires** that field \u2014 sending `custom` without it\nis an unhandled server error, not a 422.\n\nThe five remaining values carry no dates; they select entities through\n`report_filters` instead:\n `specific_test_runs` \u2192 report_filters.test_runs\n `specific_test_plans` \u2192 report_filters.test_plans\n `specific_test_cases` \u2192 report_filters.test_cases\n `specific_sessions` \u2192 report_filters.session_ids\n `requirements` \u2192 report_filters.requirements\n", - "enum": [ - "last_one_day", - "last_one_week", - "last_one_month", - "last_three_months", - "custom", - "specific_test_runs", - "specific_test_plans", - "specific_test_cases", - "specific_sessions", - "requirements" - ], - "example": "last_one_week" - }, - "FrequencyDetails": { - "type": "object", - "description": "Shape depends on `frequency` \u2014 the server rejects the wrong shape with\n\"Invalid report frequency\", which names `frequency` even when `frequency` itself\nwas fine and only `day` was missing:\n\n daily \u2192 { \"time\": \"08:00\" }\n weekly \u2192 { \"time\": \"08:00\", \"day\": \"monday\" } day = lowercase day name\n monthly \u2192 { \"time\": \"08:00\", \"day\": 15 } day = integer 1-28\n\n`time` is 24-hour `HH:MM`, interpreted as UTC.\n", - "required": [ - "time" - ], - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - } - }, - "ReportOwner": { - "type": "object", - "required": [ - "id", - "email", - "full_name", - "group_id", - "browserstack_user_id", - "onboarded", - "reports_and_notification_enabled" - ], - "properties": { - "id": { - "type": "integer", - "example": 2 - }, - "email": { - "type": "string", - "format": "email", - "example": "fake1@example.com" - }, - "full_name": { - "type": "string", - "example": "Fake Name" - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "browserstack_user_id": { - "type": "integer", - "example": 2 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "MailToUser": { - "type": "object", - "required": [ - "users", - "external_mails" - ], - "properties": { - "users": { - "type": "array", - "items": { - "$schema": "ReportOwner" - } - }, - "external_mails": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ReportDetail": { - "type": "object", - "required": [ - "id", - "report_type", - "created_at", - "description", - "title", - "frequency", - "identifier", - "next_run_at", - "report_timeframe", - "group_id", - "frequency_details", - "project_id", - "report_creation_mode", - "owner", - "mail_to" - ], - "properties": { - "id": { - "type": "integer", - "example": 3 - }, - "report_type": { - "$schema": "ReportType" - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-12T12:52:17.658Z" - }, - "description": { - "type": "string", - "example": "" - }, - "title": { - "type": "string", - "example": "Test Run Summary" - }, - "frequency": { - "type": "object" - }, - "identifier": { - "type": "string", - "example": "SC-3" - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-13T05:00:00.000Z", - "nullable": true - }, - "report_timeframe": { - "$schema": "ReportTimeframe" - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "frequency_details": { - "type": "object", - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - }, - "required": [ - "time" - ] - }, - "project_id": { - "type": "integer", - "example": 10000 - }, - "report_creation_mode": { - "type": "string", - "enum": [ - "custom_report", - "system_generated" - ], - "example": "custom_report" - }, - "custom_date_range": { - "type": "string", - "example": null, - "nullable": true - }, - "owner": { - "$schema": "ReportOwner" - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "included_projects": { - "type": "object" - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "slack_notification_supported": { - "type": "boolean", - "example": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T04:32:35.703Z" - } - } - }, - "CreateReportResponse": { - "type": "object", - "required": [ - "success", - "data", - "project" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "$schema": "ReportDetail__CreateReportByIntegerId" - }, - "project": { - "$schema": "Project__CreateReportByIntegerId" - } - } - }, - "FolderHierarchy": { - "type": "object", - "description": "Folder hierarchy mapping. Each key is a folder ID (as a string), and the value is an array of ancestor folder objects ordered from the folder itself up to the root.\n", - "example": { - "9": [ - { - "id": 9, - "name": "1", - "parent_id": null - } - ], - "10": [ - { - "id": 10, - "name": "1.2", - "parent_id": 9 }, - { - "id": 9, - "name": "1", - "parent_id": null - } - ], - "11": [ - { - "id": 11, - "name": "1.2.2", - "parent_id": 10 + "History__GetTestCaseHistory": { + "type": "object", + "description": "History as returned by `get_test_case_history`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "History" + } + ], + "properties": { + "user": { + "$schema": "User__GetTestCaseHistory" + }, + "modified": { + "type": "object", + "description": "Details of what was modified (field changes)", + "example": { + "template": { + "old": "Test Case Steps", + "new": "Test Case BDD" + } + }, + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "new": { + "type": "object", + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + }, + "old": { + "type": "object", + "properties": { + "background": { + "type": "string", + "nullable": true + }, + "feature": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed - updated" + }, + "scenario": { + "type": "string", + "nullable": true + }, + "shared_step_detail_id": { + "type": "string", + "nullable": true + }, + "shared_step_id": { + "type": "string", + "nullable": true + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } }, - { - "id": 10, - "name": "1.2", - "parent_id": 9 + "User__GetTestCaseHistory": { + "type": "object", + "description": "User as returned by `get_test_case_history`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } + } }, - { - "id": 9, - "name": "1", - "parent_id": null - } - ] - } - }, - "SharedStepBase": { - "type": "object", - "required": [ - "title", - "id", - "created_by", - "updated_by" - ], - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - } - } - }, - "SharedStepDetail": { - "type": "object", - "required": [ - "id", - "step", - "result", - "order" - ], - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string", - "description": "HTML formatted step content" - }, - "result": { - "type": "string", - "description": "HTML formatted expected result content" - }, - "order": { - "type": "integer" - }, - "test_data": { - "type": "string", - "description": "Optional test data for the step" - } - } - }, - "ResultStatus": { - "type": "object", - "description": "A configured status value. Shared by the v1 result reads, the v1 result create and the report detail; the v2 per-case result list returns a cut-down version of its own and does not use this shape.", - "required": [ - "id", - "field_value", - "internal_name", - "field_name", - "colour" - ], - "properties": { - "id": { - "type": "integer", - "example": 22 - }, - "field_value": { - "type": "string", - "example": "Failed", - "description": "Display-case name. This is the value to match on." - }, - "internal_name": { - "type": "string", - "example": "failed", - "description": "Lowercase internal token for the same status." - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "Attachment": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - } - } - }, - "TestResult": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "author": { - "$schema": "User" - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-25T20:02:05.000Z" - }, - "description": { - "type": "string" - }, - "error_description": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "example": null, - "nullable": true - }, - "failure_type": { - "type": "string", - "example": null, - "nullable": true - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "type": "string", - "example": "passed" - }, - "result_status": { - "$schema": "ResultStatus" - }, - "configuration_id": { - "type": "integer", - "description": "ID of the configuration this test result is associated with", - "example": 16, - "nullable": true - }, - "time_elapsed": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "size": { - "type": "string" + "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId": { + "type": "object", + "description": "V1TestRunTestCaseRow as returned by `list_test_run_test_cases_by_integer_id`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestRunTestCaseRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + }, + "priority": { + "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId" + } } - } - } - }, - "created_by_imported": { - "type": "boolean", - "nullable": true - }, - "issues": { - "type": "array", - "example": [], - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" - }, - "custom_fields": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473/custom-fields" - } - } - }, - "custom_fields": { - "type": "object" - }, - "test_run_step_result": { - "type": "object", - "nullable": true - }, - "updated_by": { - "type": "string", - "nullable": true - } - } - }, - "Report": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Report ID" - }, - "report_type": { - "$schema": "ReportType" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp indicating when the scheduled report was created" - }, - "description": { - "type": "string", - "description": "Description of the scheduled report" - }, - "title": { - "type": "string", - "description": "Title of the scheduled report" - }, - "frequency": { - "type": "object" - }, - "identifier": { - "type": "string", - "description": "The report's SC-NNN identifier" - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "description": "Next scheduled iteration, or null when the report is not scheduled", - "nullable": true - }, - "report_timeframe": { - "$schema": "ReportTimeframe" - }, - "group_id": { - "type": "integer" - }, - "frequency_details": { - "type": "object", - "properties": { - "time": { - "type": "string", - "description": "24-hour HH:MM, UTC.", - "example": "08:00" - }, - "day": { - "description": "Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.\n", - "example": "monday" - } - }, - "required": [ - "time" - ] - }, - "project_id": { - "type": "integer" - }, - "report_creation_mode": { - "type": "string", - "enum": [ - "custom_report", - "system_generated" - ] - }, - "custom_date_range": { - "type": "string" - }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Id of the report owner" - }, - "email": { - "type": "string", - "description": "Email to which the report is to be sent" - }, - "full_name": { - "type": "string", - "description": "Name of the owner" - }, - "group_id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "included_projects": { - "type": "object" - }, - "is_dynamic": { - "type": "boolean", - "example": false - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-21T13:41:14.555Z" - } - } - }, - "AttachmentResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "filename": { - "type": "string", - "example": "document1.pdf" - }, - "content_type": { - "type": "string", - "example": "application/pdf" - }, - "byte_size": { - "type": "integer", - "example": 102400 - } - } - }, - "configuration": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the configuration." - }, - "group_id": { - "type": "integer", - "description": "ID of the group associated with the configuration." - }, - "name": { - "type": "string", - "description": "Name of the configuration." - }, - "record_status": { - "type": "string", - "description": "Status of the record (e.g., active)." - }, - "is_suggested": { - "type": "boolean", - "description": "Indicates if the configuration is suggested." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Creation timestamp." - }, - "browser": { - "type": "string", - "example": "Chrome" - }, - "browser_version": { - "type": "string", - "example": "146" - }, - "device": { - "type": "string", - "nullable": true - }, - "is_valid_on_bstack": { - "type": "boolean", - "example": false - }, - "os": { - "type": "string", - "example": "Windows" - }, - "os_version": { - "type": "string", - "example": "10" - } - } - }, - "ConfigurationListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful." - }, - "configurations": { - "type": "array", - "description": "List of configuration objects.", - "items": { - "$schema": "configuration" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "DatasetVariable": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset column/variable." - }, - "name": { - "type": "string", - "description": "Name of the column/variable." - }, - "dataset": { - "type": "object", - "description": "Dataset information that this column belongs to.", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "count_rows": { - "type": "integer", - "description": "Number of rows in the dataset." - } - } - } - } - }, - "DatasetVariablesResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the request was successful." - }, - "variables": { - "type": "array", - "description": "List of dataset columns/variables.", - "items": { - "$schema": "DatasetVariable" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "MyselfUser": { - "type": "object", - "required": [ - "id", - "full_name", - "reports_and_notification_enabled" - ], - "properties": { - "id": { - "type": "integer", - "description": "User ID" - }, - "full_name": { - "type": "string", - "description": "Full name of the current user" - }, - "email": { - "type": "string", - "description": "Email address of the current user" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Whether reports and notifications are enabled for the user" - } - } - }, - "UsersV2Response": { - "type": "object", - "required": [ - "success", - "users", - "myself", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "users": { - "type": "array", - "items": { - "$schema": "User" - } - }, - "myself": { - "$schema": "MyselfUser" - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "V1ProjectDetail": { - "type": "object", - "description": "v1 project-detail envelope (GET /api/v1/projects/{project_id}, by INTEGER id). The translation output is data.project.identifier (the PR-NNN form), alongside data.project.id (the integer id you queried by).\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "project": { + }, + "TestCaseFieldValue__ListTestRunTestCasesByIntegerId": { "type": "object", + "description": "TestCaseFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], "properties": { - "identifier": { - "type": "string", - "description": "PR-NNN identifier \u2014 the v2 {project_id}. (Integer -> PR-NNN translation output.)", - "example": "PR-1" - }, - "id": { - "type": "integer", - "description": "Integer project id \u2014 the value passed in the path. Same as thProjectId.", - "example": 11046 - }, - "thProjectId": { - "type": "integer", - "example": 11046 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "description": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "nullable": true - }, - "test_runs_count": { - "type": "integer", - "nullable": true - }, - "test_plans_count": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "starred": { - "type": "boolean" - }, - "jira_mapped": { - "type": "boolean" - }, - "links": { - "type": "object" - }, - "import_id": { - "type": "integer", - "nullable": true, - "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." - }, - "normalisedName": { - "type": "string", - "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." - }, - "projectVisibilityBanner": { - "type": "object", - "nullable": true, - "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." - } + "field_name": { + "type": "string", + "example": "priority" + } } - } - } - } - } - }, - "ProjectMinifyResponse": { - "type": "object", - "required": [ - "success", - "projects" - ], - "properties": { - "success": { - "type": "boolean" - }, - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "import_id": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "starred": { - "type": "boolean" - }, - "access_via_secondary_team": { - "type": "boolean", - "example": false + }, + "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId": { + "type": "object", + "description": "ResultStatusFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatusFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - } - } - }, - "ProjectsPaginatedResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "import_id": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "starred": { - "type": "boolean" - }, - "access_via_secondary_team": { - "type": "boolean", - "example": false + }, + "TestRunsListResponse__ListTestPlanTestRunsByIntegerId": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_test_plan_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17574995" + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "example": "MCP+capability+probe+fixture+run+%28link_entities_to_jira_issue%29" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "configurations": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string" + }, + "links": { + "type": "object" + }, + "metadata": { + "type": "object", + "properties": { + "test_plan_error": { + "type": "string", + "example": "PLAN_LINKED" + } + } + }, + "observability_url": { + "type": "string" + }, + "updated_by": { + "type": "string", + "nullable": true + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 1 + }, + "closed": { + "type": "integer", + "example": 0 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } } - } - } - }, - "info": { - "$schema": "PageInfo" - } - }, - "required": [ - "info", - "projects", - "success" - ] - }, - "ReportFiltersDetail": { - "type": "object", - "required": [ - "custom_fields", - "test_run_ids", - "first_test_run" - ], - "properties": { - "custom_fields": { - "type": "object" - }, - "test_run_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "first_test_run": { - "type": "object", - "required": [ - "id", - "name", - "created_at", - "identifier", - "is_automation", - "run_state", - "updated_at", - "project_id", - "user_id", - "group_id" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "environment": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "run_state": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "user_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - } - } - } - } - }, - "DynamicFilters": { - "type": "object", - "properties": { - "test_run": { - "type": "object", - "required": [ - "created_at", - "status", - "owner", - "automation_status", - "type" - ], - "properties": { - "created_at": { - "type": "string" - }, - "status": { - "type": "array", - "items": { - "type": "integer" - } - }, - "owner": { - "type": "array", - "items": { - "type": "integer" - } - }, - "automation_status": { - "type": "array", - "items": { - "type": "string" - } - }, - "type": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "ReportData": { - "type": "object", - "required": [ - "test_run_data", - "issues_data", - "test_run_status", - "test_cases_status", - "user_result_stats", - "issues_by_status", - "issues_by_priority" - ], - "properties": { - "test_run_data": { - "type": "object", - "required": [ - "active", - "closed", - "test_cases", - "test_results" - ], - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - }, - "test_cases": { - "type": "integer" - }, - "test_results": { - "type": "integer" - } - } - }, - "issues_data": { - "type": "object", - "required": [ - "total", - "linked_with_test_runs", - "linked_with_test_results" - ], - "properties": { - "total": { - "type": "integer" - }, - "linked_with_test_runs": { - "type": "integer" - }, - "linked_with_test_results": { - "type": "integer" - } - } - }, - "test_run_status": { - "type": "object" - }, - "test_cases_status": { - "type": "array", - "items": { - "type": "object", - "required": [ - "count", - "result_status" - ], - "properties": { - "count": { - "type": "integer" - }, - "result_status": { - "$schema": "ResultStatus" + }, + "TestRunUserRef__GetTestRunByIntegerId": { + "type": "object", + "description": "TestRunUserRef as returned by `get_test_run_by_integer_id`. Composes TestRunUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } } - } - } - }, - "user_result_stats": { - "type": "array", - "items": { - "type": "object", - "required": [ - "user_id", - "test_results", - "user_name" - ], - "properties": { - "user_id": { - "type": "integer" - }, - "test_results": { - "type": "integer" - }, - "user_name": { - "type": "string" + }, + "TestRunsListResponse__ListTestRunsByIntegerId": { + "type": "object", + "description": "TestRunsListResponse as returned by `list_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "TestRunsListResponse" + } + ], + "properties": { + "test_runs": { + "type": "object", + "properties": { + "observability_id": { + "type": "string", + "example": "17579092" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "closed_at": { + "type": "string", + "nullable": true + }, + "closed_by": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + }, + "updated_by": { + "type": "string", + "nullable": true + }, + "build_meta": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "example": "__mcp-probe-createrun-20260921-260922110512" + }, + "build_number": { + "type": "integer", + "example": 1 + }, + "creationType": { + "type": "string", + "example": "MANUAL" + } + } + } + } + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 60 + }, + "closed": { + "type": "integer", + "example": 1 + } + } + }, + "info": { + "type": "object", + "properties": { + "next": { + "type": "integer", + "example": 2 + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } } - } - } - }, - "issues_by_status": { - "type": "array", - "items": { - "type": "object" - } - }, - "issues_by_priority": { - "type": "array", - "items": { - "type": "object" - } - }, - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "object" - } - } - }, - "ReportProjectDetail": { - "type": "object", - "required": [ - "id", - "group_id", - "user_id", - "name", - "slug", - "description", - "identifier", - "is_demo", - "publicly_readable", - "created_at", - "source", - "archived", - "team_id", - "default_team_id" - ], - "properties": { - "id": { - "type": "integer", - "example": 10000 - }, - "group_id": { - "type": "integer", - "example": 1 - }, - "user_id": { - "type": "integer", - "example": 2 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "slug": { - "type": "string", - "example": "Demo-Project-8e37e463" - }, - "description": { - "type": "string", - "example": "This project contains test cases and test runs for the demo project" - }, - "identifier": { - "type": "string", - "example": "PR-3" - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2024-06-28T00:00:00.000Z" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "archived": { - "type": "integer", - "example": 0 - }, - "team_id": { - "type": "integer", - "example": 1 - }, - "default_team_id": { - "type": "integer", - "example": null, - "nullable": true - }, - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "import_id": { - "type": "string", - "nullable": true - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "SelectionData": { - "type": "object", - "description": "A folder-keyed test-case selection: which cases, grouped by the folder they live in.\nAll three keys are required. Widen with `select_all` (whole project) or a folder's\n`test_case_select_all` (whole folder), then subtract with `deselected_test_cases`;\notherwise the cases are listed explicitly in `selected_test_cases`.\n", - "required": [ - "select_all", - "folders", - "unique_test_case_count" - ], - "properties": { - "select_all": { - "type": "boolean", - "description": "Select every case in the project.", - "example": false - }, - "folders": { - "type": "object", - "description": "Map of folder id (as a STRING key) to that folder's selection." - }, - "unique_test_case_count": { - "type": "integer", - "description": "Number of distinct cases the selection resolves to.", - "example": 3 - } - } - }, - "V1TestCaseDetail": { - "type": "object", - "description": "v1 test case by INTEGER ids (GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/{test_case_id}). Translation output: data.test_case.identifier is the TC-NNN form. The row is the full shared test-case record, not a stub.\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "description": "The full test-case record. `identifier` is the TC-NNN id and `id` is the integer passed in the path. The display field is `name`; there is no `title`.", - "$schema": "V1TestCaseRecordRow__GetTestCaseByIntegerId" - } - } - } - } - }, - "TestCaseTrend": { - "type": "object", - "required": [ - "graphData", - "field", - "value" - ], - "properties": { - "graphData": { - "type": "object", - "properties": { - "Jun": { - "type": "integer" - }, - "Jul": { - "type": "integer" - }, - "Aug": { - "type": "integer" - }, - "Sep": { - "type": "integer" - }, - "Oct": { - "type": "integer" - }, - "Nov": { - "type": "integer" - }, - "Dec": { - "type": "integer" - }, - "Jan": { - "type": "integer" - }, - "Feb": { - "type": "integer" - }, - "Mar": { - "type": "integer" - }, - "Apr": { - "type": "integer" - }, - "May": { - "type": "integer" - } - } - }, - "field": { - "type": "string" - }, - "value": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "History": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "history record ID" - }, - "entity_id": { - "type": "integer", - "description": "test case ID this history belongs to" - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "version_id": { - "type": "string", - "format": "uuid" - }, - "source": { - "type": "string", - "description": "Source of the change (e.g., \"update\", \"create\")" - }, - "modified_fields": { - "type": "array", - "description": "List of fields that were modified in this version", - "items": { - "type": "string" - } - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "comment": { - "type": "string", - "nullable": true - }, - "user_id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - } - }, - "version_name": { - "type": "string", - "nullable": true - }, - "user": { - "$schema": "User" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "description": "API endpoint to retrieve this specific history record", - "example": "/api/v1/projects/2022091/test-cases/36347792/histories/17604265" - } - } - }, - "origin_channel": { - "type": "string", - "example": "api" - } - } - }, - "HistoryListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "histories": { - "type": "array", - "description": "List of history records for the test case", - "items": { - "$schema": "History__ListTestCaseHistories" - } - }, - "info": { - "$schema": "PageInfo" - }, - "plan_restriction": { - "type": "boolean" - } - } - }, - "TestCaseDiffStep": { - "type": "object", - "required": [ - "id", - "step", - "order", - "result" - ], - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string" - }, - "order": { - "type": "integer" - }, - "result": { - "type": "string" - }, - "shared_step_id": { - "type": "integer", - "nullable": true - } - } - }, - "TestCaseConsolidatedResultFolders": { - "type": "object" - }, - "TestCaseConsolidatedListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "V1TestCaseListRow__ListTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "folders": { - "$schema": "TestCaseConsolidatedResultFolders" - } - } - }, - "V1TestPlanDetail": { - "type": "object", - "description": "v1 test-plan detail (GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}, by INTEGER ids). NOTE: there is NO `data` wrapper \u2014 the plan sits at the top level under `test_plan`. Translation output: test_plan.identifier (the TP-NNN form).\n", - "properties": { - "success": { - "type": "boolean" - }, - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { + }, + "DatasetUserRef__ListDatasets": { "type": "object", + "description": "DatasetUserRef as returned by `list_datasets`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - } - } - } - } - }, - "ResultCustomField": { - "type": "object", - "required": [ - "id", - "field_type", - "field_name", - "entity_type", - "field_user_name", - "optional", - "is_bulk_editable", - "is_filterable", - "links", - "is_required" - ], - "properties": { - "id": { - "type": "integer" - }, - "field_type": { - "type": "string", - "enum": [ - "field_string", - "field_text", - "field_user", - "field_dropdown", - "field_multi_dropdown", - "field_url", - "field_boolean", - "field_int", - "field_date", - "field_rte" - ] - }, - "field_name": { - "type": "string" - }, - "entity_type": { - "type": "string", - "enum": [ - "TestCase", - "TestResult", - "TestExecution", - "Build" - ] - }, - "field_user_name": { - "type": "string" - }, - "optional": { - "type": "boolean" - }, - "is_bulk_editable": { - "type": "boolean" - }, - "is_filterable": { - "type": "boolean" - }, - "links": { - "type": "object", - "required": [ - "self" - ], - "properties": { - "self": { - "type": "string" - } - } - }, - "placeholder": { - "type": "string" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "object" - }, - "option_values": { - "type": "array", - "items": { - "type": "object" - } - }, - "field_values": { - "type": "array", - "items": { - "type": "object" - } - }, - "sort_options": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - } - } - } - }, - "TestResultCustomFieldsResponse": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "array", - "items": { - "$schema": "ResultCustomField" - } - }, - "info": { - "$schema": "PageInfo__GetTestResultsCustomFields" - } - } - }, - "V1TestRunDetail": { - "type": "object", - "description": "v1 test-run detail (GET /api/v1/projects/{project_id}/test-runs/{test_run_id}, by INTEGER ids). Translation output: data.test_run.identifier (the TR-NNN form). The integer run id is surfaced as data.test_run.uuid.\n", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_run": { - "$schema": "V1TestRunDetailRow" - } - } - } - } - }, - "TestResultStatusValue": { - "type": "object", - "required": [ - "internal_name", - "is_default", - "colour", - "value", - "name" - ], - "properties": { - "internal_name": { - "type": "string", - "description": "Internal system name for the status" - }, - "is_default": { - "type": "integer", - "description": "Whether this is the default status (1 or 0)", - "enum": [ - 0, - 1 - ] - }, - "colour": { - "type": "string", - "description": "Hex color code for the status" - }, - "value": { - "type": "integer", - "description": "Numeric value identifier for the status" - }, - "name": { - "type": "string", - "description": "Display name of the status" - } - } - }, - "TestResultStatusField": { - "type": "object", - "required": [ - "values", - "total_count", - "links" - ], - "properties": { - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "is_default": { - "type": "integer", - "example": 0 - }, - "name": { - "type": "string", - "example": "Passed" - }, - "value": { - "type": "integer", - "example": 318610 - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "total_count": { - "type": "integer", - "example": 8 - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/test-case/status" - } - } - } - } - }, - "CustomField": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier of the custom field" - }, - "field_type": { - "type": "string", - "description": "Type of the custom field", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date" - ] - }, - "field_name": { - "type": "string", - "description": "Name of the custom field" - }, - "is_required": { - "type": "boolean", - "description": "Whether the custom field is required" - }, - "place_holder_text": { - "type": "string", - "description": "Placeholder text for the custom field", - "nullable": true - }, - "default_value": { - "type": "string", - "description": "Default value for the custom field", - "nullable": true - }, - "entity_type": { - "type": "string", - "description": "Type of entity this field is for", - "example": "TestCase" - }, - "options": { - "type": "array", - "description": "Options for dropdown or multi-dropdown fields", - "items": { - "type": "object", - "properties": { - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" + "email": { + "type": "string", + "example": "user@example.com" + } } - } - } - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Whether the custom field applies to all projects" - }, - "assigned_projects": { - "type": "array", - "description": "List of project identifiers this field is assigned to", - "items": { - "type": "string" - } - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Whether to link this field to future projects" - } - } - }, - "TestRunFormFieldsResponse": { - "type": "object", - "required": [ - "success", - "default_fields" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "default_fields": { - "type": "object", - "description": "Default field values for test results. Only 'status' field is included by default.", - "properties": { - "status": { - "$schema": "FormFieldDescriptor" - } - } - }, - "custom_fields": { - "type": "array", - "description": "Custom fields array. Only present when 'all' parameter is true.", - "items": { - "$schema": "CustomField" - } - } - } - }, - "DatasetMini": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "Unique identifier for the dataset." - }, - "name": { - "type": "string", - "description": "Name of the dataset." - }, - "rows_count": { - "type": "integer", - "description": "Total number of rows in the dataset." - }, - "test_cases_count": { - "type": "integer", - "description": "Number of test cases using this dataset." - }, - "created_by": { - "$schema": "DatasetUserRef__ListDatasets" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was created." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the dataset was last updated." - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } - } - }, - "DatasetsListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the request was successful." - }, - "datasets": { - "type": "array", - "description": "List of datasets.", - "items": { - "$schema": "DatasetMini" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "V1DuplicatesListResponse": { - "type": "object", - "description": "Suggested-duplicate list. NOTE this exact shape with an empty `duplicates` is also what\nthe server returns when the group lacks the dedupe feature flag, so an empty list is not\nevidence that the project has no duplicates \u2014 check .../ai-duplicates/status.\n", - "properties": { - "success": { - "type": "boolean" - }, - "duplicates": { - "type": "array", - "items": { - "type": "object" - } - }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "total_pages": { - "type": "integer" - } - } - } - } - }, - "ExploratorySessionLogListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_logs": { - "type": "array", - "items": { - "$schema": "ExploratorySessionLog" - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "per_page": { - "type": "integer", - "example": 50 - }, - "total_count": { - "type": "integer", - "example": 24 - }, - "total_pages": { - "type": "integer", - "example": 1 - } - } - } - } - }, - "ExploratorySessionListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_sessions": { - "type": "array", - "items": { - "$schema": "ExploratorySession" - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "per_page": { - "type": "integer", - "example": 30 - }, - "total_count": { - "type": "integer", - "example": 150 - }, - "total_pages": { - "type": "integer", - "example": 5 - } - } - }, - "count": { - "type": "integer", - "description": "Total number of sessions matching the query.", - "example": 150 - } - } - }, - "ProjectsListResponse": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful.", - "example": true - }, - "projects": { - "type": "array", - "description": "List of project objects.", - "items": { - "$schema": "Project__ListProjects" - } - }, - "info": { - "$schema": "ProjectsPageInfo" - } - } - }, - "ReportScheduleGetResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "Contains an array of Scheduled reports", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "The total number of scheduled reports in the project." - }, - "total_report_count": { - "type": "integer", - "description": "The total number of reports in the project." - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "Tag": { - "type": "object", - "required": [ - "name", - "created_at" - ], - "properties": { - "name": { - "type": "string", - "description": "Tag name", - "example": "AI Generated" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the tag was first used", - "example": "2024-10-08T09:25:09.000Z" - }, - "usage_count": { - "type": "integer", - "description": "Number of test cases using this tag (optional)", - "example": 42 - } - } - }, - "ProjectUserDetail": { - "type": "object", - "required": [ - "id", - "full_name", - "onboarded", - "group_id", - "browserstack_user_id", - "role", - "has_access", - "permissions" - ], - "properties": { - "id": { - "type": "integer", - "description": "User ID", - "example": 240620 - }, - "full_name": { - "type": "string", - "description": "User's full name", - "example": "Jasbir Singh" - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status (1 = completed, 0 = pending)", - "enum": [ - 0, - 1 - ], - "example": 1 - }, - "group_id": { - "type": "integer", - "description": "Group this user belongs to", - "example": 1 - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user identifier", - "example": 10328238 - }, - "role": { - "type": "string", - "description": "User role in the system", - "enum": [ - "admin", - "user", - "viewer" - ], - "example": "user" - }, - "has_access": { - "type": "boolean", - "description": "Whether user has access to the project", - "example": true - }, - "permissions": { - "type": "array", - "description": "List of permissions granted to user", - "example": [ - "tc_write", - "project_write", - "test_run_execute" - ], - "items": { - "type": "string", - "enum": [ - "tc_write", - "tc_delete", - "tc_archive_test_cases", - "project_write", - "test_plan_write", - "test_plan_delete", - "test_run_write", - "test_run_delete", - "test_run_execute" - ] - } - }, - "is_delighted_visible": { - "type": "boolean", - "description": "Whether Delighted feedback widget is visible", - "example": false - }, - "is_product_intro_visible": { - "type": "boolean", - "description": "Whether product introduction is visible", - "example": true - }, - "is_onboarding_project_header_visible": { - "type": "boolean", - "description": "Whether onboarding header is visible", - "example": true - }, - "is_lcnc_explore_dismissed": { - "type": "boolean", - "description": "Whether low-code/no-code explore banner is dismissed", - "example": false - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Whether reports and notifications are enabled", - "example": true - }, - "customisable_dashboard_accessible": { - "type": "boolean", - "description": "Whether customizable dashboard is accessible", - "example": true - }, - "webhook_accessible": { - "type": "boolean", - "description": "Whether webhooks are accessible", - "example": true - }, - "is_build_listing_enabled": { - "type": "boolean", - "description": "Whether build listing feature is enabled", - "example": true - }, - "productRole": { - "type": "string", - "description": "Product-level role", - "example": "Product User" - }, - "is_new_project_settings_visible": { - "type": "boolean", - "description": "Whether new project settings UI is visible", - "example": true - }, - "is_jira_app_project_panel_visible": { - "type": "boolean", - "description": "Whether Jira app project panel is visible", - "example": false - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "is_re_arch_enabled": { - "type": "boolean", - "example": true - }, - "is_test_plan_detailed_report_enabled": { - "type": "boolean", - "example": false - } - } - }, - "ReportUpdateResponse": { - "type": "object", - "required": [ - "data", - "report_data", - "report_filters", - "dynamic_filters" - ], - "properties": { - "data": { - "$schema": "ReportDetail__UpdateReport" - }, - "report_data": { - "$schema": "ReportData" - }, - "report_filters": { - "$schema": "ReportFiltersDetail" - }, - "dynamic_filters": { - "$schema": "DynamicFilters" - }, - "filters": { - "type": "object" - }, - "is_dynamic": { - "type": "boolean", - "example": false - } - } - }, - "GenericAttachment": { - "type": "object", - "required": [ - "id", - "name", - "url", - "download_url", - "content_type", - "size" - ], - "properties": { - "id": { - "type": "integer", - "description": "Attachment ID", - "example": 10288829 - }, - "name": { - "type": "string", - "description": "Original filename", - "example": "screenshot.png" - }, - "url": { - "type": "string", - "format": "uri", - "description": "Pre-signed S3 URL for viewing (inline, ~26 min expiry)", - "example": "https://example.com/media/genric/1/2921471/86de8094..." - }, - "download_url": { - "type": "string", - "format": "uri", - "description": "Pre-signed S3 URL for downloading (attachment, ~26 min expiry)", - "example": "https://example.com/media/genric/1/2921471/86de8094..." - }, - "content_type": { - "type": "string", - "description": "MIME type of the file", - "example": "image/png" - }, - "size": { - "type": "string", - "description": "Human-readable file size", - "example": "300 KB" - } - } - }, - "TestRunTestCaseV2": { - "type": "object", - "description": "One test case AS MAPPED INTO A TEST RUN \u2014 a case-plus-configuration row, not the project-level test case record. A case mapped to several configurations appears once per configuration. Shape as returned by the v2 run test-case listing.", - "properties": { - "identifier": { - "type": "string", - "example": "TC-42" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true, - "description": "The test case's description. Always present as a key; null for cases without one." - }, - "priority": { - "type": "string", - "description": "Resolved label, not the id." - }, - "status": { - "type": "string", - "description": "Test case status label." - }, - "case_type": { - "type": "string" - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." - }, - "latest_result_id": { - "type": "integer", - "description": "Id of the latest result recorded against this row; use it to read or amend that result. Populated even for never-executed rows \u2014 the result row is created when the case is mapped into the run \u2014 so it cannot be used to detect 'never executed'." - }, - "assignee": { - "type": "string", - "nullable": true, - "description": "Assignee of this row within the run." - }, - "folder_path": { - "type": "array", - "items": { - "type": "integer" - } - }, - "configuration_id": { - "type": "integer", - "nullable": true, - "description": "Configuration this row is for; null when the case is mapped without a configuration." - }, - "dataset": { - "type": "array", - "items": { - "type": "object", - "properties": { - "variable": { - "type": "string", - "description": "Name of the dataset variable (the dataset's column) this value came from. Only ever present on a data-driven row \u2014 see the array description; there are no entries at all otherwise." - }, - "row": { - "type": "string", - "description": "The value bound to that variable for this row. Same condition as `variable`." - } - } - }, - "description": "The dataset values bound to this run row, one entry per dataset variable. CONDITIONAL CONTENT \u2014 the key is there, but the array is EMPTY for every row that is not dataset-driven, which is most rows: entries exist only when the case was mapped into the run against a dataset. So `variable` and `row` below are only ever seen on data-driven rows. Asking for the compact/minified row shape drops the key altogether instead of returning it empty." - }, - "project_id": { - "type": "string", - "example": "PR-1", - "description": "Project of the run being read." - }, - "source_project_id": { - "type": "string", - "example": "PR-7", - "description": "The project the case originates from, for a case shared in from elsewhere. On the older run backend the rows embedded in a single-run read report the current project rather than the true origin, because the origin is only resolved when the caller is the standalone case listing; the newer backend resolves it per row on both. Treat it as authoritative only when it differs from the project you asked about." - }, - "source_project_name": { - "type": "string", - "nullable": true, - "description": "Display name of the project the case lives in. CONDITIONAL \u2014 only the older run backend returns this key at all; on the newer run backend it is never returned, neither in the run's case listing nor in the rows embedded in a single-run read. Where it is returned it is null unless the case is shared in from another project. `source_project_id` is always present, so use that to identify the origin project and treat this as a display-only extra." - }, - "is_shared": { - "type": "boolean", - "description": "Whether this case is shared in from another project." - }, - "execution_id": { - "type": "string", - "nullable": true, - "description": "Identifier of the current execution attempt for this row." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the case was mapped into this run \u2014 the row's creation time, not the test case's." - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "When this run row was last updated. Note the name: it is the row's updated_at, renamed on the way out." - }, - "created_by": { - "type": "string", - "nullable": true, - "description": "Email of whoever created this run row." - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Email of whoever last updated this run row; null when it has not been updated since creation." - }, - "urls": { - "type": "object", - "description": "Web-app deep links for the row. Always present. DEFECT \u2014 the link is currently built without the run segment, so it does not resolve; construct run links from the run identifier instead.", - "properties": { - "self": { - "type": "string", - "description": "Intended as an absolute link to this case within the run in the web app; currently malformed, see above." - } - } - } - } - }, - "V2BulkResultOutcome": { - "type": "object", - "description": "Per-test-case outcome of a bulk result submission. Reports whether the entry was accepted for async processing, not whether it was applied.", - "properties": { - "test_case_id": { - "type": "string", - "example": "TC-101" - }, - "status": { - "type": "string", - "values": [ - "ACCEPTED", - "FAILED" - ], - "description": "ACCEPTED means queued, not applied." - }, - "message": { - "type": "string", - "example": "Will be processed." - }, - "error": { - "type": "object", - "description": "Present only when status is FAILED.", - "properties": { - "code": { - "type": "string", - "values": [ - "INVALID_STATUS_VALUE", - "REVIEW_STATUS_GATE" - ] - }, - "message": { - "type": "string" - } - } - } - } - }, - "V2TestResultListItem": { - "type": "object", - "description": "One recorded result for a test case in a test run, as the v2 read returns it. Deliberately narrower than the v1 result shape: no author, backtrace, attachments or full status object.", - "properties": { - "id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_by": { - "type": "string", - "description": "Email of the user who recorded the result; null when the user is not in the caller's group.", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "test_case_id": { - "type": "string", - "description": "Echoed back as the TC- identifier that was requested, not a numeric id.", - "example": "TC-101" - }, - "test_run_id": { - "type": "string", - "example": "TR-14" - }, - "result_status": { - "type": "object", - "description": "Cut down to the display-case status name on this endpoint. The id, internal_name, field_name and colour that the v1 result reads return are deliberately dropped here and never appear.", - "required": [ - "field_value" - ], - "properties": { - "field_value": { - "type": "string", - "example": "Passed", - "description": "Display-case status name." - } - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues; empty when the result has none.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "example": "BUG-123" - }, - "issue_type": { - "type": "string", - "example": "jira" - }, - "created_at": { - "type": "string", - "format": "date-time" + }, + "TestCaseFieldValue__ListFolderTestCases": { + "type": "object", + "description": "TestCaseFieldValue as returned by `list_folder_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } + } + }, + "Folder__MoveFolderByIntegerId": { + "type": "object", + "description": "Folder as returned by `move_folder_by_integer_id`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Folder" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/765883" + } + } + }, + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765883 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "copy-of-__mcp-probe-subfolder-20260921" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + }, + "total_cases_count": { + "type": "integer", + "example": 2 + } } - } - } - }, - "custom_fields": { - "type": "array", - "description": "An ARRAY of entries, not an object keyed by field name; [] when the result has no custom field values. `field_name` is the field's system name. For dropdown / single-select fields `value` is the selected option's id rather than its label.", - "items": { - "type": "object", - "properties": { - "field_name": { - "type": "string" - }, - "value": { - "description": "Scalar value, or the selected option's id for option-backed fields.", - "nullable": true + }, + "FolderWriteResponse__RenameFolderByIntegerId": { + "type": "object", + "description": "FolderWriteResponse as returned by `rename_folder_by_integer_id`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$response": "FolderWriteResponse" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/765811" + } + } + }, + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, + "id": { + "type": "integer", + "example": 765811 + }, + "is_automation": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "__mcp-probe-renamefolder-20260922" + }, + "notes": { + "type": "string", + "example": "<p>Live probe of create_root_folder - safe to delete.</p>" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 16 + }, + "total_cases_count": { + "type": "integer", + "example": 169 + } + } + } + } + } } - } - } - }, - "step_result": { - "type": "object", - "description": "Present only for step-level results; null otherwise.", - "nullable": true, - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" - }, - "result_status": { + }, + "V1TestCaseSearchRow__SearchProjectEntities": { "type": "object", - "description": "Same cut-down shape as the parent result_status.", + "description": "V1TestCaseSearchRow as returned by `search_project_entities`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseSearchRow" + } + ], "properties": { - "field_value": { - "type": "string" - } + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "case_type": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "priority": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "status": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntities" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "id": { + "type": "integer", + "example": 1102619 + }, + "key": { + "type": "string", + "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" + }, + "name": { + "type": "string", + "example": "probe.txt" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" + } + } + } + } } - } - } - }, - "configuration_id": { - "type": "integer", - "nullable": true - }, - "execution_id": { - "type": "string", - "nullable": true - }, - "dataset": { - "type": "array", - "description": "Dataset row this execution used, as {variable, row} pairs. Empty for non-data-driven runs.", - "items": { - "type": "object", - "properties": { - "variable": { - "type": "string" - }, - "row": { - "type": "integer" + }, + "SearchFieldValue__SearchProjectEntities": { + "type": "object", + "description": "SearchFieldValue as returned by `search_project_entities`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SearchFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - }, - "urls": { - "type": "object", - "nullable": true, - "description": "Web link for this result. Null when the result cannot be tied to a project, run, case and run row all at once.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's row inside the run." - } - } - } - } - }, - "V2TestRunResultDetail": { - "type": "object", - "description": "The case-level result written by a v2 result submission, returned under `test-result`. This is the no-step shape: a submission that carried step_results is answered with per-step entries instead and has no `test-result` at all, so nothing here ever describes a step.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the created result. Carry this forward for a follow-up update; it is the id domain the server expects back." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "description": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "example": "Passed", - "description": "Display-case status name, echoing the name that was sent." - }, - "issues": { - "type": "array", - "description": "Linked issues as plain KEY STRINGS, deduplicated \u2014 the same shape they are sent in, not objects.", - "items": { - "type": "string", - "example": "JIRA-12" - } - }, - "urls": { - "type": "object", - "description": "Web link for this submission.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's ROW IN THE RUN \u2014 its final path segment is the run row's id, not the id of the result just created." - } - } - } - } - }, - "Template": { - "type": "object", - "description": "A test-case template as returned in list responses: metadata and counts, without the field layout.", - "required": [ - "id", - "name", - "entity", - "enabled", - "is_default", - "is_system", - "future_projects_applicable", - "created_at", - "updated_at" - ], - "properties": { - "id": { - "type": "integer", - "description": "Template id \u2014 a plain integer." - }, - "name": { - "type": "string" - }, - "entity": { - "type": "string", - "description": "What the template applies to. Always TestCase on this surface.", - "example": "TestCase" - }, - "enabled": { - "type": "boolean", - "description": "True when the template is active. False means disabled \u2014 still stored, with its fields and project links, and re-enablable." - }, - "step_type": { - "type": "string", - "nullable": true, - "description": "Step style identifier for test-case templates; null for templates that do not define one." - }, - "is_default": { - "type": "boolean", - "description": "True for the workspace default template for this entity type. The default cannot be disabled or deleted." - }, - "is_system": { - "type": "boolean", - "description": "True for a built-in template. System templates cannot be deleted." - }, - "future_projects_applicable": { - "type": "boolean", - "description": "True when projects created later are linked to this template automatically." - }, - "field_count": { - "type": "integer", - "nullable": true, - "description": "Number of fields in the template. Null when the count was not computed for this response." - }, - "linked_to_all_projects": { - "type": "boolean", - "nullable": true - }, - "project_count": { - "type": "integer", - "nullable": true, - "description": "Number of linked projects. Null when the workspace-wide count was skipped server-side \u2014 treat null as 'not computed', not zero." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "TemplateFieldMapping": { - "type": "object", - "description": "One field placed on a template, as returned by a template read or create. Note these are mapping records, not the ids sent when writing a template.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the mapping row, NOT the field id. When resending a field on an update, use custom_field_id or system_field_id, not this. It is not unique within a response: the duplicated automation row shares its original's value." - }, - "field_category": { - "type": "string", - "description": "Which section the field sits in.", - "values": [ - "description", - "properties", - "step_properties" - ] - }, - "custom_field_id": { - "type": "integer", - "nullable": true, - "description": "Set for a custom field; null for a system field. Which of the two it is also decides what field_data contains and which of its keys exist." - }, - "system_field_id": { - "type": "integer", - "nullable": true, - "description": "Set for a built-in field; null for a custom field." - }, - "order": { - "type": "integer", - "description": "Position within its section." - }, - "is_mandatory": { - "type": "boolean", - "description": "Placement-level required flag, defaulting to false when the stored mapping does not carry one. This is NOT the only required-ness signal: a field definition can separately mark itself required, and the two can disagree. Observed false on every row of every template inspected so far." - }, - "field_data": { - "type": "object", - "description": "The underlying field definition, passed through from the field service unchanged apart from two additions (`field_type_raw`, and `parent_custom_field_id` on nested dropdowns). The exact key set therefore varies by field kind \u2014 system fields and custom fields carry different subsets, and step fields carry more than the others. Treat the keys listed here as likely-present rather than guaranteed, and do not assume a missing key means an unset value.", - "properties": { - "field_type": { - "type": "string", - "description": "The product-facing type name in its prefixed form, for example field_string or field_multi_dropdown. For a small number of workspaces a nested-dropdown type outside the ordinary set can be substituted here, along with a parent field id." - }, - "system_name": { - "type": "string", - "description": "Internal name of a built-in field. Built-in fields only." - }, - "optional": { - "type": "boolean", - "description": "Built-in field's own removability flag. Built-in fields only." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "field_name": { - "type": "string", - "description": "The field's name as shown to authors. Carried on both system and custom field definitions; it is the value to match on when resolving a field a user named in words." - } - } - } - } - }, - "TemplateWithFields": { - "type": "object", - "description": "A test-case template together with its full field layout, as returned by a single-template read or a create.", - "allOf": [ - { - "$schema": "Template" - } - ], - "properties": { - "description_fields": { - "type": "array", - "description": "Fields in the description section, ordered.", - "items": { - "$schema": "TemplateFieldMapping" - } - }, - "properties_fields": { - "type": "array", - "description": "Fields in the properties section, ordered. The Review & Approve fields (reviewers, review status) are filtered out of this surface even when stored on the template.", - "items": { - "$schema": "TemplateFieldMapping" - } - }, - "step_fields": { - "type": "array", - "description": "Step-level custom fields, ordered. Always an empty array for workspaces without step custom fields enabled; the key is always present.", - "items": { - "$schema": "TemplateFieldMapping" - } - } - } - }, - "V2PublicTestCase": { - "type": "object", - "description": "A test case as the v2 public project API serialises it on read and on write. NOT the same shape as the `TestCase` schema the older endpoints return: the title is `title` (not `name`), priority/status/case_type are plain display strings (not objects), owner is an email string (not an integer id), custom fields are name/value pairs, and there is no integer `id` \u2014 the case is identified by `identifier`.", - "properties": { - "identifier": { - "type": "string", - "description": "TC-NNN, or the third-party identifier when the workspace issues project-level ids." - }, - "title": { - "type": "string", - "description": "The case title. Sent as `name` on a write, returned as `title`." - }, - "folder_id": { - "type": "integer", - "description": "Integer id of the folder the case now sits in." - }, - "priority": { - "type": "string", - "description": "The project's display value, e.g. 'Medium'. Null when unset." - }, - "status": { - "type": "string", - "description": "The project's display value for the case's own lifecycle status, e.g. 'Active'." - }, - "case_type": { - "type": "string", - "description": "The project's display value for the case type, e.g. 'Smoke & Sanity'." - }, - "automation_status": { - "type": "string", - "description": "Internal name, e.g. not_automated / automated / obsolete." - }, - "owner": { - "type": "string", - "description": "Owner's EMAIL, or the imported owner name for cases brought in from another tool. Null when the owner does not resolve to a user in this group." - }, - "description": { - "type": "string" - }, - "preconditions": { - "type": "string" - }, - "template": { - "type": "string", - "description": "Template name, e.g. test_case_steps or bdd." - }, - "is_shared": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links.", - "items": { - "type": "object", - "properties": { - "jira_id": { - "type": "string", - "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made \u2014 azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." + }, + "V1TestCaseSearchRow__SearchProjectEntitiesByFilter": { + "type": "object", + "description": "V1TestCaseSearchRow as returned by `search_project_entities_by_filter`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestCaseSearchRow" + } + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "case_type": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "priority": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "status": { + "nullable": true, + "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" + }, + "attachments": { + "type": "array", + "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", + "items": { + "type": "object", + "properties": { + "byte_size": { + "type": "integer", + "example": 17 + }, + "checksum": { + "type": "string", + "example": "JUtjCVAS84rpwIoo8bZGEw==" + }, + "content_type": { + "type": "string", + "example": "text/plain" + }, + "id": { + "type": "integer", + "example": 1102619 + }, + "key": { + "type": "string", + "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" + }, + "name": { + "type": "string", + "example": "probe.txt" + }, + "product_id": { + "type": "integer", + "example": 228224 + }, + "size": { + "type": "string", + "example": "17 Bytes" + } + } + } + } } - }, - "description": "Only two keys exist on this shape and both are always present when the array is non-empty \u2014 nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." - } - }, - "steps": { - "type": "array", - "description": "Step list, flattened to step/result only \u2014 test_data, shared-step links and per-step custom fields are not echoed here.", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" + }, + "SearchFieldValue__SearchProjectEntitiesByFilter": { + "type": "object", + "description": "SearchFieldValue as returned by `search_project_entities_by_filter`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SearchFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - }, - "custom_fields": { - "type": "array", - "description": "Name/value pairs, never the id-keyed map a write sends. A multi-select field carries `values` (array) instead of `value`. Always present, as [] when the case has none.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - }, - "values": { - "type": "array", - "items": { - "type": "string" - } + }, + "Dataset__UpdateDataset": { + "type": "object", + "description": "Dataset as returned by `update_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__UpdateDataset" + }, + "metadata": { + "type": "object" + }, + "tags": { + "type": "string", + "nullable": true + } } - } - } - }, - "attachments": { - "type": "array", - "description": "Files attached to the case. Comes back as [] when the case carries no files. Every entry carries name, size, content_type and url together \u2014 these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "File name as uploaded." - }, - "size": { - "type": "string", - "description": "Human-readable size, e.g. '1.2 MB' \u2014 not a byte count." - }, - "content_type": { - "type": "string", - "description": "MIME type recorded when the file was uploaded, e.g. image/png." - }, - "url": { - "type": "string", - "description": "Link to the stored file." + }, + "DatasetUserRef__UpdateDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `update_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + } } - } - } - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "Last update timestamp \u2014 named last_updated_at here, not updated_at." - }, - "created_by": { - "type": "string", - "description": "Email of the creating user." - }, - "updated_by": { - "type": "string", - "description": "Email of the last user to write to the case." - }, - "urls": { - "type": "object", - "description": "Web-app link for the case, for showing a human where it lives.", - "properties": { - "self": { - "type": "string" - } - } - } - } - }, - "V2History": { - "type": "object", - "description": "One revision of a test case as returned by the v2 history listing. The internal record id, entity_id, entity_type, project_id, group_id, the nested user object and the links block are stripped before the record is returned.", - "properties": { - "version_id": { - "type": "string", - "format": "uuid", - "description": "Opaque version identifier for this revision." - }, - "version_name": { - "type": "string", - "description": "Positional label for the revision \u2014 V1, V2, V3 \u2026 \u2014 where V1 is the oldest revision visible under the caller's plan. The number is what the single-version read takes.", - "example": "V2" - }, - "source": { - "type": "string", - "description": "What produced the revision: 'create' for the original, 'update' for an edit, 'restore' after a rollback.", - "example": "update" - }, - "modified_fields": { - "type": "array", - "nullable": true, - "items": { - "type": "string" - }, - "description": "Names of the fields this revision changed. Null on the creating revision." - }, - "modified": { - "type": "object", - "description": "Per-field before/after map, e.g. {\"title\": {\"old\": \"...\", \"new\": \"...\"}}. Only the first record on each page is enriched with this detail; the other rows come back without it. The creating revision never carries it either, having nothing to diff against. Rows whose old and new are both empty are dropped.", - "additionalProperties": true, - "example": { - "title": { - "old": "Original Title", - "new": "Updated Title" - } - } - }, - "comment": { - "type": "string", - "nullable": true, - "description": "Optional note recorded with the change." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the revision was recorded.", - "example": "2025-08-29T08:33:58.000Z" - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Email of the user who made the change. Null when the user id cannot be resolved to a user in this group.", - "example": "user@example.com" - }, - "user_id": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id of the editor \u2014 the raw id updated_by is resolved from." - }, - "testcase_id": { - "type": "string", - "description": "Identifier of the test case this revision belongs to \u2014 the third-party identifier when one is set, otherwise the TC-NNN identifier. Falls back to TC- if the case cannot be fetched.", - "example": "TC-123" - }, - "origin_channel": { - "type": "string", - "description": "How the edit that produced this revision arrived. One of ui, api, jira_app, azure, import, automation or ai. Revisions written through this API are always recorded as api.", - "enum": [ - "ui", - "api", - "jira_app", - "azure", - "import", - "automation", - "ai" - ], - "example": "api" - } - } - }, - "TestPlanV2": { - "type": "object", - "description": "A test plan or sub-plan as the v2 plan surface serializes it. There is no numeric id field - `identifier` (TP-NNN / STP-NNN) is the only id, and project_id is the PR-NNN identifier rather than the numeric project id. The numeric plan and project ids are recoverable only by reading them out of the web-app link under urls.self. This is the shape every write and every listing returns; only the single-plan read adds the embedded run preview and the relative API links.", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "description": { - "type": "string", - "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." - }, - "active_state": { - "type": "string", - "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", - "enum": [ - "active", - "completed" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." - }, - "test_runs_count": { - "type": "object", - "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "sub_plans_count": { - "type": "integer", - "nullable": true, - "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." - }, - "start_date": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-01-01T00:00:00Z" - }, - "end_date": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "string", - "description": "PR-NNN identifier of the owning project, not a number.", - "example": "PR-2" - }, - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - } - } - } - } - }, - "TestRunSummaryV2": { - "type": "object", - "description": "A test run as the v2 run listings serialize it \u2014 a summary shape shared by the project-wide listing and a plan's linked-run listing. It carries NO numeric id and no uuid: `identifier` (TR-NNN) is the only id present. It also never carries issues, issue_tracker or test_cases; those exist only on the single-run read.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-164" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Rich-text description, HTML-unescaped and with links rehydrated. Empty string rather than null for a run with no description." - }, - "run_state": { - "type": "string", - "description": "Lifecycle state; a brand-new run reports 'new_run'." - }, - "active_state": { - "type": "string", - "description": "Derived: 'closed' once the run is closed, otherwise 'active'.", - "enum": [ - "active", - "closed" - ] - }, - "assignee": { - "type": "string", - "nullable": true, - "description": "Email of the run's owner; null when it cannot be resolved.", - "example": "qa@acme.com" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "description": "DEFECT \u2014 currently always an empty array on both run listings, whatever configurations the run actually has. The key is always present; its emptiness carries no information. Read the run itself for its configurations. When populated it is configuration ids (integers), not configuration objects.", - "items": { - "type": "integer" - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "filter_test_cases": { - "type": "object", - "description": "Always present. Carries a single is_dynamic sub-field \u2014 whether the run's case list is filter-driven.", - "properties": { - "is_dynamic": { - "description": "Whether the run's case list is filter-driven. NOT consistently typed: the project-wide listing reports it as a boolean, the plan-scoped listing as the integer 0/1, for the same run. Test it truthily; an identity check against false is unsafe." - } - } - }, - "project_id": { - "type": "string", - "description": "The project the request was scoped to, in PR-NNN form.", - "example": "PR-2" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Omitted entirely, rather than sent as null, when the run has no recorded update time." - }, - "links": { - "type": "object", - "description": "API references for the run. Always present with exactly these two keys.", - "properties": { - "self": { - "type": "string", - "description": "Relative API reference for reading this run." - }, - "test_cases": { - "type": "string", - "description": "Relative API reference for this run's test cases." - } - } - }, - "urls": { - "type": "object", - "description": "Web-app deep links for the run. Always present on both run listings.", - "properties": { - "self": { - "type": "string", - "description": "Absolute link to this run in the web app." - } - } - } - } - }, - "V2ReportDetail": { - "type": "object", - "description": "A report's public v2 configuration record. Identifiers replace internal integer ids, and it carries no report data \u2014 only how the report is set up.", - "properties": { - "id": { - "type": "string", - "pattern": "^SC-\\d+$", - "example": "SC-42", - "description": "The report's identifier. A per-group sequence number, unrelated to the integer report id the v1 report capabilities take." - }, - "project_id": { - "type": "string", - "pattern": "^PR-\\d+$", - "description": "The owning project's identifier." - }, - "project_ids": { - "type": "array", - "items": { - "type": "string", - "pattern": "^PR-\\d+$" - }, - "description": "Every project the report covers. A single-project report lists just its own." - }, - "report_type": { - "type": "string", - "description": "Display name of the report type, e.g. 'Test Run Summary'." - }, - "title": { - "type": "string", - "description": "Display name. Auto-generated as '-MM/DD/YYYY' when the creator omitted it." - }, - "description": { - "type": "string", - "nullable": true - }, - "report_timeframe": { - "type": "string", - "description": "Rolling window or scope mode, e.g. last_one_week, custom, specific_test_runs." - }, - "report_creation_mode": { - "type": "string", - "values": [ - "custom_report", - "system_generated" - ] - }, - "custom_date_range": { - "type": "string", - "nullable": true, - "description": "Two space-separated YYYY-MM-DD dates; set only for the custom timeframe." - }, - "frequency": { - "type": "string", - "values": [ - "once", - "daily", - "weekly", - "monthly" - ], - "description": "'once' is substituted for an unscheduled report whose stored frequency is null." - }, - "frequency_details": { - "type": "object", - "nullable": true, - "description": "time (HH:MM) and, for weekly/monthly, day." - }, - "next_run_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "When the schedule next fires. Null for a one-off report." - }, - "is_scheduled": { - "type": "boolean", - "description": "True only when a valid frequency and frequency_details pair was supplied." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "owner": { - "type": "object", - "nullable": true, - "description": "Creator, as email and full_name. No user id is exposed." - }, - "mail_to": { - "type": "object", - "description": "users (email + full_name objects) and external_mails (plain addresses). Populated only for scheduled reports." - }, - "report_filters": { - "type": "object", - "description": "Configured scope echoed back as public identifiers (TR-NNN, TP-NNN, assignee emails). Sub-plans collapse to TP-NNN; values fall back to prefix+internal id if the lookup fails." - }, - "dynamic_filters": { - "type": "object", - "nullable": true, - "description": "Always null on this surface, whatever was sent at create time." - }, - "latest_attachment": { - "type": "object", - "nullable": true, - "description": "Newest generated file in the PROJECT, not necessarily this report's: generated_at, file_type (a MIME type such as application/pdf) and ready, which is true whenever the object is present." - } - } - }, - "AttachmentListItem": { - "type": "object", - "description": "One attachment row as the v2 per-entity attachment listing serializes it - a deliberately thin projection, not the full stored attachment record.", - "properties": { - "id": { - "type": "integer", - "description": "Attachment id. This is the id the delete-attachment capabilities take." - }, - "name": { - "type": "string", - "description": "Original filename as uploaded." - }, - "size": { - "type": "integer", - "description": "File size in bytes. Renamed from the stored byte_size field, which is not present under this name here." - }, - "created_at": { - "type": "string", - "description": "When the file was attached." - }, - "url": { - "type": "string", - "description": "Location to read the file from. There is no separate download endpoint for these attachments." - }, - "data_id": { - "type": "integer", - "description": "Present ONLY on inline attachments (images embedded via the rich-text editor). Its presence is how an inline row is told apart from a real file attachment." - } - } - }, - "TestResultSearchItem": { - "type": "object", - "description": "One test result row from the account-wide result search. Leaner and differently keyed than TestResult: no custom_fields, no step result and no configuration_id, but it carries the owning project, run and case so a cross-project row can be traced back. Several fields exist on the row but are never loaded by this search \u2014 see their descriptions.", - "properties": { - "id": { - "type": "integer", - "description": "Result id. Project-scoped single-result reads need this together with project_id." - }, - "author": { - "type": "object", - "description": "Who logged the result. Six keys when the user resolves. When the result came from an import and no matching user exists, only full_name and email are present, sourced from the import; when neither applies the key is omitted entirely.", - "properties": { - "id": { - "type": "integer" - }, - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "updated_by": { - "type": "object", - "nullable": true, - "description": "Who last changed the result. ALWAYS NULL on this endpoint: the account-wide result search does not load the updating user's id, so no user is ever resolved and the object is never populated here - the key is always present and always null. The project-scoped single-result reads return the real object." - }, - "status": { - "type": "string", - "nullable": true, - "example": "blocked", - "description": "Lowercase internal status token, and null on most rows. Read result_status.field_value instead \u2014 it is the populated, display-case form of the same outcome." - }, - "result_status": { - "nullable": true, - "$schema": "ResultStatus__SearchTestResults" - }, - "description": { - "type": "string", - "nullable": true, - "description": "Result notes as rich text. User mentions are rewritten to display names in this payload." - }, - "error_description": { - "type": "string", - "nullable": true - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "failure_type": { - "type": "string", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Always null on this endpoint \u2014 the account-wide search does not load it. Use a project-scoped result read if you need it." - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Always null on this endpoint, as for started_at." - }, - "time_elapsed": { - "type": "string", - "nullable": true, - "description": "Always null on this endpoint, as for started_at." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_by_imported": { - "type": "string", - "nullable": true, - "description": "Name carried over from an import when the result has no BrowserStack author. Always null on this endpoint \u2014 not loaded by the account-wide search." - }, - "attachments": { - "type": "array", - "nullable": true, - "description": "ALWAYS NULL on this endpoint - the account-wide result search does not load attachments, so this is never an array here and iterating it without a guard throws. No attachment object is ever produced on this path, so there are no entry fields to read; use the project-scoped result reads, which return real entries." - }, - "issues": { - "type": "array", - "description": "Linked tracker issues. Always an array - empty on a result with no linked defects, so the entry fields below appear only on rows that actually carry a link. This endpoint loads only the issue key and its link time, plus a duplicate `jira_id` mirroring issue_id whatever the tracker actually is. There is no issue_type here, so a row cannot tell you which tracker the link belongs to - the project-scoped reads do carry it.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "example": "BUG-123" - }, - "jira_id": { - "type": "string", - "description": "Copy of issue_id, named jira_id for every tracker. Written for every entry, so it is present whenever the row has a linked issue at all." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "When the issue was linked to this result." + }, + "FormFieldDescriptor": { + "type": "object", + "description": "One form field on the run-creation form: its selectable values plus the link and count that describe the value set.\n", + "properties": { + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/test-case/status" + } + } + }, + "total_count": { + "type": "integer", + "example": 8 + }, + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "#059669" + }, + "is_default": { + "type": "integer", + "example": 0 + }, + "name": { + "type": "string", + "example": "Passed" + }, + "value": { + "type": "integer", + "example": 318610 + }, + "value_category": { + "type": "string", + "example": "completed" + }, + "internal_name": { + "type": "string", + "example": "passed" + } + } + } } - }, - "description": "`jira_id` is written as a copy of `issue_id` on every entry, so the two always agree and always appear together \u2014 the name is historical and holds the id in whatever tracker the link uses. This shape carries no tracker name; read the run or case surfaces if you need to know which tracker an id belongs to." - } - }, - "configuration": { - "type": "object", - "nullable": true, - "description": "Configuration the result was logged against. Always requested, so present whatever compact is set to; an empty object when the result has none." - }, - "test_case": { - "type": "object", - "nullable": true, - "description": "Present ONLY when compact=false was sent; absent otherwise." - }, - "test_run": { - "type": "object", - "nullable": true, - "description": "Present ONLY when compact=false was sent. Its `id` is overwritten with the run's TR-NNN identifier string, not a number." - }, - "test_case_id": { - "type": "integer", - "nullable": true, - "description": "Integer case id from the result mapping; omitted when the mapping is absent." - }, - "test_run_id": { - "description": "Integer run id from the result mapping \u2014 but replaced by the run's TR-NNN identifier STRING whenever the test_run relation is present (compact=false). Type-check before using it.", - "nullable": true - }, - "test_run_results_mapping_id": { - "type": "integer", - "nullable": true, - "description": "Id of the run/case mapping the result hangs off." - }, - "project_id": { - "type": "integer", - "nullable": true, - "description": "Numeric id of the project the result lives in. This is how you tell cross-project rows apart; it is present only when the search service returned it." - }, - "links": { - "type": "object", - "description": "Present only when the row carried a project_id. Holds `self` only \u2014 no custom_fields link on this path.", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" - } - } - } - } - }, - "Comment": { - "type": "object", - "description": "One comment as the v1 serializer emits it \u2014 a flat object (the published spec wraps it under an extra `comment` key that the server does not send). The same object is returned on both comment surfaces: a test case's own thread and a test case's thread inside a test run.", - "properties": { - "id": { - "type": "integer", - "description": "Comment id; the handle for later edit/delete.", - "example": 3121 - }, - "user_id": { - "type": "object", - "description": "The AUTHOR, despite the name. `id` is the internal TM user id (not the BrowserStack user id) and is null for comments imported from another tool, whose original author name is carried in full_name.", - "properties": { - "id": { - "type": "integer", - "nullable": true - }, - "full_name": { - "type": "string" - }, - "email": { - "type": "string", - "nullable": true - } - } - }, - "group_id": { - "type": "integer", - "description": "Internal TM group id of the owning workspace." - }, - "project_id": { - "type": "integer" - }, - "parent_id": { - "type": "integer", - "nullable": true, - "description": "Always null through this path; replies are not threaded." - }, - "entity_id": { - "type": "integer", - "description": "What the comment is attached to, and it differs by surface: on a test case's own thread this is the test case id, while on a test case's thread inside a test run it is the id of the run/case mapping cell - neither the test case id nor the run id. Read it together with entity_type." - }, - "entity_type": { - "type": "string", - "description": "Which surface the comment lives on: 'TestCase' for a comment on the test case itself, 'TRTC' for a comment on that case inside a test run. The two are separate threads and never overlap.", - "example": "TestCase" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "comment": { - "type": "string", - "description": "HTML body with @mentions rendered to display names and links decorated; can differ from what was posted." - }, - "edited": { - "type": "boolean", - "description": "True once the comment has been changed after posting; always false on a comment that was just created." - }, - "is_editable": { - "type": "boolean", - "description": "False for system/imported comments that cannot be edited; always true on a comment created through the comment endpoints." - } - } - }, - "StatusProgressBreakdown": { - "type": "object", - "description": "Per-result-status progress of a run or one of its configurations. Keys are result-status ids as STRINGS ('1', '2', ...); each value is the count of run test cases currently in that status plus the status definition. Statuses with no cases may be absent. Empty object when nothing has been recorded.", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "description": "Number of test cases in the run (or configuration) whose latest result has this status.", - "example": 8 - }, - "result_status": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "field_value": { - "type": "string", - "description": "Display label, e.g. 'Passed'.", - "example": "Passed" - }, - "internal_name": { - "type": "string", - "description": "Stable machine name (passed, failed, blocked, untested, ...). Use this to compare across projects; ids differ per project.", - "example": "passed" - }, - "field_name": { - "type": "string", - "example": "Status" - }, - "colour": { - "type": "string", - "example": "#00FF00" + }, + "TestRunV2Response__ListTestRun": { + "type": "object", + "description": "TestRunV2Response as returned by `get_test_run`. Composes TestRunV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunV2Response" + } + ], + "properties": { + "test_run": { + "type": "object", + "properties": { + "test_cases": { + "type": "object", + "properties": { + "case_type": { + "type": "string", + "example": "Other" + }, + "configuration_id": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "string", + "example": "user@example.com" + }, + "dataset": { + "type": "string", + "nullable": true + }, + "execution_id": { + "type": "string", + "example": "ab5c1e3c40fc614f2020a9ba4dd1a8e4c075bba8fe0336a4f30646030b6f6d43" + }, + "folder_path": { + "type": "integer", + "example": 764138 + }, + "is_shared": { + "type": "boolean", + "example": false + }, + "last_updated_at": { + "type": "string", + "example": "2026-09-18T12:53:07.732Z" + }, + "latest_result_id": { + "type": "integer", + "example": 2150413868 + }, + "latest_status": { + "type": "string", + "example": "passed" + }, + "planned_execution_date": { + "type": "string", + "nullable": true + }, + "priority": { + "type": "string", + "example": "High" + }, + "source_project_id": { + "type": "string", + "example": "PR-2005" + }, + "status": { + "type": "string", + "example": "Active" + }, + "step_count": { + "type": "integer", + "example": 1 + }, + "updated_by": { + "type": "string", + "example": "user@example.com" + }, + "urls": { + "type": "object", + "properties": { + "self": { + "type": "string" + } + } + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } + } + } } - } - } - } - } - }, - "BinnedTestCase": { - "type": "object", - "description": "A test case as it appears in the recycle-bin listing: a slim row from the bin store plus resolved people, not the full TestCase shape.", - "properties": { - "id": { - "type": "integer", - "description": "Integer id \u2014 what restore/purge selections take." - }, - "identifier": { - "type": "string", - "example": "TC-3502" - }, - "name": { - "type": "string" - }, - "priority": { - "type": "object", - "description": "The priority as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", - "properties": { - "field_value": { - "type": "string", - "description": "The displayed name \u2014 the one to compare against." - }, - "colour": { - "type": "string", - "description": "Presentation only." - }, - "field_name": { - "type": "string", - "description": "Internal field name behind the value." - }, - "value_category": { - "type": "string", - "description": "Grouping the value belongs to." - }, - "internal_name": { - "type": "string", - "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming \u2014 `field_value` is what a person sees and can be edited." - } - } - }, - "status": { - "type": "object", - "description": "The status as a WHOLE OBJECT on this listing, not the flat string the v2 case shapes return. Carries `field_value` (the name a person sees), `colour`, `field_name` and `value_category`. Compare on `field_value`; the rest is presentation.", - "properties": { - "field_value": { - "type": "string", - "description": "The displayed name \u2014 the one to compare against." - }, - "colour": { - "type": "string", - "description": "Presentation only." - }, - "field_name": { - "type": "string", - "description": "Internal field name behind the value." - }, - "value_category": { - "type": "string", - "description": "Grouping the value belongs to." - }, - "internal_name": { - "type": "string", - "description": "The stable machine name for the value. Compare on this rather than `field_value` when you need a value that survives renaming \u2014 `field_value` is what a person sees and can be edited." - } - } - }, - "owner": { - "type": "integer", - "nullable": true, - "description": "Raw BrowserStack user id of the owner; see owner_details for the person." - }, - "owner_details": { - "type": "object", - "nullable": true, - "description": "Resolved owner {id, browserstack_user_id, full_name, email, group_id}; absent when owner is null or unknown." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "issue_id": { - "type": "string" - }, - "issue_type": { - "type": "string" + }, + "Project__ListProjects": { + "type": "object", + "description": "Project as returned by `list_projects`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Project" + } + ], + "properties": { + "links": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "example": "/api/v1/projects/1234/repository" + }, + "self": { + "type": "string", + "example": "/api/v1/projects/1234" + }, + "test_plans": { + "type": "string", + "example": "/api/v1/projects/1234/test-plans" + }, + "test_runs": { + "type": "string", + "example": "/api/v1/projects/1234/test-runs" + }, + "users": { + "type": "string", + "example": "/api/v1/projects/1234/users" + } + } + }, + "duplicates_tc_count": { + "type": "integer", + "example": 0 + }, + "jira_mapped": { + "type": "boolean", + "example": false + }, + "normalisedName": { + "type": "string", + "example": "__mcp-capability-fixture__" + }, + "projectVisibilityBanner": { + "type": "string", + "nullable": true + }, + "starred": { + "type": "boolean", + "example": false + }, + "test_cases_count": { + "type": "integer", + "example": 272 + }, + "test_plans_count": { + "type": "integer", + "example": 75 + }, + "test_runs_count": { + "type": "integer", + "example": 99 + }, + "thProjectId": { + "type": "integer", + "example": 1234 + } + } + }, + "TestPlanV2__CreateSubTestPlan": { + "type": "object", + "description": "TestPlanV2 as returned by `create_sub_test_plan`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestPlanV2" + } + ], + "properties": { + "parent_plan_id": { + "type": "string", + "example": "TP-1616" + } } - } - } - }, - "folder_name": { - "type": "string", - "nullable": true - }, - "folder_path": { - "type": "string", - "nullable": true, - "description": "Breadcrumb of the folder the case was in when binned." - }, - "updated_by": { - "type": "integer", - "nullable": true, - "description": "Raw BrowserStack user id of who moved it to the bin; see updator_details." - }, - "updator_details": { - "type": "object", - "nullable": true, - "description": "Resolved person who binned the case {id, browserstack_user_id, full_name, email, group_id}." - }, - "trashed_at": { - "type": "string", - "format": "date-time", - "description": "When the case was moved to the bin." - }, - "days_remaining": { - "type": "integer", - "description": "Days left before automatic permanent purge." - }, - "template": { - "type": "string", - "description": "The template the case was authored on, carried through from the case record." - }, - "created_by": { - "type": "integer", - "description": "Id of the user who created the case. `creator_details` carries the same person resolved." - }, - "creator_details": { - "type": "object", - "description": "The creating user resolved, or null when that user can no longer be found. Distinct from whoever binned the case.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "The account-level user id, not the id used for owner or assignee here." - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer", - "description": "Workspace the user belongs to." - } - } - } - } - }, - "V1DuplicatePair": { - "type": "object", - "description": "One AI-suggested duplicate test-case pair as this product serialises it. Used by the single-duplicate read and as the element type of the duplicates listing and search.", - "properties": { - "id": { - "type": "integer", - "description": "The duplicate id. This is the value the read, resolve and discard calls take." - }, - "ai_score": { - "type": "number", - "description": "Model confidence for the pair. A score below 100 is a semantic match; 100 is an exact one." - }, - "reason": { - "type": "string", - "description": "The model's stated reason for believing the two cases are duplicates." - }, - "duplicate_type": { - "type": "string", - "description": "exact or semantic.", - "values": [ - "exact", - "semantic" - ] - }, - "test_cases": { - "type": "array", - "description": "The two test cases in the pair. Always exactly two \u2014 except in a listing filtered by entity_id, where the filtered-on case is removed and only the other one remains.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Integer test case id \u2014 what the resolve call takes as source/target/archive id." - }, - "identifier": { - "type": "string", - "description": "Display identifier (TC-NNN), or the third-party identifier when the case came from an external tool." - }, - "folder_id": { - "type": "integer", - "description": "Id of the folder holding the case." - }, - "folder_path": { - "type": "string", - "description": "Human-readable folder path." - }, - "title": { - "type": "string", - "description": "The test case name (renamed from `name` on the way out)." - }, - "description": { - "type": "string", - "description": "Empty string rather than null when the case has no description." - }, - "owner_id": { - "type": "integer", - "description": "Raw BrowserStack user id of the owner." - }, - "owner": { - "type": "string", - "description": "Resolved owner display name; an empty string when the user cannot be resolved in this group." + }, + "V1ProjectDetail__GetProjectByIntegerId": { + "type": "object", + "description": "V1ProjectDetail as returned by `get_project_by_integer_id`. Composes V1ProjectDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1ProjectDetail" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "project": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "description": "PR-NNN identifier — the v2 {project_id}. (Integer -> PR-NNN translation output.)", + "example": "PR-1" + }, + "id": { + "type": "integer", + "description": "Integer project id — the value passed in the path. Same as thProjectId.", + "example": 11046 + }, + "thProjectId": { + "type": "integer", + "example": 11046 + }, + "name": { + "type": "string", + "example": "Demo Project" + }, + "description": { + "type": "string", + "nullable": true + }, + "test_cases_count": { + "type": "integer", + "nullable": true + }, + "test_runs_count": { + "type": "integer", + "nullable": true + }, + "test_plans_count": { + "type": "integer", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "starred": { + "type": "boolean" + }, + "jira_mapped": { + "type": "boolean" + }, + "links": { + "type": "object" + }, + "import_id": { + "type": "integer", + "nullable": true, + "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." + }, + "normalisedName": { + "type": "string", + "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." + }, + "projectVisibilityBanner": { + "type": "object", + "nullable": true, + "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." + }, + "permissions": { + "type": "string", + "example": "project_write" + }, + "user_role": { + "type": "string", + "example": "Admin" + } + } + } + } + } } - } - } - } - } - }, - "FolderV2": { - "type": "object", - "description": "A folder in its public (v2) shape. One shape for all three folder writes - create, rename and move all return exactly this object under `folder`.", - "properties": { - "id": { - "type": "integer", - "description": "The folder's numeric id. This is the value every later folder call takes in its path - the project is addressed by its PR-NNN identifier, but the folder is addressed by this number." - }, - "name": { - "type": "string", - "description": "The folder's name. On create and rename this is the value you sent, unless the project already had a folder of that name under the same parent and auto-renaming was allowed, in which case the server-chosen name comes back here." - }, - "description": { - "type": "string", - "nullable": true, - "description": "The folder's free-text notes. Null when the folder has none - which is every newly created folder, since create does not accept notes." - }, - "parent_id": { - "type": "integer", - "nullable": true, - "description": "The folder this one now sits under. Null for a folder at the top of the project. After a move this is the destination you asked for, so it is the field to check to confirm the move landed." - }, - "cases_count": { - "type": "integer", - "nullable": true, - "description": "Test cases directly in this folder (not descendants). The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." - }, - "sub_folders_count": { - "type": "integer", - "nullable": true, - "description": "Immediate child folders. The create capability returns null here instead of 0 for the brand-new folder; treat that as 0. Rename and move return the real count." - }, - "links": { - "type": "object", - "description": "Ready-made API links for this folder. Always present on a folder write.", - "properties": { - "sub_folders": { - "type": "string", - "description": "Link for listing this folder's immediate child folders. Built with the project's PR-NNN identifier and this folder's numeric id." - } - } - }, - "urls": { - "type": "object", - "description": "Links for a person, not for an API client. Always present on a folder write.", - "properties": { - "self": { - "type": "string", - "description": "Absolute web-app address at which someone can open this folder. Note it is built from the project's NUMERIC id, whereas the API links above use the PR-NNN identifier - do not read a project identifier out of this string." - } - } - } - } - }, - "ConfigurationGroup": { - "type": "object", - "description": "An account-level configuration dimension (for example Operating System, Browser or Device) whose selectable values are held as its options.", - "properties": { - "id": { - "type": "integer", - "description": "Integer id of the group; use it to read or change the group's options." - }, - "name": { - "type": "string", - "description": "Display name of the dimension." - }, - "is_system": { - "type": "boolean", - "description": "True for groups the product ships rather than ones the account created." - }, - "group_id": { - "type": "integer", - "description": "The owning account (BrowserStack group). Identical on every row of a single response." - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "LinkedIssueTestCase": { - "type": "object", - "description": "A test case as returned in a tracker ticket's linked-case list. Leaner than the full test case read, and carries issue-panel-only fields.", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "description": "The case's TC-NNN identifier, or the third-party identifier when the case was imported." - }, - "name": { - "type": "string" - }, - "folder_id": { - "type": "integer" - }, - "owner": { - "type": "object", - "description": "The resolved owner user, or null when the owner id cannot be resolved in the caller's group." - }, - "project": { - "type": "object", - "description": "Summary of the owning project, supplied so the panel can render across projects." - }, - "template": { - "type": "string" - }, - "template_step_type": { - "type": "string" - }, - "template_is_system": { - "type": "boolean" - }, - "datasets_linked": { - "type": "boolean" - }, - "custom_fields": { - "type": "array" - }, - "status": { - "type": "object" - }, - "case_type": { - "type": "object" - }, - "automation_state": { - "type": "object" - }, - "priority": { - "type": "string", - "description": "Flattened to the priority's internal name or display value, unlike the object form used elsewhere." - }, - "tags": { - "type": "array" - }, - "issues": { - "type": "array", - "description": "The tracker tickets this case is linked to." - }, - "reviewers": { - "type": "array" - }, - "review_status": { - "type": "string" - }, - "dropdown": { - "type": "object", - "description": "The project's default field option sets, repeated per case so the panel can label status and priority ids." - }, - "self_ui_link": { - "type": "string", - "description": "Web app path for this case \u2014 a link for a human, not an API path." - }, - "configuration": { - "type": "object" - }, - "last_test_info": { - "type": "object", - "description": "Present only when the case has been executed at least once. Absent \u2014 not null \u2014 for a never-run case.", - "properties": { - "last_run_result": { - "type": "string" - }, - "last_run_result_status": { - "type": "object" - }, - "last_run_name": { - "type": "string" - }, - "last_run_id": { - "type": "string", - "description": "The run's TR-NNN identifier, not its integer id, despite the name." - }, - "last_run_link": { - "type": "string" - }, - "last_run_status": { - "type": "string", - "description": "'closed' or 'active'." - } - } - } - } - }, - "SharedComponent": { - "type": "object", - "description": "A shared field: a reusable content block held under Shared Fields and referenced from many test cases. The same record backs all three kinds - step sequence, Gherkin background and precondition - distinguished by `field_type`. These are the fields every route returns, including the LISTING.\n", - "properties": { - "id": { - "type": "integer", - "description": "Shared-field id, from the same id space as the shared-steps endpoints." - }, - "title": { - "type": "string" - }, - "field_type": { - "type": "string", - "values": [ - "step", - "background", - "precondition" - ], - "description": "Which kind of shared field this is. Defaults to `step` when the create omits it.", - "example": "background" - }, - "test_case_count": { - "type": "integer", - "description": "How many test cases reference this block." - }, - "step_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "created_by": { - "type": "object", - "description": "Reduced user reference - id and full_name only, not the wider User shape other TM serializers return.", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - } - } - }, - "V2TestCaseDetail": { - "type": "object", - "description": "A test case as the v2 public API serializes it (create, get, update and move). Differs from the internal `TestCase` shape: `title` instead of name, `steps` [{step, result}] instead of test_case_steps, owner/created_by/updated_by as emails, custom fields by display name, and NO integer id \u2014 `identifier` is the handle.", - "properties": { - "identifier": { - "type": "string", - "description": "TC-NNN, or the customer-defined identifier when the workspace uses its own ids. Use this in later calls.", - "example": "TC-1024" - }, - "title": { - "type": "string", - "description": "The case name (sent as `name` on write)." - }, - "description": { - "type": "string", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "template": { - "type": "string", - "description": "Step style: test_case_steps or test_case_bdd. The template id itself is not returned.", - "example": "test_case_steps" - }, - "folder_id": { - "type": "integer" - }, - "status": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. Active." - }, - "priority": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. High." - }, - "case_type": { - "type": "string", - "nullable": true, - "description": "Display value, e.g. Functional." - }, - "automation_status": { - "type": "string", - "description": "Internal name, e.g. not_automated." - }, - "owner": { - "type": "string", - "nullable": true, - "description": "Owner's email; falls back to the imported owner label for migrated cases." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "jira_id": { - "type": "string", - "description": "The issue's key or id in ITS OWN tracker. The name is historical, not a restriction: a GitHub issue number, an Azure work-item id or a Linear key comes back under this key just the same." - }, - "issue_type": { - "type": "string", - "description": "Which tracker the link points at. 'jira' unless another tracker was named when the link was made \u2014 azure, asana, linear, clickup, devrev, gitlab, github, trello, zoho, or a custom tracker; document links report confluence, figma, google-drive, onedrive or sharepoint. Links created by sending bare issue ids with no tracker named are recorded as 'jira'." - } - }, - "description": "Only two keys exist on this shape and both are always present when the array is non-empty \u2014 nothing is dropped for being nil, so an absent value comes back as null rather than a missing key. There is NO `issue_id` here: the ticket id is published as `jira_id`, whose name is historical and which holds the id in whatever tracker the link uses. `issue_type` names that tracker and is null when the link was made by sending a bare ticket id. This is a narrower shape than the one a test RUN returns for the same links." - }, - "description": "Issues and documents linked to the case, one entry per link. Always present, as [] when nothing is linked, so the array itself is safe to iterate; the entries only exist for a case that has links." - }, - "steps": { - "type": "array", - "description": "Ordered steps for a classic case; always [] for a Gherkin case.", - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" + }, + "UsersV2Response__ListProjectUsers": { + "type": "object", + "description": "UsersV2Response as returned by `list_project_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "UsersV2Response" + } + ], + "properties": { + "users": { + "type": "array", + "items": { + "$schema": "User__ListProjectUsers" + } + } } - } - } - }, - "feature": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "background": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "scenario": { - "type": "string", - "nullable": true, - "description": "Gherkin cases only; absent on classic cases." - }, - "custom_fields": { - "type": "array", - "description": "One entry per set custom field, keyed by display name. Multi-dropdowns carry `values` (array) instead of `value`. Always present, as [] when the case has none.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "description": "Scalar value; dropdown option label, user email, boolean, etc." - }, - "values": { - "type": "array", - "items": { - "type": "string" - } + }, + "User__ListProjectUsers": { + "type": "object", + "description": "User as returned by `list_project_users`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "reports_and_notification_enabled": { + "type": "boolean", + "example": true + } } - } - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "File name as uploaded." - }, - "size": { - "type": "string", - "description": "Human-readable size, e.g. '1.2 MB' \u2014 not a byte count." - }, - "content_type": { - "type": "string", - "description": "MIME type recorded when the file was uploaded, e.g. image/png." - }, - "url": { - "type": "string", - "description": "Link to the stored file." - } - } - }, - "description": "Files attached to the case. On a create the key is omitted ENTIRELY rather than returned as an empty array \u2014 a case created a moment ago has nothing attached \u2014 while a read of a case with no files returns []. Test for the key before iterating. Every entry carries name, size, content_type and url together \u2014 these four never appear separately, so an entry either has all of them or there is no entry. Nothing is ever attached BY these calls: files reach a case through the attachment upload capability, and this array only echoes what is already attached." - }, - "is_shared": { - "type": "boolean", - "nullable": true - }, - "review_status": { - "type": "string", - "nullable": true, - "description": "Aggregate review state of the case. Omitted unless the project has Review & Approve enabled on a Team-Pro plan, and unset when the case has no review state." - }, - "reviewers": { - "type": "array", - "description": "Omitted entirely unless the project has Review & Approve enabled on a Team-Pro plan; shape depends on the workspace's reviewer read-shape flag (emails with review_status, or user objects).", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "created_by": { - "type": "string", - "nullable": true, - "description": "Creator's email." - }, - "last_updated_at": { - "type": "string", - "format": "date-time", - "description": "Note the name: last_updated_at, not updated_at." - }, - "updated_by": { - "type": "string", - "nullable": true, - "description": "Last editor's email." - }, - "urls": { - "type": "object", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case in the Test Management UI." - } - } - } - } - }, - "GlobalSearchEntityResults": { - "type": "object", - "description": "One entity type's slice of an account-wide search result. Present only for the types that were asked for. Used for the entity types whose rows are handed back as the search index stored them rather than re-serialized: each row carries the record's id, name or title, the project it lives in, and a relevance score, plus the `display_meta` block described on `data` below. Because the rows are passed through, treat any key beyond id, name and project as informational.", - "properties": { - "data": { - "type": "array", - "description": "The matching records for this entity type, best match first. Every row carries a `display_meta` block enriched with the owner's name and the project's name so the hit can be labelled without another call - except on the typeahead fast path, where only owner and project are filled in and the rest of display_meta is skipped. Rows whose project has since been deleted or archived are dropped before you see them, so this list can be shorter than `info.count` implies.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" - } - } - }, - "TestRunCaseBulkEditRow": { - "type": "object", - "description": "A run's case row as returned by the synchronous bulk edit. Built by a different serializer from the run's case LISTING \u2014 it carries the mapping cell's own fields (mapping id, result status object, defects, result notes) rather than the listing's projection. Kept separate so corrections to the listing shape do not silently redescribe this one.", - "properties": { - "identifier": { - "type": "string", - "example": "TC-42" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "priority": { - "type": "string", - "description": "Resolved label, not the id.", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "field_name": { - "type": "string", - "example": "priority" - }, - "id": { - "type": "integer", - "example": 318591 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status": { - "type": "string", - "description": "Test case status label." - }, - "case_type": { - "type": "string" - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS run row, e.g. untested / passed / failed." - }, - "assignee": { - "type": "string", - "description": "Assignee of this row within the run.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "project_id": { - "type": "string", - "example": "PR-1", - "description": "Project of the run being read." - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "automation_state": { - "type": "object" - }, - "automation_status": { - "type": "object" - }, - "case_type_imported": { - "type": "string" - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "configuration": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:33:18.553Z" - }, - "creator_details": { - "type": "string", - "nullable": true - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "dataset_values": { - "type": "string", - "nullable": true - }, - "defects": { - "type": "string", - "nullable": true - }, - "deleted": { - "type": "boolean", - "example": false - }, - "estimate": { - "type": "string", - "nullable": true - }, - "execution_eligible": { - "type": "boolean", - "example": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1985020 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "last_executed_at": { - "type": "string", - "nullable": true - }, - "last_executed_by": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { - "type": "object" - }, - "links": { - "type": "object" - }, - "mapping_id": { - "type": "integer", - "example": 2150428149 - }, - "metadata": { - "type": "object" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "planned_execution_date": { - "type": "string", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority_imported": { - "type": "string" - }, - "project_name": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "result_custom_fields": { - "type": "string", - "nullable": true - }, - "result_notes": { - "type": "string", - "nullable": true - }, - "result_status": { - "type": "object" - }, - "status_imported": { - "type": "string" - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "__mcp-probe-mergetag-20260922-a" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_assignee": { - "type": "string", - "nullable": true - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_folders_path": { - "type": "object" - }, - "test_case_id": { - "type": "integer", - "example": 1985020 - }, - "test_case_steps": { - "type": "string", - "nullable": true - }, - "test_run_id": { - "type": "integer", - "example": 17578918 - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:33:18.553Z" - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "TestRunV2Response": { - "description": "The test run at the top level under `test_run`, with no `data` envelope \u2014 the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true }, - "test_run": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { + "ReportDetail__GetReportDetail": { + "type": "object", + "description": "ReportDetail as returned by `get_report_detail`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { "is_dynamic": { - "type": "boolean" + "type": "boolean", + "example": false + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { + } + }, + "PathFolder__MoveFolderByIntegerId": { + "type": "object", + "description": "PathFolder as returned by `move_folder_by_integer_id`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PathFolder" + } + ], + "properties": { + "cases_count": { + "type": "integer", + "example": 2 + }, + "group_id": { + "type": "string", + "example": "3452" + }, "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + "type": "integer", + "example": 765883 }, - "identifier": { - "type": "string" + "is_automation": { + "type": "boolean", + "example": false }, "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "The run's linked defects. CONDITIONAL \u2014 present but EMPTY when the run has nothing linked; the key is absent entirely when the run payload carries no issues association at all. An absent key therefore means 'not reported', not 'no defects'. Per-entry fields have their own conditions below.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { "type": "string", - "description": "The ticket's id in the tracker, e.g. XYZ-1 \u2014 this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." - }, - "issue_type": { + "example": "copy-of-__mcp-probe-subfolder-20260921" + }, + "project_id": { + "type": "integer", + "example": 1234 + }, + "sub_folders_count": { + "type": "integer", + "example": 0 + }, + "total_cases_count": { + "type": "integer", + "example": 2 + } + } + }, + "TagsSearchResponse__SearchGroupTags": { + "type": "object", + "description": "TagsSearchResponse as returned by `search_group_tags`. Composes TagsSearchResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TagsSearchResponseBody" + } + ], + "properties": { + "tags": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 541125 + } + } + }, + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 5 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + }, + "ExploratorySessionLog__UpdateExploratorySessionLog": { + "type": "object", + "description": "ExploratorySessionLog as returned by `update_exploratory_session_log`. Composes ExploratorySessionLog and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ExploratorySessionLog" + } + ], + "properties": { + "attachment_relations": { "type": "string", - "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL \u2014 omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." - }, - "created_at": { + "nullable": true + } + } + }, + "ReportDetail__UpdateReport": { + "type": "object", + "description": "ReportDetail as returned by `update_report`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ReportDetail" + } + ], + "properties": { + "is_dynamic": { + "type": "boolean", + "example": false + }, + "included_projects": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1234 + }, + "name": { + "type": "string", + "example": "__mcp-capability-fixture__" + } + } + } + } + }, + "SharedStepsIDResponse__UpdateSharedStep": { + "type": "object", + "description": "SharedStepsIDResponse as returned by `update_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "SharedStepsIDResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "step_count": { + "type": "integer", + "example": 1 + }, + "tags": { + "type": "string", + "nullable": true + }, + "test_case_count": { + "type": "integer", + "example": 0 + }, + "created_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } + } + }, + "shared_step_details": { + "type": "object", + "properties": { + "order": { + "type": "integer", + "example": 1 + }, + "result": { + "type": "string", + "example": "Login page is displayed" + }, + "step": { + "type": "string", + "example": "Open the login page" + }, + "test_data": { + "type": "string", + "example": "user=probe" + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "full_name": { + "type": "string", + "example": "ing" + } + } + } + } + } + } + }, + "FilterListRow": { + "type": "object", + "description": "One saved view as list_filters returns it: its id, name and scope only.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_project_level": { + "type": "boolean" + } + } + }, + "Filter__CreateFilter": { + "type": "object", + "description": "Filter as returned by `create_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Filter" + } + ], + "properties": { + "group_id": { + "type": "integer", + "example": 3452 + }, + "owner_id": { + "type": "integer", + "example": 6013 + }, + "record_status": { "type": "string", - "format": "date-time" - }, - "updated_at": { + "example": "active" + }, + "filters": { + "type": "object", + "description": "Filter criteria object containing the actual filter conditions", + "properties": { + "priority": { + "type": "string", + "example": "P1" + } + } + } + } + }, + "Dataset__CreateDataset": { + "type": "object", + "description": "Dataset as returned by `create_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "Dataset" + } + ], + "properties": { + "created_by": { + "$schema": "DatasetUserRef__CreateDataset" + } + } + }, + "DatasetUserRef__CreateDataset": { + "type": "object", + "description": "DatasetUserRef as returned by `create_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "DatasetUserRef" + } + ], + "properties": { + "email": { "type": "string", - "format": "date-time" - } + "example": "user@example.com" + } + } + }, + "FolderWriteResponse__CreateRootFolder": { + "type": "object", + "description": "FolderWriteResponse as returned by `create_root_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "FolderWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "integer", + "example": 2522 + }, + "root": { + "type": "boolean", + "example": true + }, + "updated_by": { + "type": "integer", + "example": 2522 + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/766097" + } + } + } + } + }, + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "example": "2026-09-17T08:01:58.000Z" + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "example": "probe edit_project 2026-09-17T10:47:41Z" + }, + "identifier": { + "type": "string", + "example": "PR-2005" + }, + "import_id": { + "type": "string", + "nullable": true + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "FolderWriteResponse__CreateSubFolder": { + "type": "object", + "description": "FolderWriteResponse as returned by `create_sub_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "FolderWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "attachments": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "integer", + "example": 6013 + }, + "root": { + "type": "boolean", + "example": false + }, + "updated_by": { + "type": "integer", + "example": 6013 + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/1234/folder/766098" + } + } + } + } + } + } } - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" + } + }, + "PageInfo__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "PageInfo as returned by `list_test_results_for_test_case_by_integer_id`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "PageInfo" } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" + ], + "properties": { + "truncated": { + "type": "boolean", + "example": false } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" - } - } - }, - "test_cases": { - "type": "array", - "description": "The run's test cases, present only on the single-run read; absent on create and clone. The rows use the same element schema as the standalone case listing, deliberately, so a correction to one cannot drift from the other \u2014 but a few values differ by caller and say so on the field: steps are never included here, for instance.", - "items": { - "$schema": "TestRunTestCaseV2" - } - } - } - } - } - } - }, - "TestRunWriteV2Response": { - "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape \u2014 the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" + } + }, + "TestResult__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "TestResult as returned by `list_test_results_for_test_case_by_integer_id`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestResult" } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + ], + "properties": { + "author": { + "$schema": "User__ListTestResultsForTestCaseByIntegerId" }, - "identifier": { - "type": "string" + "result_status": { + "$schema": "ResultStatus__ListTestResultsForTestCaseByIntegerId" }, - "name": { - "type": "string" - } - } - }, - "issues": { - "type": "array", - "description": "The run's linked defects. CONDITIONAL, and usually absent on a write reply: this value is echoed from the update result rather than re-read, and on the newer run backend the write reply carries no linked-defect list at all. Never infer from its absence that the write dropped the links \u2014 read the run back to confirm what is linked.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string", - "description": "The ticket's id in the tracker, e.g. XYZ-1 \u2014 this is what identifies the defect and what you send back to keep the link. Dropped from the entry only when the stored link carries no ticket id." - }, - "jira_id": { + "updated_by": { "type": "string", - "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name, and present whenever `issue_id` is, whatever the tracker actually is. Prefer `issue_id`." - }, - "issue_type": { + "nullable": true, + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "ing" + } + } + } + } + }, + "User__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "User as returned by `list_test_results_for_test_case_by_integer_id`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "User" + } + ], + "properties": { + "email": { "type": "string", - "description": "Which tracker the link came from, e.g. 'jira'. CONDITIONAL \u2014 omitted for links whose tracker was never recorded, most often ones created by sending a bare ticket id instead of an object: that form keeps the id and loses the type. `issue_tracker` is derived from the first entry that has one, so it goes missing for the same runs." - }, - "created_at": { + "example": "user@example.com" + } + } + }, + "ResultStatus__ListTestResultsForTestCaseByIntegerId": { + "type": "object", + "description": "ResultStatus as returned by `list_test_results_for_test_case_by_integer_id`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatus" + } + ], + "properties": { + "colour": { "type": "string", - "format": "date-time" - }, - "updated_at": { + "example": "#059669" + }, + "value_category": { "type": "string", - "format": "date-time" - } + "example": "completed" } - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" + } + }, + "TestCase__ReorderTestCasesByFolder": { + "type": "object", + "description": "TestCase as returned by `reorder_test_cases_by_folder`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCase" } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" + ], + "properties": { + "automation_status": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Not Automated" + }, + "value": { + "type": "string", + "example": "not_automated" + } + } }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" + "step_count": { + "type": "integer", + "example": 1 + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" + }, + "priority": { + "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" } - } - } - } - } - } - } - }, - "TestPlanV2Detail": { - "type": "object", - "description": "A test plan or sub-plan as the single-plan read returns it: everything the plan shape carries, plus an embedded preview of the plan's runs and the relative API links that go with it. Only the single-plan read produces these two; a create, an update or a listing never does.", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN, or STP-NNN for a sub-plan. The id every v2 plan path takes.", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "description": { - "type": "string", - "description": "Rich text, sanitised on write; links are rewritten on read, so it can differ from what was sent." - }, - "active_state": { - "type": "string", - "description": "Derived: 'completed' once the plan is completed, otherwise 'active'. Does not distinguish new from started.", - "enum": [ - "active", - "completed" - ] - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "issues": { - "type": "array", - "description": "Linked tracker issues, each with issue_id / issue_type and any resolved metadata." - }, - "test_runs_count": { - "type": "object", - "description": "This plan's own runs, split by state. Sub-plan runs are not rolled up.", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "test_runs": { - "type": "array", - "description": "A preview of at most the first 30 runs in this plan, each with identifier and name alone. Absent - not empty - when the run fetch failed; check test_runs_count before concluding the plan has no runs. Only the single-plan read carries it.", - "items": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "example": "TR-164" - }, - "name": { - "type": "string" } - } - } - }, - "sub_plans_count": { - "type": "integer", - "nullable": true, - "description": "Number of child plans this plan holds. Returned on sub-plans as well, where it reads 0 - it is not suppressed or nulled for children. Not rolled up from grandchildren." - }, - "parent_plan_id": { - "type": "string", - "description": "The parent's TP-NNN identifier, not its numeric id. Set only when this plan is a sub-plan; on a top-level plan the key is absent from the payload entirely and is never returned as null.", - "example": "TP-10000" - }, - "start_date": { - "type": "string", - "nullable": true, - "format": "date-time", - "example": "2026-01-01T00:00:00Z" - }, - "end_date": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "string", - "description": "PR-NNN identifier of the owning project, not a number.", - "example": "PR-2" - }, - "links": { - "type": "object", - "description": "Relative API paths for this plan. Present only when the run preview was fetched successfully - these appear and disappear together with test_runs. Only the single-plan read carries them.", - "properties": { - "self": { - "type": "string", - "description": "Path to read this plan.", - "example": "/api/v2/projects/PR-2/test-plans/TP-12" - }, - "test_runs": { - "type": "string", - "description": "Path to page through the runs in this plan.", - "example": "/api/v2/projects/PR-2/test-plans/TP-12/test-runs" - } - } - }, - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - }, - "sub_test_plans": { - "type": "string", - "description": "Address of this plan's children listing. Added only when the plan has no parent, so a sub-plan never carries it. Unlike self this is an API address, not a web-app page." - } - } - } - } - }, - "V2TestRunStepResultDetail": { - "type": "object", - "description": "One per-step result written by a v2 result submission that carried step_results, returned as an entry of `test-run-step-results`. One entry per step result written, in the order the steps were sent.", - "properties": { - "id": { - "type": "integer", - "description": "Id of the result row written for this step. Carry it forward for a follow-up update; it is the id domain the server expects back." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "description": { - "type": "string", - "nullable": true, - "description": "This step's own notes, not the case-level description." - }, - "status": { - "type": "string", - "example": "passed", - "description": "This step's own status, as the LOWERCASE internal token ('passed'/'failed'/'blocked') - not the display-case name a case-level result echoes back. The display-case form of the same status is inside test_run_test_case_step_details as field_value, and the status rolled up for the case as a whole is the response's overall-result-status." - }, - "issues": { - "type": "array", - "description": "Issues linked to this step result, as plain KEY STRINGS, deduplicated - the same shape they are sent in, not objects.", - "items": { - "type": "string", - "example": "JIRA-12" - } - }, - "urls": { - "type": "object", - "description": "Web link for this submission.", - "properties": { - "self": { - "type": "string", - "description": "Deep link to the case's ROW IN THE RUN - its final path segment is the run row's id, not the id of the step result just written." - } - } - }, - "test_run_test_case_step_details": { - "type": "object", - "description": "The step this result belongs to and how it was answered. Present on every entry here - each entry IS a step result - and never on the case-level `test-result`, which only exists when no step results were submitted. Alongside the step result's own ids and timestamps it carries the status twice (`status` as the lowercase internal token, `field_value` as the display-case name) with its `colour`, and the step itself: `step` (the instruction), `result` (the expected result) and `order` (its position in the case).", - "properties": { - "id": { - "type": "integer", - "description": "Id of the step-result record." - }, - "step_mapping_id": { - "type": "integer", - "description": "Id of the step within the case that this result answers." - }, - "status": { - "type": "string", - "example": "passed", - "description": "Lowercase internal token for this step's status." - }, - "field_value": { - "type": "string", - "example": "Passed", - "description": "Display-case name of the same status." - }, - "status_id": { - "type": "integer", - "description": "Id of the configured status value." - }, - "colour": { - "type": "string", - "nullable": true, - "description": "Colour configured for that status." - }, - "step": { - "type": "string", - "nullable": true, - "description": "The step instruction, as rich text." - }, - "result": { - "type": "string", - "nullable": true, - "description": "The expected result for the step, as rich text." - }, - "order": { - "type": "integer", - "nullable": true, - "description": "Position of the step in the case." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "TestCaseFieldValue": { - "type": "object", - "description": "One resolved field value on a test case \u2014 the shared descriptor behind case_type, priority, status and automation_state. Identical across the detail, list, folder-list, search, search-v2 and run-mapping routes.", - "properties": { - "id": { - "type": "integer" - }, - "internal_name": { - "type": "string", - "description": "Machine name, e.g. `acceptance`, `low`, `not_automated`." - }, - "colour": { - "type": "string", - "nullable": true - }, - "value_category": { - "type": "string", - "nullable": true, - "description": "Coarse grouping for the value, e.g. `manual` / `automated` on automation_state. Null on most priority, status and case_type values." - }, - "name": { - "type": "string", - "description": "Display label." - } - } - }, - "V1TestRunTestCaseRow": { - "type": "object", - "description": "One test case AS MAPPED INTO A v1 TEST RUN \u2014 a case-plus-configuration row, not the project-level record. A case mapped to several configurations appears once per configuration. The row is the shared test-case record (see TestCase) plus the execution fields below; it does NOT carry attachments, estimated_duration_seconds, template_name or test_case_dataset, and it DOES carry step_count.\n\nNOT EVERY WORKSPACE RETURNS EVERY FIELD DECLARED HERE. `execution_eligible` and the `review_status`/`reviewers` pair are gated by a per-workspace feature flag and are mutually exclusive in practice: a workspace returns one side or the other, never both. The declaration is the union, so treat each of those three as absent unless you have seen it on the workspace you are actually reading. The remaining fields are returned everywhere.", - "allOf": [ - { - "$schema": "TestCaseCore" - } - ], - "properties": { - "mapping_id": { - "type": "integer", - "description": "Id of THIS case-x-configuration mapping inside the run. NOT stable: it shifts to the latest result's id after a write, so re-read it before each bulk operation rather than caching it." - }, - "test_case_id": { - "type": "integer", - "description": "Integer id of the underlying project-level test case. `id` on this row is the same case id; `mapping_id` is what identifies the row." - }, - "test_run_id": { - "type": "integer", - "description": "Integer id of the run this mapping belongs to." - }, - "configuration": { - "type": "object", - "nullable": true, - "description": "The configuration this row is mapped to.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "browser": { - "type": "string", - "nullable": true - }, - "browser_version": { - "type": "string", - "nullable": true - }, - "os": { - "type": "string", - "nullable": true - }, - "os_version": { - "type": "string", - "nullable": true - }, - "device": { - "type": "string", - "nullable": true - } - } - }, - "latest_status": { - "type": "string", - "description": "Latest execution status for THIS row, e.g. `untested`.", - "example": "untested" - }, - "result_status": { - "$schema": "ResultStatusFieldValue", - "nullable": true - }, - "result_notes": { - "type": "string", - "nullable": true, - "description": "Notes recorded with the latest result." - }, - "result_custom_fields": { - "type": "array", - "description": "Custom field values recorded with the latest result; `[]` when none.", - "items": { - "type": "object" - } - }, - "defects": { - "type": "array", - "description": "Defects linked to the latest result; `[]` when none.", - "items": { - "type": "object" - } - }, - "dataset_values": { - "type": "array", - "description": "Dataset values bound to this mapping; `[]` when none.", - "items": { - "type": "object" - } - }, - "last_executed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Null until the row has been executed." - }, - "last_executed_by": { - "type": "integer", - "nullable": true, - "description": "Null until the row has been executed." - }, - "planned_execution_date": { - "type": "string", - "format": "date", - "nullable": true - }, - "test_assignee": { - "type": "integer", - "nullable": true, - "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`." - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "deleted": { - "type": "boolean", - "description": "True when the underlying case has been removed but the mapping row is still returned." - }, - "project_name": { - "type": "string", - "nullable": true, - "description": "Populated only for cases mapped in from another project; null otherwise." - }, - "test_case_folders_path": { - "type": "array", - "description": "Breadcrumb from root to the case's folder.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "notes": { - "type": "string", - "nullable": true + }, + "TestCaseFieldValue__ReorderTestCasesByFolder": { + "type": "object", + "description": "TestCaseFieldValue as returned by `reorder_test_cases_by_folder`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "priority" + } } - } - } - }, - "execution_eligible": { - "type": "boolean", - "description": "Whether this mapping may be executed. Gated by a per-workspace feature flag: a workspace returning this field does not return `review_status`/`reviewers` on these rows, and vice versa. Treat as absent unless seen." - }, - "step_count": { - "type": "integer", - "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) \u2014 count `test_case_steps` there instead." - }, - "comments_count": { - "type": "integer" - } - } - }, - "TestRunSelectionCaseRow": { - "type": "object", - "description": "The raw test-case record as returned by the run-selection endpoint. This is NOT the shared TestCase serializer: `automation_status` is a plain string here rather than the {name,value} object, the folder key is `folder_id` rather than `test_case_folder_id`, `type` replaces the `case_type` descriptor, and assignee / priority / status / tags / custom_fields / steps are not returned at all. Do not plan against TestCase fields on this route.\n", - "properties": { - "id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "example": "TC-838569" - }, - "third_party_identifier": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "type": { - "type": "string", - "description": "Case type as a bare string, e.g. `manual`. The descriptor object is not returned here.", - "example": "manual" - }, - "folder_structure": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "nullable": true - }, - "source_entity_id": { - "type": "integer", - "nullable": true - }, - "owner": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id, not the TM user id used by `assignee.id` elsewhere." - }, - "folder_id": { - "type": "integer", - "description": "The case's folder. Called `test_case_folder_id` on every other test-case route." - }, - "group_id": { - "type": "integer" - }, - "template": { - "type": "string", - "example": "test_case_text" - }, - "template_id": { - "type": "integer", - "nullable": true - }, - "import_id": { - "type": "integer", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "updated_by": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id." - }, - "created_by": { - "type": "integer", - "nullable": true, - "description": "BrowserStack user id." - }, - "created_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "automation_status": { - "type": "string", - "description": "A PLAIN STRING on this route, e.g. `not_automated` \u2014 elsewhere `automation_status` is a {name, value} object.", - "example": "not_automated" - }, - "automation_id": { - "type": "integer", - "nullable": true - }, - "archived_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Non-null when the case is archived." - }, - "trashed_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Non-null when the case is in the bin." - }, - "is_shared": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "nullable": true - }, - "is_project_level": { - "type": "boolean" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "review_sent_date": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "integer", - "nullable": true - } - } - }, - "TestCaseCore": { - "type": "object", - "description": "Fields returned by EVERY test-case row on every route, including the run-mapping rows. Routes add to this; nothing here is route-conditional.\n", - "properties": { - "assignee": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "creator_details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "updator_details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "email": { - "type": "string", - "description": "Email address of the user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "updated_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "case_type": { - "$schema": "TestCaseFieldValue", - "nullable": true - }, - "priority": { - "$schema": "TestCaseFieldValue" - }, - "status": { - "$schema": "TestCaseFieldValue", - "nullable": true - }, - "case_type_imported": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "estimate": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "folder": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "history_count": { - "type": "integer", - "nullable": true - }, - "owner": { - "type": "integer", - "nullable": true - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "owner_imported": { - "type": "string" - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "step": { - "type": "string" - }, - "result": { - "type": "string" + }, + "TestRunWriteResponse__CloseTestRun": { + "type": "object", + "description": "TestRunWriteResponse as returned by `close_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestRunWriteResponseBody" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "project": { + "type": "object", + "properties": { + "access": { + "type": "string", + "nullable": true + }, + "app_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "applicable_role": { + "type": "string", + "example": "Admin" + }, + "applicable_role_id": { + "type": "string", + "nullable": true + }, + "archived": { + "type": "integer", + "example": 0 + }, + "build_updated_at": { + "type": "string", + "nullable": true + }, + "default_team_id": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "is_demo": { + "type": "boolean", + "example": false + }, + "publicly_readable": { + "type": "boolean", + "example": false + }, + "slug": { + "type": "string", + "example": "__mcp-capability-fixture__-e2435bde" + }, + "source": { + "type": "string", + "example": "TCM" + }, + "sub_group_id": { + "type": "string", + "nullable": true + }, + "team_id": { + "type": "string" + }, + "user_id": { + "type": "integer", + "example": 2522 + }, + "visual_scanner_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "web_percy_build_updated_at": { + "type": "string", + "nullable": true + }, + "import_id": { + "type": "string", + "nullable": true + } + } + }, + "testrun": { + "type": "object", + "properties": { + "issues": { + "type": "string", + "nullable": true + }, + "observability_id": { + "type": "integer", + "example": 17579204 + }, + "test_result_issues": { + "type": "string", + "nullable": true + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "browserstack_user_id": { + "type": "integer", + "example": 6013 + }, + "full_name": { + "type": "string", + "example": "ing" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + }, + "assignee_imported": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "string", + "nullable": true + }, + "environment": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "string", + "nullable": true + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } + } + } + } + } } - } - } - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "template": { - "type": "string" - }, - "test_case_folder_id": { - "type": "integer" - }, - "test_case_steps": { - "$schema": "TestCaseStep" - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "metadata": { - "type": "object", - "properties": { - "lcnc_build_map": { + }, + "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun": { "type": "object", + "description": "V1TestRunTestCaseRow as returned by `bulk_set_test_case_assignee_in_run`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestRunTestCaseRow" + } + ], "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - }, - "profileId": { - "type": "string", - "nullable": true, - "description": "Low-code profile id. Present under `metadata.lcnc_build_map` but NOT on the top-level `lcnc_build_map`, which carries only resource_path and status." - } - } - }, - "ai_prompt": { + "automation_status": { + "type": "object", + "properties": { + "value": { + "type": "string", + "example": "not_automated" + } + } + }, + "automation_state": { + "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state — both are returned, always in agreement.", + "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "priority": { + "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "result_status": { + "nullable": true, + "$schema": "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun" + }, + "test_assignee": { + "type": "integer", + "nullable": true, + "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`.", + "properties": { + "browserstack_user_id": { + "type": "integer", + "example": 13450 + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "full_name": { + "type": "string", + "example": "Aadarsh" + }, + "group_id": { + "type": "integer", + "example": 2615 + }, + "onboarded": { + "type": "integer", + "example": 1 + } + } + } + } + }, + "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun": { "type": "object", + "description": "TestCaseFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "TestCaseFieldValue" + } + ], "properties": { - "status": { - "type": "boolean" - } + "field_name": { + "type": "string", + "example": "priority" + } } - } - } - }, - "automation_state": { - "$schema": "TestCaseFieldValue", - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement." - }, - "is_shared": { - "type": "boolean", - "description": "True when the case is a shared case rather than one owned outright by its folder." - }, - "template_id": { - "type": "integer", - "nullable": true, - "description": "Id of the template backing this case. Null for cases on the plain-text template." - }, - "template_step_type": { - "type": "string", - "description": "Step type the template implies \u2014 `test_case_steps` or `test_case_text`. Mirrors `template`.", - "example": "test_case_steps" - }, - "test_run_results_count": { - "type": "integer", - "nullable": true, - "description": "Count of results recorded for this case across runs. Null on list routes. Known to disagree with the actual number of results \u2014 do not rely on it; page the run-results endpoint instead." - }, - "test_run_results_issues_count": { - "type": "integer", - "nullable": true, - "description": "Count of issues raised against those results. Null on list routes; carries the same reliability caveat as test_run_results_count." - } - } - }, - "V1TestCaseListRow": { - "type": "object", - "description": "A test-case row as returned by the LIST routes. Adds `step_count`, which the single-case detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "step_count": { - "type": "integer", - "description": "Number of steps on the case. Present on every LIST, SEARCH and run-mapping row; ABSENT from the two single-case detail routes (get_test_case_detail, get_test_case_by_integer_id) \u2014 count `test_case_steps` there instead." - } - } - }, - "V1TestCaseFolderListRow": { - "type": "object", - "description": "The folder listing's row: a list row that also carries `duplicates_tc_count`.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "duplicates_tc_count": { - "type": "integer", - "nullable": true, - "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListFolderTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__ListFolderTestCases" - } - } - }, - "V1TestCaseSearchRow": { - "type": "object", - "description": "A search result row: a list row that also carries `attachments`.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object" - } - }, - "case_type": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "priority": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "status": { - "$schema": "SearchFieldValue", - "nullable": true - }, - "automation_state": { - "$schema": "SearchFieldValue", - "nullable": true - } - } - }, - "V1TestCaseRecordRow": { - "type": "object", - "description": "The single-case record as returned by the integer-id read. Carries `attachments` and `comments_count`, and NOT `step_count` \u2014 count `test_case_steps` instead.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object" - } - }, - "comments_count": { - "type": "integer" - } - } - }, - "V1TestCaseDetailRow": { - "type": "object", - "description": "The slide-over detail row: the single-case record plus the resolved template and dedupe extras. Does not carry `step_count`.\n", - "allOf": [ - { - "$schema": "V1TestCaseRecordRow" - } - ], - "properties": { - "folder": { - "type": "object", - "nullable": true, - "description": "DETAIL ROUTES ONLY (get_test_case_detail). Observed null in every response seen." - }, - "testcase_template": { - "type": "object", - "description": "DETAIL ROUTES ONLY. The resolved template layout \u2014 a large nested object." - }, - "duplicates_tc_count": { - "type": "integer", - "nullable": true, - "description": "DETAIL ROUTES and list_folder_test_cases only. Populated from the dedupe store when `hasDuplicates=true` is sent; null otherwise." - }, - "folder_path": { - "type": "string", - "description": "DETAIL ROUTE ONLY, and only when `include=folder_path` is sent." - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "priority": { - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseDetail" - }, - "test_case_steps": { - "$schema": "TestCaseStep__GetTestCaseDetail" - } - } - }, - "V1TestRunDetailRow": { - "type": "object", - "description": "A single v1 test run as returned by the detail route: the shared run record plus its linked issues, which the list and search rows do not carry.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "issues": { - "type": "array", - "description": "Linked issues. Returned by the single-run detail route; not present on the list or search rows.", - "items": { - "type": "object" - } - }, - "test_result_issues": { - "type": "string", - "nullable": true, - "description": "Issues raised against this run's results. Not returned by every workspace \u2014 treat as absent unless seen." - }, - "observability_id": { - "type": "integer", - "example": 17574995 - }, - "created_by": { - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", - "$schema": "TestRunUserRef__GetTestRunByIntegerId" - }, - "updated_by": { - "nullable": true, - "description": "Expanded user object on the single-run DETAIL route; **null on the list and search routes**, where the key is present but never populated.", - "$schema": "TestRunUserRef__GetTestRunByIntegerId" - } - } - }, - "V1TestRunListRow": { - "type": "object", - "description": "A v1 test run as returned by the LIST and SEARCH routes: the shared run record plus `build_meta`, which the single-run detail route does not carry.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_meta": { - "type": "object", - "nullable": true, - "description": "Automation build this run is linked to \u2014 {id, name, creationType, buildNormalisedName, \u2026}. Populated for runs created from an automation build." - } - } - }, - "ExploratorySessionCore": { - "type": "object", - "description": "Fields returned for an exploratory session on BOTH the list and the single-session read.\n", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the exploratory session.", - "example": 123 - }, - "identifier": { - "type": "string", - "description": "ES-NNN form. NOTE: the single-session read takes the INTEGER `id` in its path \u2014 passing this identifier returns 404.", - "example": "ES-295" - }, - "title": { - "type": "string", - "description": "Title of the exploratory session.", - "example": "Checkout flow exploration" - }, - "session_state": { - "type": "string", - "description": "Current lifecycle state of the session.", - "enum": [ - "active", - "closed" - ], - "example": "active" - }, - "timebox_duration": { - "type": "integer", - "description": "Planned duration of the session in minutes.", - "example": 60, - "nullable": true - }, - "assignee": { - "$schema": "ExploratorySessionUserRef" - }, - "created_by": { - "$schema": "ExploratorySessionUserRef" - }, - "tags": { - "type": "array", - "description": "Tags associated with the session.", - "example": [ - "regression", - "payment" - ], - "items": { - "type": "string" - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the session was created.", - "example": "2026-01-16T10:30:00Z" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the session was last updated.", - "example": "2026-01-16T11:00:00Z" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "test_plan_id": { - "type": "integer", - "nullable": true - } - } - }, - "ExploratorySessionListRow": { - "type": "object", - "description": "An exploratory session as returned by the LIST route: the shared core plus `summary` and the resolved `test_plan`. It does NOT carry charter, description, durations, configurations, issues, custom_fields, timeline, owner, record_status, closed_at/closed_by or attachment_relations \u2014 read a single session for those.\n", - "allOf": [ - { - "$schema": "ExploratorySessionCore" - } - ], - "properties": { - "summary": { - "type": "string", - "nullable": true, - "description": "Returned on the LIST route; absent from the single-session read." - }, - "test_plan": { - "type": "object", - "nullable": true, - "description": "Resolved plan. Returned on the LIST route; absent from the single-session read." - } - } - }, - "UserCore": { - "type": "object", - "description": "The four fields every expanded user reference carries, on every serializer.\n", - "properties": { - "id": { - "type": "integer", - "description": "Unique identifier for the user." - }, - "full_name": { - "type": "string", - "description": "Full name of the user." - }, - "onboarded": { - "type": "integer", - "description": "Onboarding status of the user." - } - } - }, - "TestRunUserRef": { - "type": "object", - "description": "The user reference embedded in a test run's created_by / updated_by. Carries group_id and browserstack_user_id but NOT reports_and_notification_enabled.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "browserstack_user_id": { - "type": "integer", - "description": "BrowserStack user ID associated with this user." - }, - "group_id": { - "type": "integer", - "description": "Group ID the user belongs to." - } - } - }, - "DatasetUserRef": { - "type": "object", - "description": "The user reference embedded in a dataset's created_by. Carries `role`, which no other user serializer returns, and omits browserstack_user_id / group_id.\n", - "allOf": [ - { - "$schema": "UserCore" - } - ], - "properties": { - "role": { - "type": "string", - "description": "The user's role on the dataset's project, e.g. `owner`.", - "example": "owner" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "description": "Indicates if reports and notifications are enabled for the user." - } - } - }, - "ProjectsPageInfo": { - "type": "object", - "description": "The projects listing's pagination envelope: PageInfo plus `starred_count`, which no other paginated read returns.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "starred_count": { - "type": "integer", - "example": 0 - } - } - }, - "FilterDetailsFolderNode": { - "type": "object", - "description": "A folder as returned inside `filter_details.folders` \u2014 the shared folder record plus the tree/selection fields that only this projection carries.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "folder_tree": { - "type": "object" - }, - "only_child_selected_parent_ids": { - "type": "string", - "nullable": true - }, - "only_selected_parent_ids": { - "type": "integer", - "example": 764138 - }, - "partial_selected_parent_ids": { - "type": "string", - "nullable": true - }, - "select_all_parent_ids": { - "type": "integer", - "example": 764093 - }, - "folders": { - "type": "object", - "properties": { - "ancestors": { - "type": "string", - "nullable": true - }, - "folder_path": { - "type": "string", - "example": "/764093/" - }, - "id": { - "type": "integer", - "example": 764093 - }, - "name": { - "type": "string", - "example": "__readonly__" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "selected_all": { - "type": "boolean", - "example": true - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - } - } - } - } - }, - "V1TestRunCasesRunRow": { - "type": "object", - "description": "The run header returned alongside a run's test cases. Adds the build and sharing fields that the run list and single-run detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - } - } - }, - "SearchFieldValue": { - "type": "object", - "description": "The field-value descriptor as returned by the two SEARCH routes, which additionally embed the per-project binding rows.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "array", - "description": "Per-project binding rows for this value. Returned ONLY by the two search routes (search_project_entities, search_project_entities_by_filter); absent from the detail, list, folder-list and run-mapping routes.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "custom_values_id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "dataset_id": { - "type": "integer", - "nullable": true + }, + "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun": { + "type": "object", + "description": "ResultStatusFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "ResultStatusFieldValue" + } + ], + "properties": { + "field_name": { + "type": "string", + "example": "status" + } } - } - } - } - } - }, - "ResultStatusFieldValue": { - "type": "object", - "description": "The field-value descriptor as returned for a RESULT status. This serializer names the label `field_value`; every other consumer names it `name`. Both are present and they agree.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_value": { - "type": "string", - "example": "Passed" - } - } - }, - "PathFolder": { - "type": "object", - "description": "A folder as it appears in a breadcrumb list (`path_folders`, `destination_path_folders`). The shared folder record plus the row's own audit and lineage fields, which the folder LISTING routes do not return.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - } - } - }, - "V1TestRunWriteRow": { - "type": "object", - "description": "The run as echoed by a run WRITE and by the run-cases read: the shared run record plus the build and sharing fields the run list and single-run detail routes do not return.\n", - "allOf": [ - { - "$schema": "TestRun" - } - ], - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - } - } - }, - "V1TestCaseWriteRow": { - "type": "object", - "description": "The test-case row as echoed by a WRITE and by global search: the project-level record plus the extras those responses attach.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - } - } - }, - "FilterWriteRow": { - "type": "object", - "description": "A filter as echoed by create/update: the shared filter record plus the ownership and lifecycle fields the filter LISTING does not return.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "TestRunWriteResponse__EditTestRun": { - "type": "object", - "description": "TestRunWriteResponse as returned by `edit_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { + }, + "DuplicateGroup": { "type": "object", + "description": "One group of test cases the dedupe model judged duplicates. Returned by the duplicates listing; the single-duplicate read returns the same group under `duplicate`.\n", "properties": { - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17577900 - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { + "id": { "type": "integer", - "example": 13450 - }, - "email": { + "description": "The duplicate GROUP id — this is the `duplicate_id` the single-duplicate read and the write actions take." + }, + "duplicate_ids": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Ids of every pair in this group; usually `[id]`." + }, + "ai_score": { + "type": "number", + "description": "Similarity score from the dedupe model, 0-100." + }, + "avg_score": { + "type": "number", + "description": "Mean score across the group." + }, + "reason": { "type": "string", - "example": "user@example.com" - }, - "full_name": { + "description": "Model-written explanation of why the cases were judged duplicates." + }, + "duplicate_type": { "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { + "description": "How they matched, e.g. `semantic`." + }, + "case_count": { "type": "integer", - "example": 1 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { + "description": "Number of cases in the group." + }, + "folder_count": { "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { + "description": "Number of distinct folders they span." + }, + "owner_count": { "type": "integer", - "example": 2615 - }, - "onboarded": { + "description": "Number of distinct owners." + }, + "suggested_base_id": { "type": "integer", - "example": 1 - } + "description": "The case the model suggests keeping." + }, + "test_cases": { + "type": "array", + "items": { + "type": "object" + }, + "description": "The cases in this group; each carries its own id and identifier." } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } + } + }, + "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans": { + "type": "object", + "description": "V1TestPlanBulkResponse as returned by `bulk_restore_archived_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanBulkResponse" + } + ], + "properties": { + "count": { + "type": "integer" + }, + "plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "sub_plans_count": { + "type": "integer" + } + } + } + }, + "success": { + "type": "boolean" } - }, - "issues": { - "type": "string", - "nullable": true - } } - }, - "project": { + }, + "V1TestPlanBulkResponse__BulkArchiveTestPlans": { "type": "object", + "description": "V1TestPlanBulkResponse as returned by `bulk_archive_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanBulkResponse" + } + ], "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - } - } - } - } - }, - "TestRunWriteResponse__AssignTestRunOwner": { - "type": "object", - "description": "TestRunWriteResponse as returned by `assign_test_run_owner`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { + "count": { + "type": "integer" + }, + "plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "project_id": { + "type": "integer" + }, + "sub_plans_count": { + "type": "integer" + } + } + } + }, + "success": { + "type": "boolean" + } + } + }, + "V1TestPlanDetail__CreateTestPlanByIntegerId": { "type": "object", + "description": "V1TestPlanDetail as returned by `create_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", + "allOf": [ + { + "$schema": "V1TestPlanDetail" + } + ], "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 13450 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17577900 - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "run_state": { - "type": "string", - "example": "new_run" - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "example": 4 - }, - "test_plans": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:33.000Z" - }, - "updated_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { + "success": { + "type": "boolean" + }, + "test_plan": { + "type": "object", + "properties": { + "active_state": { + "type": "string" + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object" + } + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } + } + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "owner": { + "type": "integer" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "review_settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sub_plan_mode": { + "type": "string" + } + } + }, + "review_status": { + "type": "string" + }, + "reviewers": { + "type": "array", + "items": { + "type": "object" + } + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "test_plan_progress": { + "type": "object" + }, + "test_plan_progress_v2": { + "type": "string", + "nullable": true + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "string" + } + } + } + } + }, + "SharedComponentDetail": { + "type": "object", + "description": "The shared field as echoed by a CREATE or UPDATE: the listed fields plus its collection and its actual rows. The LISTING returns neither - it gives `step_count` instead of the rows, and does not carry `folder_id` at all.\n", + "allOf": [ + { + "$schema": "SharedComponent" + } + ], + "properties": { + "folder_id": { "type": "integer", - "example": 1 - } + "nullable": true, + "description": "Collection it is filed under; null for the Unassigned root." + }, + "shared_step_details": { + "type": "array", + "description": "The ordered rows. Returned by the create and update echoes; the LISTING omits it and gives `step_count` instead.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "step": { + "type": "string" + }, + "result": { + "type": "string", + "nullable": true + }, + "test_data": { + "type": "string", + "nullable": true + }, + "order": { + "type": "integer" + } + } + } } - }, - "uuid": { - "type": "integer", - "example": 17577900 - } } - }, - "project": { + }, + "FolderWriteResponseBody": { "type": "object", "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "success": { - "type": "boolean", - "example": true - } - } - } - } - }, - "Folder__BulkCopyTestCases": { - "type": "object", - "description": "Folder as returned by `bulk_copy_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 6 - }, - "total_cases_count": { - "type": "integer", - "example": 35 - } - } - }, - "TestCase__BulkCopyTestCases": { - "type": "object", - "description": "TestCase as returned by `bulk_copy_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkCopyTestCases" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkCopyTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_copy_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCase__BulkReplaceTestCaseFields": { - "type": "object", - "description": "TestCase as returned by `bulk_replace_test_case_fields`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkReplaceTestCaseFields" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkReplaceTestCaseFields": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_replace_test_case_fields`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "Folder__BulkMoveTestCases": { - "type": "object", - "description": "Folder as returned by `bulk_move_test_cases`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-21T13:26:55.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13482" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:31:46.000Z" - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 6 - }, - "total_cases_count": { - "type": "integer", - "example": 35 - } - } - }, - "TestCase__BulkMoveTestCases": { - "type": "object", - "description": "TestCase as returned by `bulk_move_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "duplicates_tc_count": { - "type": "string", - "nullable": true - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__BulkMoveTestCases" - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - } - } - }, - "TestCaseFieldValue__BulkMoveTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_move_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "valueDetails": { - "type": "object", - "properties": { - "custom_values_id": { - "type": "integer", - "example": 318605 - }, - "dataset_id": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 117295159 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "ReportDetail__CreateReportByIntegerId": { - "type": "object", - "description": "ReportDetail as returned by `create_report_by_integer_id`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:49:30.804Z" - }, - "custom_date_range": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "MCP live probe of create_report_by_integer_id capability" - }, - "frequency": { - "type": "string", - "nullable": true - }, - "frequency_details": { - "type": "string", - "nullable": true - }, - "grain": { - "type": "string", - "example": "test_case" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 10411 - }, - "identifier": { - "type": "string", - "example": "SC-510" - }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - }, - "is_dynamic": { - "type": "boolean", - "example": false - }, - "next_run_at": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "Folder" + } + } + } + }, + "description": "Response body of `FolderWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "SharedStepsIDResponseBody": { + "type": "object", + "required": [ + "data", + "success" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + }, + "description": "Response body of `SharedStepsIDResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "TagsSearchResponseBody": { + "type": "object", + "required": [ + "tags", + "tag_objects", + "info" + ], + "properties": { + "tags": { + "type": "array", + "description": "Simple array of tag names (for backward compatibility)", + "example": [ + "AI Generated", + "AI Generated Details" + ], + "items": { + "type": "string" + } + }, + "tag_objects": { + "type": "array", + "description": "Detailed tag objects with metadata", + "items": { + "$schema": "Tag" + } + }, + "info": { + "$schema": "PageInfo" + } + }, + "description": "Response body of `TagsSearchResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." + }, + "TestRunWriteResponseBody": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "$schema": "TestRun" + }, + "project": { + "$schema": "Project" + }, + "error": { + "type": "string", + "description": "Only present when success is false; the backing service's message." + } + } + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", + "items": { + "type": "integer" + } + } + }, + "description": "Response body of `TestRunWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." } - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "project_ids": { - "type": "integer", - "example": 1234 - }, - "report_creation_mode": { - "type": "string", - "example": "custom_report" - }, - "report_timeframe": { - "type": "string", - "example": "last_one_week" - }, - "report_type": { - "type": "string", - "example": "Test Run Summary" - }, - "slack_notification": { - "type": "string", - "nullable": true - }, - "slack_notification_supported": { - "type": "boolean", - "example": true - }, - "title": { - "type": "string", - "example": "__mcp-probe-report-20260921" - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:49:30.804Z" - } - } - }, - "Project__CreateReportByIntegerId": { - "type": "object", - "description": "Project as returned by `create_report_by_integer_id`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Project" - } - ], - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-17T08:01:58.000Z" - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "probe edit_project 2026-09-17T10:47:41Z" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "V1TestCaseCreateResponse__CreateTestCaseByIntegerId": { - "type": "object", - "description": "V1TestCaseCreateResponse as returned by `create_test_case_by_integer_id`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "V1TestCaseCreateResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "cases_count": { - "type": "integer", - "example": 47 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } - } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 48 - } - } - }, - "path_folders": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13472" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "cases_count": { - "type": "integer", - "example": 47 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 48 - } - } - }, - "test_case": { - "type": "object", - "properties": { - "comments_count": { - "type": "integer", - "example": 0 - }, - "assignee": { + }, + "responses": { + "TestRunWriteResponse": { + "description": "Successfully returned the test run after a WRITE\n(create / edit / assign / close / delete). NOTE the envelope key is `data.testrun` with NO\nunderscore on these operations, unlike clone and the plain reads, which use `data.test_run`.\n`skipped_test_case_ids` is a SIBLING of `data`, not inside it, and lists cases the review gate\nrefused to add. Client errors from the backing service replace the payload with\n{data: {success: false, error: }} at the 4xx status.\n", + "schema": { "type": "object", "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "$schema": "TestRun" + }, + "project": { + "$schema": "Project" + }, + "error": { + "type": "string", + "description": "Only present when success is false; the backing service's message." + } + } + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", + "items": { + "type": "integer" + } + } } - }, - "automation_state": { + } + }, + "BadRequest": { + "description": "Bad request due to invalid parameters.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Unauthorized": { + "description": "Unauthorized access. Authentication is required.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "InternalServerError": { + "description": "Internal server error.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BulkArchiveResponse": { + "description": "Test cases archived successfully", + "schema": { "type": "object", + "required": [ + "success", + "folders" + ], "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 2509329 - }, - "internal_name": { - "type": "string", - "example": "not_automated" - }, - "name": { - "type": "string", - "example": "Not Automated" - }, - "value_category": { - "type": "string", - "example": "manual" - } + "success": { + "type": "boolean" + }, + "folders": { + "type": "object" + } } - }, - "automation_status": { + } + }, + "Forbidden": { + "description": "Forbidden access. You don't have permission.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "UnprocessableEntity": { + "description": "The request was well-formed but contains semantic errors.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BulkCopyTestCasesResponse": { + "description": "Test cases copied successfully", + "schema": { "type": "object", + "required": [ + "success", + "test_cases", + "info", + "cases_count", + "source_path_folders", + "destination_path_folders" + ], "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkCopyTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object" + }, + "source_path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "Folder__BulkCopyTestCases" + } + }, + "folders": { + "type": "object" + } } - }, - "case_type": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318626 - }, - "internal_name": { - "type": "string", - "example": "other" - }, - "name": { - "type": "string", - "example": "Other" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "case_type_imported": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:42.487Z" - }, - "creator_details": { + } + }, + "BulkDeleteTestCasesResponse": { + "description": "Response after bulk deleting test cases", + "schema": { "type": "object", "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "estimate": { - "type": "string", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1986203 - }, - "identifier": { - "type": "string", - "example": "TC-54559" - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object" + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + } + } + } + }, + "BulkMoveTestCasesResponse": { + "description": "CORRECTION of an existing key (only bulk_move_test_cases uses it). A synchronous bulk move renders the refreshed source listing, not {success, folders}. Above the async threshold the same 200 carries {success, async:true, unique_id} and none of these keys.", + "schema": { "type": "object", "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase__BulkMoveTestCases" + } + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object", + "description": "Case counts keyed by folder id, for the source and destination folders." + }, + "destination_path_folders": { + "type": "array", + "items": { + "$schema": "Folder__BulkMoveTestCases" + } + }, + "source_path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Only present when the request scoped a source folder." + }, + "moved_cases_count": { + "type": "integer", + "description": "Only present on a cross-project move." + }, + "folders": { + "type": "object" + } } - }, - "links": { + } + }, + "TestRunResponse": { + "description": "Successfully returned a test run under a\n`data` envelope, keyed `test_run` WITH an underscore. Used by the v1 run reads and by clone.\nNOTE the write operations on this entity (create / edit / assign / close / delete) return\n`data.testrun` with NO underscore instead, and the v2 run endpoints have no `data` envelope\nat all.\n", + "schema": { "type": "object", "properties": { - "detail": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203/detail" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986203" - } + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_run": { + "$schema": "TestRun" + } + } + } } - }, - "metadata": { + } + }, + "FolderCopyResponse": { + "description": "Folder copy operation initiated successfully", + "schema": { "type": "object", + "required": [ + "success", + "async", + "message" + ], "properties": { - "ai_prompt": { - "type": "object", - "properties": { - "status": { + "success": { "type": "boolean", - "example": false - } - } - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "profileId": { - "type": "string", - "nullable": true - }, - "resource_path": { + "example": true + }, + "async": { + "type": "boolean", + "description": "Whether operation is running asynchronously", + "example": true + }, + "unique_id": { "type": "string", - "nullable": true - }, - "status": { + "format": "uuid", + "description": "Tracking ID for async operation (present when async=true)", + "example": "6a206593-865c-41ec-9459-d94bce72c1b9" + }, + "message": { "type": "string", - "nullable": true - } - } - } - } - }, - "name": { - "type": "string", - "example": "__mcp-probe-createcase-20260921" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318618 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "status": { + "example": "Folder copy operation queued successfully" + }, + "folder_id": { + "type": "integer", + "description": "New folder ID (present when async=false)", + "example": 4 + } + } + } + }, + "FilterCreateResponse": { + "description": "Successfully created a new filter", + "schema": { "type": "object", "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "id": { - "type": "integer", - "example": 318632 - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "name": { - "type": "string", - "example": "Active" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "mcp-probe" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_name": { - "type": "string", - "example": "Test Case Steps" - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_case_dataset": { - "type": "object" - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_steps": { + "data": { + "$schema": "Filter__CreateFilter" + }, + "success": { + "type": "boolean" + } + } + } + }, + "CreateReportResponse": { + "description": "Successfully created scheduled report", + "schema": { + "$schema": "CreateReportResponse" + } + }, + "RootFolderDetailsResponse": { + "description": "Response containing only root folder details including test case counts, and links.", + "schema": { "type": "object", "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:42.508Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "id": { - "type": "integer", - "example": 12373418 - }, - "order": { - "type": "integer", - "example": 1 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_case_id": { - "type": "integer", - "example": 1986203 - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:42.508Z" - } - } - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "nullable": true - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - } - } - }, - "V1TestCaseCreateResponse__CreateTestCases": { - "type": "object", - "description": "V1TestCaseCreateResponse as returned by `create_test_cases`. Composes V1TestCaseCreateResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "V1TestCaseCreateResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "cases_count": { - "type": "integer", - "example": 48 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { + "info": { + "$schema": "PageInfo" + }, + "folder_hierarchy": { + "$schema": "FolderHierarchy" + }, + "folders": { + "type": "array", + "items": { + "$schema": "Folder__ListRootFolders" + } + }, + "self": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + } + } + } + } + }, + "success": { + "type": "boolean" + } + } + } + }, + "SharedStepsPostResponse": { + "description": "Response containing details of a shared step.", + "schema": { "type": "object", + "required": [ + "data", + "success" + ], "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } - } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 49 - } - } - }, - "path_folders": { - "type": "object", - "properties": { - "created_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "deleted": { - "type": "boolean", - "example": false - }, - "entity_type": { - "type": "string", - "nullable": true - }, - "identifier": { - "type": "string", - "example": "FD-13472" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "parent_id": { - "type": "string", - "nullable": true - }, - "source": { - "type": "string", - "example": "TCM" - }, - "updated_at": { - "type": "string", - "example": "2026-09-17T08:13:35.000Z" - }, - "cases_count": { - "type": "integer", - "example": 48 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 12 - }, - "total_cases_count": { - "type": "integer", - "example": 49 - } - } - }, - "test_case": { - "type": "object", - "properties": { - "comments_count": { - "type": "integer", - "example": 0 - }, - "assignee": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "test_case_count": { + "type": "integer", + "description": "Number of test cases using this shared step" + }, + "step_count": { + "type": "integer", + "description": "Number of steps in this shared step" + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "step_count", + "test_case_count", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + } + } + }, + "TestResultCreateResponse": { + "description": "Successful response for creating a test result", + "schema": { "type": "object", + "required": [ + "data" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } + "data": { + "type": "object", + "required": [ + "success", + "test-result", + "unique_issues" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test-result": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "author": { + "$schema": "User__CreateTestResultForTestCase" + }, + "backtrace": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-05-25T20:02:05.000Z" + }, + "description": { + "type": "string" + }, + "error_description": { + "type": "string", + "nullable": true + }, + "expanded": { + "type": "string", + "example": null, + "nullable": true + }, + "failure_type": { + "type": "string", + "example": null, + "nullable": true + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "log_type": { + "type": "string", + "nullable": true + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "status": { + "type": "string", + "example": "passed" + }, + "result_status": { + "$schema": "ResultStatus__CreateTestResultForTestCase" + }, + "configuration_id": { + "type": "integer", + "description": "ID of the configuration this test result is associated with", + "example": 16, + "nullable": true + }, + "time_elapsed": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "key": { + "type": "string" + }, + "product_id": { + "type": "integer" + }, + "byte_size": { + "type": "integer" + }, + "checksum": { + "type": "string" + }, + "url": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "size": { + "type": "string" + } + } + } + }, + "created_by_imported": { + "type": "boolean", + "nullable": true + }, + "issues": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473" + }, + "custom_fields": { + "type": "string", + "example": "/api/v1/projects/10000/test-results/1473/custom-fields" + } + } + }, + "custom_fields": { + "type": "object" + }, + "updated_by": { + "type": "string", + "nullable": true + } + } + }, + "unique_issues": { + "type": "array", + "items": { + "type": "object" + } + } + } + } } - }, - "automation_state": { + } + }, + "FilterDeleteResponse": { + "description": "Successfully deleted the filter", + "schema": { "type": "object", "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 2509329 - }, - "internal_name": { - "type": "string", - "example": "not_automated" - }, - "name": { - "type": "string", - "example": "Not Automated" - }, - "value_category": { - "type": "string", - "example": "manual" - } + "success": { + "type": "boolean" + } } - }, - "automation_status": { + } + }, + "DeleteScheduleResponse": { + "description": "Successfully deleted scheduled report(s)", + "schema": { "type": "object", + "required": [ + "success", + "data", + "info" + ], "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "array", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "The total number of scheduled reports in the project." + }, + "total_report_count": { + "type": "integer", + "description": "The total number of reports in the project." + }, + "info": { + "$schema": "PageInfo" + } } - }, - "case_type": { + } + }, + "DownloadReportResponse": { + "description": "Successfully initiated report download", + "schema": { "type": "object", + "required": [ + "channel", + "success" + ], "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318626 - }, - "internal_name": { - "type": "string", - "example": "other" - }, - "name": { - "type": "string", - "example": "Other" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "case_type_imported": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:43.059Z" - }, - "creator_details": { + "channel": { + "type": "string", + "description": "Unique channel identifier for streaming or polling download status.", + "example": "53bd342e-b4d8-4003-bb3d-d448a2e8780d" + }, + "success": { + "type": "boolean", + "example": true + } + } + } + }, + "DashboardExportInitiatedResponse": { + "description": "Dashboard export successfully initiated", + "schema": { "type": "object", + "required": [ + "success", + "export_id" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "id": { - "type": "integer", - "example": 2522 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "estimate": { - "type": "string", - "nullable": true - }, - "estimated_duration_seconds": { - "type": "string", - "nullable": true - }, - "expected_result": { - "type": "string", - "nullable": true - }, - "history_count": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 1986204 - }, - "identifier": { - "type": "string", - "example": "TC-54560" - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "issues": { - "type": "string", - "nullable": true - }, - "lcnc_build_map": { + "success": { + "type": "boolean", + "example": true + }, + "export_id": { + "type": "integer", + "description": "Export record ID for tracking progress", + "example": 12345 + }, + "message": { + "type": "string", + "description": "Optional status message", + "example": "Export queued successfully" + } + } + } + }, + "ActiveTestRunsInfo": { + "description": "An OBJECT with a `data` array — not a bare top-level array.", + "schema": { "type": "object", "properties": { - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } + "data": { + "type": "array", + "description": "One entry per result status across the project's active test runs. When more than six statuses exist only the top six by count are kept and the remainder are collapsed into a single 'Other' entry that has links but NO result_status.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Status display name, or the literal 'Other' for the collapsed bucket." + }, + "y": { + "type": "integer", + "description": "Number of results in this status." + }, + "result_status": { + "type": "object", + "description": "Absent on the collapsed 'Other' entry.", + "properties": { + "id": { + "type": "integer" + }, + "field_value": { + "type": "string" + }, + "internal_name": { + "type": "string" + }, + "field_name": { + "type": "string" + }, + "colour": { + "type": "string" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + }, + "links": { + "type": "object", + "description": "Present on the collapsed 'Other' entry; carries a UI filter URL covering the statuses that were merged in.", + "properties": { + "filter": { + "type": "string" + } + } + } + } + } + }, + "empty_data": { + "type": "boolean", + "nullable": true + }, + "filter_details": { + "type": "object", + "description": "Merged in only when filter_details=true and q was present." + } } - }, - "links": { + } + }, + "AutomationStats": { + "description": "Response containing the automation statistics for the test cases.", + "schema": { "type": "object", "properties": { - "detail": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204/detail" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138/test-cases/1986204" - } + "success": { + "type": "boolean" + }, + "automated_coverage": { + "type": "number", + "format": "float" + }, + "automated_test_cases": { + "type": "integer" + }, + "total_test_cases": { + "type": "integer" + }, + "manual_test_cases": { + "type": "integer" + }, + "empty_data": { + "type": "boolean" + } } - }, - "metadata": { + } + }, + "TestRunsListResponse": { + "description": "Successfully retrieved test runs", + "schema": { "type": "object", "properties": { - "ai_prompt": { - "type": "object", - "properties": { - "status": { + "success": { "type": "boolean", - "example": false - } + "example": true + }, + "test_runs": { + "type": "array", + "items": { + "$schema": "V1TestRunListRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "count": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "example": 5 + }, + "closed": { + "type": "integer", + "example": 2 + } + } } - }, - "lcnc_build_map": { - "type": "object", - "properties": { - "profileId": { - "type": "string", - "nullable": true - }, - "resource_path": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "nullable": true - } - } - } - } - }, - "name": { - "type": "string", - "example": "__mcp-probe-createcases-20260921-1" - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "owner_imported": { - "type": "string" - }, - "preconditions": { - "type": "string", - "nullable": true - }, - "priority": { + } + } + }, + "ClosedTestRunsInfo": { + "description": "Closed test runs analytics data by month", + "schema": { "type": "object", "properties": { - "colour": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 318618 - }, - "internal_name": { - "type": "string", - "example": "medium" - }, - "name": { - "type": "string", - "example": "Medium" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "priority_imported": { - "type": "string" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "status": { + "data": { + "type": "array", + "example": [ + { + "month": "Apr", + "count": 3 + }, + { + "month": "May", + "count": 0 + } + ], + "items": { + "type": "object", + "properties": {} + } + }, + "empty_data": { + "type": "boolean" + } + } + } + }, + "ClosedTestRunsSplit": { + "description": "Closed test run results split by month and status", + "schema": { "type": "object", "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "id": { - "type": "integer", - "example": 318632 - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "name": { - "type": "string", - "example": "Active" - }, - "value_category": { - "type": "string", - "nullable": true - } - } - }, - "status_imported": { - "type": "string" - }, - "steps": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "string", - "example": "mcp-probe" - }, - "template": { - "type": "string", - "example": "test_case_steps" - }, - "template_id": { - "type": "integer", - "example": 1164 - }, - "template_name": { - "type": "string", - "example": "Test Case Steps" - }, - "template_step_type": { - "type": "string", - "example": "test_case_steps" - }, - "test_case_dataset": { - "type": "object" - }, - "test_case_folder_id": { - "type": "integer", - "example": 764138 - }, - "test_case_steps": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "result_status": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "field_value": { + "type": "string" + }, + "internal_name": { + "type": "string" + }, + "field_name": { + "type": "string" + }, + "colour": { + "type": "string" + }, + "value_category": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "date_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "empty_data": { + "type": "boolean" + } + } + } + }, + "FilterDetailResponse": { + "description": "Successfully retrieved filter details", + "schema": { "type": "object", "properties": { - "created_at": { - "type": "string", - "example": "2026-09-22T09:43:43.080Z" - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "id": { - "type": "integer", - "example": 12373419 - }, - "order": { - "type": "integer", - "example": 1 - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_case_id": { - "type": "integer", - "example": 1986204 - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:43.080Z" - } - } - }, - "test_run_results_count": { - "type": "string", - "nullable": true - }, - "test_run_results_issues_count": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "nullable": true - }, - "updator_details": { - "type": "string", - "nullable": true - } - } - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - } - } - }, - "TestRunWriteResponse__CreateTestRunByIntegerId": { - "type": "object", - "description": "TestRunWriteResponse as returned by `create_test_run_by_integer_id`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "testrun": { - "type": "object", - "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { + "data": { + "$schema": "Filter" + }, + "success": { + "type": "boolean" + } + } + } + }, + "RemoveFolderSummaryResponse": { + "description": "A summary of entities that would be deleted when removing a folder", + "schema": { "type": "object", + "required": [ + "success", + "has_other_product_entity", + "deleted_entity_count" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17579088 - }, - "overall_progress": { + "success": { + "type": "boolean", + "description": "Indicates if the operation was successful" + }, + "has_other_product_entity": { + "type": "boolean", + "description": "Indicates if the folder contains entities from other products" + }, + "deleted_entity_count": { + "type": "object", + "required": [ + "active_test_case_count", + "archived_test_case_count", + "sub_folders" + ], + "properties": { + "test_recordings_count": { + "type": "integer", + "description": "Number of test recordings that would be deleted" + }, + "active_test_case_count": { + "type": "integer", + "description": "Number of active test cases that would be deleted" + }, + "archived_test_case_count": { + "type": "integer", + "description": "Number of archived test cases that would be deleted" + }, + "sub_folders": { + "type": "integer", + "description": "Number of sub-folders that would be deleted" + } + } + } + } + } + }, + "UsersV2Response": { + "description": "Successfully retrieved users list", + "schema": { + "$schema": "UsersV2Response" + } + }, + "IssuesCountInfo": { + "description": "Response containing the issue count trend for each month.", + "schema": { "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "overall_progress_by_status_id": { + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "count" + ], + "properties": {} + } + }, + "empty_data": { + "type": "boolean" + } + } + } + }, + "CustomFieldsV2Response": { + "description": "Successful response with paginated custom fields list", + "schema": { "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "run_state": { - "type": "string", - "example": "new_run" - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "example": 3 - }, - "test_plans": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T09:43:43.000Z" - }, - "updated_by": { + "required": [ + "success", + "custom_fields", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the custom field", + "example": "cf-123" + }, + "field_type": { + "type": "string", + "description": "Type of the custom field", + "enum": [ + "text", + "textarea", + "dropdown", + "multiselect", + "number", + "date", + "boolean", + "url" + ], + "example": "dropdown" + }, + "field_name": { + "type": "string", + "description": "Name of the custom field", + "example": "Priority Level" + }, + "entity_type": { + "type": "string", + "description": "Entity type this field belongs to", + "example": "TestCase" + }, + "is_required": { + "type": "boolean", + "description": "Whether the field is required", + "example": false + }, + "default_value": { + "description": "Default value for the field" + }, + "place_holder_text": { + "type": "string", + "description": "Hint text shown in the empty field. Omitted entirely when the field has no hint text. This is the key to read: it is filled from the field's hint under either of the two names the stored record may use.", + "example": "Select priority level" + }, + "system_name": { + "type": "string", + "description": "System name for the field" + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be bulk edited" + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used in filters" + }, + "linked_projects_count": { + "type": "integer", + "description": "Number of projects this field is linked to", + "example": 5 + }, + "group_id": { + "type": "string", + "description": "Group ID this field belongs to" + }, + "placeholder": { + "type": "string", + "description": "The same hint text as place_holder_text, under a second name. Present only when the stored field carries its hint under this name - which is the common case, so both keys usually appear with identical values, but neither is guaranteed. Read place_holder_text and ignore this one; it exists for older clients." + } + } + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "ProjectsPaginatedResponse": { + "description": "Successfully retrieved paginated projects", + "schema": { + "$schema": "ProjectsPaginatedResponse" + } + }, + "ProjectMinifyResponse": { + "description": "Successfully retrieved minified projects list", + "schema": { + "$schema": "ProjectMinifyResponse" + } + }, + "GetReportDetailResponse": { + "description": "Successfully fetched report details", + "schema": { "type": "object", + "required": [ + "data", + "report_filters", + "dynamic_filters", + "report_data", + "project" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } + "data": { + "$schema": "ReportDetail__GetReportDetail" + }, + "report_filters": { + "$schema": "ReportFiltersDetail" + }, + "dynamic_filters": { + "$schema": "DynamicFilters" + }, + "report_data": { + "$schema": "ReportData__GetReportDetail" + }, + "project": { + "$schema": "ReportProjectDetail" + }, + "filters": { + "type": "object" + }, + "is_dynamic": { + "type": "boolean", + "example": false + } } - }, - "uuid": { - "type": "integer", - "example": 17579088 - } } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - }, - "success": { - "type": "boolean", - "example": true - } - } - } - } - }, - "TestCase__EditTestCase": { - "type": "object", - "description": "TestCase as returned by `edit_test_case`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "comments_count": { - "type": "integer", - "example": 0 - }, - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "test_case_steps": { - "$schema": "TestCaseStep__EditTestCase" - } - } - }, - "UsersV2Response__ListGroupUsers": { - "type": "object", - "description": "UsersV2Response as returned by `list_group_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "UsersV2Response" - } - ], - "properties": { - "info": { - "$schema": "PageInfo__ListGroupUsers" - } - } - }, - "PageInfo__ListGroupUsers": { - "type": "object", - "description": "PageInfo as returned by `list_group_users`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "count": { - "type": "integer", - "example": 10 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "V1TestPlanDetail__GetTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `get_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "active_state": { - "type": "string", - "example": "active" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-18T10:40:45.000Z" - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "example": "2026-10-18T00:00:00.000Z" - }, - "id": { - "type": "integer", - "example": 48300 - }, - "issues": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "example": "2026-09-18T00:00:00.000Z" - }, - "sub_plans_count": { - "type": "integer", - "example": 0 - }, - "tags": { - "type": "string", - "example": "disposable" - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-18T10:40:45.000Z" - } - } - } - } - }, - "PageInfo__GetTestResultsCustomFields": { - "type": "object", - "description": "PageInfo as returned by `get_test_results_custom_fields`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "count": { - "type": "integer", - "example": 0 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "TestRunResponse__ListTestRunCases": { - "type": "object", - "description": "TestRunResponse as returned by `get_test_run_detail`. Composes TestRunResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "test_run": { - "type": "object", - "properties": { - "build_normalised_name": { - "type": "string", - "example": "__mcp-probe-run-2026-09-18T06-35-00Z" - }, - "build_serial_id": { - "type": "integer", - "example": 1 - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "is_read_from_tra_enabled": { - "type": "boolean", - "example": false - }, - "tc_sharing_enabled": { - "type": "boolean", - "example": true - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "created_by": { + }, + "ReportSummaryResponse": { + "description": "Successful response for report details", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "estimated_vs_actual": { + "type": "string", + "nullable": true + }, + "issues_by_priority": { + "type": "string", + "nullable": true + }, + "issues_by_status": { + "type": "string", + "nullable": true + }, + "issues_data": { + "type": "string", + "nullable": true + }, + "test_cases_status": { + "type": "string", + "nullable": true + }, + "test_run_data": { + "type": "object" + }, + "test_run_status": { + "type": "string", + "nullable": true + }, + "time_tracking": { + "type": "string", + "nullable": true + }, + "user_result_stats": { + "type": "string", + "nullable": true + } + }, + "required": [ + "success" + ] + } + }, + "GetReportTestCases": { + "description": "Successfully fetched report testcases details", + "schema": { "type": "object", + "required": [ + "success", + "selection_data" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } + "success": { + "type": "boolean", + "example": true + }, + "selection_data": { + "$schema": "SelectionData" + }, + "selection": { + "type": "object", + "description": "The primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).", + "example": { + "select_all": false, + "folders": { + "827": { + "test_case_select_all": false, + "selected_test_cases": [ + 1515 + ], + "deselected_test_cases": [] + } + }, + "unique_test_case_count": 1 + } + }, + "shared_selection": { + "type": "object", + "description": "Per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"\": }). Present only for cross-project Test Case Activity reports; `{}` otherwise.", + "example": { + "10209": { + "select_all": true, + "folders": {}, + "unique_test_case_count": 2 + } + } + } } - }, - "overall_progress": { + } + }, + "SharedStepsIDResponse": { + "description": "Response containing details of a specific shared step by ID.", + "schema": { "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "updated_by": { + "required": [ + "data", + "success" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + } + }, + "required": [ + "created_by", + "id", + "shared_step_details", + "title", + "updated_by" + ] + }, + "success": { + "type": "boolean" + } + } + } + }, + "SharedStepsGetResponse": { + "description": "Response containing a list of shared steps with pagination info.", + "schema": { "type": "object", + "required": [ + "data", + "info", + "success" + ], "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "dynamic_filter": { - "type": "string", - "nullable": true - }, - "filters": { - "type": "string", - "nullable": true - }, - "issues": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17575382 - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "trtc_folder_progress_enabled": { - "type": "boolean", - "example": false - } - } - } - } - } - } - }, - "Filter__UpdateFilter": { - "type": "object", - "description": "Filter as returned by `update_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions", - "properties": { - "customFields": { - "type": "object" - }, - "priority": { - "type": "string", - "example": "P1" - } - } - } - } - }, - "V1TestPlanDetail__UpdateTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `update_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "TP-NNN identifier \u2014 the v2 {test_plan_id}. (Integer -> TP-NNN translation output.)", - "example": "TP-12345" - }, - "name": { - "type": "string", - "example": "Release 2.0 plan" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - }, - "id": { - "type": "integer", - "example": 2522 - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "active_state": { - "type": "string", - "example": "active" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-21T13:44:07.000Z" - }, - "custom_fields": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer", - "example": 48319 - }, - "issues": { - "type": "string", - "nullable": true - }, - "owner": { - "type": "integer", - "example": 2522 - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string", - "example": "new" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer", - "example": 0 - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "example": "2026-09-22T05:11:26.000Z" - } - } - } - } - }, - "TestPlanV2Response__CreateTestPlan": { - "type": "object", - "description": "TestPlanV2Response as returned by `create_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestPlanV2Response" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "urls": { - "type": "object", - "properties": { - "sub_test_plans": { - "type": "string" - }, - "self": { - "type": "string", - "example": "https://test-management.browserstack.com/projects/1234/test-plans/49442" - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 0 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - } - } - } - } - }, - "V1TestCaseListRow__ListTestCases": { - "type": "object", - "description": "V1TestCaseListRow as returned by `list_test_cases`. Composes V1TestCaseListRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseListRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListTestCases" - }, - "priority": { - "$schema": "TestCaseFieldValue__ListTestCases" - } - } - }, - "TestCaseFieldValue__ListTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `list_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "TestCase__SearchAllProjects": { - "type": "object", - "description": "TestCase as returned by `search_all_projects`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "display_meta": { - "type": "object", - "properties": { - "created_by": { - "type": "string", - "example": "6013" - }, - "description": { - "type": "string", - "example": "-" - }, - "owner": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 2522 - }, - "name": { - "type": "string", - "example": "ing" - } - } - }, - "template": { - "type": "string", - "example": "test_case_text" - }, - "template_id": { - "type": "string", - "example": "1164" - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 415798 - }, - "name": { - "type": "string", - "example": "Reports Sanity Test Temp" - } - } - } - } - }, - "user_id": { - "type": "integer", - "example": 6013 - } - } - }, - "TestPlanV2__ListTestPlans": { - "type": "object", - "description": "TestPlanV2 as returned by `list_test_plans`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestPlanV2" - } - ], - "properties": { - "urls": { - "type": "object", - "description": "Absolute links for this plan. self is always present; sub_test_plans only on a top-level plan.", - "properties": { - "self": { - "type": "string", - "description": "Web-app deep link for a human, built from the numeric project and plan ids - the only place those numeric ids appear in this payload." - }, - "sub_test_plans": { - "type": "string" - } - } - } - } - }, - "ResultStatus__SearchTestResults": { - "type": "object", - "description": "ResultStatus as returned by `search_test_results`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "valueDetails": { - "type": "string", - "nullable": true - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "TestPlanV2Response__UpdateTestPlan": { - "type": "object", - "description": "TestPlanV2Response as returned by `update_test_plan`. Composes TestPlanV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestPlanV2Response" - } - ], - "properties": { - "test_plan": { - "type": "object", - "properties": { - "urls": { - "type": "object", - "properties": { - "sub_test_plans": { - "type": "string" - }, - "self": { - "type": "string", - "example": "https://test-management.browserstack.com/projects/1234/test-plans/48283" - } - } - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 1 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - } - } - } - } - }, - "PathFolder__CreateBulkTestCases": { - "type": "object", - "description": "PathFolder as returned by `create_bulk_test_cases`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PathFolder" - } - ], - "properties": { - "cases_count": { - "type": "integer", - "example": 51 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 13 - }, - "total_cases_count": { - "type": "integer", - "example": 52 - } - } - }, - "TestCase__CreateBulkTestCases": { - "type": "object", - "description": "TestCase as returned by `create_bulk_test_cases`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "folder": { - "type": "object", - "properties": { - "cases_count": { - "type": "integer", - "example": 51 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 764138 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764138" - } + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "created_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "updated_by": { + "type": "object", + "required": [ + "id", + "full_name" + ], + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": "string" + } + } + }, + "test_case_count": { + "type": "integer", + "description": "Number of test cases using this shared step" + }, + "step_count": { + "type": "integer", + "description": "Number of steps in this shared step" + }, + "tags": { + "type": "string", + "nullable": true + } + }, + "required": [ + "created_by", + "id", + "step_count", + "test_case_count", + "title", + "updated_by" + ] + } + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "description": "Total number of shared steps" + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true + } + }, + "required": [ + "count", + "next", + "page", + "page_size", + "prev" + ] + }, + "pre_fetched_shared_step": { + "type": "object", + "description": "Pre-fetched details for the first shared step (when pre_fetch=true)", + "nullable": true, + "required": [ + "shared_step_details", + "page_info" + ], + "properties": { + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + }, + "page_info": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Current page number." + }, + "page_size": { + "type": "integer", + "description": "Number of items per page." + }, + "count": { + "description": "Total number of step details" + }, + "prev": { + "type": "integer", + "description": "Previous page number if applicable; null otherwise.", + "nullable": true + }, + "next": { + "type": "integer", + "description": "Next page number if applicable; null otherwise.", + "nullable": true + } + }, + "required": [ + "count", + "next", + "page", + "page_size", + "prev" + ] + } + } + }, + "success": { + "type": "boolean" + } + } } - }, - "name": { - "type": "string", - "example": "__scratch__" - }, - "notes": { - "type": "string", - "example": "<p>write probes create their own objects in here</p>" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 13 - }, - "total_cases_count": { - "type": "integer", - "example": 52 - } - } - }, - "test_case_steps": { - "$schema": "TestCaseStep__CreateBulkTestCases" - } - } - }, - "TestCaseStep__CreateBulkTestCases": { - "type": "object", - "description": "TestCaseStep as returned by `create_bulk_test_cases`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "hashed_id": { - "type": "string", - "example": "dcf9955bb4c3de3da47a04212b67ee4281dbcfa5" - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "TestResult__CreateStepResult": { - "type": "object", - "description": "TestResult as returned by `create_step_result`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestResult" - } - ], - "properties": { - "author": { - "$schema": "User__CreateStepResult" - }, - "result_status": { - "$schema": "ResultStatus__CreateStepResult" - }, - "test_run_step_result": { - "type": "object", - "nullable": true, - "properties": { - "btcer_id": { - "type": "integer", - "example": 2150428297 - }, - "colour": { - "type": "string", - "example": "#059669" - }, - "field_value": { - "type": "string", - "example": "Passed" - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "App opens successfully" - }, - "status_id": { - "type": "integer", - "example": 318610 - }, - "step": { - "type": "string", - "example": "Open the app" - }, - "step_mapping_id": { - "type": "integer", - "example": 12372581 - }, - "test_data": { - "type": "string", - "nullable": true - } - } - } - } - }, - "User__CreateStepResult": { - "type": "object", - "description": "User as returned by `create_step_result`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "ResultStatus__CreateStepResult": { - "type": "object", - "description": "ResultStatus as returned by `create_step_result`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "User__CreateTestResultForTestCase": { - "type": "object", - "description": "User as returned by `create_test_result_for_test_case`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "ResultStatus__CreateTestResultForTestCase": { - "type": "object", - "description": "ResultStatus as returned by `create_test_result_for_test_case`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "TestCaseStep__EditTestCase": { - "type": "object", - "description": "TestCaseStep as returned by `edit_test_case`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" - } - } - }, - "TestRunsListResponse__ListClosedTestRuns": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_closed_test_runs`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17578623" - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string" - }, - "metadata": { - "type": "object" - }, - "updated_by": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - } - } - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 60 - }, - "closed": { - "type": "integer", - "example": 1 - } - } - }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - } - } - }, - "Dataset__GetDataset": { - "type": "object", - "description": "Dataset as returned by `get_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__GetDataset" - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } - } - }, - "DatasetUserRef__GetDataset": { - "type": "object", - "description": "DatasetUserRef as returned by `get_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "ResultStatus__ListEntityFilterDetails": { - "type": "object", - "description": "ResultStatus as returned by `list_entity_filter_details`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "entity_type": { - "type": "string", - "example": "TestCase" - } - } - }, - "User__ListEntityFilterDetails": { - "type": "object", - "description": "User as returned by `list_entity_filter_details`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "ReportData__GetReportDetail": { - "type": "object", - "description": "ReportData as returned by `get_report_detail`. Composes ReportData and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportData" - } - ], - "properties": { - "time_tracking_enabled": { - "type": "boolean", - "example": false - }, - "test_cases_status": { - "type": "array", - "items": { - "type": "object", - "required": [ - "count", - "result_status" - ], - "properties": { - "count": { - "type": "integer" - }, - "result_status": { - "$schema": "ResultStatus__GetReportDetail" + }, + "TestCaseCountTrend": { + "description": "The response contains the trend data for each test case category over the months.", + "schema": { + "type": "object", + "required": [ + "data", + "empty_data", + "order" + ], + "properties": { + "data": { + "type": "object", + "description": "KEYED BY CASE TYPE NAME, not a record with fixed fields — e.g. {\"Acceptance\": {…}, \"API\": {…}}. The keys are project data and change as case types are added or renamed, so they are deliberately not enumerated. Each value carries that type's counts plus a `graphData` map keyed by month abbreviation." + }, + "empty_data": { + "type": "boolean" + }, + "order": { + "type": "array", + "items": { + "type": "string" + } + } + } } - } - } - }, - "test_run_status": { - "type": "object", - "properties": { - "Closed": { - "type": "integer", - "example": 1 - }, - "Done": { - "type": "integer", - "example": 0 - }, - "In_Progress": { - "type": "integer", - "example": 0 - }, - "New": { - "type": "integer", - "example": 59 - }, - "Rejected": { - "type": "integer", - "example": 0 - }, - "Under_Review": { - "type": "integer", - "example": 1 - } - } - }, - "time_tracking": { - "type": "object", - "properties": { - "cases_in_scope": { - "type": "integer", - "example": 166 - }, - "cases_with_elapsed": { - "type": "integer", - "example": 0 - }, - "total_elapsed_seconds": { - "type": "integer", - "example": 0 - } - } - }, - "user_result_stats": { - "type": "array", - "items": { - "type": "object", - "required": [ - "user_id", - "test_results", - "user_name" - ], - "properties": { - "user_id": { - "type": "integer" - }, - "test_results": { - "type": "integer" - }, - "user_name": { - "type": "string" - }, - "local_user_id": { - "type": "integer", - "example": 3741 - }, - "tc_count": { - "type": "integer", - "example": 2 + }, + "HistoryDiffResponse": { + "description": "Response containing the diff of test case steps between two histories.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "diff": { + "type": "object", + "required": [ + "test_case_steps" + ], + "properties": { + "test_case_steps": { + "type": "object", + "properties": { + "old": { + "type": "array", + "items": { + "$schema": "TestCaseDiffStep" + } + }, + "new": { + "type": "array", + "items": { + "$schema": "TestCaseDiffStep" + } + } + } + }, + "status": { + "type": "object", + "properties": { + "old": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "colour": { + "type": "string" + } + } + }, + "new": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + } + }, + "links": { + "type": "object", + "example": { + "99": "/api/v1/projects/10000/test-cases/1/histories/99", + "100": "/api/v1/projects/10000/test-cases/1/histories/100" + } + } + } } - } - } - } - } - }, - "ResultStatus__GetReportDetail": { - "type": "object", - "description": "ResultStatus as returned by `get_report_detail`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - } - } - }, - "Folder__ListRootFolders": { - "type": "object", - "description": "Folder as returned by `list_root_folders`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/764093" - } - } - } - } - }, - "SharedStepsIDResponse__GetSharedStep": { - "type": "object", - "description": "SharedStepsIDResponse as returned by `get_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "SharedStepsIDResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "page_info": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "example": 1 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 150 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "created_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } + }, + "TestCaseTypeSplit": { + "description": "Test case count split by case type", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "y": { + "type": "integer" + }, + "value": { + "type": "integer" + }, + "field": { + "type": "string" + } + } + } + }, + "empty_data": { + "type": "boolean" + } + } } - }, - "shared_step_details": { - "type": "object", - "properties": { - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "probe result" - }, - "step": { - "type": "string", - "example": "probe step" - }, - "test_data": { - "type": "string", - "nullable": true - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } + }, + "TestResultCustomFieldsResponse": { + "description": "Successfully retrieved custom fields for test results", + "schema": { + "$schema": "TestResultCustomFieldsResponse" } - }, - "tags": { - "type": "string", - "nullable": true - } - } - } - } - }, - "V1TestCaseRecordRow__GetTestCaseByIntegerId": { - "type": "object", - "description": "V1TestCaseRecordRow as returned by `get_test_case_by_integer_id`. Composes V1TestCaseRecordRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseRecordRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" - }, - "case_type": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" - }, - "priority": { - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" - }, - "status": { - "nullable": true, - "$schema": "TestCaseFieldValue__GetTestCaseByIntegerId" - }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "download_url": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" + }, + "TestResultListResponse": { + "description": "List of test results", + "schema": { + "type": "object", + "required": [ + "success", + "test-results", + "info", + "unique_issues" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test-results": { + "type": "array", + "items": { + "$schema": "TestResult__ListTestResultsForTestCaseByIntegerId" + } + }, + "info": { + "$schema": "PageInfo__ListTestResultsForTestCaseByIntegerId" + }, + "unique_issues": { + "type": "array", + "example": [], + "items": { + "type": "string" + } + } + } + } + }, + "TestRunFormFieldsResponse": { + "description": "Successfully retrieved test result form fields", + "schema": { + "$schema": "TestRunFormFieldsResponse" + } + }, + "TestRunEditSelectionResponse": { + "description": "A test run's stored case selection and editable fields, as the full edit form reads them before an edit.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "selection": { + "type": "object", + "description": "The run's stored case selection: {select_all, unique_test_case_count, folders}, where `folders` maps a folder id (string) to {test_case_select_all, selected_test_cases, deselected_test_cases}. Can be {}. Not kept in step with the add-only and remove calls." + }, + "shared_selection": { + "type": "object", + "nullable": true, + "description": "Cases shared in from other projects, keyed by project; null when the run has none." + }, + "test_run": { + "$schema": "TestRun" + }, + "dynamic_filter": { + "type": "object", + "nullable": true, + "description": "The dynamic-run filter rule; null for a static run." + }, + "filters": { + "type": "object", + "nullable": true, + "description": "Same content as dynamic_filter, under its alias." + }, + "trtc_configuration_mappings": { + "type": "array", + "description": "Per-configuration case mappings; a run without configurations returns one default entry with configuration_id null and select_all true.", + "items": { + "type": "object" + } + } + } + } + } + } }, - "url": { - "type": "string" + "TestRunsSelectionResponse": { + "description": "The test CASES a selection resolves to (this endpoint returns cases, not runs).\n`selected` is present only when show='all', and lists the ids of the returned rows that also\nmatch the `q` filter.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunSelectionCaseRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "selected": { + "type": "array", + "description": "Test case ids, among those returned, that also match the `q` filter. Only rendered when show='all' and at least one row matched.", + "items": { + "type": "integer" + } + } + } } - } - } - } - } - }, - "TestCaseFieldValue__GetTestCaseByIntegerId": { - "type": "object", - "description": "TestCaseFieldValue as returned by `get_test_case_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCaseFieldValue__GetTestCaseDetail": { - "type": "object", - "description": "TestCaseFieldValue as returned by `get_test_case_detail`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestCaseStep__GetTestCaseDetail": { - "type": "object", - "description": "TestCaseStep as returned by `get_test_case_detail`. Composes TestCaseStep and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseStep" - } - ], - "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "hashed_id": { - "type": "string", - "nullable": true - }, - "scenario": { - "type": "string", - "nullable": true - }, - "sharedStep": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "test_data": { - "type": "string", - "nullable": true - }, - "test_recording_id": { - "type": "string", - "nullable": true - }, - "title": { - "type": "string", - "nullable": true - } - } - }, - "History__ListTestCaseHistories": { - "type": "object", - "description": "History as returned by `list_test_case_histories`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "History" - } - ], - "properties": { - "user": { - "$schema": "User__ListTestCaseHistories" - }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - }, - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "new": { + }, + "FilterListResponse": { + "description": "Successfully retrieved list of filters", + "schema": { "type": "object", "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } + "data": { + "type": "array", + "description": "Array of saved views, each carrying only id, name and is_project_level", + "items": { + "$schema": "FilterListRow" + } + }, + "info": { + "$schema": "PageInfo" + }, + "success": { + "type": "boolean" + } } - }, - "old": { + } + }, + "FolderDetailsResponse": { + "description": "Response containing folder details including subfolders, counts, and links.", + "schema": { "type": "object", "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } + "folders": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "self": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "group_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "notes": { + "type": "string" + }, + "is_automation": { + "type": "boolean" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + }, + "contents": { + "type": "array", + "items": { + "$schema": "Folder" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + } + } + } + } + }, + "parent_id": { + "type": "integer" + }, + "ancestors": { + "type": "object", + "nullable": true + }, + "test_cases": { + "type": "object", + "nullable": true + }, + "info": { + "$schema": "PageInfo" + }, + "success": { + "type": "boolean" + } } - } } - } - } - } - } - }, - "User__ListTestCaseHistories": { - "type": "object", - "description": "User as returned by `list_test_case_histories`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "History__GetTestCaseHistory": { - "type": "object", - "description": "History as returned by `get_test_case_history`. Composes History and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "History" - } - ], - "properties": { - "user": { - "$schema": "User__GetTestCaseHistory" - }, - "modified": { - "type": "object", - "description": "Details of what was modified (field changes)", - "example": { - "template": { - "old": "Test Case Steps", - "new": "Test Case BDD" - } - }, - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "new": { + }, + "TagsSearchResponse": { + "description": "Tags successfully retrieved", + "schema": { "type": "object", + "required": [ + "tags", + "tag_objects", + "info" + ], "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } + "tags": { + "type": "array", + "description": "Simple array of tag names (for backward compatibility)", + "example": [ + "AI Generated", + "AI Generated Details" + ], + "items": { + "type": "string" + } + }, + "tag_objects": { + "type": "array", + "description": "Detailed tag objects with metadata", + "items": { + "$schema": "Tag" + } + }, + "info": { + "$schema": "PageInfo" + } } - }, - "old": { + } + }, + "ProjectUsersSearchResponse": { + "description": "Project users successfully retrieved", + "schema": { "type": "object", + "required": [ + "users", + "info" + ], "properties": { - "background": { - "type": "string", - "nullable": true - }, - "feature": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed - updated" - }, - "scenario": { - "type": "string", - "nullable": true - }, - "shared_step_detail_id": { - "type": "string", - "nullable": true - }, - "shared_step_id": { - "type": "string", - "nullable": true - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "nullable": true - } + "users": { + "type": "array", + "description": "List of users with access to the project", + "items": { + "$schema": "ProjectUserDetail" + } + }, + "info": { + "$schema": "PageInfo" + } } - } } - } - } - } - } - }, - "User__GetTestCaseHistory": { - "type": "object", - "description": "User as returned by `get_test_case_history`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "V1TestRunTestCaseRow__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "V1TestRunTestCaseRow as returned by `list_test_run_test_cases_by_integer_id`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestRunTestCaseRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" - }, - "priority": { - "$schema": "TestCaseFieldValue__ListTestRunTestCasesByIntegerId" - }, - "result_status": { - "nullable": true, - "$schema": "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId" - } - } - }, - "TestCaseFieldValue__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "TestCaseFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "ResultStatusFieldValue__ListTestRunTestCasesByIntegerId": { - "type": "object", - "description": "ResultStatusFieldValue as returned by `list_test_run_test_cases_by_integer_id`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatusFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "TestRunsListResponse__ListTestPlanTestRunsByIntegerId": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_test_plan_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17574995" - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "example": "MCP+capability+probe+fixture+run+%28link_entities_to_jira_issue%29" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "configurations": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string" - }, - "links": { - "type": "object" - }, - "metadata": { - "type": "object", - "properties": { - "test_plan_error": { - "type": "string", - "example": "PLAN_LINKED" - } - } - }, - "observability_url": { - "type": "string" - }, - "updated_by": { - "type": "string", - "nullable": true - } - } - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 1 - }, - "closed": { - "type": "integer", - "example": 0 - } - } - }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - } - } - }, - "TestRunUserRef__GetTestRunByIntegerId": { - "type": "object", - "description": "TestRunUserRef as returned by `get_test_run_by_integer_id`. Composes TestRunUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "TestRunsListResponse__ListTestRunsByIntegerId": { - "type": "object", - "description": "TestRunsListResponse as returned by `list_test_runs_by_integer_id`. Composes TestRunsListResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "TestRunsListResponse" - } - ], - "properties": { - "test_runs": { - "type": "object", - "properties": { - "observability_id": { - "type": "string", - "example": "17579092" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "closed_at": { - "type": "string", - "nullable": true - }, - "closed_by": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - }, - "updated_by": { - "type": "string", - "nullable": true - }, - "build_meta": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "example": "__mcp-probe-createrun-20260921-260922110512" - }, - "build_number": { - "type": "integer", - "example": 1 - }, - "creationType": { - "type": "string", - "example": "MANUAL" - } - } - } - } - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 60 - }, - "closed": { - "type": "integer", - "example": 1 - } - } - }, - "info": { - "type": "object", - "properties": { - "next": { - "type": "integer", - "example": 2 - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - } - } - }, - "DatasetUserRef__ListDatasets": { - "type": "object", - "description": "DatasetUserRef as returned by `list_datasets`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "TestCaseFieldValue__ListFolderTestCases": { - "type": "object", - "description": "TestCaseFieldValue as returned by `list_folder_test_cases`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "Folder__MoveFolderByIntegerId": { - "type": "object", - "description": "Folder as returned by `move_folder_by_integer_id`. Composes Folder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Folder" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/765883" - } - } - }, - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765883 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "copy-of-__mcp-probe-subfolder-20260921" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - }, - "total_cases_count": { - "type": "integer", - "example": 2 - } - } - }, - "FolderWriteResponse__RenameFolderByIntegerId": { - "type": "object", - "description": "FolderWriteResponse as returned by `rename_folder_by_integer_id`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$response": "FolderWriteResponse" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "links": { + }, + "FilterUpdateResponse": { + "description": "Successfully updated the filter", + "schema": { "type": "object", "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/765811" - } - } - }, - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765811 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "__mcp-probe-renamefolder-20260922" - }, - "notes": { - "type": "string", - "example": "<p>Live probe of create_root_folder - safe to delete.</p>" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 16 - }, - "total_cases_count": { - "type": "integer", - "example": 169 - } - } - } - } - } - } - }, - "V1TestCaseSearchRow__SearchProjectEntities": { - "type": "object", - "description": "V1TestCaseSearchRow as returned by `search_project_entities`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseSearchRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "case_type": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "priority": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "status": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntities" - }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "id": { - "type": "integer", - "example": 1102619 - }, - "key": { - "type": "string", - "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" - }, - "name": { - "type": "string", - "example": "probe.txt" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" + "data": { + "$schema": "Filter__UpdateFilter" + }, + "success": { + "type": "boolean" + } + } } - } - } - } - } - }, - "SearchFieldValue__SearchProjectEntities": { - "type": "object", - "description": "SearchFieldValue as returned by `search_project_entities`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SearchFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "V1TestCaseSearchRow__SearchProjectEntitiesByFilter": { - "type": "object", - "description": "V1TestCaseSearchRow as returned by `search_project_entities_by_filter`. Composes V1TestCaseSearchRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestCaseSearchRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "case_type": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "priority": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "status": { - "nullable": true, - "$schema": "SearchFieldValue__SearchProjectEntitiesByFilter" - }, - "attachments": { - "type": "array", - "description": "Files attached to the case, each with filename, size and checksum. Returned by the two detail routes and the two search routes; ABSENT from list_test_cases, list_folder_test_cases and the run-mapping rows. `list_test_case_attachments` is the dedicated reader but currently returns 500, so these embedded copies are the only working source.", - "items": { - "type": "object", - "properties": { - "byte_size": { - "type": "integer", - "example": 17 - }, - "checksum": { - "type": "string", - "example": "JUtjCVAS84rpwIoo8bZGEw==" - }, - "content_type": { - "type": "string", - "example": "text/plain" - }, - "id": { - "type": "integer", - "example": 1102619 - }, - "key": { - "type": "string", - "example": "media/genric/2615/1234/8ca762de-411a-43c2-ba9c-c4d397cdb65e" - }, - "name": { - "type": "string", - "example": "probe.txt" - }, - "product_id": { - "type": "integer", - "example": 228224 - }, - "size": { - "type": "string", - "example": "17 Bytes" + }, + "UpdateReportResponse": { + "description": "Successfully updated scheduled report", + "schema": { + "$schema": "ReportUpdateResponse" } - } - } - } - } - }, - "SearchFieldValue__SearchProjectEntitiesByFilter": { - "type": "object", - "description": "SearchFieldValue as returned by `search_project_entities_by_filter`. Composes SearchFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SearchFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "Dataset__UpdateDataset": { - "type": "object", - "description": "Dataset as returned by `update_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__UpdateDataset" - }, - "metadata": { - "type": "object" - }, - "tags": { - "type": "string", - "nullable": true - } - } - }, - "DatasetUserRef__UpdateDataset": { - "type": "object", - "description": "DatasetUserRef as returned by `update_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "FormFieldDescriptor": { - "type": "object", - "description": "One form field on the run-creation form: its selectable values plus the link and count that describe the value set.\n", - "properties": { - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/test-case/status" - } - } - }, - "total_count": { - "type": "integer", - "example": 8 - }, - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "is_default": { - "type": "integer", - "example": 0 - }, - "name": { - "type": "string", - "example": "Passed" - }, - "value": { - "type": "integer", - "example": 318610 - }, - "value_category": { - "type": "string", - "example": "completed" - }, - "internal_name": { - "type": "string", - "example": "passed" - } - } - } - } - }, - "TestRunV2Response__ListTestRun": { - "type": "object", - "description": "TestRunV2Response as returned by `get_test_run`. Composes TestRunV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunV2Response" - } - ], - "properties": { - "test_run": { - "type": "object", - "properties": { - "test_cases": { - "type": "object", - "properties": { - "case_type": { - "type": "string", - "example": "Other" - }, - "configuration_id": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "string", - "example": "user@example.com" - }, - "dataset": { - "type": "string", - "nullable": true - }, - "execution_id": { - "type": "string", - "example": "ab5c1e3c40fc614f2020a9ba4dd1a8e4c075bba8fe0336a4f30646030b6f6d43" - }, - "folder_path": { - "type": "integer", - "example": 764138 - }, - "is_shared": { - "type": "boolean", - "example": false - }, - "last_updated_at": { - "type": "string", - "example": "2026-09-18T12:53:07.732Z" - }, - "latest_result_id": { - "type": "integer", - "example": 2150413868 - }, - "latest_status": { - "type": "string", - "example": "passed" - }, - "planned_execution_date": { - "type": "string", - "nullable": true - }, - "priority": { - "type": "string", - "example": "High" - }, - "source_project_id": { - "type": "string", - "example": "PR-2005" - }, - "status": { - "type": "string", - "example": "Active" - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "updated_by": { - "type": "string", - "example": "user@example.com" - }, - "urls": { + }, + "GenericAttachmentsResponse": { + "description": "Attachments successfully uploaded", + "schema": { + "type": "object", + "required": [ + "generic_attachment" + ], + "properties": { + "generic_attachment": { + "type": "array", + "description": "List of uploaded attachments with metadata", + "items": { + "$schema": "GenericAttachment" + } + } + } + } + }, + "TestRunTestCasesV2Response": { + "description": "Paginated test cases of a test run. `info` carries the paging counts and reflects any filters applied.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunTestCaseV2" + } + } + } + } + }, + "V2TestRunResultSubmitResponse": { + "description": "Result submission accepted. An ordinary single submission returns the written result under `test-result`; a submission carrying step results returns `test-run-step-results` plus `overall-result-status` and NO `test-result`. The bulk form returns only `success: true` when everything was accepted, or `success: false` with `status: \"PARTIAL\"` and per-case `results` when some entries were rejected — still HTTP 200.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "false on a PARTIAL bulk response even though the status is 200." + }, + "status": { + "type": "string", + "example": "PARTIAL", + "description": "Present only when some bulk entries were rejected and some were accepted." + }, + "message": { + "type": "string", + "description": "Present only alongside rejected bulk entries; names the unconfigured statuses and/or the count of cases blocked by the review-status gate." + }, + "test-result": { + "$schema": "V2TestRunResultDetail", + "description": "Single form only, and only when no step results were submitted." + }, + "test-run-step-results": { + "type": "array", + "description": "Single form, step submissions only — one entry per step result written. Mutually exclusive with test-result.", + "items": { + "$schema": "V2TestRunStepResultDetail" + } + }, + "overall-result-status": { + "type": "string", + "description": "Status rolled up from the submitted step results. Accompanies test-run-step-results." + }, + "results": { + "type": "array", + "description": "Bulk form, present only when at least one entry was rejected.", + "items": { + "$schema": "V2BulkResultOutcome" + } + }, + "attachments": { + "type": "array", + "description": "Attachments bound on a multipart single submission. This is the bind result, not the stored attachment record — it does not carry content type, size or checksum.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Attachment id, usable with the attachment endpoints." + }, + "name": { + "type": "string", + "description": "Filename of the multipart part that was bound." + }, + "url": { + "type": "string", + "description": "Time-limited download url." + }, + "url_expires_at": { + "type": "string", + "format": "date-time", + "description": "When url stops working." + }, + "test_result_id": { + "type": "integer" + } + } + } + }, + "attachment_errors": { + "type": "array", + "description": "Present only when at least one file failed to bind. The result itself was still written.", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "code": { + "type": "string", + "values": [ + "ATTACHMENT_BIND_INCOMPLETE", + "ATTACHMENT_BIND_FAILED" + ], + "description": "ATTACHMENT_BIND_INCOMPLETE means the file WAS stored but its id could not be read back; re-read the result's attachments to recover it. ATTACHMENT_BIND_FAILED means the file was discarded." + }, + "message": { + "type": "string" + } + } + } + }, + "dropped_attachments": { + "type": "array", + "description": "Bulk form only. Files that were uploaded but belonged to an entry that got rejected, plus inline rich-text references that could not be resolved. Two entry shapes appear in the same array.", + "items": { + "type": "object", + "properties": { + "test_case_id": { + "type": "string", + "description": "Rejected-entry form." + }, + "filenames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Rejected-entry form." + }, + "reason": { + "type": "string", + "example": "status_validation_failed", + "description": "Rejected-entry form." + }, + "filename": { + "type": "string", + "description": "Inline-reference form." + }, + "code": { + "type": "string", + "example": "INLINE_ATTACHMENT_DROPPED", + "description": "Inline-reference form." + } + } + } + } + } + } + }, + "V2BulkResultStatusAcceptedResponse": { + "description": "The bulk status change was queued. It says nothing about which cases were changed or whether the work succeeded.", + "schema": { "type": "object", "properties": { - "self": { - "type": "string" - } + "success": { + "type": "boolean", + "example": true + } } - } } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } - } - } - } - }, - "Project__ListProjects": { - "type": "object", - "description": "Project as returned by `list_projects`. Composes Project and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Project" - } - ], - "properties": { - "links": { - "type": "object", - "properties": { - "repository": { - "type": "string", - "example": "/api/v1/projects/1234/repository" - }, - "self": { - "type": "string", - "example": "/api/v1/projects/1234" - }, - "test_plans": { - "type": "string", - "example": "/api/v1/projects/1234/test-plans" - }, - "test_runs": { - "type": "string", - "example": "/api/v1/projects/1234/test-runs" - }, - "users": { - "type": "string", - "example": "/api/v1/projects/1234/users" - } - } - }, - "duplicates_tc_count": { - "type": "integer", - "example": 0 - }, - "jira_mapped": { - "type": "boolean", - "example": false - }, - "normalisedName": { - "type": "string", - "example": "__mcp-capability-fixture__" - }, - "projectVisibilityBanner": { - "type": "string", - "nullable": true - }, - "starred": { - "type": "boolean", - "example": false - }, - "test_cases_count": { - "type": "integer", - "example": 272 - }, - "test_plans_count": { - "type": "integer", - "example": 75 - }, - "test_runs_count": { - "type": "integer", - "example": 99 - }, - "thProjectId": { - "type": "integer", - "example": 1234 - } - } - }, - "TestPlanV2__CreateSubTestPlan": { - "type": "object", - "description": "TestPlanV2 as returned by `create_sub_test_plan`. Composes TestPlanV2 and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestPlanV2" - } - ], - "properties": { - "parent_plan_id": { - "type": "string", - "example": "TP-1616" - } - } - }, - "V1ProjectDetail__GetProjectByIntegerId": { - "type": "object", - "description": "V1ProjectDetail as returned by `get_project_by_integer_id`. Composes V1ProjectDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1ProjectDetail" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "project": { - "type": "object", - "properties": { - "identifier": { - "type": "string", - "description": "PR-NNN identifier \u2014 the v2 {project_id}. (Integer -> PR-NNN translation output.)", - "example": "PR-1" - }, - "id": { - "type": "integer", - "description": "Integer project id \u2014 the value passed in the path. Same as thProjectId.", - "example": 11046 - }, - "thProjectId": { - "type": "integer", - "example": 11046 - }, - "name": { - "type": "string", - "example": "Demo Project" - }, - "description": { - "type": "string", - "nullable": true - }, - "test_cases_count": { - "type": "integer", - "nullable": true - }, - "test_runs_count": { - "type": "integer", - "nullable": true - }, - "test_plans_count": { - "type": "integer", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "starred": { - "type": "boolean" - }, - "jira_mapped": { - "type": "boolean" - }, - "links": { - "type": "object" - }, - "import_id": { - "type": "integer", - "nullable": true, - "description": "Set when the project was brought in from another tool by an import, and carries that import's id; null for a project created directly in the product. Use it only to tell imported projects apart - it is not a handle you can pass to any call here." - }, - "normalisedName": { - "type": "string", - "description": "The project's name percent-encoded for use inside a web address (spaces become +). A display and link convenience; it is never accepted as an input anywhere, so address the project by `identifier` or `id`." - }, - "projectVisibilityBanner": { + }, + "V2TestResultListResponse": { + "description": "Recorded results for one test case in one test run, newest executions included as separate entries. Note the key is `test_results` with an underscore, unlike the v1 list's `test-results`.", + "schema": { "type": "object", - "nullable": true, - "description": "Whether the product should show this project's visibility notice, and what set it. Passed through from the project record, so the keys inside are not fixed by this API; when populated it reports a visibility flag and a source. Expect null on a project that has no notice set - notably right after an edit, where a read of the same project moments later can return an object instead. Treat null and an object saying 'not visible' as the same thing, and do not branch on this field being present." - }, - "permissions": { - "type": "string", - "example": "project_write" - }, - "user_role": { - "type": "string", - "example": "Admin" - } - } - } - } - } - } - }, - "UsersV2Response__ListProjectUsers": { - "type": "object", - "description": "UsersV2Response as returned by `list_project_users`. Composes UsersV2Response and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "UsersV2Response" - } - ], - "properties": { - "users": { - "type": "array", - "items": { - "$schema": "User__ListProjectUsers" - } - } - } - }, - "User__ListProjectUsers": { - "type": "object", - "description": "User as returned by `list_project_users`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "reports_and_notification_enabled": { - "type": "boolean", - "example": true - } - } - }, - "ReportDetail__GetReportDetail": { - "type": "object", - "description": "ReportDetail as returned by `get_report_detail`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "is_dynamic": { - "type": "boolean", - "example": false - }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - }, - "PathFolder__MoveFolderByIntegerId": { - "type": "object", - "description": "PathFolder as returned by `move_folder_by_integer_id`. Composes PathFolder and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PathFolder" - } - ], - "properties": { - "cases_count": { - "type": "integer", - "example": 2 - }, - "group_id": { - "type": "string", - "example": "3452" - }, - "id": { - "type": "integer", - "example": 765883 - }, - "is_automation": { - "type": "boolean", - "example": false - }, - "name": { - "type": "string", - "example": "copy-of-__mcp-probe-subfolder-20260921" - }, - "project_id": { - "type": "integer", - "example": 1234 - }, - "sub_folders_count": { - "type": "integer", - "example": 0 - }, - "total_cases_count": { - "type": "integer", - "example": 2 - } - } - }, - "TagsSearchResponse__SearchGroupTags": { - "type": "object", - "description": "TagsSearchResponse as returned by `search_group_tags`. Composes TagsSearchResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TagsSearchResponseBody" - } - ], - "properties": { - "tags": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 541125 - } - } - }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "example": 5 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - } - } - }, - "ExploratorySessionLog__UpdateExploratorySessionLog": { - "type": "object", - "description": "ExploratorySessionLog as returned by `update_exploratory_session_log`. Composes ExploratorySessionLog and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ExploratorySessionLog" - } - ], - "properties": { - "attachment_relations": { - "type": "string", - "nullable": true - } - } - }, - "ReportDetail__UpdateReport": { - "type": "object", - "description": "ReportDetail as returned by `update_report`. Composes ReportDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ReportDetail" - } - ], - "properties": { - "is_dynamic": { - "type": "boolean", - "example": false - }, - "included_projects": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1234 - }, - "name": { - "type": "string", - "example": "__mcp-capability-fixture__" - } - } - } - } - }, - "SharedStepsIDResponse__UpdateSharedStep": { - "type": "object", - "description": "SharedStepsIDResponse as returned by `update_shared_step`. Composes SharedStepsIDResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "SharedStepsIDResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "step_count": { - "type": "integer", - "example": 1 - }, - "tags": { - "type": "string", - "nullable": true - }, - "test_case_count": { - "type": "integer", - "example": 0 - }, - "created_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } - } - }, - "shared_step_details": { - "type": "object", - "properties": { - "order": { - "type": "integer", - "example": 1 - }, - "result": { - "type": "string", - "example": "Login page is displayed" - }, - "step": { - "type": "string", - "example": "Open the login page" - }, - "test_data": { - "type": "string", - "example": "user=probe" - } - } - }, - "updated_by": { - "type": "object", - "properties": { - "full_name": { - "type": "string", - "example": "ing" - } + "required": [ + "success", + "test_results", + "info" + ], + "properties": { + "success": { + "type": "boolean" + }, + "test_results": { + "type": "array", + "items": { + "$schema": "V2TestResultListItem" + } + }, + "info": { + "$schema": "PageInfo" + } + } } - } - } - } - } - }, - "FilterListRow": { - "type": "object", - "description": "One saved view as list_filters returns it: its id, name and scope only.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "is_project_level": { - "type": "boolean" - } - } - }, - "Filter__CreateFilter": { - "type": "object", - "description": "Filter as returned by `create_filter`. Composes Filter and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Filter" - } - ], - "properties": { - "group_id": { - "type": "integer", - "example": 3452 - }, - "owner_id": { - "type": "integer", - "example": 6013 - }, - "record_status": { - "type": "string", - "example": "active" - }, - "filters": { - "type": "object", - "description": "Filter criteria object containing the actual filter conditions", - "properties": { - "priority": { - "type": "string", - "example": "P1" - } - } - } - } - }, - "Dataset__CreateDataset": { - "type": "object", - "description": "Dataset as returned by `create_dataset`. Composes Dataset and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "Dataset" - } - ], - "properties": { - "created_by": { - "$schema": "DatasetUserRef__CreateDataset" - } - } - }, - "DatasetUserRef__CreateDataset": { - "type": "object", - "description": "DatasetUserRef as returned by `create_dataset`. Composes DatasetUserRef and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "DatasetUserRef" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "FolderWriteResponse__CreateRootFolder": { - "type": "object", - "description": "FolderWriteResponse as returned by `create_root_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "FolderWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "integer", - "example": 2522 - }, - "root": { - "type": "boolean", - "example": true - }, - "updated_by": { - "type": "integer", - "example": 2522 - }, - "links": { + }, + "TemplateListResponse": { + "description": "A page of test-case templates with pagination metadata.", + "schema": { "type": "object", + "required": [ + "success", + "templates" + ], "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/766097" - } + "success": { + "type": "boolean" + }, + "templates": { + "type": "array", + "items": { + "$schema": "Template" + } + }, + "info": { + "$schema": "PageInfo" + } } - } } - }, - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "example": "2026-09-17T08:01:58.000Z" - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "example": "probe edit_project 2026-09-17T10:47:41Z" - }, - "identifier": { - "type": "string", - "example": "PR-2005" - }, - "import_id": { - "type": "string", - "nullable": true - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - } - } - } - } - } - } - }, - "FolderWriteResponse__CreateSubFolder": { - "type": "object", - "description": "FolderWriteResponse as returned by `create_sub_folder`. Composes FolderWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "FolderWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "folder": { - "type": "object", - "properties": { - "attachments": { - "type": "string", - "nullable": true - }, - "created_by": { - "type": "integer", - "example": 6013 - }, - "root": { - "type": "boolean", - "example": false - }, - "updated_by": { - "type": "integer", - "example": 6013 - }, - "links": { + }, + "TemplateDetailResponse": { + "description": "A single test-case template with its full field layout.", + "schema": { "type": "object", + "required": [ + "success", + "template" + ], "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/1234/folder/766098" - } + "success": { + "type": "boolean" + }, + "template": { + "$schema": "TemplateWithFields" + } } - } } - } - } - } - } - }, - "PageInfo__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "PageInfo as returned by `list_test_results_for_test_case_by_integer_id`. Composes PageInfo and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "PageInfo" - } - ], - "properties": { - "truncated": { - "type": "boolean", - "example": false - } - } - }, - "TestResult__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "TestResult as returned by `list_test_results_for_test_case_by_integer_id`. Composes TestResult and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestResult" - } - ], - "properties": { - "author": { - "$schema": "User__ListTestResultsForTestCaseByIntegerId" - }, - "result_status": { - "$schema": "ResultStatus__ListTestResultsForTestCaseByIntegerId" - }, - "updated_by": { - "type": "string", - "nullable": true, - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "ing" - } - } - } - } - }, - "User__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "User as returned by `list_test_results_for_test_case_by_integer_id`. Composes User and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "User" - } - ], - "properties": { - "email": { - "type": "string", - "example": "user@example.com" - } - } - }, - "ResultStatus__ListTestResultsForTestCaseByIntegerId": { - "type": "object", - "description": "ResultStatus as returned by `list_test_results_for_test_case_by_integer_id`. Composes ResultStatus and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatus" - } - ], - "properties": { - "colour": { - "type": "string", - "example": "#059669" - }, - "value_category": { - "type": "string", - "example": "completed" - } - } - }, - "TestCase__ReorderTestCasesByFolder": { - "type": "object", - "description": "TestCase as returned by `reorder_test_cases_by_folder`. Composes TestCase and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCase" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "Not Automated" - }, - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "step_count": { - "type": "integer", - "example": 1 - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" - }, - "priority": { - "$schema": "TestCaseFieldValue__ReorderTestCasesByFolder" - } - } - }, - "TestCaseFieldValue__ReorderTestCasesByFolder": { - "type": "object", - "description": "TestCaseFieldValue as returned by `reorder_test_cases_by_folder`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "TestRunWriteResponse__CloseTestRun": { - "type": "object", - "description": "TestRunWriteResponse as returned by `close_test_run`. Composes TestRunWriteResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestRunWriteResponseBody" - } - ], - "properties": { - "data": { - "type": "object", - "properties": { - "project": { - "type": "object", - "properties": { - "access": { - "type": "string", - "nullable": true - }, - "app_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "applicable_role": { - "type": "string", - "example": "Admin" - }, - "applicable_role_id": { - "type": "string", - "nullable": true - }, - "archived": { - "type": "integer", - "example": 0 - }, - "build_updated_at": { - "type": "string", - "nullable": true - }, - "default_team_id": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "is_demo": { - "type": "boolean", - "example": false - }, - "publicly_readable": { - "type": "boolean", - "example": false - }, - "slug": { - "type": "string", - "example": "__mcp-capability-fixture__-e2435bde" - }, - "source": { - "type": "string", - "example": "TCM" - }, - "sub_group_id": { - "type": "string", - "nullable": true - }, - "team_id": { - "type": "string" - }, - "user_id": { - "type": "integer", - "example": 2522 - }, - "visual_scanner_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "web_percy_build_updated_at": { - "type": "string", - "nullable": true - }, - "import_id": { - "type": "string", - "nullable": true - } - } - }, - "testrun": { - "type": "object", - "properties": { - "issues": { - "type": "string", - "nullable": true - }, - "observability_id": { - "type": "integer", - "example": 17579204 - }, - "test_result_issues": { - "type": "string", - "nullable": true - }, - "created_by": { + }, + "TemplateWriteAckResponse": { + "description": "Bare acknowledgement of a template write. Carries no template body, so re-read the template to confirm the result.", + "schema": { "type": "object", + "required": [ + "success" + ], "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } + "success": { + "type": "boolean", + "description": "True on success. A free-plan refusal also arrives with HTTP 200 but sets this false and adds a message, so check this flag rather than the status code." + } } - }, - "updated_by": { + } + }, + "V2HistoryListResponse": { + "description": "A page of test case revisions, newest first.", + "schema": { "type": "object", "properties": { - "email": { - "type": "string", - "example": "user@example.com" - }, - "browserstack_user_id": { - "type": "integer", - "example": 6013 - }, - "full_name": { - "type": "string", - "example": "ing" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - }, - "assignee_imported": { - "type": "string", - "nullable": true - }, - "attachments": { - "type": "string", - "nullable": true - }, - "environment": { - "type": "string", - "nullable": true - }, - "metadata": { - "type": "string", - "nullable": true - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } - } - } - } - } - } - }, - "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun": { - "type": "object", - "description": "V1TestRunTestCaseRow as returned by `bulk_set_test_case_assignee_in_run`. Composes V1TestRunTestCaseRow and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestRunTestCaseRow" - } - ], - "properties": { - "automation_status": { - "type": "object", - "properties": { - "value": { - "type": "string", - "example": "not_automated" - } - } - }, - "automation_state": { - "description": "Automation state as the full field-value descriptor. Distinct from `automation_status`, which is the reduced {name, value} pair carrying the same state \u2014 both are returned, always in agreement.", - "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "priority": { - "$schema": "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "result_status": { - "nullable": true, - "$schema": "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun" - }, - "test_assignee": { - "type": "integer", - "nullable": true, - "description": "Assignee for the execution of THIS row, distinct from the case-level `assignee`.", - "properties": { - "browserstack_user_id": { - "type": "integer", - "example": 13450 - }, - "email": { - "type": "string", - "example": "user@example.com" - }, - "full_name": { - "type": "string", - "example": "Aadarsh" - }, - "group_id": { - "type": "integer", - "example": 2615 - }, - "onboarded": { - "type": "integer", - "example": 1 - } - } - } - } - }, - "TestCaseFieldValue__BulkSetTestCaseAssigneeInRun": { - "type": "object", - "description": "TestCaseFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes TestCaseFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "TestCaseFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "priority" - } - } - }, - "ResultStatusFieldValue__BulkSetTestCaseAssigneeInRun": { - "type": "object", - "description": "ResultStatusFieldValue as returned by `bulk_set_test_case_assignee_in_run`. Composes ResultStatusFieldValue and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "ResultStatusFieldValue" - } - ], - "properties": { - "field_name": { - "type": "string", - "example": "status" - } - } - }, - "DuplicateGroup": { - "type": "object", - "description": "One group of test cases the dedupe model judged duplicates. Returned by the duplicates listing; the single-duplicate read returns the same group under `duplicate`.\n", - "properties": { - "id": { - "type": "integer", - "description": "The duplicate GROUP id \u2014 this is the `duplicate_id` the single-duplicate read and the write actions take." - }, - "duplicate_ids": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Ids of every pair in this group; usually `[id]`." - }, - "ai_score": { - "type": "number", - "description": "Similarity score from the dedupe model, 0-100." - }, - "avg_score": { - "type": "number", - "description": "Mean score across the group." - }, - "reason": { - "type": "string", - "description": "Model-written explanation of why the cases were judged duplicates." - }, - "duplicate_type": { - "type": "string", - "description": "How they matched, e.g. `semantic`." - }, - "case_count": { - "type": "integer", - "description": "Number of cases in the group." - }, - "folder_count": { - "type": "integer", - "description": "Number of distinct folders they span." - }, - "owner_count": { - "type": "integer", - "description": "Number of distinct owners." - }, - "suggested_base_id": { - "type": "integer", - "description": "The case the model suggests keeping." - }, - "test_cases": { - "type": "array", - "items": { - "type": "object" - }, - "description": "The cases in this group; each carries its own id and identifier." - } - } - }, - "V1TestPlanBulkResponse__BulkRestoreArchivedTestPlans": { - "type": "object", - "description": "V1TestPlanBulkResponse as returned by `bulk_restore_archived_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanBulkResponse" - } - ], - "properties": { - "count": { - "type": "integer" - }, - "plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "sub_plans_count": { - "type": "integer" - } - } - } - }, - "success": { - "type": "boolean" - } - } - }, - "V1TestPlanBulkResponse__BulkArchiveTestPlans": { - "type": "object", - "description": "V1TestPlanBulkResponse as returned by `bulk_archive_test_plans`. Composes V1TestPlanBulkResponse and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanBulkResponse" - } - ], - "properties": { - "count": { - "type": "integer" - }, - "plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "project_id": { - "type": "integer" - }, - "sub_plans_count": { - "type": "integer" + "success": { + "type": "boolean" + }, + "history": { + "type": "array", + "description": "Revision records for the test case, newest first. Note the key is singular `history`, unlike the v1 listing's `histories`.", + "items": { + "$schema": "V2History" + } + }, + "info": { + "$schema": "PageInfo" + } + } } - } - } - }, - "success": { - "type": "boolean" - } - } - }, - "V1TestPlanDetail__CreateTestPlanByIntegerId": { - "type": "object", - "description": "V1TestPlanDetail as returned by `create_test_plan_by_integer_id`. Composes V1TestPlanDetail and adds only the fields this response actually returns, so the shared schema stays correct for its other consumers.\n", - "allOf": [ - { - "$schema": "V1TestPlanDetail" - } - ], - "properties": { - "success": { - "type": "boolean" - }, - "test_plan": { - "type": "object", - "properties": { - "active_state": { - "type": "string" - }, - "assignee": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object" - } - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "object", - "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "owner": { - "type": "integer" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "review_settings": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "sub_plan_mode": { - "type": "string" - } - } - }, - "review_status": { - "type": "string" - }, - "reviewers": { - "type": "array", - "items": { - "type": "object" - } - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "test_plan_progress": { - "type": "object" - }, - "test_plan_progress_v2": { - "type": "string", - "nullable": true - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "total_test_runs_count": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } - } - }, - "updated_at": { - "type": "string" - } - } - } - } - }, - "SharedComponentDetail": { - "type": "object", - "description": "The shared field as echoed by a CREATE or UPDATE: the listed fields plus its collection and its actual rows. The LISTING returns neither - it gives `step_count` instead of the rows, and does not carry `folder_id` at all.\n", - "allOf": [ - { - "$schema": "SharedComponent" - } - ], - "properties": { - "folder_id": { - "type": "integer", - "nullable": true, - "description": "Collection it is filed under; null for the Unassigned root." - }, - "shared_step_details": { - "type": "array", - "description": "The ordered rows. Returned by the create and update echoes; the LISTING omits it and gives `step_count` instead.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "step": { - "type": "string" - }, - "result": { - "type": "string", - "nullable": true - }, - "test_data": { - "type": "string", - "nullable": true - }, - "order": { - "type": "integer" + }, + "TestPlanV2Response": { + "description": "A single test plan (or sub-plan read through the plan route). No data wrapper — the plan sits at the top level under `test_plan`.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plan": { + "$schema": "TestPlanV2" + } + } } - } - } - } - } - }, - "FolderWriteResponseBody": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "folder": { - "$schema": "Folder" - } - } - } - }, - "description": "Response body of `FolderWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "SharedStepsIDResponseBody": { - "type": "object", - "required": [ - "data", - "success" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } - }, - "description": "Response body of `SharedStepsIDResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "TagsSearchResponseBody": { - "type": "object", - "required": [ - "tags", - "tag_objects", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "Simple array of tag names (for backward compatibility)", - "example": [ - "AI Generated", - "AI Generated Details" - ], - "items": { - "type": "string" - } - }, - "tag_objects": { - "type": "array", - "description": "Detailed tag objects with metadata", - "items": { - "$schema": "Tag" - } - }, - "info": { - "$schema": "PageInfo" - } - }, - "description": "Response body of `TagsSearchResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - }, - "TestRunWriteResponseBody": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "$schema": "TestRun" - }, - "project": { - "$schema": "Project" - }, - "error": { - "type": "string", - "description": "Only present when success is false; the backing service's message." - } - } - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", - "items": { - "type": "integer" - } - } - }, - "description": "Response body of `TestRunWriteResponse`, promoted so privatised per-capability rows can compose on it with allOf. The response entry keeps its own copy for direct consumers." - } - }, - "responses": { - "TestRunWriteResponse": { - "description": "Successfully returned the test run after a WRITE\n(create / edit / assign / close / delete). NOTE the envelope key is `data.testrun` with NO\nunderscore on these operations, unlike clone and the plain reads, which use `data.test_run`.\n`skipped_test_case_ids` is a SIBLING of `data`, not inside it, and lists cases the review gate\nrefused to add. Client errors from the backing service replace the payload with\n{data: {success: false, error: }} at the 4xx status.\n", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "$schema": "TestRun" - }, - "project": { - "$schema": "Project" - }, - "error": { - "type": "string", - "description": "Only present when success is false; the backing service's message." - } - } - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Top-level sibling of `data`. Cases the review gate excluded from the write. Always [] on the build-backed flow.", - "items": { - "type": "integer" - } - } - } - } - }, - "BadRequest": { - "description": "Bad request due to invalid parameters.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Unauthorized": { - "description": "Unauthorized access. Authentication is required.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "InternalServerError": { - "description": "Internal server error.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BulkArchiveResponse": { - "description": "Test cases archived successfully", - "schema": { - "type": "object", - "required": [ - "success", - "folders" - ], - "properties": { - "success": { - "type": "boolean" - }, - "folders": { - "type": "object" - } - } - } - }, - "Forbidden": { - "description": "Forbidden access. You don't have permission.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "UnprocessableEntity": { - "description": "The request was well-formed but contains semantic errors.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BulkCopyTestCasesResponse": { - "description": "Test cases copied successfully", - "schema": { - "type": "object", - "required": [ - "success", - "test_cases", - "info", - "cases_count", - "source_path_folders", - "destination_path_folders" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkCopyTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object" - }, - "source_path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "Folder__BulkCopyTestCases" - } - }, - "folders": { - "type": "object" - } - } - } - }, - "BulkDeleteTestCasesResponse": { - "description": "Response after bulk deleting test cases", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object" - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - } - } - } - }, - "BulkMoveTestCasesResponse": { - "description": "CORRECTION of an existing key (only bulk_move_test_cases uses it). A synchronous bulk move renders the refreshed source listing, not {success, folders}. Above the async threshold the same 200 carries {success, async:true, unique_id} and none of these keys.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase__BulkMoveTestCases" - } - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object", - "description": "Case counts keyed by folder id, for the source and destination folders." - }, - "destination_path_folders": { - "type": "array", - "items": { - "$schema": "Folder__BulkMoveTestCases" - } - }, - "source_path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Only present when the request scoped a source folder." - }, - "moved_cases_count": { - "type": "integer", - "description": "Only present on a cross-project move." - }, - "folders": { - "type": "object" - } - } - } - }, - "TestRunResponse": { - "description": "Successfully returned a test run under a\n`data` envelope, keyed `test_run` WITH an underscore. Used by the v1 run reads and by clone.\nNOTE the write operations on this entity (create / edit / assign / close / delete) return\n`data.testrun` with NO underscore instead, and the v2 run endpoints have no `data` envelope\nat all.\n", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_run": { - "$schema": "TestRun" + }, + "SubTestPlanV2Response": { + "description": "A sub-plan returned by the parent-scoped route. The plan sits under `sub_test_plan`, NOT `test_plan`, and its identifier is STP-NNN.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "sub_test_plan": { + "$schema": "TestPlanV2__CreateSubTestPlan" + } + } } - } - } - } - } - }, - "FolderCopyResponse": { - "description": "Folder copy operation initiated successfully", - "schema": { - "type": "object", - "required": [ - "success", - "async", - "message" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Whether operation is running asynchronously", - "example": true - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Tracking ID for async operation (present when async=true)", - "example": "6a206593-865c-41ec-9459-d94bce72c1b9" - }, - "message": { - "type": "string", - "example": "Folder copy operation queued successfully" - }, - "folder_id": { - "type": "integer", - "description": "New folder ID (present when async=false)", - "example": 4 - } - } - } - }, - "FilterCreateResponse": { - "description": "Successfully created a new filter", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Filter__CreateFilter" - }, - "success": { - "type": "boolean" - } - } - } - }, - "CreateReportResponse": { - "description": "Successfully created scheduled report", - "schema": { - "$schema": "CreateReportResponse" - } - }, - "RootFolderDetailsResponse": { - "description": "Response containing only root folder details including test case counts, and links.", - "schema": { - "type": "object", - "properties": { - "info": { - "$schema": "PageInfo" - }, - "folder_hierarchy": { - "$schema": "FolderHierarchy" - }, - "folders": { - "type": "array", - "items": { - "$schema": "Folder__ListRootFolders" - } - }, - "self": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" + }, + "TestPlanV2ListResponse": { + "description": "A page of test plans. Rows omit the embedded test_runs preview and links that the single-plan read adds.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plans": { + "type": "array", + "items": { + "$schema": "TestPlanV2__ListTestPlans" + } + }, + "info": { + "$schema": "PageInfo" + } } - } } - } }, - "success": { - "type": "boolean" - } - } - } - }, - "SharedStepsPostResponse": { - "description": "Response containing details of a shared step.", - "schema": { - "type": "object", - "required": [ - "data", - "success" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" + "TestRunsListV2Response": { + "description": "A page of v2 test runs — the shape returned by both the project-wide run listing and a plan's linked-run listing, which genuinely do share one serializer (confirmed in the backend). The rows are a SUMMARY projection, not the single-run read: they never carry issues, issue_tracker or test_cases. One row field differs by route — test_plan is present on the project-wide listing and absent from the plan-scoped one. success is hard-coded true even when the upstream run fetch failed; in that case test_runs and info are null and the HTTP status carries the failure.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_runs": { + "type": "array", + "nullable": true, + "items": { + "$schema": "TestRunSummaryV2" + } + }, + "info": { + "$schema": "PageInfo" + } } - } - }, - "test_case_count": { - "type": "integer", - "description": "Number of test cases using this shared step" - }, - "step_count": { - "type": "integer", - "description": "Number of steps in this shared step" - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "step_count", - "test_case_count", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } - } - } - }, - "TestResultCreateResponse": { - "description": "Successful response for creating a test result", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "success", - "test-result", - "unique_issues" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test-result": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "author": { - "$schema": "User__CreateTestResultForTestCase" - }, - "backtrace": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "example": "2025-05-25T20:02:05.000Z" - }, - "description": { - "type": "string" - }, - "error_description": { - "type": "string", - "nullable": true - }, - "expanded": { - "type": "string", - "example": null, - "nullable": true - }, - "failure_type": { - "type": "string", - "example": null, - "nullable": true - }, - "finished_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "log_type": { - "type": "string", - "nullable": true - }, - "started_at": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "status": { - "type": "string", - "example": "passed" - }, - "result_status": { - "$schema": "ResultStatus__CreateTestResultForTestCase" - }, - "configuration_id": { - "type": "integer", - "description": "ID of the configuration this test result is associated with", - "example": 16, - "nullable": true - }, - "time_elapsed": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "content_type": { - "type": "string" - }, - "key": { - "type": "string" - }, - "product_id": { - "type": "integer" - }, - "byte_size": { - "type": "integer" - }, - "checksum": { - "type": "string" - }, - "url": { - "type": "string" - }, - "download_url": { - "type": "string" - }, - "size": { - "type": "string" - } + } + }, + "CustomFieldV2DetailResponse": { + "description": "A single custom field after a create or edit. The options, assigned_projects, applies_to_all_projects and link_to_future_projects keys are replayed from the request rather than read back from storage, and are omitted when the request did not carry them.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "custom_field": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric field id — the value the single-field read and the field edit take in their path.", + "example": 4821 + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Stored type. Always the stored value, never the one sent on an edit." + }, + "field_name": { + "type": "string", + "description": "Stored label, already trimmed." + }, + "entity_type": { + "type": "string", + "example": "TestCase", + "description": "Always TestCase on this surface." + }, + "is_required": { + "type": "boolean", + "description": "Whether a test case must fill the field." + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored default. Null after a create, because a default cannot be set at create time. On a boolean field it is stored as \"1\" or \"0\"." + }, + "place_holder_text": { + "type": "string", + "description": "Hint text. Absent when the field has none." + }, + "options": { + "type": "array", + "description": "Present only when the field actually has options — a free-text type returns no key at all rather than an empty list.", + "items": { + "type": "object", + "properties": { + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + } + } + } + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Echo of the request flag, defaulting to false — not a statement about the field's stored project links." + }, + "assigned_projects": { + "type": "array", + "description": "Present only when the field is linked to specific projects. A field that applies to all projects, or one created without any, returns no key rather than an empty list.", + "items": { + "type": "string", + "example": "PR-12345" + } + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Echo of the request flag, defaulting to false." + }, + "system_name": { + "type": "string", + "description": "The stable internal name for a system-provided field. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "group_id": { + "type": "integer", + "description": "Workspace the definition belongs to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "linked_projects_count": { + "type": "integer", + "description": "How many projects the field is linked to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + } + } } - } - }, - "created_by_imported": { - "type": "boolean", - "nullable": true - }, - "issues": { - "type": "array", - "example": [], - "items": { - "type": "object" - } }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473" + "required": [ + "success", + "custom_field" + ] + } + }, + "CustomFieldV2WithDatasetsResponse": { + "description": "A single custom field read by id: stored metadata plus references to the datasets that hold its option values. Carries no option values and no real project configuration.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true }, - "custom_fields": { - "type": "string", - "example": "/api/v1/projects/10000/test-results/1473/custom-fields" + "custom_field": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 4821 + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ] + }, + "field_name": { + "type": "string" + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored default; \"1\" / \"0\" on a boolean field." + }, + "place_holder_text": { + "type": "string", + "description": "Absent when the field has none." + }, + "applies_to_all_projects": { + "type": "boolean", + "description": "Always false on this read — a placeholder, not the field's real project scope." + }, + "link_to_future_projects": { + "type": "boolean", + "description": "Always false on this read — a placeholder, not the field's real future-project setting." + }, + "dataset_ids": { + "type": "array", + "description": "Ids of the datasets holding this field's option values and project links. Empty for a field type that has no options.", + "items": { + "type": "integer" + } + }, + "datasets_url": { + "type": "string", + "description": "Ready-made link to this field's dataset collection." + }, + "dataset_urls": { + "type": "array", + "description": "Ready-made link per dataset, in the same order as dataset_ids.", + "items": { + "type": "string" + } + }, + "system_name": { + "type": "string", + "description": "The stable internal name for a system-provided field. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "is_filterable": { + "type": "boolean", + "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." + }, + "placeholder": { + "type": "string", + "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "group_id": { + "type": "integer", + "description": "Workspace the definition belongs to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + }, + "linked_projects_count": { + "type": "integer", + "description": "How many projects the field is linked to. Included only when it has a value — the serializer omits it otherwise, so absence is not the same as a false or empty value." + } + } } - } - }, - "custom_fields": { - "type": "object" }, - "updated_by": { - "type": "string", - "nullable": true - } - } - }, - "unique_issues": { - "type": "array", - "items": { - "type": "object" - } + "required": [ + "success", + "custom_field" + ] } - } - } - } - } - }, - "FilterDeleteResponse": { - "description": "Successfully deleted the filter", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - } - } - }, - "DeleteScheduleResponse": { - "description": "Successfully deleted scheduled report(s)", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "array", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "The total number of scheduled reports in the project." - }, - "total_report_count": { - "type": "integer", - "description": "The total number of reports in the project." - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "DownloadReportResponse": { - "description": "Successfully initiated report download", - "schema": { - "type": "object", - "required": [ - "channel", - "success" - ], - "properties": { - "channel": { - "type": "string", - "description": "Unique channel identifier for streaming or polling download status.", - "example": "53bd342e-b4d8-4003-bb3d-d448a2e8780d" - }, - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "DashboardExportInitiatedResponse": { - "description": "Dashboard export successfully initiated", - "schema": { - "type": "object", - "required": [ - "success", - "export_id" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "export_id": { - "type": "integer", - "description": "Export record ID for tracking progress", - "example": 12345 - }, - "message": { - "type": "string", - "description": "Optional status message", - "example": "Export queued successfully" - } - } - } - }, - "ActiveTestRunsInfo": { - "description": "An OBJECT with a `data` array \u2014 not a bare top-level array.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "One entry per result status across the project's active test runs. When more than six statuses exist only the top six by count are kept and the remainder are collapsed into a single 'Other' entry that has links but NO result_status.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Status display name, or the literal 'Other' for the collapsed bucket." - }, - "y": { - "type": "integer", - "description": "Number of results in this status." - }, - "result_status": { + }, + "V2ReportListResponse": { + "description": "Paginated list of a project's report configurations, plus group/project totals for the filtered set.", + "schema": { "type": "object", - "description": "Absent on the collapsed 'Other' entry.", "properties": { - "id": { - "type": "integer" - }, - "field_value": { - "type": "string" - }, - "internal_name": { - "type": "string" - }, - "field_name": { - "type": "string" - }, - "colour": { - "type": "string" - }, - "value_category": { - "type": "string", - "nullable": true - } + "success": { + "type": "boolean" + }, + "reports": { + "type": "array", + "items": { + "$schema": "V2ReportDetail" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata: page, next, prev, count, page_size.", + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer", + "nullable": true + }, + "prev": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer", + "description": "Total matching rows, not rows on this page." + }, + "page_size": { + "type": "integer" + } + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "Scheduled reports within the filtered set, across all pages." + }, + "total_report_count": { + "type": "integer", + "description": "All reports within the filtered set, across all pages." + } } - }, - "links": { + } + }, + "V2ReportDetailResponse": { + "description": "A single report configuration, returned by the v2 report create (201) and single-report read (200). Carries no report data.", + "schema": { "type": "object", - "description": "Present on the collapsed 'Other' entry; carries a UI filter URL covering the statuses that were merged in.", "properties": { - "filter": { - "type": "string" - } + "success": { + "type": "boolean" + }, + "data": { + "$schema": "V2ReportDetail" + } } - } } - } - }, - "empty_data": { - "type": "boolean", - "nullable": true }, - "filter_details": { - "type": "object", - "description": "Merged in only when filter_details=true and q was present." - } - } - } - }, - "AutomationStats": { - "description": "Response containing the automation statistics for the test cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "automated_coverage": { - "type": "number", - "format": "float" - }, - "automated_test_cases": { - "type": "integer" - }, - "total_test_cases": { - "type": "integer" - }, - "manual_test_cases": { - "type": "integer" - }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "TestRunsListResponse": { - "description": "Successfully retrieved test runs", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_runs": { - "type": "array", - "items": { - "$schema": "V1TestRunListRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "count": { - "type": "object", - "properties": { - "active": { - "type": "integer", - "example": 5 - }, - "closed": { - "type": "integer", - "example": 2 - } - } - } - } - } - }, - "ClosedTestRunsInfo": { - "description": "Closed test runs analytics data by month", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "example": [ - { - "month": "Apr", - "count": 3 - }, - { - "month": "May", - "count": 0 + "AttachmentListV2Response": { + "description": "A page of attachments on one test case, test result or test plan.", + "schema": { + "type": "object", + "required": [ + "success", + "attachments" + ], + "properties": { + "success": { + "type": "boolean" + }, + "attachments": { + "type": "array", + "items": { + "$schema": "AttachmentListItem" + } + }, + "info": { + "$schema": "PageInfo" + } + } } - ], - "items": { - "type": "object", - "properties": {} - } }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "ClosedTestRunsSplit": { - "description": "Closed test run results split by month and status", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "integer" - } - }, - "result_status": { + "TestResultSearchResponse": { + "description": "Account-wide test result search page. Always HTTP 200 with success:true — an upstream search failure is swallowed and shows up as an empty test_results array with info null, not as an error status.", + "schema": { "type": "object", + "required": [ + "success", + "test_results", + "info" + ], "properties": { - "id": { - "type": "integer" - }, - "field_value": { - "type": "string" - }, - "internal_name": { - "type": "string" - }, - "field_name": { - "type": "string" - }, - "colour": { - "type": "string" - }, - "value_category": { - "type": "string", - "nullable": true - } + "success": { + "type": "boolean", + "example": true, + "description": "Always true on this endpoint, including when the search itself failed. Do not use it as a success signal." + }, + "test_results": { + "type": "array", + "description": "Matching results, at most 30 per page (10 if per_page was sent at all). Empty both when nothing matched and when the search errored.", + "items": { + "$schema": "TestResultSearchItem" + } + }, + "info": { + "$schema": "PageInfo", + "nullable": true, + "description": "Paging metadata from the search service. NULL when the search failed — that, not the status code, is how you detect the failure." + } } - } } - } - }, - "date_list": { - "type": "array", - "items": { - "type": "string" - } }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "FilterDetailResponse": { - "description": "Successfully retrieved filter details", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Filter" - }, - "success": { - "type": "boolean" - } - } - } - }, - "RemoveFolderSummaryResponse": { - "description": "A summary of entities that would be deleted when removing a folder", - "schema": { - "type": "object", - "required": [ - "success", - "has_other_product_entity", - "deleted_entity_count" - ], - "properties": { - "success": { - "type": "boolean", - "description": "Indicates if the operation was successful" - }, - "has_other_product_entity": { - "type": "boolean", - "description": "Indicates if the folder contains entities from other products" - }, - "deleted_entity_count": { - "type": "object", - "required": [ - "active_test_case_count", - "archived_test_case_count", - "sub_folders" - ], - "properties": { - "test_recordings_count": { - "type": "integer", - "description": "Number of test recordings that would be deleted" - }, - "active_test_case_count": { - "type": "integer", - "description": "Number of active test cases that would be deleted" - }, - "archived_test_case_count": { - "type": "integer", - "description": "Number of archived test cases that would be deleted" - }, - "sub_folders": { - "type": "integer", - "description": "Number of sub-folders that would be deleted" + "CommentsListResponse": { + "description": "A page of comments on one run/case cell, newest first, fixed 30 per page. info.count is the total across pages; a page past the end repeats the last page rather than returning an empty array.", + "schema": { + "type": "object", + "required": [ + "comments", + "success", + "info" + ], + "properties": { + "comments": { + "type": "array", + "items": { + "$schema": "Comment" + } + }, + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo" + } + } } - } - } - } - } - }, - "UsersV2Response": { - "description": "Successfully retrieved users list", - "schema": { - "$schema": "UsersV2Response" - } - }, - "IssuesCountInfo": { - "description": "Response containing the issue count trend for each month.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "label", - "count" - ], - "properties": {} - } }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "CustomFieldsV2Response": { - "description": "Successful response with paginated custom fields list", - "schema": { - "type": "object", - "required": [ - "success", - "custom_fields", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the custom field", - "example": "cf-123" - }, - "field_type": { - "type": "string", - "description": "Type of the custom field", - "enum": [ - "text", - "textarea", - "dropdown", - "multiselect", - "number", - "date", - "boolean", - "url" + "CommentResponse": { + "description": "The comment just posted, wrapped as data.comment. HTTP 200, not 201. Both inner keys are always present - the handler emits them as one literal with no branches.", + "schema": { + "type": "object", + "required": [ + "data" ], - "example": "dropdown" - }, - "field_name": { - "type": "string", - "description": "Name of the custom field", - "example": "Priority Level" - }, - "entity_type": { - "type": "string", - "description": "Entity type this field belongs to", - "example": "TestCase" - }, - "is_required": { - "type": "boolean", - "description": "Whether the field is required", - "example": false - }, - "default_value": { - "description": "Default value for the field" - }, - "place_holder_text": { - "type": "string", - "description": "Hint text shown in the empty field. Omitted entirely when the field has no hint text. This is the key to read: it is filled from the field's hint under either of the two names the stored record may use.", - "example": "Select priority level" - }, - "system_name": { - "type": "string", - "description": "System name for the field" - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be bulk edited" - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used in filters" - }, - "linked_projects_count": { - "type": "integer", - "description": "Number of projects this field is linked to", - "example": 5 - }, - "group_id": { - "type": "string", - "description": "Group ID this field belongs to" - }, - "placeholder": { - "type": "string", - "description": "The same hint text as place_holder_text, under a second name. Present only when the stored field carries its hint under this name - which is the common case, so both keys usually appear with identical values, but neither is guaranteed. Read place_holder_text and ignore this one; it exists for older clients." - } - } - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "ProjectsPaginatedResponse": { - "description": "Successfully retrieved paginated projects", - "schema": { - "$schema": "ProjectsPaginatedResponse" - } - }, - "ProjectMinifyResponse": { - "description": "Successfully retrieved minified projects list", - "schema": { - "$schema": "ProjectMinifyResponse" - } - }, - "GetReportDetailResponse": { - "description": "Successfully fetched report details", - "schema": { - "type": "object", - "required": [ - "data", - "report_filters", - "dynamic_filters", - "report_data", - "project" - ], - "properties": { - "data": { - "$schema": "ReportDetail__GetReportDetail" - }, - "report_filters": { - "$schema": "ReportFiltersDetail" - }, - "dynamic_filters": { - "$schema": "DynamicFilters" - }, - "report_data": { - "$schema": "ReportData__GetReportDetail" + "properties": { + "data": { + "type": "object", + "required": [ + "success", + "comment" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "comment": { + "$schema": "Comment" + } + } + } + } + } }, - "project": { - "$schema": "ReportProjectDetail" + "TestRunConfigurationProgressResponse": { + "description": "Execution progress of a test run by result status. TWO shapes: when configuration_id was sent the body is {success, overall_progress_by_status_id} and `configurations`/`info` are absent; when it was not, the body is {success, configurations:[...], info} and the top-level `overall_progress_by_status_id` is absent. `info` is the backing service's paging block passed through unchanged (its exact fields are not fixed by this service).", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "configurations": { + "type": "array", + "description": "Only in the all-configurations form. One page of the run's configurations, each with its progress attached.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 101 + }, + "name": { + "type": "string", + "example": "Chrome on Windows" + }, + "overall_progress_by_status_id": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + } + } + } + }, + "info": { + "type": "object", + "nullable": true, + "description": "Only in the all-configurations form. Paging metadata from the backing service, passed through as-is." + } + } + } }, - "filters": { - "type": "object" + "BinnedTestCasesListResponse": { + "description": "A page of binned test cases. Paging lives under `metadata` (total/page/page_size/total_pages), not the usual `info` envelope. Field names follow the backing service's client model; confirm against a live call if a field is missing.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "BinnedTestCase" + } + }, + "metadata": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + } + } + } }, - "is_dynamic": { - "type": "boolean", - "example": false - } - } - } - }, - "ReportSummaryResponse": { - "description": "Successful response for report details", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "estimated_vs_actual": { - "type": "string", - "nullable": true - }, - "issues_by_priority": { - "type": "string", - "nullable": true - }, - "issues_by_status": { - "type": "string", - "nullable": true - }, - "issues_data": { - "type": "string", - "nullable": true - }, - "test_cases_status": { - "type": "string", - "nullable": true - }, - "test_run_data": { - "type": "object" - }, - "test_run_status": { - "type": "string", - "nullable": true - }, - "time_tracking": { - "type": "string", - "nullable": true - }, - "user_result_stats": { - "type": "string", - "nullable": true - } - }, - "required": [ - "success" - ] - } - }, - "GetReportTestCases": { - "description": "Successfully fetched report testcases details", - "schema": { - "type": "object", - "required": [ - "success", - "selection_data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "selection_data": { - "$schema": "SelectionData" - }, - "selection": { - "type": "object", - "description": "The primary project's persisted folder-structured test-case selection (with select_all / per-folder flags + unique_test_case_count), in the same shape as the test-run edit/selection endpoint. `{}` when the report has no saved selection for the primary project. Used by the Edit dialog to re-check saved test cases (including folder/project-level select_all, which the flat ids cannot represent).", - "example": { - "select_all": false, - "folders": { - "827": { - "test_case_select_all": false, - "selected_test_cases": [ - 1515 + "TestCaseReviewOutcomeResponse": { + "description": "Outcome of a bulk review submission or verdict. Under 100 cases: the updated cases inline (testCases) — verdicts with a comment also carry `comment`. Over 100: {success, async:true, unique_id} and NO testCases; the change lands in the background.", + "schema": { + "type": "object", + "required": [ + "success" ], - "deselected_test_cases": [] - } - }, - "unique_test_case_count": 1 - } - }, - "shared_selection": { - "type": "object", - "description": "Per-project folder-structured selection for the report's non-primary projects, keyed by project id ({ \"\": }). Present only for cross-project Test Case Activity reports; `{}` otherwise.", - "example": { - "10209": { - "select_all": true, - "folders": {}, - "unique_test_case_count": 2 - } - } - } - } - } - }, - "SharedStepsIDResponse": { - "description": "Response containing details of a specific shared step by ID.", - "schema": { - "type": "object", - "required": [ - "data", - "success" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "testCases": { + "type": "array", + "items": { + "$schema": "TestCase" + }, + "description": "Updated cases (serialized test case, including review_status and reviewers). Absent on the async path." + }, + "comment": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Verdict only, when a comment was sent: the created comment objects {id, comment, review_status, user{id, full_name, email}, created_at ...}." + }, + "async": { + "type": "boolean", + "description": "true when the selection exceeded 100 and was queued." + }, + "unique_id": { + "type": "string", + "format": "uuid", + "description": "Identifier of the queued background operation (async path only)." + } } - } - }, - "updated_by": { - "type": "object", - "required": [ - "id", - "full_name" - ], - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" + } + }, + "ReauthRequired": { + "description": "Part 11 e-signature required: the reauth_token was missing, expired (5 min) or already used. Re-authenticate and resend with a fresh token.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "error_code": { + "type": "string", + "example": "reauth_required" + } } - } - }, - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - } - }, - "required": [ - "created_by", - "id", - "shared_step_details", - "title", - "updated_by" - ] - }, - "success": { - "type": "boolean" - } - } - } - }, - "SharedStepsGetResponse": { - "description": "Response containing a list of shared steps with pagination info.", - "schema": { - "type": "object", - "required": [ - "data", - "info", - "success" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "created_by": { + } + }, + "LcncCreateWrapperResponse": { + "description": "Wrapper around the automation service's reply. `status` is the upstream HTTP code and `body` is the upstream body as a JSON-encoded STRING to parse (except the 'already in progress' short-circuit, where body is an object {message, title} and status is 'ok'). HTTP 200 outside does not mean success inside — inspect status and body.", + "schema": { "type": "object", "required": [ - "id", - "full_name" + "status", + "body" ], "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } + "status": { + "type": "integer", + "example": 200, + "description": "Upstream HTTP status (an integer), or the string 'ok' on the already-in-progress short-circuit." + }, + "body": { + "type": "string", + "description": "Upstream body as a JSON string; typically {status:'submitted'|'failed', message, test_id?, test_version_id?, reason?} on the bulk-pipeline path." + } } - }, - "updated_by": { + } + }, + "BddExportValidationResponse": { + "description": "Verdict of the BDD export pre-check; the same shape is returned on pass (200, success:true) and on every failure (400, success:false) — read message.", + "schema": { "type": "object", "required": [ - "id", - "full_name" + "success", + "message" ], "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": "string" - } - } - }, - "test_case_count": { - "type": "integer", - "description": "Number of test cases using this shared step" - }, - "step_count": { - "type": "integer", - "description": "Number of steps in this shared step" - }, - "tags": { - "type": "string", - "nullable": true - } - }, - "required": [ - "created_by", - "id", - "step_count", - "test_case_count", - "title", - "updated_by" - ] - } - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "description": "Total number of shared steps" - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true - } - }, - "required": [ - "count", - "next", - "page", - "page_size", - "prev" - ] - }, - "pre_fetched_shared_step": { - "type": "object", - "description": "Pre-fetched details for the first shared step (when pre_fetch=true)", - "nullable": true, - "required": [ - "shared_step_details", - "page_info" - ], - "properties": { - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - }, - "page_info": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Current page number." - }, - "page_size": { - "type": "integer", - "description": "Number of items per page." - }, - "count": { - "description": "Total number of step details" - }, - "prev": { - "type": "integer", - "description": "Previous page number if applicable; null otherwise.", - "nullable": true - }, - "next": { - "type": "integer", - "description": "Next page number if applicable; null otherwise.", - "nullable": true + "success": { + "type": "boolean" + }, + "message": { + "type": "string", + "example": "Validation successful. All test cases belong to the same feature and background." + } } - }, - "required": [ - "count", - "next", - "page", - "page_size", - "prev" - ] } - } }, - "success": { - "type": "boolean" - } - } - } - }, - "TestCaseCountTrend": { - "description": "The response contains the trend data for each test case category over the months.", - "schema": { - "type": "object", - "required": [ - "data", - "empty_data", - "order" - ], - "properties": { - "data": { - "type": "object", - "description": "KEYED BY CASE TYPE NAME, not a record with fixed fields \u2014 e.g. {\"Acceptance\": {\u2026}, \"API\": {\u2026}}. The keys are project data and change as case types are added or renamed, so they are deliberately not enumerated. Each value carries that type's counts plus a `graphData` map keyed by month abbreviation." - }, - "empty_data": { - "type": "boolean" - }, - "order": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "HistoryDiffResponse": { - "description": "Response containing the diff of test case steps between two histories.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "diff": { - "type": "object", - "required": [ - "test_case_steps" - ], - "properties": { - "test_case_steps": { - "type": "object", - "properties": { - "old": { - "type": "array", - "items": { - "$schema": "TestCaseDiffStep" - } - }, - "new": { - "type": "array", - "items": { - "$schema": "TestCaseDiffStep" - } - } - } - }, - "status": { - "type": "object", - "properties": { - "old": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "colour": { - "type": "string" - } - } - }, - "new": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - } - } - } - } - }, - "links": { - "type": "object", - "example": { - "99": "/api/v1/projects/10000/test-cases/1/histories/99", - "100": "/api/v1/projects/10000/test-cases/1/histories/100" - } - } - } - } - }, - "TestCaseTypeSplit": { - "description": "Test case count split by case type", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "y": { - "type": "integer" - }, - "value": { - "type": "integer" - }, - "field": { - "type": "string" - } - } - } - }, - "empty_data": { - "type": "boolean" - } - } - } - }, - "TestResultCustomFieldsResponse": { - "description": "Successfully retrieved custom fields for test results", - "schema": { - "$schema": "TestResultCustomFieldsResponse" - } - }, - "TestResultListResponse": { - "description": "List of test results", - "schema": { - "type": "object", - "required": [ - "success", - "test-results", - "info", - "unique_issues" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test-results": { - "type": "array", - "items": { - "$schema": "TestResult__ListTestResultsForTestCaseByIntegerId" - } - }, - "info": { - "$schema": "PageInfo__ListTestResultsForTestCaseByIntegerId" - }, - "unique_issues": { - "type": "array", - "example": [], - "items": { - "type": "string" - } - } - } - } - }, - "TestRunFormFieldsResponse": { - "description": "Successfully retrieved test result form fields", - "schema": { - "$schema": "TestRunFormFieldsResponse" - } - }, - "TestRunEditSelectionResponse": { - "description": "A test run's stored case selection and editable fields, as the full edit form reads them before an edit.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "selection": { - "type": "object", - "description": "The run's stored case selection: {select_all, unique_test_case_count, folders}, where `folders` maps a folder id (string) to {test_case_select_all, selected_test_cases, deselected_test_cases}. Can be {}. Not kept in step with the add-only and remove calls." - }, - "shared_selection": { - "type": "object", - "nullable": true, - "description": "Cases shared in from other projects, keyed by project; null when the run has none." - }, - "test_run": { - "$schema": "TestRun" - }, - "dynamic_filter": { - "type": "object", - "nullable": true, - "description": "The dynamic-run filter rule; null for a static run." - }, - "filters": { - "type": "object", - "nullable": true, - "description": "Same content as dynamic_filter, under its alias." - }, - "trtc_configuration_mappings": { - "type": "array", - "description": "Per-configuration case mappings; a run without configurations returns one default entry with configuration_id null and select_all true.", - "items": { - "type": "object" - } - } - } - } - } - } - }, - "TestRunsSelectionResponse": { - "description": "The test CASES a selection resolves to (this endpoint returns cases, not runs).\n`selected` is present only when show='all', and lists the ids of the returned rows that also\nmatch the `q` filter.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunSelectionCaseRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "selected": { - "type": "array", - "description": "Test case ids, among those returned, that also match the `q` filter. Only rendered when show='all' and at least one row matched.", - "items": { - "type": "integer" - } - } - } - } - }, - "FilterListResponse": { - "description": "Successfully retrieved list of filters", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "Array of saved views, each carrying only id, name and is_project_level", - "items": { - "$schema": "FilterListRow" - } - }, - "info": { - "$schema": "PageInfo" - }, - "success": { - "type": "boolean" - } - } - } - }, - "FolderDetailsResponse": { - "description": "Response containing folder details including subfolders, counts, and links.", - "schema": { - "type": "object", - "properties": { - "folders": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "self": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "group_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "parent_id": { - "type": "integer" - }, - "notes": { - "type": "string" - }, - "is_automation": { - "type": "boolean" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - }, - "contents": { - "type": "array", - "items": { - "$schema": "Folder" - } - }, - "links": { - "type": "object", - "properties": { - "self": { - "type": "string" + "TestRunV2Response": { + "description": "The test run at the top level under `test_run`, with no `data` envelope — the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_run": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "issues": { + "type": "array", + "description": "CONDITIONAL — the key is absent, not empty, when the run payload carries no issues association.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string" + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` — the same value under a tracker-specific name. Prefer `issue_id`." + }, + "issue_type": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's — use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" + } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" + } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" + } + } + } + } + } } - } } - } - }, - "parent_id": { - "type": "integer" - }, - "ancestors": { - "type": "object", - "nullable": true - }, - "test_cases": { - "type": "object", - "nullable": true - }, - "info": { - "$schema": "PageInfo" - }, - "success": { - "type": "boolean" - } - } - } - }, - "TagsSearchResponse": { - "description": "Tags successfully retrieved", - "schema": { - "type": "object", - "required": [ - "tags", - "tag_objects", - "info" - ], - "properties": { - "tags": { - "type": "array", - "description": "Simple array of tag names (for backward compatibility)", - "example": [ - "AI Generated", - "AI Generated Details" - ], - "items": { - "type": "string" - } - }, - "tag_objects": { - "type": "array", - "description": "Detailed tag objects with metadata", - "items": { - "$schema": "Tag" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "ProjectUsersSearchResponse": { - "description": "Project users successfully retrieved", - "schema": { - "type": "object", - "required": [ - "users", - "info" - ], - "properties": { - "users": { - "type": "array", - "description": "List of users with access to the project", - "items": { - "$schema": "ProjectUserDetail" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "FilterUpdateResponse": { - "description": "Successfully updated the filter", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Filter__UpdateFilter" - }, - "success": { - "type": "boolean" - } - } - } - }, - "UpdateReportResponse": { - "description": "Successfully updated scheduled report", - "schema": { - "$schema": "ReportUpdateResponse" - } - }, - "GenericAttachmentsResponse": { - "description": "Attachments successfully uploaded", - "schema": { - "type": "object", - "required": [ - "generic_attachment" - ], - "properties": { - "generic_attachment": { - "type": "array", - "description": "List of uploaded attachments with metadata", - "items": { - "$schema": "GenericAttachment" - } - } - } - } - }, - "TestRunTestCasesV2Response": { - "description": "Paginated test cases of a test run. `info` carries the paging counts and reflects any filters applied.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunTestCaseV2" - } - } - } - } - }, - "V2TestRunResultSubmitResponse": { - "description": "Result submission accepted. An ordinary single submission returns the written result under `test-result`; a submission carrying step results returns `test-run-step-results` plus `overall-result-status` and NO `test-result`. The bulk form returns only `success: true` when everything was accepted, or `success: false` with `status: \"PARTIAL\"` and per-case `results` when some entries were rejected \u2014 still HTTP 200.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "false on a PARTIAL bulk response even though the status is 200." - }, - "status": { - "type": "string", - "example": "PARTIAL", - "description": "Present only when some bulk entries were rejected and some were accepted." - }, - "message": { - "type": "string", - "description": "Present only alongside rejected bulk entries; names the unconfigured statuses and/or the count of cases blocked by the review-status gate." - }, - "test-result": { - "$schema": "V2TestRunResultDetail", - "description": "Single form only, and only when no step results were submitted." - }, - "test-run-step-results": { - "type": "array", - "description": "Single form, step submissions only \u2014 one entry per step result written. Mutually exclusive with test-result.", - "items": { - "$schema": "V2TestRunStepResultDetail" - } - }, - "overall-result-status": { - "type": "string", - "description": "Status rolled up from the submitted step results. Accompanies test-run-step-results." - }, - "results": { - "type": "array", - "description": "Bulk form, present only when at least one entry was rejected.", - "items": { - "$schema": "V2BulkResultOutcome" - } - }, - "attachments": { - "type": "array", - "description": "Attachments bound on a multipart single submission. This is the bind result, not the stored attachment record \u2014 it does not carry content type, size or checksum.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Attachment id, usable with the attachment endpoints." - }, - "name": { - "type": "string", - "description": "Filename of the multipart part that was bound." - }, - "url": { - "type": "string", - "description": "Time-limited download url." - }, - "url_expires_at": { - "type": "string", - "format": "date-time", - "description": "When url stops working." - }, - "test_result_id": { - "type": "integer" - } - } - } - }, - "attachment_errors": { - "type": "array", - "description": "Present only when at least one file failed to bind. The result itself was still written.", - "items": { - "type": "object", - "properties": { - "filename": { - "type": "string" - }, - "code": { - "type": "string", - "values": [ - "ATTACHMENT_BIND_INCOMPLETE", - "ATTACHMENT_BIND_FAILED" - ], - "description": "ATTACHMENT_BIND_INCOMPLETE means the file WAS stored but its id could not be read back; re-read the result's attachments to recover it. ATTACHMENT_BIND_FAILED means the file was discarded." - }, - "message": { - "type": "string" - } - } - } - }, - "dropped_attachments": { - "type": "array", - "description": "Bulk form only. Files that were uploaded but belonged to an entry that got rejected, plus inline rich-text references that could not be resolved. Two entry shapes appear in the same array.", - "items": { - "type": "object", - "properties": { - "test_case_id": { - "type": "string", - "description": "Rejected-entry form." - }, - "filenames": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Rejected-entry form." - }, - "reason": { - "type": "string", - "example": "status_validation_failed", - "description": "Rejected-entry form." - }, - "filename": { - "type": "string", - "description": "Inline-reference form." - }, - "code": { - "type": "string", - "example": "INLINE_ATTACHMENT_DROPPED", - "description": "Inline-reference form." - } - } - } - } - } - } - }, - "V2BulkResultStatusAcceptedResponse": { - "description": "The bulk status change was queued. It says nothing about which cases were changed or whether the work succeeded.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "V2TestResultListResponse": { - "description": "Recorded results for one test case in one test run, newest executions included as separate entries. Note the key is `test_results` with an underscore, unlike the v1 list's `test-results`.", - "schema": { - "type": "object", - "required": [ - "success", - "test_results", - "info" - ], - "properties": { - "success": { - "type": "boolean" - }, - "test_results": { - "type": "array", - "items": { - "$schema": "V2TestResultListItem" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TemplateListResponse": { - "description": "A page of test-case templates with pagination metadata.", - "schema": { - "type": "object", - "required": [ - "success", - "templates" - ], - "properties": { - "success": { - "type": "boolean" - }, - "templates": { - "type": "array", - "items": { - "$schema": "Template" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TemplateDetailResponse": { - "description": "A single test-case template with its full field layout.", - "schema": { - "type": "object", - "required": [ - "success", - "template" - ], - "properties": { - "success": { - "type": "boolean" - }, - "template": { - "$schema": "TemplateWithFields" - } - } - } - }, - "TemplateWriteAckResponse": { - "description": "Bare acknowledgement of a template write. Carries no template body, so re-read the template to confirm the result.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "description": "True on success. A free-plan refusal also arrives with HTTP 200 but sets this false and adds a message, so check this flag rather than the status code." - } - } - } - }, - "V2HistoryListResponse": { - "description": "A page of test case revisions, newest first.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "history": { - "type": "array", - "description": "Revision records for the test case, newest first. Note the key is singular `history`, unlike the v1 listing's `histories`.", - "items": { - "$schema": "V2History" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TestPlanV2Response": { - "description": "A single test plan (or sub-plan read through the plan route). No data wrapper \u2014 the plan sits at the top level under `test_plan`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true }, - "test_plan": { - "$schema": "TestPlanV2" - } - } - } - }, - "SubTestPlanV2Response": { - "description": "A sub-plan returned by the parent-scoped route. The plan sits under `sub_test_plan`, NOT `test_plan`, and its identifier is STP-NNN.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "sub_test_plan": { - "$schema": "TestPlanV2__CreateSubTestPlan" - } - } - } - }, - "TestPlanV2ListResponse": { - "description": "A page of test plans. Rows omit the embedded test_runs preview and links that the single-plan read adds.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_plans": { - "type": "array", - "items": { - "$schema": "TestPlanV2__ListTestPlans" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TestRunsListV2Response": { - "description": "A page of v2 test runs \u2014 the shape returned by both the project-wide run listing and a plan's linked-run listing, which genuinely do share one serializer (confirmed in the backend). The rows are a SUMMARY projection, not the single-run read: they never carry issues, issue_tracker or test_cases. One row field differs by route \u2014 test_plan is present on the project-wide listing and absent from the plan-scoped one. success is hard-coded true even when the upstream run fetch failed; in that case test_runs and info are null and the HTTP status carries the failure.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_runs": { - "type": "array", - "nullable": true, - "items": { - "$schema": "TestRunSummaryV2" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "CustomFieldV2DetailResponse": { - "description": "A single custom field after a create or edit. The options, assigned_projects, applies_to_all_projects and link_to_future_projects keys are replayed from the request rather than read back from storage, and are omitted when the request did not carry them.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_field": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric field id \u2014 the value the single-field read and the field edit take in their path.", - "example": 4821 - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Stored type. Always the stored value, never the one sent on an edit." - }, - "field_name": { - "type": "string", - "description": "Stored label, already trimmed." - }, - "entity_type": { - "type": "string", - "example": "TestCase", - "description": "Always TestCase on this surface." - }, - "is_required": { - "type": "boolean", - "description": "Whether a test case must fill the field." - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored default. Null after a create, because a default cannot be set at create time. On a boolean field it is stored as \"1\" or \"0\"." - }, - "place_holder_text": { - "type": "string", - "description": "Hint text. Absent when the field has none." - }, - "options": { - "type": "array", - "description": "Present only when the field actually has options \u2014 a free-text type returns no key at all rather than an empty list.", - "items": { + "TestRunWriteV2Response": { + "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape — the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", + "schema": { "type": "object", "properties": { - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - } + "success": { + "type": "boolean", + "example": true + }, + "testrun": { + "type": "object", + "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", + "properties": { + "identifier": { + "type": "string", + "example": "TR-9062", + "description": "The run's identity on v2. Unconditional." + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." + }, + "assignee": { + "type": "string", + "nullable": true, + "example": "qa@acme.com", + "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." + }, + "run_state": { + "type": "string", + "enum": [ + "new_run", + "in_progress", + "under_review", + "rejected", + "done", + "closed" + ], + "description": "The stored value 'new' is rewritten to 'new_run' on the way out." + }, + "active_state": { + "type": "string", + "enum": [ + "active", + "closed" + ], + "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "configurations": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Configuration ids only — an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has — the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." + }, + "filter_test_cases": { + "type": "object", + "description": "Unconditional. The only place is_dynamic is exposed on v2.", + "properties": { + "is_dynamic": { + "type": "boolean" + } + } + }, + "overall_progress": { + "type": "object", + "description": "KEYED BY STATUS — a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." + }, + "test_plan": { + "type": "object", + "description": "CONDITIONAL — present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", + "properties": { + "id": { + "type": "string", + "example": "TP-1616", + "description": "The linked plan's TP-NNN identifier, NOT a numeric id — the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." + }, + "identifier": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "issues": { + "type": "array", + "description": "CONDITIONAL — the key is absent, not empty, when the run payload carries no issues association.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." + }, + "issue_id": { + "type": "string" + }, + "jira_id": { + "type": "string", + "description": "Duplicate of `issue_id` — the same value under a tracker-specific name. Prefer `issue_id`." + }, + "issue_type": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's — use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." + } + }, + "issue_tracker": { + "type": "object", + "description": "CONDITIONAL — derived from the first linked issue that names a tracker; absent when none does.", + "properties": { + "name": { + "type": "string" + } + } + }, + "project_id": { + "type": "string", + "example": "PR-2005", + "description": "The project's PR-NNN identifier, not an integer id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "CONDITIONAL — omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." + }, + "links": { + "type": "object", + "description": "Relative API paths. There is no detail link on v2.", + "properties": { + "self": { + "type": "string" + }, + "test_cases": { + "type": "string" + } + } + }, + "urls": { + "type": "object", + "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", + "properties": { + "self": { + "type": "string" + } + } + } + } + } } - } - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Echo of the request flag, defaulting to false \u2014 not a statement about the field's stored project links." - }, - "assigned_projects": { - "type": "array", - "description": "Present only when the field is linked to specific projects. A field that applies to all projects, or one created without any, returns no key rather than an empty list.", - "items": { - "type": "string", - "example": "PR-12345" - } - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Echo of the request flag, defaulting to false." - }, - "system_name": { - "type": "string", - "description": "The stable internal name for a system-provided field. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "placeholder": { - "type": "string", - "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "group_id": { - "type": "integer", - "description": "Workspace the definition belongs to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "linked_projects_count": { - "type": "integer", - "description": "How many projects the field is linked to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." } - } - } - }, - "required": [ - "success", - "custom_field" - ] - } - }, - "CustomFieldV2WithDatasetsResponse": { - "description": "A single custom field read by id: stored metadata plus references to the datasets that hold its option values. Carries no option values and no real project configuration.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true }, - "custom_field": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 4821 - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ] - }, - "field_name": { - "type": "string" - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored default; \"1\" / \"0\" on a boolean field." - }, - "place_holder_text": { - "type": "string", - "description": "Absent when the field has none." - }, - "applies_to_all_projects": { - "type": "boolean", - "description": "Always false on this read \u2014 a placeholder, not the field's real project scope." - }, - "link_to_future_projects": { - "type": "boolean", - "description": "Always false on this read \u2014 a placeholder, not the field's real future-project setting." - }, - "dataset_ids": { - "type": "array", - "description": "Ids of the datasets holding this field's option values and project links. Empty for a field type that has no options.", - "items": { - "type": "integer" - } - }, - "datasets_url": { - "type": "string", - "description": "Ready-made link to this field's dataset collection." - }, - "dataset_urls": { - "type": "array", - "description": "Ready-made link per dataset, in the same order as dataset_ids.", - "items": { - "type": "string" - } - }, - "system_name": { - "type": "string", - "description": "The stable internal name for a system-provided field. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "Whether the field can be set through a bulk edit. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "is_filterable": { - "type": "boolean", - "description": "Whether the field can be used as a filter. Included only when TRUE. The serializer tests presence, and `false` does not pass that test, so an absent key means false OR unset and the two cannot be told apart here." - }, - "placeholder": { - "type": "string", - "description": "Placeholder text. The same value also appears as `place_holder_text`; this spelling is the one the field service uses. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "group_id": { - "type": "integer", - "description": "Workspace the definition belongs to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - }, - "linked_projects_count": { - "type": "integer", - "description": "How many projects the field is linked to. Included only when it has a value \u2014 the serializer omits it otherwise, so absence is not the same as a false or empty value." - } - } - } - }, - "required": [ - "success", - "custom_field" - ] - } - }, - "V2ReportListResponse": { - "description": "Paginated list of a project's report configurations, plus group/project totals for the filtered set.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "reports": { - "type": "array", - "items": { - "$schema": "V2ReportDetail" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata: page, next, prev, count, page_size.", - "properties": { - "page": { - "type": "integer" - }, - "next": { - "type": "integer", - "nullable": true - }, - "prev": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer", - "description": "Total matching rows, not rows on this page." - }, - "page_size": { - "type": "integer" + "CustomFieldAdminWriteResponse": { + "description": "A custom field definition after a create or edit on the workspace settings surface. The field is echoed under `data` exactly as storage returned it — no options, no datasets and no project links, so confirm those with a separate read.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "description": "The stored field. Take `id` — it is what every other custom-field call puts in its path.", + "properties": { + "id": { + "type": "integer", + "example": 4821 + }, + "field_name": { + "type": "string", + "description": "Stored label, already whitespace-trimmed." + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ], + "description": "Canonical spelling, not the legacy field_* form the project form-field reads return." + }, + "entity_type": { + "type": "string", + "example": "TestCase" + }, + "is_required": { + "type": "boolean" + }, + "default_value": { + "type": "string", + "nullable": true, + "description": "Stored as \"1\" / \"0\" on a boolean field; null on every other field type, because this surface forces it to null for non-boolean types." + }, + "place_holder_text": { + "type": "string", + "nullable": true + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "is_bulk_editable": { + "type": "boolean", + "example": true + }, + "is_filterable": { + "type": "boolean", + "example": true + }, + "parent_custom_field_id": { + "type": "string", + "nullable": true + }, + "system_name": { + "type": "string", + "example": "__mcp-probe-cfdef-20260921-renamed" + } + } + } + } } - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "Scheduled reports within the filtered set, across all pages." }, - "total_report_count": { - "type": "integer", - "description": "All reports within the filtered set, across all pages." - } - } - } - }, - "V2ReportDetailResponse": { - "description": "A single report configuration, returned by the v2 report create (201) and single-report read (200). Carries no report data.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$schema": "V2ReportDetail" - } - } - } - }, - "AttachmentListV2Response": { - "description": "A page of attachments on one test case, test result or test plan.", - "schema": { - "type": "object", - "required": [ - "success", - "attachments" - ], - "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "type": "array", - "items": { - "$schema": "AttachmentListItem" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "TestResultSearchResponse": { - "description": "Account-wide test result search page. Always HTTP 200 with success:true \u2014 an upstream search failure is swallowed and shows up as an empty test_results array with info null, not as an error status.", - "schema": { - "type": "object", - "required": [ - "success", - "test_results", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true, - "description": "Always true on this endpoint, including when the search itself failed. Do not use it as a success signal." - }, - "test_results": { - "type": "array", - "description": "Matching results, at most 30 per page (10 if per_page was sent at all). Empty both when nothing matched and when the search errored.", - "items": { - "$schema": "TestResultSearchItem" - } - }, - "info": { - "$schema": "PageInfo", - "nullable": true, - "description": "Paging metadata from the search service. NULL when the search failed \u2014 that, not the status code, is how you detect the failure." - } - } - } - }, - "CommentsListResponse": { - "description": "A page of comments on one run/case cell, newest first, fixed 30 per page. info.count is the total across pages; a page past the end repeats the last page rather than returning an empty array.", - "schema": { - "type": "object", - "required": [ - "comments", - "success", - "info" - ], - "properties": { - "comments": { - "type": "array", - "items": { - "$schema": "Comment" - } - }, - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "CommentResponse": { - "description": "The comment just posted, wrapped as data.comment. HTTP 200, not 201. Both inner keys are always present - the handler emits them as one literal with no branches.", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "success", - "comment" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "comment": { - "$schema": "Comment" - } - } - } - } - } - }, - "TestRunConfigurationProgressResponse": { - "description": "Execution progress of a test run by result status. TWO shapes: when configuration_id was sent the body is {success, overall_progress_by_status_id} and `configurations`/`info` are absent; when it was not, the body is {success, configurations:[...], info} and the top-level `overall_progress_by_status_id` is absent. `info` is the backing service's paging block passed through unchanged (its exact fields are not fixed by this service).", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "overall_progress_by_status_id": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "configurations": { - "type": "array", - "description": "Only in the all-configurations form. One page of the run's configurations, each with its progress attached.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 101 - }, - "name": { - "type": "string", - "example": "Chrome on Windows" - }, - "overall_progress_by_status_id": { + "CustomFieldAdminDetailResponse": { + "description": "One custom field definition read by id, with its datasets and their linked projects. Carries no option values and no entity_type.", + "schema": { "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - } + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the datasets list; page size is fixed at 30." + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "field_name": { + "type": "string" + }, + "field_type": { + "type": "string", + "enum": [ + "string", + "text", + "user", + "dropdown", + "multi_dropdown", + "url", + "boolean", + "int", + "date", + "nested_dropdown" + ] + }, + "system_name": { + "type": "string", + "nullable": true, + "description": "Internal name; null for user-defined fields." + }, + "is_required": { + "type": "boolean" + }, + "place_holder_text": { + "type": "string", + "nullable": true, + "description": "Read from the upstream `placeholder` attribute and renamed here." + }, + "is_bulk_editable": { + "type": "boolean" + }, + "is_filterable": { + "type": "boolean" + }, + "default_value": { + "description": "Converted to a real boolean when field_type is `boolean`; returned as stored otherwise." + }, + "type": { + "type": "string", + "example": "Custom", + "description": "Always the literal \"Custom\" on this read." + }, + "levels": { + "type": "array", + "description": "Present only for a nested_dropdown field.", + "items": { + "type": "object" + } + }, + "datasets": { + "type": "array", + "description": "Empty array when the field has none. Options are NOT included — read them from the dataset options list.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Dataset id — what the dataset, option and project-mapping calls take in their paths." + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer" + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Derived per request by comparing linked_project_count against the workspace project count; not a stored flag." + }, + "linked_projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "identifier": { + "type": "string", + "example": "PR-1" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + } + } } - } }, - "info": { - "type": "object", - "nullable": true, - "description": "Only in the all-configurations form. Paging metadata from the backing service, passed through as-is." - } - } - } - }, - "BinnedTestCasesListResponse": { - "description": "A page of binned test cases. Paging lives under `metadata` (total/page/page_size/total_pages), not the usual `info` envelope. Field names follow the backing service's client model; confirm against a live call if a field is missing.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "BinnedTestCase" - } - }, - "metadata": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_pages": { - "type": "integer" + "WorkspaceFieldsListResponse": { + "description": "A page of the workspace's fields, system and custom together. Page size is fixed at 10; system fields are injected on page 1 only.", + "schema": { + "type": "object", + "required": [ + "success", + "data", + "info" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "type": "object", + "description": "Paging metadata. On this endpoint it is {count, page, page_size, total_pages} — there is no prev/next.", + "properties": { + "count": { + "type": "integer", + "description": "Total matching fields; includes the matched system fields when field_source is `all`." + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + }, + "next": { + "type": "integer", + "example": 2 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric field id — absent on the injected system-field rows, which are identified by system_name instead." + }, + "field_name": { + "type": "string" + }, + "field_type": { + "type": "string", + "description": "Canonical spelling. Injected system-field rows always report `dropdown`." + }, + "entity_type": { + "type": "string" + }, + "system_name": { + "type": "string", + "nullable": true, + "description": "Internal name, e.g. priority / case_type / status / automation_state on system rows." + }, + "place_holder_text": { + "type": "string", + "nullable": true + }, + "linked_projects_count": { + "type": "integer" + }, + "dataset_count": { + "type": "integer", + "description": "Number of datasets behind this field; 0 on system rows." + }, + "is_system_field": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "System", + "Custom" + ] + }, + "field_values": { + "type": "array", + "description": "Populated for system rows on page 1; always an empty array for custom fields on this listing.", + "items": { + "type": "object" + } + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Derived by comparing linked_projects_count against the workspace project count; always true on system rows." + } + } + } + } + } } - } - } - } - } - }, - "TestCaseReviewOutcomeResponse": { - "description": "Outcome of a bulk review submission or verdict. Under 100 cases: the updated cases inline (testCases) \u2014 verdicts with a comment also carry `comment`. Over 100: {success, async:true, unique_id} and NO testCases; the change lands in the background.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testCases": { - "type": "array", - "items": { - "$schema": "TestCase" - }, - "description": "Updated cases (serialized test case, including review_status and reviewers). Absent on the async path." - }, - "comment": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Verdict only, when a comment was sent: the created comment objects {id, comment, review_status, user{id, full_name, email}, created_at ...}." - }, - "async": { - "type": "boolean", - "description": "true when the selection exceeded 100 and was queued." - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Identifier of the queued background operation (async path only)." - } - } - } - }, - "ReauthRequired": { - "description": "Part 11 e-signature required: the reauth_token was missing, expired (5 min) or already used. Re-authenticate and resend with a fresh token.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "error_code": { - "type": "string", - "example": "reauth_required" - } - } - } - }, - "LcncCreateWrapperResponse": { - "description": "Wrapper around the automation service's reply. `status` is the upstream HTTP code and `body` is the upstream body as a JSON-encoded STRING to parse (except the 'already in progress' short-circuit, where body is an object {message, title} and status is 'ok'). HTTP 200 outside does not mean success inside \u2014 inspect status and body.", - "schema": { - "type": "object", - "required": [ - "status", - "body" - ], - "properties": { - "status": { - "type": "integer", - "example": 200, - "description": "Upstream HTTP status (an integer), or the string 'ok' on the already-in-progress short-circuit." - }, - "body": { - "type": "string", - "description": "Upstream body as a JSON string; typically {status:'submitted'|'failed', message, test_id?, test_version_id?, reason?} on the bulk-pipeline path." - } - } - } - }, - "BddExportValidationResponse": { - "description": "Verdict of the BDD export pre-check; the same shape is returned on pass (200, success:true) and on every failure (400, success:false) \u2014 read message.", - "schema": { - "type": "object", - "required": [ - "success", - "message" - ], - "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string", - "example": "Validation successful. All test cases belong to the same feature and background." - } - } - } - }, - "TestRunV2Response": { - "description": "The test run at the top level under `test_run`, with no `data` envelope \u2014 the v1 run reads wrap theirs in `data`, these do not. The run object is the NARROW v2 projection: it carries `identifier` (TR-NNN) and no integer id, and omits the v1-only fields (`owner`, `is_automation`, `is_dynamic`, `test_cases_count`, `overall_progress_by_status_id`, `environment`). Do not read it against the wide run shape the v1 responses use.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true }, - "test_run": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" + "CustomFieldAdminDatasetResponse": { + "description": "One dataset of a custom field — its project links and, when the read asked for them, its option values. There is no paging metadata even though the linked-projects list is paged.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Dataset id — what the option and project-mapping calls take in their paths." + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer", + "description": "Total linked projects, not the number in this page of `linked_projects`." + }, + "linked_to_all_projects": { + "type": "boolean", + "description": "Added by this layer by comparing linked_project_count against the workspace project count. Absent when linked_project_count is not set." + }, + "linked_projects": { + "type": "array", + "description": "One page of the linked projects (30 per page, selected by `p`).", + "items": { + "type": "object" + } + }, + "options": { + "type": "array", + "description": "Present only when the request asked for options (fetch_options), and always present on the project-mapping update response. Absent otherwise — which is indistinguishable from a dataset with no options.", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "example": "2026-09-11T06:50:12.000Z" + }, + "custom_field_id": { + "type": "integer", + "example": 105915 + }, + "datasetProjects": { + "type": "object" + }, + "future_projects_applicable": { + "type": "integer", + "example": 0 + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + } } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" + } + }, + "CustomFieldDatasetProjectMappingUpdateResponse": { + "description": "Result of relinking a dataset's projects. Two different shapes: synchronously the re-read dataset under `data`; above 300 affected projects a bare async acknowledgement with no dataset at all.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "description": "Present and true only when the change was handed to a background job (more than 300 projects affected). When it is true there is no `data`, and the path ids were never validated." + }, + "unique_id": { + "type": "string", + "description": "Job handle for the async case only. There is no endpoint on this surface to poll it — verify by re-reading the dataset." + }, + "data": { + "type": "object", + "description": "The dataset re-read after the change, with its options included. Absent on the async path.", + "properties": { + "id": { + "type": "integer" + }, + "link_to_future_projects": { + "type": "boolean" + }, + "linked_project_count": { + "type": "integer" + }, + "linked_to_all_projects": { + "type": "boolean" + }, + "linked_projects": { + "type": "array", + "items": { + "type": "object" + } + }, + "options": { + "type": "array", + "items": { + "type": "object" + } + }, + "created_at": { + "type": "string", + "example": "2026-09-21T13:34:12.000Z" + }, + "custom_field_id": { + "type": "integer", + "example": 106131 + }, + "datasetProjects": { + "type": "object" + }, + "future_projects_applicable": { + "type": "integer", + "example": 1 + }, + "group_id": { + "type": "integer", + "example": 3452 + }, + "record_status": { + "type": "string", + "example": "active" + } + } + } } - } - }, - "issues": { - "type": "array", - "description": "CONDITIONAL \u2014 the key is absent, not empty, when the run payload carries no issues association.", - "items": { + } + }, + "CustomFieldAdminDatasetOptionsResponse": { + "description": "A page of a dataset's option values. When any option has a parent the list is returned as a tree and children move under their parent's `children` key rather than appearing at the top level.", + "schema": { "type": "object", + "required": [ + "success", + "data", + "info" + ], "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string" - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name. Prefer `issue_id`." - }, - "issue_type": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's \u2014 use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" - } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" - } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" + "success": { + "type": "boolean", + "example": true + }, + "info": { + "$schema": "PageInfo", + "description": "Paging over the options; page size is fixed at 30." + }, + "data": { + "type": "array", + "description": "Empty array when the dataset has no options.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Option id — what the option update and delete calls take in their paths." + }, + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "colour": { + "type": "string", + "nullable": true + }, + "parent_option_id": { + "type": "integer", + "nullable": true, + "description": "Set only on nested_dropdown children." + }, + "children": { + "type": "array", + "description": "Present only when the page was rebuilt as a tree and this option has children.", + "items": { + "type": "object" + } + } + } + } + } } - } } - } - } - } - } - }, - "TestRunWriteV2Response": { - "description": "The updated run under `testrun` (no underscore) at the top level, alongside `success`. No `data` envelope. The run object is the narrow v2 projection built by the v2 run serializer and is deliberately NOT the shared v1 TestRun shape \u2014 the previous `$schema: TestRun` reference was the source of 15 fields this endpoint never returns. Five of the properties below are conditional and marked as such. Consumed by replace_test_run and update_test_run only.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "testrun": { - "type": "object", - "description": "The narrow v2 run projection. It is NOT the v1 run shape: there is no integer id, no uuid, no type, no owner, no environment, no test_cases_count, no is_automation, no observability_url, no metadata, no assignee_imported, no self_ui_link, no top-level is_dynamic, no test_plans array and no overall_progress_by_status_id. The run's identity here is the TR-NNN string in identifier.", - "properties": { - "identifier": { - "type": "string", - "example": "TR-9062", - "description": "The run's identity on v2. Unconditional." - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Stored HTML, unescaped and with rich-text links re-inserted before it is returned, so the echo can differ from what was sent." - }, - "assignee": { - "type": "string", - "nullable": true, - "example": "qa@acme.com", - "description": "The run owner's email address as a plain string, or null when the owner cannot be resolved. Not a user object." - }, - "run_state": { - "type": "string", - "enum": [ - "new_run", - "in_progress", - "under_review", - "rejected", - "done", - "closed" - ], - "description": "The stored value 'new' is rewritten to 'new_run' on the way out." - }, - "active_state": { - "type": "string", - "enum": [ - "active", - "closed" - ], - "description": "Derived: 'closed' when run_state is closed, otherwise 'active'." - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "configurations": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Configuration ids only \u2014 an array of integers, not of objects. Comes back empty on the older run backend regardless of what the run actually has \u2014 the read there fails silently and falls back to an empty list. A non-empty value is reliable; an empty one is not evidence the run has no configurations. Read the run's cases if you need to know for certain." - }, - "filter_test_cases": { - "type": "object", - "description": "Unconditional. The only place is_dynamic is exposed on v2.", - "properties": { - "is_dynamic": { - "type": "boolean" - } - } - }, - "overall_progress": { - "type": "object", - "description": "KEYED BY STATUS \u2014 a count per status. The v1 LIST rows use display names (`Passed`, `Untested`); the v1 DETAIL row uses lowercase internal names (`passed`, `untested`, `in_progress`, `retest`, `blocked`, `skipped`, `failed`). The product owns these keys, so neither set is enumerated." - }, - "test_plan": { - "type": "object", - "description": "CONDITIONAL \u2014 present only when the run is linked to a plan. Singular; there is no test_plans array on v2.", - "properties": { - "id": { - "type": "string", - "example": "TP-1616", - "description": "The linked plan's TP-NNN identifier, NOT a numeric id \u2014 the serializer overwrites this key with the identifier string. Carry it as-is; converting it to a number will not resolve." - }, - "identifier": { - "type": "string" - }, - "name": { - "type": "string" + }, + "CustomFieldAdminOptionResponse": { + "description": "One option value after a create or rename. Always the five serialised keys — keys the request did not set come back null rather than absent.", + "schema": { + "type": "object", + "required": [ + "success", + "data" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Option id — carry this forward; the update and delete calls take it in their paths." + }, + "option_value": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "colour": { + "type": "string", + "nullable": true + }, + "parent_option_id": { + "type": "integer", + "nullable": true, + "description": "Null unless the option was created under a nested_dropdown parent. The update call cannot change it." + } + } + } } - } - }, - "issues": { - "type": "array", - "description": "CONDITIONAL \u2014 the key is absent, not empty, when the run payload carries no issues association.", - "items": { + } + }, + "CustomFieldAdminAckResponse": { + "description": "Bare success acknowledgement returned by the custom-field delete calls. Nothing about what was removed is echoed back.", + "schema": { "type": "object", + "required": [ + "success" + ], "properties": { - "id": { - "type": "integer", - "description": "The link record's own id, not the ticket's. To address the ticket use `issue_id`." - }, - "issue_id": { - "type": "string" - }, - "jira_id": { - "type": "string", - "description": "Duplicate of `issue_id` \u2014 the same value under a tracker-specific name. Prefer `issue_id`." - }, - "issue_type": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - }, - "description": "Each entry is built by dropping any key whose value is nil, so a field is ABSENT rather than null. `issue_type` is the one that commonly goes: a link created by sending a bare ticket id rather than an object never stores a tracker type, and that same nil is what removes the run's `issue_tracker` object. `id` is the LINK RECORD's own id, not the ticket's \u2014 use `issue_id` to address the ticket. `jira_id` duplicates `issue_id` under a tracker-specific name kept for history; it holds the id in whatever tracker the link uses, not only Jira." - } - }, - "issue_tracker": { - "type": "object", - "description": "CONDITIONAL \u2014 derived from the first linked issue that names a tracker; absent when none does.", - "properties": { - "name": { - "type": "string" + "success": { + "type": "boolean", + "example": true + } } - } - }, - "project_id": { - "type": "string", - "example": "PR-2005", - "description": "The project's PR-NNN identifier, not an integer id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "CONDITIONAL \u2014 omitted when the upstream reply carries no timestamp. On a write reply this value is echoed rather than re-read and has been observed not matching the stored run." - }, - "links": { - "type": "object", - "description": "Relative API paths. There is no detail link on v2.", - "properties": { - "self": { - "type": "string" - }, - "test_cases": { - "type": "string" + } + }, + "ProjectFormFieldsV2Response": { + "description": "One project's test-case form: its custom fields, the pick lists behind the standard dropdowns, and the built-in system fields. Three top-level lists, no data envelope and no paging.", + "schema": { + "type": "object", + "required": [ + "success", + "custom_fields", + "default_fields", + "system_fields" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "custom_fields": { + "type": "array", + "description": "Every TestCase custom field this project shows, unpaginated. Same row shape as the test-result custom fields listing; rows on a nested_dropdown field additionally carry parent_custom_field_id. `field_type` is the legacy field_* spelling. Empty when the path carried no project.", + "items": { + "$schema": "ResultCustomField" + } + }, + "default_fields": { + "type": "object", + "description": "Pick lists for the standard test-case dropdowns, keyed by field name (priority, case_type, status, …), each {values, total_count}. `automation_status` is assembled in this layer rather than read from storage." + }, + "system_fields": { + "type": "array", + "description": "Built-in fields available on the test-case template, with id, field_name, field_type, system_name and whether they can be removed from a template.", + "items": { + "type": "object" + } + } } - } - }, - "urls": { - "type": "object", - "description": "Merged in by a shared helper. self is an absolute web-UI link built from the frontend base address, not an API path.", - "properties": { - "self": { - "type": "string" + } + }, + "CustomFieldValuesResponse": { + "description": "The values one custom field offers in one project. The payload sits under a DYNAMIC key — the field id exactly as it was spelled in the request path — so there is no fixed property name to read.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "info": { + "description": "Paging metadata from the backing service. Null when the project id in the path was not numeric.", + "nullable": true, + "properties": { + "count": { + "type": "integer", + "example": 0 + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "prev": { + "type": "string", + "nullable": true + } + } + }, + "": { + "type": "object", + "description": "Dynamic key: the field id as sent in the path, e.g. \"4821\". Not a literal property name.", + "properties": { + "field_type": { + "type": "string", + "nullable": true, + "enum": [ + "field_string", + "field_text", + "field_int", + "field_date", + "field_url", + "field_user", + "field_boolean", + "field_checkbox", + "field_dropdown", + "field_multi_dropdown" + ], + "description": "LEGACY spelling, not the canonical vocabulary the admin write calls take. Null for a nested_dropdown field, which has no legacy spelling, and null when the project id was not numeric." + }, + "values": { + "type": "array", + "nullable": true, + "description": "The allowed values. Converted to real booleans for a boolean field. Null when the project id in the path was not numeric.", + "items": { + "type": "object" + } + } + } + }, + "105915": { + "type": "object" + } } - } } - } - } - } - } - }, - "CustomFieldAdminWriteResponse": { - "description": "A custom field definition after a create or edit on the workspace settings surface. The field is echoed under `data` exactly as storage returned it \u2014 no options, no datasets and no project links, so confirm those with a separate read.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "description": "The stored field. Take `id` \u2014 it is what every other custom-field call puts in its path.", - "properties": { - "id": { - "type": "integer", - "example": 4821 - }, - "field_name": { - "type": "string", - "description": "Stored label, already whitespace-trimmed." - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ], - "description": "Canonical spelling, not the legacy field_* form the project form-field reads return." - }, - "entity_type": { - "type": "string", - "example": "TestCase" - }, - "is_required": { - "type": "boolean" - }, - "default_value": { - "type": "string", - "nullable": true, - "description": "Stored as \"1\" / \"0\" on a boolean field; null on every other field type, because this surface forces it to null for non-boolean types." - }, - "place_holder_text": { - "type": "string", - "nullable": true - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "is_bulk_editable": { - "type": "boolean", - "example": true - }, - "is_filterable": { - "type": "boolean", - "example": true - }, - "parent_custom_field_id": { - "type": "string", - "nullable": true - }, - "system_name": { - "type": "string", - "example": "__mcp-probe-cfdef-20260921-renamed" + }, + "ExploratorySessionListV1Response": { + "description": "Paged list of exploratory sessions. Top level, no data envelope.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "exploratory_sessions": { + "type": "array", + "items": { + "$schema": "ExploratorySessionListRow" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "count": { + "type": "integer", + "description": "Total rows matching the query — this, not the top-level `count`, is the number to paginate against.", + "example": 150 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } + } + }, + "count": { + "type": "object", + "nullable": true, + "description": "Per-state session totals for the project — NOT the number of rows returned or matched.", + "properties": { + "active": { + "type": "integer", + "example": 12 + }, + "closed": { + "type": "integer", + "example": 138 + } + } + } + } } - } - } - } - } - }, - "CustomFieldAdminDetailResponse": { - "description": "One custom field definition read by id, with its datasets and their linked projects. Carries no option values and no entity_type.", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the datasets list; page size is fixed at 30." - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "field_name": { - "type": "string" - }, - "field_type": { - "type": "string", - "enum": [ - "string", - "text", - "user", - "dropdown", - "multi_dropdown", - "url", - "boolean", - "int", - "date", - "nested_dropdown" - ] - }, - "system_name": { - "type": "string", - "nullable": true, - "description": "Internal name; null for user-defined fields." - }, - "is_required": { - "type": "boolean" - }, - "place_holder_text": { - "type": "string", - "nullable": true, - "description": "Read from the upstream `placeholder` attribute and renamed here." - }, - "is_bulk_editable": { - "type": "boolean" - }, - "is_filterable": { - "type": "boolean" - }, - "default_value": { - "description": "Converted to a real boolean when field_type is `boolean`; returned as stored otherwise." - }, - "type": { - "type": "string", - "example": "Custom", - "description": "Always the literal \"Custom\" on this read." - }, - "levels": { - "type": "array", - "description": "Present only for a nested_dropdown field.", - "items": { - "type": "object" - } - }, - "datasets": { - "type": "array", - "description": "Empty array when the field has none. Options are NOT included \u2014 read them from the dataset options list.", - "items": { + }, + "ExploratorySessionLogListV1Response": { + "description": "Paged list of log entries for one exploratory session. Top level, no data envelope.", + "schema": { "type": "object", "properties": { - "id": { - "type": "integer", - "description": "Dataset id \u2014 what the dataset, option and project-mapping calls take in their paths." - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer" - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Derived per request by comparing linked_project_count against the workspace project count; not a stored flag." - }, - "linked_projects": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "identifier": { - "type": "string", - "example": "PR-1" - }, - "name": { - "type": "string" + "success": { + "type": "boolean", + "example": true + }, + "session_logs": { + "type": "array", + "items": { + "$schema": "ExploratorySessionLog" + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 50 + }, + "count": { + "type": "integer", + "example": 24 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } } - } + }, + "status_counts": { + "type": "object", + "description": "Intended as whole-session per-status log totals, unfiltered by q[status]. Currently always rendered as an empty object because the upstream response object does not expose the field — do not depend on it.", + "additionalProperties": { + "type": "integer" + }, + "example": {} } - } } - } } - } - } - } - } - }, - "WorkspaceFieldsListResponse": { - "description": "A page of the workspace's fields, system and custom together. Page size is fixed at 10; system fields are injected on page 1 only.", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "type": "object", - "description": "Paging metadata. On this endpoint it is {count, page, page_size, total_pages} \u2014 there is no prev/next.", - "properties": { - "count": { - "type": "integer", - "description": "Total matching fields; includes the matched system fields when field_source is `all`." - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_pages": { - "type": "integer" - }, - "next": { - "type": "integer", - "example": 2 - }, - "prev": { - "type": "string", - "nullable": true + }, + "ExploratorySessionDefectsV1Response": { + "description": "Paged list of issue links raised on an exploratory session and on its log entries. Top level, no data envelope.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "defects": { + "type": "array", + "description": "Defect links raised on this session's LOG ENTRIES. Session-level `issues` do NOT appear here.", + "items": { + "type": "object", + "properties": { + "issue_id": { + "type": "string", + "description": "Identifier in the external tracker.", + "example": "PROBE-2" + }, + "issue_type": { + "type": "string", + "description": "Tracker type.", + "example": "jira" + }, + "jira_id": { + "type": "string", + "nullable": true, + "description": "Repeat of issue_id for jira links." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "info": { + "type": "object", + "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", + "properties": { + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 30 + }, + "count": { + "type": "integer", + "example": 5 + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } + } + } + } } - } }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric field id \u2014 absent on the injected system-field rows, which are identified by system_name instead." - }, - "field_name": { - "type": "string" - }, - "field_type": { - "type": "string", - "description": "Canonical spelling. Injected system-field rows always report `dropdown`." - }, - "entity_type": { - "type": "string" - }, - "system_name": { - "type": "string", - "nullable": true, - "description": "Internal name, e.g. priority / case_type / status / automation_state on system rows." - }, - "place_holder_text": { - "type": "string", - "nullable": true - }, - "linked_projects_count": { - "type": "integer" - }, - "dataset_count": { - "type": "integer", - "description": "Number of datasets behind this field; 0 on system rows." - }, - "is_system_field": { - "type": "boolean" - }, - "type": { - "type": "string", - "enum": [ - "System", - "Custom" - ] - }, - "field_values": { - "type": "array", - "description": "Populated for system rows on page 1; always an empty array for custom fields on this listing.", - "items": { - "type": "object" + "ExploratorySessionCloneSyncResponse": { + "description": "Clone completed inline (source session had fewer than 50 logs). Status 201.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "example": false, + "description": "false on this branch — the clone and all its logs already exist." + }, + "unique_id": { + "type": "string", + "nullable": true, + "example": null, + "description": "Always null on the synchronous branch." + }, + "exploratory_session": { + "$schema": "ExploratorySession" + } } - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Derived by comparing linked_projects_count against the workspace project count; always true on system rows." - } } - } - } - } - } - }, - "CustomFieldAdminDatasetResponse": { - "description": "One dataset of a custom field \u2014 its project links and, when the read asked for them, its option values. There is no paging metadata even though the linked-projects list is paged.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Dataset id \u2014 what the option and project-mapping calls take in their paths." - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer", - "description": "Total linked projects, not the number in this page of `linked_projects`." - }, - "linked_to_all_projects": { - "type": "boolean", - "description": "Added by this layer by comparing linked_project_count against the workspace project count. Absent when linked_project_count is not set." - }, - "linked_projects": { - "type": "array", - "description": "One page of the linked projects (30 per page, selected by `p`).", - "items": { - "type": "object" - } - }, - "options": { - "type": "array", - "description": "Present only when the request asked for options (fetch_options), and always present on the project-mapping update response. Absent otherwise \u2014 which is indistinguishable from a dataset with no options.", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "example": "2026-09-11T06:50:12.000Z" - }, - "custom_field_id": { - "type": "integer", - "example": 105915 - }, - "datasetProjects": { - "type": "object" - }, - "future_projects_applicable": { - "type": "integer", - "example": 0 - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" + }, + "ExploratorySessionCloneAsyncResponse": { + "description": "Clone accepted and the log copy handed to a background job (source session had 50 or more logs). Status 202. The new session's id is NOT returned.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "example": true, + "description": "true on this branch — the session shell exists but its logs are still being copied." + }, + "unique_id": { + "type": "string", + "format": "uuid", + "description": "Realtime broadcast channel name for the completion event. It is not a job id and no endpoint reports the job's status — to confirm the clone, re-read the sessions listing.", + "example": "8f1c2b30-6d1a-4a1e-9c1e-2a7b3f0d5e44" + } + } } - } - } - } - } - }, - "CustomFieldDatasetProjectMappingUpdateResponse": { - "description": "Result of relinking a dataset's projects. Two different shapes: synchronously the re-read dataset under `data`; above 300 affected projects a bare async acknowledgement with no dataset at all.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Present and true only when the change was handed to a background job (more than 300 projects affected). When it is true there is no `data`, and the path ids were never validated." - }, - "unique_id": { - "type": "string", - "description": "Job handle for the async case only. There is no endpoint on this surface to poll it \u2014 verify by re-reading the dataset." - }, - "data": { - "type": "object", - "description": "The dataset re-read after the change, with its options included. Absent on the async path.", - "properties": { - "id": { - "type": "integer" - }, - "link_to_future_projects": { - "type": "boolean" - }, - "linked_project_count": { - "type": "integer" - }, - "linked_to_all_projects": { - "type": "boolean" - }, - "linked_projects": { - "type": "array", - "items": { - "type": "object" - } - }, - "options": { - "type": "array", - "items": { - "type": "object" - } - }, - "created_at": { - "type": "string", - "example": "2026-09-21T13:34:12.000Z" - }, - "custom_field_id": { - "type": "integer", - "example": 106131 - }, - "datasetProjects": { - "type": "object" - }, - "future_projects_applicable": { - "type": "integer", - "example": 1 - }, - "group_id": { - "type": "integer", - "example": 3452 - }, - "record_status": { - "type": "string", - "example": "active" + }, + "SharedStepDeleteResponse": { + "description": "Shared step deleted. Status is 200 with this body — NOT 204 with an empty body.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + } + } } - } - } - } - } - }, - "CustomFieldAdminDatasetOptionsResponse": { - "description": "A page of a dataset's option values. When any option has a parent the list is returned as a tree and children move under their parent's `children` key rather than appearing at the top level.", - "schema": { - "type": "object", - "required": [ - "success", - "data", - "info" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "$schema": "PageInfo", - "description": "Paging over the options; page size is fixed at 30." - }, - "data": { - "type": "array", - "description": "Empty array when the dataset has no options.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Option id \u2014 what the option update and delete calls take in their paths." - }, - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - }, - "colour": { - "type": "string", - "nullable": true - }, - "parent_option_id": { - "type": "integer", - "nullable": true, - "description": "Set only on nested_dropdown children." - }, - "children": { + }, + "TestCaseTagListResponse": { + "description": "The tags on one test case, as a BARE JSON ARRAY at the top level — there is no object envelope, no `data` and no `success`. Items are tag name strings for projects served from the test-hub backend. For legacy Rails-backed projects the same action renders tag records, whose items are objects {id, name, taggings_count}; treat a non-string item as that shape.", + "schema": { "type": "array", - "description": "Present only when the page was rebuilt as a tree and this option has children.", "items": { - "type": "object" + "type": "string", + "example": "regression" } - } } - } - } - } - } - }, - "CustomFieldAdminOptionResponse": { - "description": "One option value after a create or rename. Always the five serialised keys \u2014 keys the request did not set come back null rather than absent.", - "schema": { - "type": "object", - "required": [ - "success", - "data" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Option id \u2014 carry this forward; the update and delete calls take it in their paths." - }, - "option_value": { - "type": "string" - }, - "is_default": { - "type": "boolean" - }, - "colour": { - "type": "string", - "nullable": true - }, - "parent_option_id": { - "type": "integer", - "nullable": true, - "description": "Null unless the option was created under a nested_dropdown parent. The update call cannot change it." + }, + "ConfigurationCreateResponse": { + "description": "Configuration created. The record is under `configuration` (not `data`), beside a `success` flag. It is the backing service's raw configuration object, not the serialized listing row, so the os/browser/device breakdown may be absent here even though the listing carries it.", + "schema": { + "type": "object", + "required": [ + "success", + "configuration" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "configuration": { + "$schema": "Configuration" + } + } } - } - } - } - } - }, - "CustomFieldAdminAckResponse": { - "description": "Bare success acknowledgement returned by the custom-field delete calls. Nothing about what was removed is echoed back.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "ProjectFormFieldsV2Response": { - "description": "One project's test-case form: its custom fields, the pick lists behind the standard dropdowns, and the built-in system fields. Three top-level lists, no data envelope and no paging.", - "schema": { - "type": "object", - "required": [ - "success", - "custom_fields", - "default_fields", - "system_fields" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "custom_fields": { - "type": "array", - "description": "Every TestCase custom field this project shows, unpaginated. Same row shape as the test-result custom fields listing; rows on a nested_dropdown field additionally carry parent_custom_field_id. `field_type` is the legacy field_* spelling. Empty when the path carried no project.", - "items": { - "$schema": "ResultCustomField" - } - }, - "default_fields": { - "type": "object", - "description": "Pick lists for the standard test-case dropdowns, keyed by field name (priority, case_type, status, \u2026), each {values, total_count}. `automation_status` is assembled in this layer rather than read from storage." - }, - "system_fields": { - "type": "array", - "description": "Built-in fields available on the test-case template, with id, field_name, field_type, system_name and whether they can be removed from a template.", - "items": { - "type": "object" - } - } - } - } - }, - "CustomFieldValuesResponse": { - "description": "The values one custom field offers in one project. The payload sits under a DYNAMIC key \u2014 the field id exactly as it was spelled in the request path \u2014 so there is no fixed property name to read.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "info": { - "description": "Paging metadata from the backing service. Null when the project id in the path was not numeric.", - "nullable": true, - "properties": { - "count": { - "type": "integer", - "example": 0 - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "prev": { - "type": "string", - "nullable": true - } - } - }, - "": { - "type": "object", - "description": "Dynamic key: the field id as sent in the path, e.g. \"4821\". Not a literal property name.", - "properties": { - "field_type": { - "type": "string", - "nullable": true, - "enum": [ - "field_string", - "field_text", - "field_int", - "field_date", - "field_url", - "field_user", - "field_boolean", - "field_checkbox", - "field_dropdown", - "field_multi_dropdown" - ], - "description": "LEGACY spelling, not the canonical vocabulary the admin write calls take. Null for a nested_dropdown field, which has no legacy spelling, and null when the project id was not numeric." - }, - "values": { - "type": "array", - "nullable": true, - "description": "The allowed values. Converted to real booleans for a boolean field. Null when the project id in the path was not numeric.", - "items": { - "type": "object" - } - } - } - }, - "105915": { - "type": "object" - } - } - } - }, - "ExploratorySessionListV1Response": { - "description": "Paged list of exploratory sessions. Top level, no data envelope.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "exploratory_sessions": { - "type": "array", - "items": { - "$schema": "ExploratorySessionListRow" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "count": { - "type": "integer", - "description": "Total rows matching the query \u2014 this, not the top-level `count`, is the number to paginate against.", - "example": 150 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - }, - "count": { - "type": "object", - "nullable": true, - "description": "Per-state session totals for the project \u2014 NOT the number of rows returned or matched.", - "properties": { - "active": { - "type": "integer", - "example": 12 - }, - "closed": { - "type": "integer", - "example": 138 + }, + "FolderWriteResponse": { + "description": "Single-folder write acknowledgement used by the v1 folder create and rename actions. The folder is nested under `data` — there is no top-level `folder` and no top-level `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "Folder" + } + } + } + } } - } - } - } - } - }, - "ExploratorySessionLogListV1Response": { - "description": "Paged list of log entries for one exploratory session. Top level, no data envelope.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "session_logs": { - "type": "array", - "items": { - "$schema": "ExploratorySessionLog" - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 50 - }, - "count": { - "type": "integer", - "example": 24 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true - } - } - }, - "status_counts": { - "type": "object", - "description": "Intended as whole-session per-status log totals, unfiltered by q[status]. Currently always rendered as an empty object because the upstream response object does not expose the field \u2014 do not depend on it.", - "additionalProperties": { - "type": "integer" - }, - "example": {} - } - } - } - }, - "ExploratorySessionDefectsV1Response": { - "description": "Paged list of issue links raised on an exploratory session and on its log entries. Top level, no data envelope.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "defects": { - "type": "array", - "description": "Defect links raised on this session's LOG ENTRIES. Session-level `issues` do NOT appear here.", - "items": { - "type": "object", - "properties": { - "issue_id": { - "type": "string", - "description": "Identifier in the external tracker.", - "example": "PROBE-2" - }, - "issue_type": { - "type": "string", - "description": "Tracker type.", - "example": "jira" - }, - "jira_id": { - "type": "string", - "nullable": true, - "description": "Repeat of issue_id for jira links." - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "info": { - "type": "object", - "description": "Pagination metadata. Falls back to {page:1, page_size:, count:0, prev:null, next:null} when upstream omits it.", - "properties": { - "page": { - "type": "integer", - "example": 1 - }, - "page_size": { - "type": "integer", - "example": 30 - }, - "count": { - "type": "integer", - "example": 5 - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true + }, + "UserWorkloadDrilldownResponse": { + "description": "Single-user workload breakdown, returned verbatim from the analytics service with no envelope and no success flag.", + "schema": { + "type": "object", + "properties": { + "tcs": { + "type": "array", + "description": "Test cases this user executed in the report's window, with per-case execution counts.", + "items": { + "type": "object" + } + }, + "trs": { + "type": "array", + "description": "Test runs this user participated in over the report's window.", + "items": { + "type": "object" + } + }, + "days": { + "type": "array", + "description": "Day-by-day activity timeline for this user over the report's window.", + "items": { + "type": "object" + } + } + } } - } - } - } - } - }, - "ExploratorySessionCloneSyncResponse": { - "description": "Clone completed inline (source session had fewer than 50 logs). Status 201.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "example": false, - "description": "false on this branch \u2014 the clone and all its logs already exist." - }, - "unique_id": { - "type": "string", - "nullable": true, - "example": null, - "description": "Always null on the synchronous branch." }, - "exploratory_session": { - "$schema": "ExploratorySession" - } - } - } - }, - "ExploratorySessionCloneAsyncResponse": { - "description": "Clone accepted and the log copy handed to a background job (source session had 50 or more logs). Status 202. The new session's id is NOT returned.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "example": true, - "description": "true on this branch \u2014 the session shell exists but its logs are still being copied." - }, - "unique_id": { - "type": "string", - "format": "uuid", - "description": "Realtime broadcast channel name for the completion event. It is not a job id and no endpoint reports the job's status \u2014 to confirm the clone, re-read the sessions listing.", - "example": "8f1c2b30-6d1a-4a1e-9c1e-2a7b3f0d5e44" - } - } - } - }, - "SharedStepDeleteResponse": { - "description": "Shared step deleted. Status is 200 with this body \u2014 NOT 204 with an empty body.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "TestCaseTagListResponse": { - "description": "The tags on one test case, as a BARE JSON ARRAY at the top level \u2014 there is no object envelope, no `data` and no `success`. Items are tag name strings for projects served from the test-hub backend. For legacy Rails-backed projects the same action renders tag records, whose items are objects {id, name, taggings_count}; treat a non-string item as that shape.", - "schema": { - "type": "array", - "items": { - "type": "string", - "example": "regression" - } - } - }, - "ConfigurationCreateResponse": { - "description": "Configuration created. The record is under `configuration` (not `data`), beside a `success` flag. It is the backing service's raw configuration object, not the serialized listing row, so the os/browser/device breakdown may be absent here even though the listing carries it.", - "schema": { - "type": "object", - "required": [ - "success", - "configuration" - ], - "properties": { - "success": { - "type": "boolean", - "example": true + "TestCaseAttachmentDeleteResponse": { + "description": "Acknowledgement for a v1 test-case attachment delete. The payload is wrapped in `data` — unlike the attachment list and upload on the same path, which return their payload at the top level.", + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "description": "Envelope. Reading a top-level `attachments` finds nothing on this action.", + "properties": { + "success": { + "type": "boolean" + }, + "attachments": { + "description": "The attachment record that was removed. Its stored file is purged, not just unlinked.", + "$schema": "AttachmentResponse" + } + } + } + } + } }, - "configuration": { - "$schema": "Configuration" - } - } - } - }, - "FolderWriteResponse": { - "description": "Single-folder write acknowledgement used by the v1 folder create and rename actions. The folder is nested under `data` \u2014 there is no top-level `folder` and no top-level `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "folder": { - "$schema": "Folder" + "BulkRetrieveTestCasesResponse": { + "description": "Bulk un-archive acknowledgement: folder case counts after the restore, not the restored test cases. Above the async threshold the same 200 instead carries {success, async:true, unique_id}.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "description": "false with an `error` string and still HTTP 200 when the workspace is not on a paid plan." + }, + "folders": { + "type": "object", + "description": "Per-folder case counts after the restore." + }, + "error": { + "type": "string", + "description": "Present only on the plan-gated 200 refusal." + } + } } - } - } - } - } - }, - "UserWorkloadDrilldownResponse": { - "description": "Single-user workload breakdown, returned verbatim from the analytics service with no envelope and no success flag.", - "schema": { - "type": "object", - "properties": { - "tcs": { - "type": "array", - "description": "Test cases this user executed in the report's window, with per-case execution counts.", - "items": { - "type": "object" - } }, - "trs": { - "type": "array", - "description": "Test runs this user participated in over the report's window.", - "items": { - "type": "object" - } + "TestRunCasesBulkEditResponse": { + "description": "Synchronous bulk edit of a run's cases: the refreshed run listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no listing.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestRunCaseBulkEditRow" + }, + "description": "The run's per-case rows (mapping id + latest result), not bare test cases." + }, + "folder": { + "$schema": "Folder", + "description": "Only present when the request scoped a folder_id." + }, + "ancestors": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Only present on the folder-scoped response." + }, + "skipped_test_case_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Cases the edit could not be applied to." + } + } + } }, - "days": { - "type": "array", - "description": "Day-by-day activity timeline for this user over the report's window.", - "items": { - "type": "object" - } - } - } - } - }, - "TestCaseAttachmentDeleteResponse": { - "description": "Acknowledgement for a v1 test-case attachment delete. The payload is wrapped in `data` \u2014 unlike the attachment list and upload on the same path, which return their payload at the top level.", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "description": "Envelope. Reading a top-level `attachments` finds nothing on this action.", - "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "description": "The attachment record that was removed. Its stored file is purged, not just unlinked.", - "$schema": "AttachmentResponse" + "V1TestCaseCreateResponse": { + "description": "v1 single test-case create. Everything is inside `data`, including `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "TestCase", + "description": "The created case. `id` is the integer id, `identifier` the TC-NNN form, `estimate` always null." + }, + "folder": { + "$schema": "Folder", + "description": "The destination folder, with its case counts already incremented for this create." + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + } + } + }, + "path_folders": { + "type": "array", + "items": { + "$schema": "Folder" + }, + "description": "Root-to-folder breadcrumb for the new case." + }, + "request_trace_id": { + "type": "string", + "description": "Trace id for the AI enrichment that continues after the response; null when no enrichment was queued." + } + } + } + } } - } - } - } - } - }, - "BulkRetrieveTestCasesResponse": { - "description": "Bulk un-archive acknowledgement: folder case counts after the restore, not the restored test cases. Above the async threshold the same 200 instead carries {success, async:true, unique_id}.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "description": "false with an `error` string and still HTTP 200 when the workspace is not on a paid plan." - }, - "folders": { - "type": "object", - "description": "Per-folder case counts after the restore." - }, - "error": { - "type": "string", - "description": "Present only on the plan-gated 200 refusal." - } - } - } - }, - "TestRunCasesBulkEditResponse": { - "description": "Synchronous bulk edit of a run's cases: the refreshed run listing. Above the async threshold the same 200 instead carries {success, async:true, unique_id} and no listing.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestRunCaseBulkEditRow" - }, - "description": "The run's per-case rows (mapping id + latest result), not bare test cases." }, - "folder": { - "$schema": "Folder", - "description": "Only present when the request scoped a folder_id." - }, - "ancestors": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Only present on the folder-scoped response." - }, - "skipped_test_case_ids": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Cases the edit could not be applied to." - } - } - } - }, - "V1TestCaseCreateResponse": { - "description": "v1 single test-case create. Everything is inside `data`, including `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "TestCase", - "description": "The created case. `id` is the integer id, `identifier` the TC-NNN form, `estimate` always null." - }, - "folder": { - "$schema": "Folder", - "description": "The destination folder, with its case counts already incremented for this create." - }, - "project": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "identifier": { - "type": "string" + "V1TestCaseWriteResponse": { + "description": "v1 full test-case edit (POST .../edit). The updated case sits under `data`, alongside `success` — there is no top-level success key.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "TestCase__EditTestCase" + } + } + } } - } - }, - "path_folders": { - "type": "array", - "items": { - "$schema": "Folder" - }, - "description": "Root-to-folder breadcrumb for the new case." - }, - "request_trace_id": { - "type": "string", - "description": "Trace id for the AI enrichment that continues after the response; null when no enrichment was queued." } - } - } - } - } - }, - "V1TestCaseWriteResponse": { - "description": "v1 full test-case edit (POST .../edit). The updated case sits under `data`, alongside `success` \u2014 there is no top-level success key.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "TestCase__EditTestCase" + }, + "TestCaseWriteAckResponse": { + "description": "Bare acknowledgement — `{success: true}` and nothing else. The updated record is NOT returned; re-read the case to see the new values.", + "schema": { + "type": "object", + "required": [ + "success" + ], + "properties": { + "success": { + "type": "boolean", + "example": true + } + } } - } - } - } - } - }, - "TestCaseWriteAckResponse": { - "description": "Bare acknowledgement \u2014 `{success: true}` and nothing else. The updated record is NOT returned; re-read the case to see the new values.", - "schema": { - "type": "object", - "required": [ - "success" - ], - "properties": { - "success": { - "type": "boolean", - "example": true - } - } - } - }, - "V1TestCaseDetailResponse": { - "description": "v1 test-case detail (slide-over). Everything is inside `data`, including `success`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_case": { - "$schema": "V1TestCaseDetailRow", - "description": "The case's full slide-over record. `success` sits INSIDE `data` beside it." - }, - "is_archived": { - "type": "boolean" - }, - "linked_issues": { - "type": "array", - "items": { - "type": "object" - } - }, - "pagy": { - "type": "object", - "description": "Always an empty object on this path." + }, + "V1TestCaseDetailResponse": { + "description": "v1 test-case detail (slide-over). Everything is inside `data`, including `success`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_case": { + "$schema": "V1TestCaseDetailRow", + "description": "The case's full slide-over record. `success` sits INSIDE `data` beside it." + }, + "is_archived": { + "type": "boolean" + }, + "linked_issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "pagy": { + "type": "object", + "description": "Always an empty object on this path." + } + } + } + } } - } - } - } - } - }, - "ArchivedTestCasesListResponse": { - "description": "Archived test-case listing. Plan-gated: on a non-paid workspace the same 200 carries {success:false, error} and no test cases.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "TestCase" - }, - "description": "Archived cases, each with its original folder path." - }, - "info": { - "$schema": "PageInfo" - }, - "cases_count": { - "type": "object", - "description": "Single-entry map keyed by the requested folder id \u2014 null on this route, since it takes no folder." - }, - "error": { - "type": "string", - "description": "Present only on the plan-gated 200 refusal." - } - } - } - }, - "SystemFieldValuesResponse": { - "description": "Option list for one system field. The options hang off a key NAMED AFTER the field you requested (priority / status / case_type / automation_state / defects), so the path is .values.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "": { - "type": "object", - "description": "Key is the field_name from the path.", - "properties": { - "values": { - "type": "array", - "items": { + }, + "ArchivedTestCasesListResponse": { + "description": "Archived test-case listing. Plan-gated: on a non-paid workspace the same 200 carries {success:false, error} and no test cases.", + "schema": { "type": "object", "properties": { - "value": { - "type": "integer", - "description": "The numeric option id \u2014 this is what the write paths take." - }, - "name": { - "type": "string", - "description": "The project's display name for the option." - } + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "TestCase" + }, + "description": "Archived cases, each with its original folder path." + }, + "info": { + "$schema": "PageInfo" + }, + "cases_count": { + "type": "object", + "description": "Single-entry map keyed by the requested folder id — null on this route, since it takes no folder." + }, + "error": { + "type": "string", + "description": "Present only on the plan-gated 200 refusal." + } } - } } - } }, - "info": { - "$schema": "PageInfo" + "SystemFieldValuesResponse": { + "description": "Option list for one system field. The options hang off a key NAMED AFTER the field you requested (priority / status / case_type / automation_state / defects), so the path is .values.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "": { + "type": "object", + "description": "Key is the field_name from the path.", + "properties": { + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "description": "The numeric option id — this is what the write paths take." + }, + "name": { + "type": "string", + "description": "The project's display name for the option." + } + } + } + } + } + }, + "info": { + "$schema": "PageInfo" + }, + "status": { + "type": "object", + "properties": { + "values": { + "type": "object", + "properties": { + "colour": { + "type": "string", + "example": "active" + }, + "internal_name": { + "type": "string", + "example": "active" + }, + "is_default": { + "type": "integer", + "example": 1 + } + } + } + } + } + } + } }, - "status": { - "type": "object", - "properties": { - "values": { - "type": "object", - "properties": { - "colour": { - "type": "string", - "example": "active" - }, - "internal_name": { - "type": "string", - "example": "active" - }, - "is_default": { - "type": "integer", - "example": 1 + "TestCaseFolderListResponse": { + "description": "Folder-scoped test-case listing. `folders` appears only when the request asked for subfolders.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "test_cases": { + "type": "array", + "items": { + "$schema": "V1TestCaseFolderListRow" + } + }, + "info": { + "$schema": "PageInfo", + "description": "page_size is the server-clamped value, not necessarily the per_page you sent." + }, + "folders": { + "$schema": "TestCaseConsolidatedResultFolders" + } } - } } - } - } - } - } - }, - "TestCaseFolderListResponse": { - "description": "Folder-scoped test-case listing. `folders` appears only when the request asked for subfolders.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_cases": { - "type": "array", - "items": { - "$schema": "V1TestCaseFolderListRow" - } - }, - "info": { - "$schema": "PageInfo", - "description": "page_size is the server-clamped value, not necessarily the per_page you sent." - }, - "folders": { - "$schema": "TestCaseConsolidatedResultFolders" - } - } - } - }, - "LegacyTestCaseSearchResponse": { - "description": "BARE JSON ARRAY of rows from the deprecated local test-case table \u2014 no success, no info, no test_cases wrapper. Empty for any project whose data lives in the current backend.", - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Legacy table row: id, name, identifier, project_id, group_id, template, steps and the other legacy columns. Not the serialised TestCase the rest of this surface returns." - } - } - }, - "TestPlanCloneAcceptedResponse": { - "description": "A clone was QUEUED, not completed. The new plan does not exist yet and nothing identifying it is returned \u2014 no id, no identifier, no name echo. `unique_id` is a correlation id for the background job only; there is no job-status endpoint for it in this profile. Poll the project's plan listing for the name you sent to find the result.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "unique_id": { - "type": "string", - "description": "Correlation id of the clone job (32-character hex). Not a plan id.", - "example": "9f1c2ab34de5678901234567890abcde" - } - } - } - }, - "V1TestPlanListResponse": { - "description": "A page of v1 test plans. Rows use the same serializer as the single-plan read. `count` is a project-wide {active, completed} rollup, NOT a page total \u2014 page with `info`. `filter_details` appears only when filter_details=true was sent together with an owner or custom-field filter. In id-picker mode (filter_mapping=true) each row is reduced to {id, identifier} only.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Integer plan id \u2014 the value every v1 plan route takes in its path." - }, - "identifier": { - "type": "string", - "description": "TP-NNN (STP-NNN for a sub-plan). Display form; not accepted in a v1 path.", - "example": "TP-12" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "plan_status": { - "type": "string", - "description": "new / started / completed." - }, - "active_state": { - "type": "string", - "description": "'completed' when plan_status is completed, else 'active'." - }, - "record_status": { - "type": "string", - "description": "'archived' for an archived plan." - }, - "start_date": { - "type": "string" - }, - "end_date": { - "type": "string" - }, - "owner": { - "type": "integer", - "description": "Owner as a BrowserStack user id; `assignee` carries the resolved user object." - }, - "assignee": { - "type": "object" - }, - "created_by": { - "type": "object" - }, - "tags": { + }, + "LegacyTestCaseSearchResponse": { + "description": "BARE JSON ARRAY of rows from the deprecated local test-case table — no success, no info, no test_cases wrapper. Empty for any project whose data lives in the current backend.", + "schema": { "type": "array", "items": { - "type": "string" + "type": "object", + "description": "Legacy table row: id, name, identifier, project_id, group_id, template, steps and the other legacy columns. Not the serialised TestCase the rest of this surface returns." } - }, - "issues": { - "type": "array", - "items": { - "type": "object" + } + }, + "TestPlanCloneAcceptedResponse": { + "description": "A clone was QUEUED, not completed. The new plan does not exist yet and nothing identifying it is returned — no id, no identifier, no name echo. `unique_id` is a correlation id for the background job only; there is no job-status endpoint for it in this profile. Poll the project's plan listing for the name you sent to find the result.\n", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "unique_id": { + "type": "string", + "description": "Correlation id of the clone job (32-character hex). Not a plan id.", + "example": "9f1c2ab34de5678901234567890abcde" + } } - }, - "custom_fields": { - "type": "array", - "description": "Empty ARRAY (not an object) when the plan has no custom-field values." - }, - "parent_plan_id": { - "type": "integer", - "description": "Null on a top-level plan; set on a sub-plan." - }, - "sub_plans_count": { - "type": "integer" - }, - "test_runs_count": { + } + }, + "V1TestPlanListResponse": { + "description": "A page of v1 test plans. Rows use the same serializer as the single-plan read. `count` is a project-wide {active, completed} rollup, NOT a page total — page with `info`. `filter_details` appears only when filter_details=true was sent together with an owner or custom-field filter. In id-picker mode (filter_mapping=true) each row is reduced to {id, identifier} only.\n", + "schema": { "type": "object", - "description": "{active, closed} for runs linked directly to this plan." - }, - "total_test_runs_count": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Integer plan id — the value every v1 plan route takes in its path." + }, + "identifier": { + "type": "string", + "description": "TP-NNN (STP-NNN for a sub-plan). Display form; not accepted in a v1 path.", + "example": "TP-12" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "plan_status": { + "type": "string", + "description": "new / started / completed." + }, + "active_state": { + "type": "string", + "description": "'completed' when plan_status is completed, else 'active'." + }, + "record_status": { + "type": "string", + "description": "'archived' for an archived plan." + }, + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "owner": { + "type": "integer", + "description": "Owner as a BrowserStack user id; `assignee` carries the resolved user object." + }, + "assignee": { + "type": "object" + }, + "created_by": { + "type": "object" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "custom_fields": { + "type": "array", + "description": "Empty ARRAY (not an object) when the plan has no custom-field values." + }, + "parent_plan_id": { + "type": "integer", + "description": "Null on a top-level plan; set on a sub-plan." + }, + "sub_plans_count": { + "type": "integer" + }, + "test_runs_count": { + "type": "object", + "description": "{active, closed} for runs linked directly to this plan." + }, + "total_test_runs_count": { + "type": "object", + "description": "{active, closed} including the runs of its sub-plans." + }, + "test_plan_progress_v2": { + "type": "object", + "description": "Result rollup keyed by status id. test_plan_progress is always {} and carries nothing." + }, + "project_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "links": { + "type": "object" + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "review_status": { + "type": "string", + "example": "pending" + }, + "test_plan_progress": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + } + } + } + }, + "info": { + "$schema": "PageInfo" + }, + "count": { + "type": "object", + "description": "Project-wide plan rollup, not a page total.", + "properties": { + "active": { + "type": "integer", + "description": "Plans whose status is not completed." + }, + "completed": { + "type": "integer" + } + } + }, + "filter_details": { + "type": "object", + "description": "Present only when filter_details=true was sent. Resolves the owner and custom-field filters back into displayable values.", + "properties": { + "owner": { + "type": "array", + "items": { + "type": "object" + } + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "V1ArchivedTestPlanListResponse": { + "description": "A page of ARCHIVED v1 test plans. Rows are serialized exactly like the active listing (so record_status is 'archived'), but there is no `count` rollup on this response and archived sub-plans are never included.\n", + "schema": { "type": "object", - "description": "{active, closed} including the runs of its sub-plans." - }, - "test_plan_progress_v2": { + "properties": { + "success": { + "type": "boolean" + }, + "test_plans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "active_state": { + "type": "string" + }, + "assignee": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + } + } + }, + "attachments": { + "type": "string", + "nullable": true + }, + "completed_by": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "object", + "properties": { + "browserstack_user_id": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "onboarded": { + "type": "integer" + }, + "reports_and_notification_enabled": { + "type": "boolean" + } + } + }, + "custom_fields": { + "type": "array", + "items": { + "type": "object" + } + }, + "description": { + "type": "string" + }, + "end_date": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer" + }, + "identifier": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "object" + } + }, + "links": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "test_runs": { + "type": "string" + } + } + }, + "name": { + "type": "string" + }, + "owner": { + "type": "integer" + }, + "parent_plan_id": { + "type": "string", + "nullable": true + }, + "plan_status": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "record_status": { + "type": "string" + }, + "review_settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sub_plan_mode": { + "type": "string" + } + } + }, + "review_status": { + "type": "string" + }, + "start_date": { + "type": "string", + "nullable": true + }, + "sub_plans_count": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + } + }, + "test_plan_progress": { + "type": "object" + }, + "test_plan_progress_v2": { + "type": "object" + }, + "test_results_trend": { + "type": "string", + "nullable": true + }, + "test_results_trend_v2": { + "type": "string", + "nullable": true + }, + "test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "total_test_runs_count": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "closed": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "string" + } + } + } + }, + "info": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "next": { + "type": "string", + "nullable": true + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "prev": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "BulkAssignTestCasesResponse": { + "description": "Result of a bulk assign on a test run. TWO shapes, decided by the size of the selection.\nUnder the async threshold the write is applied inline and the body is the run's test-case\nlisting re-read afterwards: {success, info, test_cases} (plus `folder` when a folder_id was\nsent, and a top-level skipped_test_case_ids when the review gate excluded cases). At or above\nthe threshold the write is queued and the body is {success, async: true, unique_id} with NO\ntest_cases. Branch on `async`.\n", + "schema": { "type": "object", - "description": "Result rollup keyed by status id. test_plan_progress is always {} and carries nothing." - }, - "project_id": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "links": { - "type": "object" - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "review_status": { - "type": "string", - "example": "pending" - }, - "test_plan_progress": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - } - } - } - }, - "info": { - "$schema": "PageInfo" - }, - "count": { - "type": "object", - "description": "Project-wide plan rollup, not a page total.", - "properties": { - "active": { - "type": "integer", - "description": "Plans whose status is not completed." - }, - "completed": { - "type": "integer" - } - } - }, - "filter_details": { - "type": "object", - "description": "Present only when filter_details=true was sent. Resolves the owner and custom-field filters back into displayable values.", - "properties": { - "owner": { - "type": "array", - "items": { - "type": "object" - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "async": { + "type": "boolean", + "description": "Present and true only when the assignment was queued rather than applied inline." + }, + "unique_id": { + "type": "string", + "description": "Handle for the queued bulk operation. Only present on the async shape." + }, + "info": { + "$schema": "PageInfo" + }, + "test_cases": { + "type": "array", + "description": "One entry per case x configuration mapping, each carrying both `id` (test case) and `mapping_id` (the run mapping). Absent on the async shape.", + "items": { + "$schema": "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun" + } + }, + "folder": { + "type": "object", + "description": "Only present when a folder_id was sent; the folder's status breakdown.", + "nullable": true + }, + "skipped_test_case_ids": { + "type": "array", + "description": "Cases the review gate excluded. Top level, a sibling of test_cases.", + "items": { + "type": "integer" + } + } + } } - } - } - } - } - }, - "V1ArchivedTestPlanListResponse": { - "description": "A page of ARCHIVED v1 test plans. Rows are serialized exactly like the active listing (so record_status is 'archived'), but there is no `count` rollup on this response and archived sub-plans are never included.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "test_plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "active_state": { - "type": "string" - }, - "assignee": { + }, + "ConfigurationGroupListResponse": { + "description": "Successfully retrieved the account's configuration groups", + "schema": { "type": "object", "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "email": { - "type": "string" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - } - } - }, - "attachments": { - "type": "string", - "nullable": true - }, - "completed_by": { - "type": "string", - "nullable": true - }, - "created_at": { - "type": "string" - }, - "created_by": { + "success": { + "type": "boolean", + "example": true + }, + "configuration_groups": { + "type": "array", + "items": { + "$schema": "ConfigurationGroup" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "UsersListResponse": { + "description": "All active users in the caller's account plus a reduced record for the caller.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "users": { + "type": "array", + "description": "Every active user in the account, ordered by full_name; not filtered to the project.", + "items": { + "$schema": "User" + } + }, + "myself": { + "$schema": "MyselfUser" + } + } + } + }, + "CommentCreateResponse": { + "description": "The comment just posted, wrapped in a data envelope.", + "schema": { "type": "object", + "required": [ + "data" + ], "properties": { - "browserstack_user_id": { - "type": "integer" - }, - "full_name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "onboarded": { - "type": "integer" - }, - "reports_and_notification_enabled": { - "type": "boolean" - } - } - }, - "custom_fields": { - "type": "array", - "items": { - "type": "object" - } - }, - "description": { - "type": "string" - }, - "end_date": { - "type": "string", - "nullable": true - }, - "id": { - "type": "integer" - }, - "identifier": { - "type": "string" - }, - "issues": { - "type": "array", - "items": { - "type": "object" + "data": { + "type": "object", + "required": [ + "success", + "comment" + ], + "properties": { + "success": { + "type": "boolean" + }, + "comment": { + "$schema": "Comment" + } + } + } } - }, - "links": { + } + }, + "ExploratorySessionFormFieldsResponse": { + "description": "The extra fields configured for exploratory sessions in this project. The list of built-in fields is always empty on this path. Both lists can legitimately be empty on a valid project.", + "schema": { "type": "object", + "required": [ + "success", + "custom_fields", + "default_fields" + ], "properties": { - "self": { - "type": "string" - }, - "test_runs": { - "type": "string" - } - } - }, - "name": { - "type": "string" - }, - "owner": { - "type": "integer" - }, - "parent_plan_id": { - "type": "string", - "nullable": true - }, - "plan_status": { - "type": "string" - }, - "project_id": { - "type": "integer" - }, - "record_status": { - "type": "string" - }, - "review_settings": { + "success": { + "type": "boolean" + }, + "default_fields": { + "type": "array", + "description": "Always empty on this path - built-in session details are not described here. The key is always present.", + "items": { + "type": "object" + } + }, + "custom_fields": { + "type": "array", + "description": "One entry per configured field. Empty when the project has no extra session fields.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The field's identifier, used when referring to this field elsewhere." + }, + "field_name": { + "type": "string", + "description": "The field's internal name." + }, + "field_user_name": { + "type": "string", + "description": "The name your team sees in the product; match a person's plain-words request against this." + }, + "field_type": { + "type": "string", + "description": "The kind of input, given in the product's prefixed form - for example a single-line string, a text area, a date, a dropdown, a multi-dropdown, a checkbox, a boolean, a URL, an integer or a user picker.", + "example": "field_dropdown" + }, + "entity_type": { + "type": "string", + "description": "The record kind this field belongs to." + }, + "is_required": { + "type": "boolean", + "description": "True when a session cannot be saved without this field. Always present, defaulting to false." + }, + "optional": { + "type": "boolean", + "description": "The exact inverse of is_required; both are always returned." + }, + "option_values": { + "type": "array", + "description": "The choices a dropdown offers. Returned only when the request asked for the configured choices AND the field has any - and the choices variant of this request is currently answered with a not-found, so in practice this is not reachable today.", + "items": { + "type": "object" + } + }, + "field_values": { + "type": "array", + "description": "Current values recorded for the field. Same conditions and same practical caveat as option_values.", + "items": { + "type": "object" + } + }, + "default_value": { + "type": "string", + "description": "Pre-filled value, if the field has one. Always present and may be null. For a yes/no field this comes back as a true or false rather than as a number.", + "nullable": true + }, + "placeholder": { + "type": "string", + "description": "Hint text shown in the empty field. Omitted entirely when no hint text is configured." + }, + "is_bulk_editable": { + "type": "boolean", + "description": "True when the field can be set on many records at once." + }, + "is_filterable": { + "type": "boolean", + "description": "True when the field can be used to narrow a list." + }, + "parent_custom_field_id": { + "type": "integer", + "description": "Present only for a dependent field; names the field whose value decides this one's choices. Omitted otherwise." + }, + "links": { + "type": "object", + "description": "A single self link. For most fields it points at where that field's own choices can be read; for a user-picker field it points at the project's user list instead." + }, + "sort_options": { + "type": "object", + "description": "A pre-built sort token for this field. Present only when the field's stored type is sortable (integer, string, date, URL, boolean or dropdown) and the workspace has custom-field sorting enabled, so most responses omit it entirely.", + "properties": { + "value": { + "type": "string", + "description": "Opaque sort key combining the field id and its stored type." + } + } + } + } + } + } + } + } + }, + "SharedComponentsGetResponse": { + "description": "A page of a project's shared fields with page details, optionally carrying the first field's steps.", + "schema": { "type": "object", + "required": [ + "data", + "info", + "success" + ], "properties": { - "enabled": { - "type": "boolean" - }, - "sub_plan_mode": { - "type": "string" - } - } - }, - "review_status": { - "type": "string" - }, - "start_date": { - "type": "string", - "nullable": true - }, - "sub_plans_count": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - } - }, - "test_plan_progress": { - "type": "object" - }, - "test_plan_progress_v2": { - "type": "object" - }, - "test_results_trend": { - "type": "string", - "nullable": true - }, - "test_results_trend_v2": { - "type": "string", - "nullable": true - }, - "test_runs_count": { + "data": { + "type": "array", + "items": { + "$schema": "SharedComponent" + } + }, + "info": { + "$schema": "PageInfo" + }, + "pre_fetched_shared_step": { + "type": "object", + "nullable": true, + "description": "Only present when the pre-fetch option was used on page 1; null otherwise.", + "properties": { + "shared_step_details": { + "type": "array", + "items": { + "$schema": "SharedStepDetail" + } + }, + "page_info": { + "$schema": "PageInfo" + } + } + }, + "success": { + "type": "boolean" + } + } + } + }, + "CommentListResponse": { + "description": "A page of comments on an item, newest first, with page details.", + "schema": { "type": "object", + "required": [ + "comments", + "success", + "info" + ], "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } + "comments": { + "type": "array", + "items": { + "$schema": "Comment" + } + }, + "success": { + "type": "boolean" + }, + "info": { + "$schema": "PageInfo" + } } - }, - "total_test_runs_count": { + } + }, + "FolderAttachmentsResponse": { + "description": "The files attached to the folder itself.", + "schema": { "type": "object", + "required": [ + "success", + "attachments" + ], "properties": { - "active": { - "type": "integer" - }, - "closed": { - "type": "integer" - } + "success": { + "type": "boolean" + }, + "attachments": { + "type": "array", + "description": "One entry per file on the folder, empty when the folder has none. Alongside the fields below each entry also carries 'size', a ready-formatted version of byte_size for display.", + "items": { + "$schema": "Attachment" + } + } } - }, - "updated_at": { - "type": "string" - } } - } }, - "info": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "next": { - "type": "string", - "nullable": true - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "prev": { - "type": "string", - "nullable": true + "V2TestCaseResponse": { + "description": "v2 public-API single-case envelope: {data: {success, test_case}}. Note the case is under `data`, uses `title` (not name) and has no integer id.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_case": { + "$schema": "V2TestCaseDetail" + } + } + } + } } - } - } - } - } - }, - "BulkAssignTestCasesResponse": { - "description": "Result of a bulk assign on a test run. TWO shapes, decided by the size of the selection.\nUnder the async threshold the write is applied inline and the body is the run's test-case\nlisting re-read afterwards: {success, info, test_cases} (plus `folder` when a folder_id was\nsent, and a top-level skipped_test_case_ids when the review gate excluded cases). At or above\nthe threshold the write is queued and the body is {success, async: true, unique_id} with NO\ntest_cases. Branch on `async`.\n", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "async": { - "type": "boolean", - "description": "Present and true only when the assignment was queued rather than applied inline." - }, - "unique_id": { - "type": "string", - "description": "Handle for the queued bulk operation. Only present on the async shape." - }, - "info": { - "$schema": "PageInfo" - }, - "test_cases": { - "type": "array", - "description": "One entry per case x configuration mapping, each carrying both `id` (test case) and `mapping_id` (the run mapping). Absent on the async shape.", - "items": { - "$schema": "V1TestRunTestCaseRow__BulkSetTestCaseAssigneeInRun" - } }, - "folder": { - "type": "object", - "description": "Only present when a folder_id was sent; the folder's status breakdown.", - "nullable": true - }, - "skipped_test_case_ids": { - "type": "array", - "description": "Cases the review gate excluded. Top level, a sibling of test_cases.", - "items": { - "type": "integer" - } - } - } - } - }, - "ConfigurationGroupListResponse": { - "description": "Successfully retrieved the account's configuration groups", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "configuration_groups": { - "type": "array", - "items": { - "$schema": "ConfigurationGroup" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "UsersListResponse": { - "description": "All active users in the caller's account plus a reduced record for the caller.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "users": { - "type": "array", - "description": "Every active user in the account, ordered by full_name; not filtered to the project.", - "items": { - "$schema": "User" - } - }, - "myself": { - "$schema": "MyselfUser" - } - } - } - }, - "CommentCreateResponse": { - "description": "The comment just posted, wrapped in a data envelope.", - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "success", - "comment" - ], - "properties": { - "success": { - "type": "boolean" - }, - "comment": { - "$schema": "Comment" + "GlobalSearchResponse": { + "description": "Account-wide search results. There is one top-level bucket per entity type you asked for - `test_case`, `test_run`, `test_plan`, `project`, `shared_step`, `report` - and a bucket appears ONLY if you named that type in the request; a type you did not ask for has no bucket at all, while a type that simply had no matches comes back as a present bucket with an empty `data` and a zero count. Alongside the buckets sits `project_details`, a lookup for naming the project each hit belongs to. When results are combined, the per-entity buckets are replaced entirely by a single sorted `data` list plus one `info` block.", + "schema": { + "type": "object", + "properties": { + "test_case": { + "type": "object", + "description": "Test-case matches. The only bucket that also carries a folder lookup.", + "properties": { + "data": { + "type": "array", + "description": "Matching test cases, in the same serialized shape the project-scoped case search returns, plus two search-only extras per row: `display_meta` (what the query matched - title, description, steps, preconditions, folder path - with the owner and project resolved to names) and `user_id` (the case's creator). On the typeahead fast path the rows are lean stubs - id, identifier, name, project, owner - and none of the serialized detail is filled in.", + "items": { + "$schema": "TestCase__SearchAllProjects" + } + }, + "info": { + "$schema": "PageInfo" + }, + "folders": { + "type": "object", + "description": "Folder-hierarchy lookup for these test-case hits, keyed by folder id, so each row's folder can be shown as a path without another call. Test cases are the only entity type that gets this." + } + } + }, + "test_run": { + "type": "object", + "description": "Test-run matches.", + "properties": { + "data": { + "type": "array", + "description": "Matching test runs, in the same serialized shape the project-scoped run search and the run listings return, plus a search-only `user_id` (the run's creator). Lean stubs on the typeahead fast path.", + "items": { + "$schema": "TestRun" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "test_plan": { + "type": "object", + "description": "Test-plan matches.", + "properties": { + "data": { + "type": "array", + "description": "Matching test plans, in the same serialized shape a project's plan listing returns - numeric `id` AND `identifier` (TP-NNN, or STP-NNN for a sub-plan), name, description, plan_status and the derived active_state, owner and assignee, tags, start and end dates, the {active, closed} run counts both direct and including sub-plans, parent_plan_id and sub_plans_count. This is the numeric-id plan shape, NOT the identifier-only shape the v2 plan surface returns. Each row also carries a search-only `display_meta` (with the parent plan resolved) and `user_id` (the plan's creator). Lean stubs on the typeahead fast path.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo" + } + } + }, + "project": { + "$schema": "GlobalSearchEntityResults", + "description": "Project matches. Rows are search-index records, not the project read shape: expect the project's id, name, identifier, description, the owning group, created and updated times and a relevance score - and NOT the counts, links or access fields a project read returns. An owner filter does not narrow this bucket." + }, + "shared_step": { + "$schema": "GlobalSearchEntityResults", + "description": "Shared-step matches. Rows are search-index records carrying the step's id, title and owning project - leaner than a shared-step read, and with no step bodies. An owner filter does not narrow this bucket." + }, + "report": { + "type": "object", + "description": "Report matches. Absent on the typeahead fast path even when reports were requested. Unlike the other entity types, this bucket ignores the requested page size and always pages at 30, and it is not a search-index bucket - it is a direct lookup over the account's saved and scheduled reports, matched on report type, title and, when no owner filter is set, owner name.", + "properties": { + "data": { + "type": "array", + "description": "Matching reports, in the same serialized shape a project's saved-report listing returns.", + "items": { + "$schema": "Report" + } + }, + "scheduled_report_count": { + "type": "integer", + "description": "How many of the matches are scheduled reports." + }, + "total_report_count": { + "type": "integer", + "description": "How many reports matched in total, before paging." + }, + "info": { + "$schema": "PageInfo", + "description": "Pagination for the report bucket. Always returned. Its page size is always 30, whatever page size was requested." + } + } + }, + "project_details": { + "type": "array", + "description": "One entry per project referenced by any hit in any bucket, so a row's project can be named without another call - match a row's project id against these. Entries are project records as stored, with id and name being the parts to rely on; this is a lookup, not the project read shape. Always present on an uncombined search, as an empty list when nothing matched; on a combined one it is included only when non-empty.", + "items": { + "type": "object" + } + }, + "data": { + "type": "array", + "description": "Only when results are combined: all types merged, newest first, each row carrying an entity_type. Capped at 5 rows.", + "items": { + "type": "object" + } + }, + "info": { + "$schema": "PageInfo", + "description": "Only when results are combined. Its count is the true total across all types, but its page size is the per-type page sizes added together rather than a real page size - three types searched at 30 each reports 90." + }, + "test_case_folders": { + "type": "object", + "description": "Only when results are combined and the merged list contains test-case rows: the folder lookup for those rows." + }, + "filter_details": { + "type": "object", + "description": "Only when explicitly requested with the exact lowercase word true: the filters that were applied, with ids resolved to names. Absent if resolution failed - the search itself still succeeds." + } + } } - } - } - } - } - }, - "ExploratorySessionFormFieldsResponse": { - "description": "The extra fields configured for exploratory sessions in this project. The list of built-in fields is always empty on this path. Both lists can legitimately be empty on a valid project.", - "schema": { - "type": "object", - "required": [ - "success", - "custom_fields", - "default_fields" - ], - "properties": { - "success": { - "type": "boolean" - }, - "default_fields": { - "type": "array", - "description": "Always empty on this path - built-in session details are not described here. The key is always present.", - "items": { - "type": "object" - } }, - "custom_fields": { - "type": "array", - "description": "One entry per configured field. Empty when the project has no extra session fields.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "The field's identifier, used when referring to this field elsewhere." - }, - "field_name": { - "type": "string", - "description": "The field's internal name." - }, - "field_user_name": { - "type": "string", - "description": "The name your team sees in the product; match a person's plain-words request against this." - }, - "field_type": { - "type": "string", - "description": "The kind of input, given in the product's prefixed form - for example a single-line string, a text area, a date, a dropdown, a multi-dropdown, a checkbox, a boolean, a URL, an integer or a user picker.", - "example": "field_dropdown" - }, - "entity_type": { - "type": "string", - "description": "The record kind this field belongs to." - }, - "is_required": { - "type": "boolean", - "description": "True when a session cannot be saved without this field. Always present, defaulting to false." - }, - "optional": { - "type": "boolean", - "description": "The exact inverse of is_required; both are always returned." - }, - "option_values": { - "type": "array", - "description": "The choices a dropdown offers. Returned only when the request asked for the configured choices AND the field has any - and the choices variant of this request is currently answered with a not-found, so in practice this is not reachable today.", - "items": { - "type": "object" + "FolderV2WriteResponse": { + "description": "Public (v2) envelope returned after creating, renaming or moving a folder: the affected folder in its public shape.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "folder": { + "$schema": "FolderV2" + } } - }, - "field_values": { - "type": "array", - "description": "Current values recorded for the field. Same conditions and same practical caveat as option_values.", - "items": { - "type": "object" - } - }, - "default_value": { - "type": "string", - "description": "Pre-filled value, if the field has one. Always present and may be null. For a yes/no field this comes back as a true or false rather than as a number.", - "nullable": true - }, - "placeholder": { - "type": "string", - "description": "Hint text shown in the empty field. Omitted entirely when no hint text is configured." - }, - "is_bulk_editable": { - "type": "boolean", - "description": "True when the field can be set on many records at once." - }, - "is_filterable": { - "type": "boolean", - "description": "True when the field can be used to narrow a list." - }, - "parent_custom_field_id": { - "type": "integer", - "description": "Present only for a dependent field; names the field whose value decides this one's choices. Omitted otherwise." - }, - "links": { + } + }, + "TestPlanV2DetailResponse": { + "description": "A single test plan (or sub-plan read through the plan route). No data wrapper - the plan sits at the top level under `test_plan`, and on this route alone it can carry the embedded run preview and the relative API links.", + "schema": { "type": "object", - "description": "A single self link. For most fields it points at where that field's own choices can be read; for a user-picker field it points at the project's user list instead." - }, - "sort_options": { + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "test_plan": { + "$schema": "TestPlanV2Detail" + } + } + } + }, + "SharedComponentWriteResponse": { + "description": "The saved shared field, echoed back under `data`.", + "schema": { "type": "object", - "description": "A pre-built sort token for this field. Present only when the field's stored type is sortable (integer, string, date, URL, boolean or dropdown) and the workspace has custom-field sorting enabled, so most responses omit it entirely.", "properties": { - "value": { - "type": "string", - "description": "Opaque sort key combining the field id and its stored type." - } + "success": { + "type": "boolean", + "example": true + }, + "data": { + "$schema": "SharedComponentDetail" + } } - } } - } } - } - } - }, - "SharedComponentsGetResponse": { - "description": "A page of a project's shared fields with page details, optionally carrying the first field's steps.", - "schema": { - "type": "object", - "required": [ - "data", - "info", - "success" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "SharedComponent" - } - }, - "info": { - "$schema": "PageInfo" - }, - "pre_fetched_shared_step": { - "type": "object", - "nullable": true, - "description": "Only present when the pre-fetch option was used on page 1; null otherwise.", - "properties": { - "shared_step_details": { - "type": "array", - "items": { - "$schema": "SharedStepDetail" - } - }, - "page_info": { - "$schema": "PageInfo" - } - } + }, + "paging": { + "POST /api/v1/projects/{project_id}/test-cases/bulk-archive": { + "page": "p" }, - "success": { - "type": "boolean" - } - } - } - }, - "CommentListResponse": { - "description": "A page of comments on an item, newest first, with page details.", - "schema": { - "type": "object", - "required": [ - "comments", - "success", - "info" - ], - "properties": { - "comments": { - "type": "array", - "items": { - "$schema": "Comment" - } - }, - "success": { - "type": "boolean" - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "FolderAttachmentsResponse": { - "description": "The files attached to the folder itself.", - "schema": { - "type": "object", - "required": [ - "success", - "attachments" - ], - "properties": { - "success": { - "type": "boolean" - }, - "attachments": { - "type": "array", - "description": "One entry per file on the folder, empty when the folder has none. Alongside the fields below each entry also carries 'size', a ready-formatted version of byte_size for display.", - "items": { - "$schema": "Attachment" - } - } - } - } - }, - "V2TestCaseResponse": { - "description": "v2 public-API single-case envelope: {data: {success, test_case}}. Note the case is under `data`, uses `title` (not name) and has no integer id.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "test_case": { - "$schema": "V2TestCaseDetail" - } - } + "POST /api/v1/projects/{project_id}/test-cases/bulk-copy": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-delete": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-edit": { + "page": "p" + }, + "PATCH /api/v1/projects/{project_id}/test-cases": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-move": { + "page": "p" + }, + "POST /api/v1/projects/{project_id}/test-cases/bulk-retrieve": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets/{uuid}/test-cases": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets/variables": { + "page": "p" + }, + "GET /api/v1/projects/basic": { + "page": "p", + "size": "count", + "max": 300 + }, + "GET /api/v1/projects/minify": { + "page": "p", + "size": "count", + "max": 300 + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}/{section_name}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/folders": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/shared-steps/{shared_step_id}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/shared-steps": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-case/{field_name}": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-cases": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-results/custom-fields": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-plans/archived_test_plans": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/datasets": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/ai-duplicates": { + "page": "page" + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/exploratory-sessions": { + "page": "page", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/filter": { + "page": "page" + }, + "GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/reports/schedules": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-case/tags-v2": { + "page": "p" + }, + "GET /api/v1/admin-v2/settings/templates": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/test-plans": { + "page": "p" + }, + "GET /api/v1/admin-v2/settings/fields": { + "page": "p" + }, + "PATCH /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder": { + "page": "page" + }, + "POST /api/v1/projects/{project_id}/ai-duplicates/search-v2": { + "page": "page" + }, + "GET /api/v1/group/{entity_type}/tags": { + "page": "p" + }, + "GET /api/v1/projects/{project_id}/{entity}/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "POST /api/v1/projects/{project_id}/{entity}/search-v2": { + "page": "p", + "size": "per_page", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/users/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "GET /api/v1/projects/{project_id}/test-case/tags/search": { + "page": "p", + "size": "page_size", + "max": 100 + }, + "POST /api/v1/projects/{project_id}/reports/{report_id}/drilldown": { + "page": "p", + "size": "per_page", + "max": 100 } - } - } - }, - "GlobalSearchResponse": { - "description": "Account-wide search results. There is one top-level bucket per entity type you asked for - `test_case`, `test_run`, `test_plan`, `project`, `shared_step`, `report` - and a bucket appears ONLY if you named that type in the request; a type you did not ask for has no bucket at all, while a type that simply had no matches comes back as a present bucket with an empty `data` and a zero count. Alongside the buckets sits `project_details`, a lookup for naming the project each hit belongs to. When results are combined, the per-entity buckets are replaced entirely by a single sorted `data` list plus one `info` block.", - "schema": { - "type": "object", - "properties": { - "test_case": { - "type": "object", - "description": "Test-case matches. The only bucket that also carries a folder lookup.", - "properties": { - "data": { - "type": "array", - "description": "Matching test cases, in the same serialized shape the project-scoped case search returns, plus two search-only extras per row: `display_meta` (what the query matched - title, description, steps, preconditions, folder path - with the owner and project resolved to names) and `user_id` (the case's creator). On the typeahead fast path the rows are lean stubs - id, identifier, name, project, owner - and none of the serialized detail is filled in.", - "items": { - "$schema": "TestCase__SearchAllProjects" - } - }, - "info": { - "$schema": "PageInfo" - }, - "folders": { - "type": "object", - "description": "Folder-hierarchy lookup for these test-case hits, keyed by folder id, so each row's folder can be shown as a path without another call. Test cases are the only entity type that gets this." - } - } + }, + "entities": { + "activity": { + "entity": "activity", + "title": "Activity feed (audit trail)", + "description": "Group-wide audit trail of who changed what, on which entity, and when.", + "aliases": [ + "activity", + "activities", + "activity feed", + "audit log", + "audit trail", + "history feed", + "who changed" + ], + "id_convention": "integer", + "parents": [], + "relations": [ + { + "entity": "project", + "via": "scopes events" + }, + { + "entity": "user", + "via": "actor of an event" + }, + { + "entity": "test_case", + "via": "subject of an event" + }, + { + "entity": "test_run", + "via": "subject of an event" + } + ], + "capabilities": [ + "list_activity_events" + ], + "key_facts": [ + "READ-ONLY by design. Activity events are emitted by the system as a side effect of other writes — there is no create, update, or delete, and there never will be. If asked to edit or remove an audit entry, say it is immutable.", + "HARD 15-DAY WINDOW. The server clamps every query to the last 15 days no matter what you ask for. For anything older, say the history is not retrievable through the API and point to the Test Management UI — do NOT report 'no activity' for an older period, which would be a false negative.", + "CURSOR pagination, not page numbers. Follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no p param and no info.count, so you CANNOT state a total number of events or jump to a page.", + "Not project-scoped in the path — it is group-wide. Pass project_id as a QUERY param to narrow it to one project.", + "starred_only=true restricts to the caller's starred projects. When they have none the response is {events: [], no_starred_projects: true} — that means 'no starred projects', NOT 'no activity'. Check for that flag before reporting emptiness.", + "The response carries a projects map (project id → metadata) so you can resolve the project an event refers to without extra reads.", + "Gated by a group feature flag. If the call is refused, report that the activity feed isn't enabled for the group rather than that there is no activity." + ] + }, + "attachment": { + "entity": "attachment", + "title": "Attachments", + "description": "A file hanging off a parent test case or test result.", + "aliases": [ + "attachment", + "file", + "attachments", + "blob", + "upload", + "evidence", + "screenshot", + "blob id" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "attached to" + }, + { + "entity": "result", + "via": "attached to" + } + ], + "capabilities": [ + "delete_test_case_attachment", + "list_test_case_attachments", + "list_entity_attachments", + "upload_ai_attachments", + "upload_generic_attachments", + "link_attachments_to_test_case" + ], + "key_facts": [ + "ai_uploads uploads a file as AI CONTEXT (restricted types, smaller cap) for seeding case content — see create-test-case.", + "Each blob/attachment row carries an integer id and a url (the file's location) — read the file from that url; there is no dedicated download endpoint.", + "A file is attached in TWO steps: upload it to the project to get a numeric blob id, then link that id to the test case, result or step. The link calls take ids, never file content or filenames.", + "A blob can be linked ONCE. Attaching one that is already referenced elsewhere fails the whole request; upload a separate copy for each place the file should appear.", + "Putting a NEW file into the product cannot be done through this interface at all — an agent can link an already-uploaded file, remove one, and read attachment metadata, but the upload itself has to happen in the product UI.", + "Removing an attachment purges the stored file, not just the link. There is no recycle bin for attachments and no undo.", + "The view and download URLs handed back on upload are pre-signed and expire after about 30 minutes — fetch the file promptly or re-read the attachment to get a fresh one.", + "The older per-test-case attachment reads return metadata only (id, filename, type, size) with no URL; the newer entity-attachment listing is the one that hands back a readable URL per file.", + "A file cannot be attached to a test case through this surface at all. The call that does it works, but it takes the file itself as a form upload, which this transport cannot send — the same reason the upload capabilities here accept no file and quietly do nothing. Attaching is done in the product, not here.", + "An empty attachment list is not evidence that nothing is attached; it is the expected result whenever the attaching was done elsewhere." + ] + }, + "configuration": { + "entity": "configuration", + "title": "Configurations", + "description": "An operating system, browser or device combination that a test run executes against.", + "aliases": [ + "config", + "configuration", + "environment", + "browser", + "os", + "device", + "configs", + "configurations", + "platform" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "case status tracked against" + } + ], + "capabilities": [ + "create_configuration", + "list_configurations" + ], + "key_facts": [ + "Configurations live at workspace level, not per project: one created while working in a project appears in every project in the workspace, and the project listing returns the same workspace-wide set everywhere.", + "Only a configuration's name can be set when creating one. The operating system, browser and device breakdown that reads return cannot be supplied through the API.", + "Test runs reference a configuration by its integer id, never by name or by the configuration object.", + "Configurations are gated on the test-run permissions rather than the test-case ones, and there is no way to delete or rename one through this profile — cleanup is a UI operation." + ] + }, + "custom_field": { + "entity": "custom_field", + "title": "Custom fields & system fields", + "description": "A user-defined field on test cases, results or plans, alongside the built-in system fields.", + "aliases": [ + "custom field", + "custom fields", + "field", + "system field", + "form field", + "dropdown", + "picklist", + "option", + "option value", + "dataset", + "field definition", + "field values", + "extra field", + "user defined field" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "extends" + }, + { + "entity": "result", + "via": "extends" + } + ], + "capabilities": [ + "create_custom_field_dataset_option", + "create_custom_field_dataset", + "create_custom_field_definition", + "create_custom_field", + "delete_custom_field_dataset_option", + "delete_custom_field_dataset", + "delete_custom_field_definition", + "get_custom_field_dataset", + "get_custom_field_definition", + "get_custom_field", + "get_custom_field_values", + "get_project_form_fields", + "get_test_results_custom_fields", + "list_custom_field_dataset_options", + "list_custom_fields", + "list_workspace_fields", + "update_custom_field_dataset_option", + "update_custom_field_dataset_project_mapping", + "update_custom_field_definition", + "update_custom_field" + ], + "key_facts": [ + "A field applies to ONE entity type (TestCase | TestResult | TestPlan) — pick the matching entity_type; project scope is via the integer project id in the path.", + "A definition write is workspace-wide, not project-scoped: it affects every project the field's datasets link to. Say which projects are affected BEFORE writing.", + "field_name IS editable via .../update; field_type is SILENTLY immutable — sending a new type returns 200 and changes nothing. Never report a type change as done; a real one means delete-and-recreate, which destroys every stored value.", + "Update is a full REPLACE: field_name + field_type + is_required must all be present (missing is_required = 400, missing field_name = 500). Read the field first and resend the whole set.", + "The project-mapping endpoint uses link_projects / unlink_projects — NOT the linked_projects of the create bodies. The wrong spelling is silently dropped: 200, nothing changed.", + "Three operations destroy stored data across every linked project: deleting a field, deleting an option, and unlinking a project. There is no bin. Count and name the affected projects before asking to proceed.", + "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Only values are writable here, through the test-case create/update body's custom_fields.", + "A field applies to ONE entity type (TestCase | TestResult | TestPlan | ExploratorySession | TestCaseStep) and cannot be moved later. TestCaseStep fields need the group's step-custom-fields feature, and cannot be url or nested_dropdown.", + "A field DEFINITION is workspace-wide; a DATASET is what makes it visible in a project, by pairing an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, exists but appears nowhere.", + "A project may belong to only one dataset per field. Linking a project that a sibling dataset already holds is rejected — read the field's existing datasets before linking.", + "Editing a field definition is a full REPLACE: the label, the mandatory flag and the type must all be resent, and a placeholder or boolean default you leave out is cleared. Read the field first and send back everything you want to keep.", + "Three operations destroy stored values across every linked project and cannot be undone: deleting a field, deleting an option, and unlinking a project from a dataset. There is no bin. Count and name the affected projects before asking to proceed.", + "Renaming an option keeps its id, and stored values point at the id — so a rename loses nothing and is the safe way to correct a wrong label. Deleting and recreating is not.", + "Field types have two spellings. Definitions are written with the canonical names (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown); the project form-field and field-value reads return the legacy field_* forms (field_dropdown, field_string, …). Translate between them, and expect null where a nested_dropdown has no legacy name.", + "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Nothing here writes values — those are set when creating or editing the test case, result or plan itself.", + "A default value can only ever be set on a boolean field. On every other type the admin write calls force it to null, so a default cannot be created or preserved there." + ] + }, + "dataset": { + "entity": "dataset", + "title": "Dataset", + "description": "A named table of variables and rows that drives data-driven execution of a test case.", + "aliases": [ + "datasets", + "data-driven-data", + "dds", + "data driven testing", + "data-driven test data", + "test data table", + "dataset variables", + "dataset columns", + "parameterised test data", + "csv test data" + ], + "id_convention": "uuid", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "drives" + }, + { + "entity": "variable", + "via": "has columns" + } + ], + "capabilities": [ + "create_dataset", + "delete_dataset", + "get_dataset", + "list_dataset_linked_test_cases", + "get_dataset_variables", + "import_dataset_csv", + "list_datasets", + "update_dataset" + ], + "key_facts": [ + "Team Pro plan gated: without it EVERY dataset endpoint returns 422 \"Feature not available\". A 422 here means NOT-ENTITLED, not a bad payload — say so and stop, don't retry with a different body.", + "Datasets keep NO version history and there is no restore: an update replaces the whole table of columns and rows, and the previous contents are unrecoverable. Read a dataset before writing to it.", + "What you send is not what gets stored. Blank rows and columns that are empty in every row are dropped, cell values are trimmed, rows that end up identical are collapsed to one, and row numbers are reassigned from 1. Always read the stored result back.", + "Column names are restricted to letters, digits and underscores, must start with a letter, and must be unique within a dataset (compared case-insensitively).", + "Default size limits are 40 columns and 100 rows per dataset; groups on the raised-limit flag get 100 columns and 200 rows.", + "Deleting a dataset is permanent and is not blocked by test cases still bound to it — check what it drives first." + ] + }, + "duplicate": { + "entity": "duplicate", + "title": "Duplicate recommendation (AI dedupe)", + "description": "A machine-suggested pair of near-identical test cases, for a human to review and merge.", + "aliases": [ + "duplicate", + "duplicates", + "dedupe", + "deduplication", + "duplicate recommendation", + "ai duplicates", + "redundant test cases", + "duplicate test cases", + "near-duplicate", + "merge duplicates", + "duplicate review", + "duplicate suggestions", + "dedupe queue", + "similar test cases" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "links the pair it believes are duplicates" + } + ], + "capabilities": [ + "discard_duplicate", + "get_dedupe_status", + "get_duplicate_source_test_case", + "get_duplicate", + "list_duplicates", + "resolve_duplicate", + "search_duplicates" + ], + "key_facts": [ + "There is NO create. Duplicate suggestions are produced by the dedupe pipeline; a caller cannot author one.", + "Only status=suggested can be read or resolved; already-merged/archived/discarded suggestions return 404. A 404 here usually means 'already handled', not 'never existed'.", + "source_tc works ONLY for status=merged, and the snapshot may legitimately be absent (404 with error 'Source test case snapshot not available'). Treat that as 'not retained', not as a retryable error.", + "This list family pages with `page`, not the `p` used elsewhere in the profile.", + "Dedupe REVIEW is exposed here; TCG test-case GENERATION is not. Don't offer to generate cases.", + "Duplicate review can be paused entirely while the dedupe data is migrated — resolving or discarding then fails with a conflict and an explicit 'try again shortly' message. Nothing was changed; it is safe to retry later.", + "Resolving a duplicate requires the same permission as removing a test case, while discarding one needs only edit permission. A reviewer who can dismiss suggestions may still be unable to merge them.", + "Merging archives the source test case and tags it 'Archived as Duplicate', posts a comment on the surviving case, and stores a frozen snapshot of the source. Archived cases are recoverable; the comment and the status change are not undone by recovering one.", + "The review queue is served ten pairs at a time, ordered by model confidence rather than age, and the page size cannot be increased — the oldest suggestion is not reachable by asking for more rows.", + "A pair whose test case can no longer be resolved is dropped from the queue, so the reported total can exceed the pairs actually returned." + ] + }, + "exploratory_session": { + "entity": "exploratory_session", + "title": "Exploratory session", + "description": "An unscripted, charter-driven testing session and the observations recorded during it.", + "aliases": [ + "session", + "exploratory", + "et-session", + "exploratory testing", + "charter", + "session log", + "session note", + "bug bash" + ], + "id_convention": "integer", + "parents": [ + "project", + "folder" + ], + "relations": [ + { + "entity": "exploratory_log", + "via": "records" + }, + { + "entity": "issue", + "via": "links defects" + }, + { + "entity": "configuration", + "via": "runs against" + }, + { + "entity": "test_case", + "via": "notes become" + } + ], + "capabilities": [ + "clone_exploratory_session", + "close_exploratory_session", + "create_exploratory_session", + "create_exploratory_session_log", + "delete_exploratory_session", + "delete_exploratory_session_log", + "get_exploratory_session", + "list_exploratory_session_defects", + "list_exploratory_session_logs", + "list_exploratory_sessions", + "update_exploratory_session", + "update_exploratory_session_log" + ], + "key_facts": [ + "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1).", + "Logs hold rich-text HTML sanitised on write (off-project anchor/image links stripped); defects are the issues linked to the session.", + "Clone runs async when the session has many logs → re-fetch to confirm (see async-jobs).", + "No convert endpoint: turn session notes into test cases via the create-test-case flow.", + "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1). The display identifier (ES-NNN) is never accepted as an input.", + "A log entry belongs to exactly one session — both the session id and the log id are needed to change or remove a log, and a log id is not addressable under a different session.", + "Logs hold rich-text HTML sanitised on write (anchors and images pointing outside the project or group are stripped); screenshots are attached by uploading the image first and referencing its id inside the HTML, never by sending bytes.", + "Session-level 'defects' are the issue-tracker links recorded during the session — both links on the session and links made on individual logs; each row says which it is.", + "Closing a session is one-way: nothing accepts a state change and there is no reopen, so a closed session only leaves the default listing, it never comes back to it.", + "Neither sessions nor logs keep version history, and neither has a recycle bin — a deletion or an overwritten note cannot be recovered.", + "Listing sessions shows only ACTIVE ones unless the closed state is asked for explicitly.", + "Cloning a session with 50 or more logs is handled in the background and returns before the copy exists; re-read the session list to confirm. A clone does not carry over the charter or the folder.", + "Exploratory sessions are governed by the same permissions as test runs; deleting a closed session needs the closed-run delete permission specifically." + ] + }, + "filter": { + "entity": "filter", + "title": "Saved filter view", + "description": "A saved, named set of filter conditions a user can reapply to a list view.", + "aliases": [ + "filter", + "filters", + "saved view", + "saved filter", + "view", + "saved search", + "smart filter", + "filter view" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "filters" + }, + { + "entity": "test_run", + "via": "filters" + }, + { + "entity": "test_plan", + "via": "filters" + } + ], + "capabilities": [ + "create_filter", + "delete_filter", + "get_filter", + "list_filters", + "update_filter" + ], + "key_facts": [ + "A saved filter view persists a reusable filter: { name, entity, filters, is_project_level }. entity = testcase | testplan | testrun | exploratory_session; filter_id is an integer.", + "is_project_level scopes the view to the whole project vs. just the creator.", + "A saved filter view has no version history and no bin — an overwritten name or condition set, and a deleted view, cannot be restored. Read one before changing it if the conditions matter.", + "Reading a single saved view can change it: conditions on custom fields that no longer exist are pruned from the stored filter as a side effect of the read. The listing does not do this.", + "Saving a view as project-level publishes it to everyone on the project, and anyone with project access can then edit or delete it — there is no owner-only protection." + ] + }, + "folder": { + "entity": "folder", + "title": "Folder", + "description": "A container organising test cases inside a project; folders nest to form a tree.", + "aliases": [ + "dir", + "directory", + "test case folder", + "repository folder", + "test suite", + "suite", + "module" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "folder", + "via": "nests under" + }, + { + "entity": "test_case", + "via": "organises" + } + ], + "capabilities": [ + "copy_folder", + "create_root_folder", + "create_sub_folder", + "delete_folder_and_contents", + "get_folder_remove_summary", + "get_folder_tree", + "list_root_folders", + "list_folder_contents", + "move_folder_by_integer_id", + "rename_folder_by_integer_id", + "reorder_folders", + "delete_folder_and_subtree" + ], + "key_facts": [ + "Folder ids are plain INTEGERS (e.g. 54) — never FL-… strings. Nesting is via parent_id (integer, null = root).", + "mv cross-project and copy run ASYNC ({ success, async, unique_id }) — confirm by re-reading the destination (see async-jobs).", + "Deleting a folder is recursive and takes every subfolder and every test case beneath it. Nothing in this product restores a deleted folder — only the test cases can come back, and only from the recycle bin when that feature is switched on for the group.", + "Folders keep no version history, so a rename or a notes edit cannot be rolled back to a previous value.", + "Folder names must be unique among their siblings. Creating or renaming into a clash fails unless auto-rename is asked for; copying a folder resolves the clash silently by prefixing the copy's name.", + "Folder notes are stored as sanitised HTML with signed image links injected on read, so the text that comes back is not byte-identical to the text that was sent.", + "Copying a folder, and moving one to a different project, are background jobs: the call returns a tracking id and nothing else, and the only way to confirm the outcome is to re-read the destination." + ] }, - "test_run": { - "type": "object", - "description": "Test-run matches.", - "properties": { - "data": { - "type": "array", - "description": "Matching test runs, in the same serialized shape the project-scoped run search and the run listings return, plus a search-only `user_id` (the run's creator). Lean stubs on the typeahead fast path.", - "items": { - "$schema": "TestRun" - } - }, - "info": { - "$schema": "PageInfo" - } - } + "project": { + "entity": "project", + "title": "Project", + "description": "The top-level container that owns every folder, test case, run, plan and report.", + "aliases": [ + "pr", + "workspace", + "project id", + "pr-id", + "test project" + ], + "id_convention": "PR-NNN", + "parents": [], + "relations": [ + { + "entity": "folder" + }, + { + "entity": "test_case" + }, + { + "entity": "test_run" + }, + { + "entity": "test_plan" + }, + { + "entity": "configuration", + "via": "scopes" + }, + { + "entity": "custom_field", + "via": "scopes" + }, + { + "entity": "user", + "via": "grants access to" + } + ], + "capabilities": [ + "create_project", + "export_dashboard_analytics", + "get_active_test_runs_info", + "get_automation_stats", + "get_closed_test_runs_info", + "get_closed_test_runs_split", + "list_entity_filter_details", + "get_issues_count_info", + "get_project_by_integer_id", + "get_project_settings", + "list_project_users", + "get_projects_basic", + "get_projects_minify", + "get_test_case_count_trend", + "get_test_case_type_split", + "list_projects", + "search_project_entities", + "update_project_settings" + ], + "key_facts": [ + "Project ids come in two forms that are not interchangeable: an integer id and a PR-NNN identifier. Nearly every v1 project capability takes the integer; the v2 routes take PR-NNN. Read a project once to get both.", + "A project the caller cannot see is reported as 'not found' rather than 'forbidden', so a missing project and a permission gap look identical.", + "Projects are never deleted through the capabilities in this index — a project created by mistake can only be removed in the product UI.", + "Project settings are stored as free-form name/value pairs and keep no history: overwriting one loses the previous value, and a setting that was never written simply has no entry rather than a default.", + "Project-level analytics are aggregate counters only; none of the dashboard figures can be drilled down to the individual cases or runs behind them.", + "Listing projects and searching inside a project are different surfaces with different pagination: project listings cap at 300 per page, in-project search at 300 for test cases and 30 for test runs." + ] }, - "test_plan": { - "type": "object", - "description": "Test-plan matches.", - "properties": { - "data": { - "type": "array", - "description": "Matching test plans, in the same serialized shape a project's plan listing returns - numeric `id` AND `identifier` (TP-NNN, or STP-NNN for a sub-plan), name, description, plan_status and the derived active_state, owner and assignee, tags, start and end dates, the {active, closed} run counts both direct and including sub-plans, parent_plan_id and sub_plans_count. This is the numeric-id plan shape, NOT the identifier-only shape the v2 plan surface returns. Each row also carries a search-only `display_meta` (with the parent plan resolved) and `user_id` (the plan's creator). Lean stubs on the typeahead fast path.", - "items": { - "type": "object" - } - }, - "info": { - "$schema": "PageInfo" - } - } + "report": { + "entity": "report", + "title": "Report", + "description": "A saved, optionally scheduled analytics view over a project's test data.", + "aliases": [ + "reports", + "scheduled-report", + "schedule", + "analytics-report", + "report-section", + "report-widget", + "report-drilldown", + "traceability-report", + "user-workload-report", + "exploratory-session-summary", + "report-export" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "summarises" + }, + { + "entity": "test_plan", + "via": "summarises" + }, + { + "entity": "test_case", + "via": "traces (traceability)" + }, + { + "entity": "user", + "via": "mailed to" + } + ], + "capabilities": [ + "create_report_by_integer_id", + "create_report_by_integer_id", + "delete_report_schedule", + "download_report", + "get_exploratory_session_summary", + "get_report_attachment_url", + "get_report_detail", + "get_report_section", + "get_report_summary_by_type", + "get_report", + "get_selected_report_testcases", + "list_reports", + "list_schedules", + "send_report_email_now", + "update_report", + "get_user_workload_drilldown" + ], + "key_facts": [ + "report_id and schedule_id are INTEGERS; the parent project takes the INTEGER project id (v1).", + "report_type on create/update is a DISPLAY NAME and the set is CLOSED — exactly these nine: Test Run Summary, Test Run Detailed Report, Test Plan Summary, Requirement Traceability Report, Test Case Activity, Exploratory Session Summary, User Workload Report, Defects Summary, Defects Detailed Report. Never invent one and never substitute a neighbouring type: if the user asks for activity, it is 'Test Case Activity', not 'Test Run Summary'.", + "Defects Summary and Defects Detailed Report are accepted on create but have no data branch — they always read back report_data: null. For defect counts use Test Run Summary's issues_by_priority / issues_by_status sections instead.", + "null inside report_data means NOT COMPUTED, not 'no data'. A Test Case Activity report fills its headline counts on a bare read; a Test Run Summary leaves issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null until you name them in sections. Never conclude 'there is no defect or status data' from a null — request the section; an empty payload like {total: 0} is a real finding, null is not. And never answer a 400 on sections by retreating to a bare read.", + "*_drilldown sections return PAGINATED ROWS, not aggregates — never tally rows into a count; read the aggregate section or report_data.", + "frequency + frequency_details travel together or not at all; omit both for an unscheduled on-demand report. weekly needs day as a lowercase name, monthly needs day as an integer 1-28. There is no 'once'. report_timeframe: custom REQUIRES custom_date_range.", + "\"Understand a report\" = fetch the report + its section/widget data and summarise agent-side; the API returns structured data, not narrative.", + "There is a tenth report type, Test Plan Detailed Report, hidden behind a per-group feature flag — creating, reading or exporting one without the flag is refused as not permitted.", + "A report is found by its id within your group, not within the project you name: the project you address it through does not narrow the lookup, so an id belonging to a sibling project still resolves (and still deletes).", + "Reports are not versioned and there is no undo. An edit replaces the entire definition — every field you leave out is cleared, and filters you leave out are deleted — and a delete is permanent, silent, and reports success even for an id that never existed.", + "Whether a report is scheduled is inferred, never declared: it is scheduled exactly when both a cadence and its details are present. Drop either one on an edit and the report stops emailing. Recipients are only stored on a scheduled report.", + "A report can span up to 60 projects, but only for Test Run Summary, Test Plan Summary, Test Run Detailed, Test Plan Detailed, Test Case Activity, Requirement Traceability and User Workload — and User Workload stops at 5.", + "A report file cannot be downloaded through the API: the export call only hands back a job channel, so it is withheld. To get the file, mail it with send_report_email_now (confirm the recipient first — it sends immediately); to work with the figures, read them with get_report_summary_by_type or get_report_detail.", + "Listing reports is fixed at 30 per page and always newest-first; the page size cannot be changed and asking for a page past the end is an error rather than an empty page.", + "Requirement Traceability reports have a grain — test case (default) or test plan — fixed when the report is created and refused on any later edit." + ] }, - "project": { - "$schema": "GlobalSearchEntityResults", - "description": "Project matches. Rows are search-index records, not the project read shape: expect the project's id, name, identifier, description, the owning group, created and updated times and a relevance score - and NOT the counts, links or access fields a project read returns. An owner filter does not narrow this bucket." + "result": { + "entity": "result", + "title": "Result", + "description": "The recorded outcome of one test case in one run: its status, and who set it.", + "aliases": [ + "outcome", + "execution-result", + "test-result" + ], + "id_convention": "integer", + "parents": [ + "test_run", + "test_case" + ], + "relations": [ + { + "entity": "test_case", + "via": "references" + }, + { + "entity": "test_run", + "via": "references" + } + ], + "capabilities": [ + "bulk_delete_test_results", + "bulk_set_test_result_status", + "create_step_result", + "create_test_result_for_test_case", + "create_test_results_for_test_run", + "delete_test_result", + "list_test_results_for_test_case_by_integer_id", + "list_test_results_for_test_case", + "search_test_results", + "update_latest_test_result_for_test_case" + ], + "key_facts": [ + "Bodies are wrapped in test_result; set status by NAME (status) or id (status_id) — use a real configured status (see statuses-and-states).", + "bulk-delete SUCCEEDS PARTIALLY and still returns 200. Authorisation is per id (author OR project admin); ids the caller may not delete come back in `skipped` — and BOTH lists are nested under a top-level `result` key, with `skipped` holding OBJECTS ({id, reason}) not bare ids, so read result.deleted_ids / result.skipped. Not as an error. ALWAYS compare deleted_ids against what you asked for and report the skipped ids — treating 200 as 'all deleted' will misreport.", + "bulk-delete needs a non-empty result_ids array (400 otherwise) of at most 300 ids (422 beyond) — batch bigger sets yourself.", + "On the v2 surface a result's status is given by NAME (a status configured for the project, matched case-insensitively); the numeric status_id the v1 surface takes is not accepted there.", + "Logging a result APPENDS to a test case's result history in that run, so the previous outcome stays readable. A bulk status set REPLACES the case's current outcome in place instead — the overwritten status is not recoverable.", + "Bulk result writes are queued, not applied inline: a success response means accepted for processing. Per-case outcomes come back as ACCEPTED/FAILED, a partly-valid batch still answers 200 with success:false and status PARTIAL, and a bulk status set reports nothing per case at all — read the results back to confirm.", + "A project can restrict which review statuses are allowed to log an execution result; cases outside that set are rejected with a REVIEW_STATUS_GATE error rather than being logged.", + "Result attachments only attach on a multipart upload that carries the files alongside the JSON body; naming filenames in a plain JSON request is accepted and does nothing. Limits are 10 files per result, 100 per request and 50 MB per file, and a result description over 16 KB is truncated, not rejected.", + "Amending the latest result edits it in place and keeps no prior copy of the amended values; logging a new result instead preserves the earlier outcome as its own entry in the case's history for that run.", + "Deleting a result is permanent and silently changes the case's standing in the run: its outcome rolls back to the previous attempt, or to unexecuted if that was the only one.", + "Only the person who logged a result may delete it, unless they are a project admin — a mixed batch comes back partly done rather than refused.", + "Result attachments are always referenced by the numeric id of a previously uploaded file; naming a filename in the body does nothing." + ] }, "shared_step": { - "$schema": "GlobalSearchEntityResults", - "description": "Shared-step matches. Rows are search-index records carrying the step's id, title and owning project - leaner than a shared-step read, and with no step bodies. An owner filter does not narrow this bucket." + "entity": "shared_step", + "title": "Shared step", + "description": "A reusable block of test steps embedded by reference, held in one flat untyped list per project.", + "aliases": [ + "shared step", + "shared steps", + "reusable step", + "step template", + "shared field", + "step block", + "shared component" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "reused by" + } + ], + "capabilities": [ + "create_shared_step", + "delete_shared_step", + "get_shared_step", + "get_shared_steps", + "update_shared_step" + ], + "key_facts": [ + "A shared step is embedded by MANY test cases, so editing or deleting it changes every one of them at once. That is the point of shared steps, but it also means a delete is a wide blast radius — say how it is used before changing it.", + "Reference a shared step from a test case's steps via shared_step_id in test_case_steps — see test-case-authoring.", + "Shared steps keep no version history, unlike test cases and test plans, so an edit to one cannot be rolled back and a delete cannot be undone — there is no bin for them.", + "Each row inside a shared step has its own id, and a test case's step holds both the shared step's id and that row's id. Rebuilding the rows instead of updating them in place breaks every reference into the block.", + "An edit replaces the whole step list rather than merging: read the block, change the array, send it back whole. Rows left out are removed.", + "The count of test cases embedding a block is returned on every read of it — that count is the blast radius of any edit or delete, and it is the number to check first." + ] }, - "report": { - "type": "object", - "description": "Report matches. Absent on the typeahead fast path even when reports were requested. Unlike the other entity types, this bucket ignores the requested page size and always pages at 30, and it is not a search-index bucket - it is a direct lookup over the account's saved and scheduled reports, matched on report type, title and, when no owner filter is set, owner name.", - "properties": { - "data": { - "type": "array", - "description": "Matching reports, in the same serialized shape a project's saved-report listing returns.", - "items": { - "$schema": "Report" - } - }, - "scheduled_report_count": { - "type": "integer", - "description": "How many of the matches are scheduled reports." - }, - "total_report_count": { - "type": "integer", - "description": "How many reports matched in total, before paging." - }, - "info": { - "$schema": "PageInfo", - "description": "Pagination for the report bucket. Always returned. Its page size is always 30, whatever page size was requested." - } - } + "tag": { + "entity": "tag", + "title": "Tag", + "description": "A free-text label on a test case, run or plan, used to group and filter them.", + "aliases": [ + "tags", + "label", + "labels", + "tag", + "keyword", + "keywords" + ], + "id_convention": "name", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "labels" + }, + { + "entity": "test_run", + "via": "labels" + }, + { + "entity": "test_plan", + "via": "labels" + } + ], + "capabilities": [ + "bulk_replace_test_case_fields", + "get_test_case_tags", + "list_test_case_tags", + "merge_tags", + "search_group_tags", + "search_test_case_tags" + ], + "key_facts": [ + "Tags are referenced by NAME in filters and on cases; the merge endpoint takes integer source_id/target_id, so resolve ids via the tag reads first.", + "That body MUST carry custom_fields (send {} when not changing any) — omitting it returns 500 'undefined method each for nil', even though the server's validator lists it as optional.", + "tags/merge is admin-gated (tags_management flag) — may be unavailable for a group; degrade gracefully and say so.", + "TAG-OBJECT WRITES ARE NOT EXPOSED. Renaming or deleting a tag project-wide is deliberately out of this profile: the endpoints exist but sit behind the tags_management flag that most groups do not have, and tagging is always driven through the ENTITY. If asked to rename or delete a tag, say it is not available here and point to the Test Management UI.", + "Tags are referenced by NAME on entities and in filters. Tag IDs exist but are only returned by the workspace-wide tag search; the project-level tag reads give names only, so resolve ids there when an operation needs them.", + "Tag vocabularies are per entity kind — test case, test run, test plan and shared step tags are separate sets. A name existing in one says nothing about the others, and an operation must name which vocabulary it means.", + "Merging one tag into another is not reversible and is not confined to a project: the losing tag stops existing and everything that carried it is re-pointed at the surviving tag, across the whole workspace. Capture what carries a tag before merging it — nothing afterwards tells you what changed.", + "Tag management — merging and renaming — is restricted to account owners and admins. A caller without that role is refused.", + "Renaming or deleting a tag as an object is deliberately not exposed here — tagging is driven through the entity. If asked to rename or delete a tag, say it is not available and point to the Test Management UI." + ] }, - "project_details": { - "type": "array", - "description": "One entry per project referenced by any hit in any bucket, so a row's project can be named without another call - match a row's project id against these. Entries are project records as stored, with id and name being the parts to rely on; this is a lookup, not the project read shape. Always present on an uncombined search, as an empty list when nothing matched; on a combined one it is included only when non-empty.", - "items": { - "type": "object" - } + "template": { + "entity": "template", + "title": "Test case template", + "description": "A reusable field layout a test case is authored on — step or Gherkin style — setting which fields appear and in what order.", + "aliases": [ + "templates", + "test case template", + "case template", + "field template", + "test case layout", + "custom template", + "BDD template", + "Gherkin template", + "field layout" + ], + "id_convention": "integer, with no prefix. Discover ids through the template list call rather than guessing them.", + "parents": [], + "relations": [ + { + "entity": "test_case", + "via": "shapes the fields of" + }, + { + "entity": "project", + "via": "linked to" + }, + { + "entity": "custom_field", + "via": "arranges" + } + ], + "capabilities": [ + "create_template", + "delete_template", + "get_template", + "list_templates", + "list_test_case_templates", + "update_template" + ], + "key_facts": [ + "Templates belong to the workspace, not to a project — one template can be linked to many projects and keeps the same id everywhere. Do not look for them inside a project.", + "Templates exist for test cases, test results, test plans and exploratory sessions, but only test-case templates can be created or edited here; the older listing can enumerate the other three types and returns test-case templates unless asked for another kind.", + "Template ids are plain integers with no prefix (not PR- or TR- style). Discover an id from the template list rather than guessing it.", + "Templates have no version history and no recycle bin: an edit overwrites the previous field layout and a deletion cannot be undone from Test Management.", + "Disabling a template is the reversible alternative to deleting it — a disabled template stops being offered for new test cases but keeps its fields and project links and can be re-enabled.", + "Changing a template's field layout changes the fields shown on every test case using it, across every linked project. Say how many projects are affected before writing.", + "Creating, editing and deleting templates are paid-plan features; on a free plan the call reports failure in the body while still answering 200, and reading templates stays available.", + "The Review & Approve system fields (reviewers and review status) are hidden from reads on this surface and re-added automatically whenever the property fields are rewritten, so they can be neither seen nor removed here.", + "A template only affects a project once it is linked to it; creating a template links it to nothing unless linkage is requested.", + "Failures arrive as a single human-readable message next to a false success flag rather than as a structured error code.", + "Only test-case and test-result templates can be listed through the older settings surface. Asking it for test-plan or exploratory-session templates fails outright rather than returning an empty list, and the kind must always be stated — there is no default.", + "Only test-case templates exist today; there are no templates for runs, plans or results, so every template's entity reads TestCase.", + "A template's step style is fixed at creation: classic step rows or Gherkin (feature, background, scenario). A case authored on a Gherkin template cannot take step rows and vice versa, so choose the template before writing steps.", + "Templates belong to the workspace but apply only in the projects they are linked to. Naming a template that is unknown to, or not visible from, the caller's workspace is refused outright rather than falling back to the default.", + "Exactly one template is the default; a case created without naming a template lands on it. System templates ship with the workspace and cannot be deleted.", + "Disabled templates still appear in the listing (enabled false) but should not be offered for new cases.", + "Creating, editing, cloning, linking and deleting templates are paid-plan operations and are not exposed through these capabilities; only listing is." + ] }, - "data": { - "type": "array", - "description": "Only when results are combined: all types merged, newest first, each row carrying an entity_type. Capped at 5 rows.", - "items": { - "type": "object" - } + "test_case": { + "entity": "test_case", + "title": "Test case", + "description": "The unit of test logic: title, preconditions, steps and expected results.", + "aliases": [ + "tc", + "case", + "test case", + "testcase", + "test-case", + "tc-id", + "scenario" + ], + "id_convention": "TC-NNN", + "parents": [ + "folder", + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "executed in" + }, + { + "entity": "result", + "via": "produces" + }, + { + "entity": "custom_field", + "via": "extended by" + }, + { + "entity": "attachment", + "via": "has" + }, + { + "entity": "tag", + "via": "labelled by" + }, + { + "entity": "shared_step", + "via": "reuses" + } + ], + "capabilities": [ + "apply_test_case_review_verdict", + "bulk_archive_test_cases_by_project", + "bulk_copy_test_cases", + "bulk_delete_test_cases", + "bulk_replace_test_case_fields", + "bulk_edit_test_cases_in_test_run", + "bulk_move_test_cases", + "bulk_restore_archived_test_cases", + "count_binned_test_cases", + "create_bulk_test_cases", + "create_test_case_by_integer_id", + "create_test_cases", + "edit_test_case_partial", + "edit_test_case", + "generate_test_case_automation", + "list_archived_test_cases", + "get_system_field_values", + "get_test_case_by_integer_id", + "get_test_case_detail", + "list_test_cases", + "list_binned_test_cases", + "list_folder_test_cases", + "move_test_case", + "reorder_test_cases_by_folder", + "search_project_entities_by_filter", + "search_test_cases_by_folder", + "submit_test_cases_for_review", + "update_test_case", + "validate_bdd_export_selection" + ], + "key_facts": [ + "A filter is a (key, value) pair and each half fails silently: an unknown KEY is dropped (you get the whole project with success: true), and a wrong-form VALUE matches nothing (count: 0, also success: true). So resolve both — never guess either — and see pagination-and-filtering for the key table and each value's source.", + "A surprising count means check the FORM, not try another value: 0 on a value the user plainly meant = wrong form, so go resolve ids; filtered count == unfiltered count = the key was dropped. Never take either count from a per_page: 1 call — it returns count: 1 regardless of the real total.", + "required.fields ADDS joined columns; it does NOT slim a row, so naming fields never buys more rows per page. required.fields=[\"count\"] ALONE is the only value that changes the response.", + "The compact response has a DIFFERENT envelope: no `success` key ({test_cases, info, filter_details} only — absence of success is NOT failure), info.page_size echoes the server ceiling not your per_page, and filter_details is {} so confirm filter intent before the call.", + "READING ROWS to show a user: leave per_page at its default and walk p until info.next is null. p advances by per_page and not by what came back, so paging over an incomplete read never recovers the gap.", + "Paths take INTEGER ids (project + folder + case). The case's TC-NNN comes back as data.test_case.identifier on any read; folder ids are plain integers.", + "Every successful edit to a test case writes a new version, and an earlier version can be restored — so an edit is recoverable even though nothing offers an undo. Deleting the case is not.", + "That safety net is plan-gated: below an Enterprise or Team-Pro plan only the last 30 days of versions are visible, and restoring one needs a paid plan — so an older edit may not be recoverable in practice.", + "The newer v2 project surface addresses a test case by its TC-NNN identifier — or by its third-party identifier in workspaces that issue project-level ids — and does not accept the internal integer id; the older v1 surface is the reverse.", + "Custom fields on a test case are addressed by their display NAME on the v2 surface and by numeric field id on the v1 surface; a name the project does not define is skipped without any error, so a typo reads back as a successful call that changed nothing.", + "Priority, status and case type are per-project configurable values, not a fixed vocabulary: send the project's own display value (or its numeric id) and resolve the set from the project's system field values before writing.", + "Priority, status, case type and automation state are per-project configurable option sets. The single-case write paths accept either the numeric option id or the project's display name, but every BULK write requires the numeric id — a display name there is stored as a broken id with no error. Resolve the ids from the project's system field values first.", + "`status` is the exception on every path, single and bulk alike: it is only ever taken as a numeric option id, never resolved from a display name.", + "A case's steps are only ever read from `test_case_steps`. A key called `steps` is legacy, is parsed as a JSON-encoded STRING, and turns a real JSON array into an empty step list — wiping the case's steps while still reporting success.", + "`estimate` is a dead field: it is accepted on the write paths and then never stored, and every read serialises it as null. The estimate that actually persists is `estimated_duration_seconds`, in seconds, and it is not settable through any bulk edit.", + "Bulk operations switch to a background job above a size threshold that differs per operation — around 30 for a copy, 100 for an edit or move, 300 for a delete or restore. Past it the call returns an acknowledgement with a unique id instead of the result, so anything that reads the response body as the outcome is wrong for large selections.", + "Two bulk operations cannot touch the same cases at once: the second is refused with a 'TestCases in progress with another operation' error. That is a retry, not bad input.", + "Archiving and un-archiving test cases, and listing the archive, are gated on a paid plan — and the refusal comes back as a SUCCESSFUL response carrying success:false, not as an error status.", + "Whether deleting a case is recoverable depends on the workspace: where the Bin is enabled the case is moved there and can be restored, otherwise the delete is permanent. Either way the case's results and schedule links do not come back.", + "Creating a case from a template is not automatic: when neither a template name nor a step type is sent, the write paths default to the plain-text template, which stores no steps at all and still reports success." + ] + }, + "test_plan": { + "entity": "test_plan", + "title": "Test plan (and sub-plan)", + "description": "A container grouping several test runs, typically for one release or milestone.", + "aliases": [ + "tp", + "plan", + "milestone", + "sub test plan", + "subplan", + "stp", + "release plan", + "test cycle", + "sub-plan", + "child plan", + "parent plan" + ], + "id_convention": "TP-NNN (sub-plan STP-NNN)", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "groups" + }, + { + "entity": "test_plan", + "via": "parent/child via parent_plan_id" + } + ], + "capabilities": [ + "bulk_archive_test_plans", + "bulk_restore_archived_test_plans", + "clone_test_plan", + "create_test_plan_by_integer_id", + "delete_test_plan", + "get_archived_test_plan_count", + "get_test_plan_by_integer_id", + "get_test_plan_execution_trend", + "get_test_plan_linked_sessions_chart_data", + "list_test_plan_test_runs_by_integer_id", + "list_archived_test_plans", + "list_test_plans_by_integer_id", + "unlink_test_runs_from_test_plan", + "update_test_plan_by_integer_id", + "list_test_plans", + "create_test_plan", + "get_test_plan", + "create_sub_test_plan", + "list_test_plan_test_runs", + "update_test_plan" + ], + "key_facts": [ + "Paths take the INTEGER plan id. The prefixed identifier (TP-NNN / STP-NNN) is for display; never put it in a v1 path.", + "The read detail has NO data wrapper: the plan sits under a top-level test_plan key, and test_plan.identifier is the TP-NNN / STP-NNN.", + "Sub-plans are HIDDEN by default in the list. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", + "name IS REQUIRED ON EVERY PLAN UPDATE, including one that only touches test_runs. Omit it and the call is rejected with 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is — the server's edit branch is gated on name being present. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt. For example, {test_plan: {test_runs: [198]}} is a 400, and the same call with name added succeeds.", + "Run ids in test_runs are the INTEGER uuid from a run read — NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream service error). Take uuid, never id.", + "Linking a run MOVES it: a run belongs to at most one plan, so linking silently unlinks it from the plan that held it, with nothing in the 200 to say so. Read the runs' current membership first and tell the user which plans will lose runs.", + "test_runs accepts an id array OR a bulk-selection object {select_all, de_selected_ids, search_query}. select_all means 'the newest active runs', NOT 'everything matching' — and `filters` is accepted but SILENTLY IGNORED, so a filtered select_all links the whole recent set. Narrow with search_query (name/identifier substring) or send explicit ids.", + "bulk-archive / bulk-retrieve go ASYNC above the bulk threshold and return {success, async: true, unique_id} — the plans are NOT changed yet. Re-read to confirm; see async-jobs.", + "execution-trend is scoped by EXACTLY ONE of test_plan_id / test_run_id. Both → 400 'Provide either test_plan_id or test_run_id, not both'; neither → 400 'test_plan_id or test_run_id is required'.", + "execution-trend and linked-sessions-chart-data return chart DATA. There is no insights-widget object to create, update, or delete in this profile.", + "A plan's status only moves forward, one step at a time: new -> started -> completed. Once completed the plan is FROZEN — even a rename is refused with 'Cannot update completed test plan' — so complete it last.", + "Plans keep no version history. An overwritten name, description or date is gone for good; there is no prior revision to restore, unlike a test case.", + "The same plan is addressed two different ways depending on which generation of the API you are on: the newer surface takes the prefixed identifier (TP-NNN / STP-NNN), the older one the numeric id. Carry whichever the capability you picked declares.", + "On the newer surface a run's plan membership is changed from the RUN, never from the plan: the plan's own edit ignores a list of runs entirely (the older surface accepts one). Linking still moves the run out of whatever plan held it.", + "On the newer surface a sub-plan's parent is fixed when it is created — there is no re-parenting or promotion to a top-level plan there, and the plan's owner is always whoever created it.", + "Creating a plan takes a name and nothing else; a name is mandatory on create, on clone, and on almost every edit. A status supplied at creation time is accepted and then ignored — a new plan always starts in the `new` state.", + "A status change and a content edit cannot travel together: when a request moves the plan's status, everything else it carries (name, description, tags, issues, owner) is silently dropped. Rename first, then move the status.", + "Starting a plan needs its start date in the same request and completing it needs its end date; the dates already stored on the plan are not used as a fallback.", + "Archiving is an optional feature for the account. When it is switched off, every archive, restore and archived read answers as though the address did not exist — that is a feature gap, not a missing plan.", + "Reading or restoring archived plans requires the same permission as DELETING a plan, so someone who can happily create and edit plans may still be refused the archived view.", + "Archived plans vanish from the normal plan listing entirely and have their own listing and their own count; archived sub-plans appear in neither.", + "An archived plan reads as missing unless the read explicitly opts in to archived records.", + "Cloning a plan is a background job: the reply confirms the job was queued and carries no id, name or identifier for the copy, and nothing except the runs and sub-plans is inherited — the new plan's description, dates, tags and owner all come from the clone request.", + "Owner is given as the person's test-management user id, not their BrowserStack id or email, and an id that does not resolve inside the account is discarded rather than rejected — leaving the plan with no owner.", + "Tags are all-or-nothing: if any entry in the list is not a plain string the entire tag list is dropped without an error." + ] }, - "info": { - "$schema": "PageInfo", - "description": "Only when results are combined. Its count is the true total across all types, but its page size is the per-type page sizes added together rather than a real page size - three types searched at 30 each reports 90." + "test_run": { + "entity": "test_run", + "title": "Test run", + "description": "One execution cycle over a fixed set of test cases.", + "aliases": [ + "tr", + "run", + "execution", + "test cycle", + "cycle", + "test execution" + ], + "id_convention": "TR-NNN", + "parents": [ + "test_plan", + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "includes" + }, + { + "entity": "result", + "via": "produces" + }, + { + "entity": "configuration", + "via": "runs against" + }, + { + "entity": "test_plan", + "via": "grouped by" + } + ], + "capabilities": [ + "get_test_run_edit_selection", + "assign_test_run_owner", + "assign_test_run_test_cases", + "bulk_set_test_case_assignee_in_run", + "bulk_update_test_run_test_cases", + "clone_test_run_by_integer_id", + "clone_test_run_by_integer_id", + "close_test_run", + "count_run_selection", + "create_test_run_by_integer_id", + "create_test_run", + "delete_test_run", + "edit_test_run", + "export_test_run_csv", + "list_closed_test_runs", + "list_test_run_test_cases_by_integer_id", + "get_test_run_by_integer_id", + "get_test_run_detail", + "get_test_run_progress", + "list_test_run_test_cases", + "get_test_run", + "get_test_runs_form_fields", + "list_test_runs_selection", + "list_test_runs_by_integer_id", + "list_test_runs", + "replace_test_run", + "update_test_run" + ], + "key_facts": [ + "Only test_case_ids / selection / shared_selection put cases in a run. `filters` and `dynamic_filter` are a forward-sync rule, NOT a selection: a create carrying only `filters` returns 200 with ZERO cases, and sets is_dynamic: true even though you did not ask.", + "Dynamic runs match on nine fields only: priority, status, category, owner, automation_status, automation_state, folder, tags, issues. created_at/updated_at are stored and echoed on read but NEVER matched, so a 'last 7 days' dynamic run drops the date window and over-collects forever. Create runs static unless the user explicitly asks for sync.", + "test_run.run_state is MANDATORY on create and edit (new_run | in_progress | under_review | rejected | done | closed). It is validated before the selection, so a bare 400 'invalid data' means a bad test_run field — NOT a bad selection shape.", + "A run's `id` FIELD is the TR-NNN STRING, not the integer — on the project list, a plan's linked-run list and the single-run detail alike. The integer lives in `uuid` ({id: 'TR-90', uuid: 198}). Paths tolerate either form, which hides this; bodies and UI paths do not. Carrying `id` into a test plan's test_runs array sends a string that reaches the backing service uncoerced and 502s there. Take `uuid` whenever a run id leaves a read.", + "Full lifecycle is exposed: create, edit, assign, bulk-assign cases, bulk-edit results, clone, close, delete.", + "A test run keeps NO version history and has no restore: unlike a test case, an edited name, cleared tag list or removed case cannot be recovered. Confirm before overwriting or removing.", + "Removing a case from a run deletes the results recorded against it unless the caller explicitly asks to preserve them — the default is to discard.", + "Changing which cases a run covers is always queued: the call returns an acceptance immediately and the run still shows the old case list for a moment. Re-read the run's cases to confirm, and do not retry on the acceptance.", + "Every way of editing a run only ever ADDS cases to it. Taking cases out is a separate operation, and only that one can shrink a run.", + "The newer API generation identifies a run and its project only by their prefixed ids (TR-14, PR-1) and rejects the plain integers the older generation still tolerates.", + "Listing a project's runs hides closed ones by default. Unless the caller asks to include closed runs, or filters on a lifecycle state, a run count or 'all our runs' answer silently omits every closed run and still looks complete.", + "Filtering a run listing by owner fails open: an email that matches nobody in the group is dropped from the filter rather than refused, so a misspelled name returns a wider list that reads as a correct answer. Filtering by plan or by date fails closed and errors instead.", + "Creating a run does not populate it synchronously. The run exists as soon as the call succeeds, but its test cases are attached by background work, so an immediate read of the run's cases can come back empty or short. Re-read rather than retrying the create, which makes a second run.", + "A run's name is optional at creation: leave it out and the run is named 'Test Run' plus today's date, which is how projects end up with several identically named runs.", + "Marking a run as an automation run is not something a caller can do at creation — the flag is accepted and discarded, but it still decides which permission is demanded, so asking for it can be refused outright.", + "Rows in a run are per case-and-configuration pair, not per test case: a case covered by three configurations is three rows with the same case id and three different mapping ids. Every bulk operation on a run addresses those mapping ids, and passing the test case's own id instead returns success and changes nothing.", + "Cloning a run copies NO test cases unless the caller explicitly asks for them. A clone requested without a case selection succeeds and produces an empty run, and the clone always starts in the new-run state with no configurations, owner or sync rule carried over.", + "Bulk changes inside a run switch from immediate to queued above 300 selected rows, and the queued form returns an acknowledgement with no data. The selected rows are locked while either form runs, so a second bulk operation touching any of them is refused as contention rather than queued behind it.", + "Deleting a finished run needs a different permission from deleting one still in progress, so a role that can clear up working runs can be refused on completed ones.", + "Discovering how many cases a selection really covers is a distinct read: it multiplies the cases by the configurations, so the number it returns is routinely far larger than the number of cases chosen.", + "Editing a run requires resending its lifecycle state on every call, including edits that only change the name. A rejected edit that reports nothing more than 'invalid data' is almost always that missing state, not a bad case selection." + ] }, - "test_case_folders": { - "type": "object", - "description": "Only when results are combined and the merged list contains test-case rows: the folder lookup for those rows." + "user": { + "entity": "user", + "title": "User", + "description": "A member with access to a project: who you assign a case to, or set as an owner.", + "aliases": [ + "users", + "member", + "assignee", + "owner", + "user", + "members", + "people", + "teammate" + ], + "id_convention": "integer", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "member of" + }, + { + "entity": "test_run", + "via": "assigned to" + }, + { + "entity": "test_case", + "via": "owns" + } + ], + "capabilities": [ + "list_group_users", + "search_project_users" + ], + "key_facts": [ + "WRITE BODIES take the integer: test_case.owner, test_run.owner/assignee_id — send 14.", + "An unrecognised q key is dropped, not rejected — the server silently omits that condition and returns an unfiltered set that looks like a successful filter. Use the documented key names verbatim.", + "Every person has two ids: a Test Management user id and a BrowserStack account id. Owner and assignee fields take the Test Management id; several listing filters take the BrowserStack one instead. Sending the wrong one usually matches nobody rather than failing, so check which the operation wants.", + "Both user reads here are scoped to the whole workspace, not to a project — neither can answer 'who has access to this project', and one of them says so in its path while doing the opposite.", + "Name search matches the full name only; there is no partial-email search. An exact-email lookup exists and returns at most one person.", + "Only active users are listed, so someone who has left the workspace cannot be resolved even when their name is still on a case or run.", + "An unrecognised search key is dropped rather than rejected — the condition silently disappears and an unfiltered set comes back looking like a successful filter. Use the documented key names verbatim." + ] }, - "filter_details": { - "type": "object", - "description": "Only when explicitly requested with the exact lowercase word true: the filters that were applied, with ids resolved to names. Absent if resolution failed - the search itself still succeeds." - } - } - } - }, - "FolderV2WriteResponse": { - "description": "Public (v2) envelope returned after creating, renaming or moving a folder: the affected folder in its public shape.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" + "version": { + "entity": "version", + "title": "Test case version", + "description": "A point-in-time snapshot of a test case, for viewing or restoring an earlier revision.", + "aliases": [ + "history", + "histories", + "revision", + "version-history" + ], + "id_convention": "integer", + "parents": [ + "test_case", + "project" + ], + "relations": [ + { + "entity": "test_case", + "via": "versions" + }, + { + "entity": "user", + "via": "changed by" + } + ], + "capabilities": [ + "list_test_case_histories", + "get_test_case_history_diff", + "get_test_case_history", + "restore_history", + "get_test_case_histories" + ], + "key_facts": [ + "Each record carries source (create/update), modified_fields, a modified map (per-field old/new), user and created_at.", + "diff (compare two versions) and restore require a PAID plan; listing + single-version reads work otherwise. Degrade gracefully.", + "A plain-language diff summary is agent-side — read the field deltas and describe them; the API returns structured data, not prose.", + "A revision is addressed two different ways depending on the generation of the read: the older listing hands back a numeric history record id, the newer one identifies a revision only by its position label V1, V2, … with V1 the oldest revision still visible.", + "Without an Enterprise or Team Pro plan only the last 30 days of a test case's revisions are visible; older ones are absent with no flag saying so, and position labels count only what is visible, so they renumber as old revisions age out.", + "In the newer payload the editor arrives as updated_by, an email string that is null when the user cannot be resolved in this group, instead of the nested user object the older records carry.", + "A revision listing populates the per-field old/new detail only for the FIRST record on each page; every other record names the changed fields but not their values, so summarising a page of changes costs one extra read per revision.", + "Comparing two revisions only looks at a fixed set of fields (name, description, preconditions, automation status, type, status, priority, owner, assignee, folder, tags, defects, custom fields, steps, attachments, reviewers, review status). A change to anything else is invisible in a comparison.", + "Restoring is a full overwrite of the snapshotted fields, not a merge: a custom field that is empty in the snapshot is cleared, steps added since are removed, and attachments added since are unlinked. Say what will be lost before restoring.", + "A restore is applied as an ordinary edit, so the pre-restore state stays in the revision trail and can itself be restored — that is the only way back, and there is no undo action." + ] }, - "folder": { - "$schema": "FolderV2" - } - } - } - }, - "TestPlanV2DetailResponse": { - "description": "A single test plan (or sub-plan read through the plan route). No data wrapper - the plan sits at the top level under `test_plan`, and on this route alone it can carry the embedded run preview and the relative API links.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true + "comment": { + "entity": "comment", + "title": "Comment", + "description": "A rich-text note a team member posts on a test case, on its execution in a test run, or on a test plan, to discuss it.", + "aliases": [ + "comments", + "note", + "notes", + "remark", + "feedback", + "discussion", + "thread", + "review comment", + "review feedback", + "mention", + "@mention", + "annotation", + "message" + ], + "id_convention": "Plain integer, assigned on creation and returned as `id` on the posted comment. Existing comment ids are not guessable and must be discovered through the comment listing.", + "parents": [ + "test_run", + "test_case", + "test_plan" + ], + "relations": [ + { + "entity": "test_case", + "via": "posted on" + }, + { + "entity": "test_run", + "via": "scoped to the case's execution in" + }, + { + "entity": "user", + "via": "authored by / mentions" + } + ], + "capabilities": [ + "list_test_run_test_case_comments", + "create_test_run_test_case_comment" + ], + "key_facts": [ + "A comment can hang off a test case in its folder, off a test case's execution inside a specific test run, or off a test plan. Only the test-run variant is published in this pass; folder-scoped test-case comments and test-plan comments exist in the product but are not exposed here.", + "Comments are a paid-plan feature. On a free plan every comment call comes back HTTP 200 with success:false and the message 'This Feature is only for paid customers' — the body, not the status, carries the refusal.", + "Reading and posting comments both need the test-case VIEW permission; no separate comment or write permission exists, so anyone who can see the case can comment on it.", + "Comments cannot be posted on automation test runs (Automate/Observability-backed), and not on an archived test case; reading an archived case's comments still works.", + "Mentioning a colleague — via the mention span markup carrying data-lexical-mention, data-mention (their display name) and data-id (their BrowserStack user id) — notifies them immediately, and the run owner is alerted on every new comment. A mention of someone outside the group fails the whole post; a span missing data-mention is accepted but shows as plain text rather than a tag.", + "Display names are not unique within an account — two people can share one — so resolve the person to mention by email, not name, and take their BrowserStack user id from that row.", + "The body is HTML rich text. Whitespace-only or tag-only content is refused; an inline image counts as content. Stored HTML can differ from what was sent (links normalised, mentions rewritten).", + "Threads are per run/case/configuration cell: a case mapped to three configurations in a run has three separate comment threads, chosen by the mapping id.", + "Listings come newest first, 30 to a batch, and the size cannot be changed. Asking beyond the end returns the last batch again rather than an empty one, so stop when the next-batch marker is null.", + "Posting a comment is recoverable: the author can edit it or delete it afterwards, and deletion hides the comment rather than destroying it. Neither edit nor delete is exposed in this pass.", + "On each returned comment `edited` is true once it has been changed after posting; the author object's `id` is the internal TM user id (not the BrowserStack user id) and is null for imported comments, which show the original author's name instead.", + "Mentioning a colleague in a comment notifies them straight away, and the owner of the item commented on is notified of every new comment. Both are real messages that leave the product and cannot be recalled.", + "A person can only be mentioned if they belong to the same account; naming anyone else is refused rather than quietly ignored.", + "Only the person who wrote a comment can edit or delete it. Editing replaces the earlier text and keeps no previous version, and a deleted comment disappears from every listing with no self-serve way to restore it.", + "Comments are stored as rich text, so formatting, links and images survive, and a mention is stored as markup that displays as the person's name.", + "New comments cannot be added to an archived test case, although the comments already on it stay readable.", + "Comments are flat - each one sits on the item itself, and there is no way to reply beneath another comment.", + "Commenting is a paid-plan feature; on a plan without it the answer reports the feature as unavailable instead of failing." + ] }, - "test_plan": { - "$schema": "TestPlanV2Detail" - } - } - } - }, - "SharedComponentWriteResponse": { - "description": "The saved shared field, echoed back under `data`.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "data": { - "$schema": "SharedComponentDetail" + "issue": { + "entity": "issue", + "title": "Linked issue", + "description": "A ticket in an external tracker such as Jira that test cases and runs are linked to.", + "aliases": [ + "requirement", + "ticket", + "jira issue", + "bug", + "work item" + ], + "id_convention": "The external tracker's own ticket key, as a string — for Jira an uppercase project key, a hyphen and digits, e.g. PROJ-123; other trackers use their own form. It is never a Test Management integer id and cannot be minted here: take it from the tracker itself, or read it from the linked-ticket list carried on a test case or test run. Test Management does not validate that the key exists, so a wrong key links silently.", + "parents": [], + "relations": [ + { + "entity": "test_case", + "via": "linked to" + }, + { + "entity": "test_run", + "via": "linked to" + }, + { + "entity": "project", + "via": "scoped by" + } + ], + "capabilities": [ + "link_entities_to_jira_issue", + "get_test_case_linked_issues", + "get_linked_test_case_selection", + "unlink_test_case", + "unlink_test_run" + ], + "key_facts": [ + "A linked issue is not stored by Test Management — only the link is. Unlinking removes the association and leaves the test case, the test run and the tracker ticket untouched, so every unlink here is reversible by linking again.", + "Linking and unlinking never write anything back into the external tracker; the ticket is not commented on, transitioned or modified.", + "One ticket can carry many test cases and many test runs, and one test case can be linked to several tickets, including tickets in different trackers.", + "The tracker a link belongs to is part of its identity: the same ticket key recorded under two different trackers is two independent links.", + "Unlinking identifies the link by the test case or test run plus the tracker, not by the ticket, so a case linked to several tickets in one tracker cannot be unlinked from a chosen one.", + "Test Management also has a separate Requirements feature of its own; a linked issue always lives in an external tracker and is a different thing, even though customers call both 'requirements'.", + "Ticket keys are never checked against the tracker, so a mistyped key produces a link to a ticket that does not exist and reports success." + ] + }, + "shared_field": { + "entity": "shared_field", + "title": "Shared field", + "description": "The foldered, typed form of a reusable content block: the same step rows plus a field type, a folder and tags.", + "aliases": [ + "shared component", + "shared field", + "reusable field", + "collection" + ], + "id_convention": "Plain integer, not guessable; discover ids by listing a project's shared fields. A shared step id comes from a different set and will not resolve as a shared field.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "belongs to" + }, + { + "entity": "test_case", + "via": "embedded by" + }, + { + "entity": "folder", + "via": "organised into collections" + }, + { + "entity": "tag", + "via": "labelled with" + } + ], + "capabilities": [ + "list_shared_components" + ], + "key_facts": [ + "A shared field is not a shared step. They are separate objects with separate listings and separate ids, and they are easy to confuse because both are reusable content - establish which one the user means before acting on an id.", + "The API calls this object a shared component while the product calls it a shared field; they are the same thing under two names.", + "Every shared field has a type - step, background or precondition - and the type decides where a test case can embed it.", + "Shared fields are organised into collections, which nest. A field placed in no collection sits in the Unassigned root.", + "A shared field is embedded by many test cases at once, so editing or deleting one changes every case that uses it; the usage count on the field is the measure of that blast radius.", + "Distinct from a shared step and stored separately: creating one does not make the other, and each is listed only by its own read.", + "Only the listing is available here. Creating, editing, deleting, copying and reading one by id all exist in the product but are not offered on this surface, so a block seen here cannot be changed here." + ] } - } - } - } - }, - "paging": { - "POST /api/v1/projects/{project_id}/test-cases/bulk-archive": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-copy": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-delete": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-edit": { - "page": "p" - }, - "PATCH /api/v1/projects/{project_id}/test-cases": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-move": { - "page": "p" - }, - "POST /api/v1/projects/{project_id}/test-cases/bulk-retrieve": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/{entity_type}/custom-fields/{field_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets/{uuid}/test-cases": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets/variables": { - "page": "p" - }, - "GET /api/v1/projects/basic": { - "page": "p", - "size": "count", - "max": 300 - }, - "GET /api/v1/projects/minify": { - "page": "p", - "size": "count", - "max": 300 - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}/{section_name}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/{report_id}/detail/{report_type}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/folders": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/shared-steps/{shared_step_id}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/shared-steps": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-case/{field_name}": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-cases": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-results/custom-fields": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-plans/archived_test_plans": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/datasets": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/ai-duplicates": { - "page": "page" - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/defects": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions/{session_id}/logs": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/exploratory-sessions": { - "page": "page", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/filter": { - "page": "page" - }, - "GET /api/v1/projects/{project_id}/folder/{folder_id}/test-cases": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/reports/schedules": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-case/tags-v2": { - "page": "p" - }, - "GET /api/v1/admin-v2/settings/templates": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/test-plans": { - "page": "p" - }, - "GET /api/v1/admin-v2/settings/fields": { - "page": "p" - }, - "PATCH /api/v1/projects/{project_id}/folder/{folder_id}/test-cases/reorder": { - "page": "page" - }, - "POST /api/v1/projects/{project_id}/ai-duplicates/search-v2": { - "page": "page" - }, - "GET /api/v1/group/{entity_type}/tags": { - "page": "p" - }, - "GET /api/v1/projects/{project_id}/{entity}/search": { - "page": "p", - "size": "page_size", - "max": 100 - }, - "POST /api/v1/projects/{project_id}/{entity}/search-v2": { - "page": "p", - "size": "per_page", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/users/search": { - "page": "p", - "size": "page_size", - "max": 100 - }, - "GET /api/v1/projects/{project_id}/test-case/tags/search": { - "page": "p", - "size": "page_size", - "max": 100 - }, - "POST /api/v1/projects/{project_id}/reports/{report_id}/drilldown": { - "page": "p", - "size": "per_page", - "max": 100 - } - }, - "entities": { - "activity": { - "entity": "activity", - "title": "Activity feed (audit trail)", - "description": "Group-wide audit trail of who changed what, on which entity, and when.", - "aliases": [ - "activity", - "activities", - "activity feed", - "audit log", - "audit trail", - "history feed", - "who changed" - ], - "id_convention": "integer", - "parents": [], - "relations": [ - { - "entity": "project", - "via": "scopes events" - }, - { - "entity": "user", - "via": "actor of an event" - }, - { - "entity": "test_case", - "via": "subject of an event" - }, - { - "entity": "test_run", - "via": "subject of an event" - } - ], - "capabilities": [ - "list_activity_events" - ], - "key_facts": [ - "READ-ONLY by design. Activity events are emitted by the system as a side effect of other writes \u2014 there is no create, update, or delete, and there never will be. If asked to edit or remove an audit entry, say it is immutable.", - "HARD 15-DAY WINDOW. The server clamps every query to the last 15 days no matter what you ask for. For anything older, say the history is not retrievable through the API and point to the Test Management UI \u2014 do NOT report 'no activity' for an older period, which would be a false negative.", - "CURSOR pagination, not page numbers. Follow pagination.next_cursor and stop when pagination.has_next_page is false. There is no p param and no info.count, so you CANNOT state a total number of events or jump to a page.", - "Not project-scoped in the path \u2014 it is group-wide. Pass project_id as a QUERY param to narrow it to one project.", - "starred_only=true restricts to the caller's starred projects. When they have none the response is {events: [], no_starred_projects: true} \u2014 that means 'no starred projects', NOT 'no activity'. Check for that flag before reporting emptiness.", - "The response carries a projects map (project id \u2192 metadata) so you can resolve the project an event refers to without extra reads.", - "Gated by a group feature flag. If the call is refused, report that the activity feed isn't enabled for the group rather than that there is no activity." - ] - }, - "attachment": { - "entity": "attachment", - "title": "Attachments", - "description": "A file hanging off a parent test case or test result.", - "aliases": [ - "attachment", - "file", - "attachments", - "blob", - "upload", - "evidence", - "screenshot", - "blob id" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "attached to" - }, - { - "entity": "result", - "via": "attached to" - } - ], - "capabilities": [ - "delete_test_case_attachment", - "list_test_case_attachments", - "list_entity_attachments", - "upload_ai_attachments", - "upload_generic_attachments", - "link_attachments_to_test_case" - ], - "key_facts": [ - "ai_uploads uploads a file as AI CONTEXT (restricted types, smaller cap) for seeding case content \u2014 see create-test-case.", - "Each blob/attachment row carries an integer id and a url (the file's location) \u2014 read the file from that url; there is no dedicated download endpoint.", - "A file is attached in TWO steps: upload it to the project to get a numeric blob id, then link that id to the test case, result or step. The link calls take ids, never file content or filenames.", - "A blob can be linked ONCE. Attaching one that is already referenced elsewhere fails the whole request; upload a separate copy for each place the file should appear.", - "Putting a NEW file into the product cannot be done through this interface at all \u2014 an agent can link an already-uploaded file, remove one, and read attachment metadata, but the upload itself has to happen in the product UI.", - "Removing an attachment purges the stored file, not just the link. There is no recycle bin for attachments and no undo.", - "The view and download URLs handed back on upload are pre-signed and expire after about 30 minutes \u2014 fetch the file promptly or re-read the attachment to get a fresh one.", - "The older per-test-case attachment reads return metadata only (id, filename, type, size) with no URL; the newer entity-attachment listing is the one that hands back a readable URL per file.", - "A file cannot be attached to a test case through this surface at all. The call that does it works, but it takes the file itself as a form upload, which this transport cannot send \u2014 the same reason the upload capabilities here accept no file and quietly do nothing. Attaching is done in the product, not here.", - "An empty attachment list is not evidence that nothing is attached; it is the expected result whenever the attaching was done elsewhere." - ] - }, - "configuration": { - "entity": "configuration", - "title": "Configurations", - "description": "An operating system, browser or device combination that a test run executes against.", - "aliases": [ - "config", - "configuration", - "environment", - "browser", - "os", - "device", - "configs", - "configurations", - "platform" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "case status tracked against" - } - ], - "capabilities": [ - "create_configuration", - "list_configurations" - ], - "key_facts": [ - "Configurations live at workspace level, not per project: one created while working in a project appears in every project in the workspace, and the project listing returns the same workspace-wide set everywhere.", - "Only a configuration's name can be set when creating one. The operating system, browser and device breakdown that reads return cannot be supplied through the API.", - "Test runs reference a configuration by its integer id, never by name or by the configuration object.", - "Configurations are gated on the test-run permissions rather than the test-case ones, and there is no way to delete or rename one through this profile \u2014 cleanup is a UI operation." - ] - }, - "custom_field": { - "entity": "custom_field", - "title": "Custom fields & system fields", - "description": "A user-defined field on test cases, results or plans, alongside the built-in system fields.", - "aliases": [ - "custom field", - "custom fields", - "field", - "system field", - "form field", - "dropdown", - "picklist", - "option", - "option value", - "dataset", - "field definition", - "field values", - "extra field", - "user defined field" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "extends" - }, - { - "entity": "result", - "via": "extends" - } - ], - "capabilities": [ - "create_custom_field_dataset_option", - "create_custom_field_dataset", - "create_custom_field_definition", - "create_custom_field", - "delete_custom_field_dataset_option", - "delete_custom_field_dataset", - "delete_custom_field_definition", - "get_custom_field_dataset", - "get_custom_field_definition", - "get_custom_field", - "get_custom_field_values", - "get_project_form_fields", - "get_test_results_custom_fields", - "list_custom_field_dataset_options", - "list_custom_fields", - "list_workspace_fields", - "update_custom_field_dataset_option", - "update_custom_field_dataset_project_mapping", - "update_custom_field_definition", - "update_custom_field" - ], - "key_facts": [ - "A field applies to ONE entity type (TestCase | TestResult | TestPlan) \u2014 pick the matching entity_type; project scope is via the integer project id in the path.", - "A definition write is workspace-wide, not project-scoped: it affects every project the field's datasets link to. Say which projects are affected BEFORE writing.", - "field_name IS editable via .../update; field_type is SILENTLY immutable \u2014 sending a new type returns 200 and changes nothing. Never report a type change as done; a real one means delete-and-recreate, which destroys every stored value.", - "Update is a full REPLACE: field_name + field_type + is_required must all be present (missing is_required = 400, missing field_name = 500). Read the field first and resend the whole set.", - "The project-mapping endpoint uses link_projects / unlink_projects \u2014 NOT the linked_projects of the create bodies. The wrong spelling is silently dropped: 200, nothing changed.", - "Three operations destroy stored data across every linked project: deleting a field, deleting an option, and unlinking a project. There is no bin. Count and name the affected projects before asking to proceed.", - "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Only values are writable here, through the test-case create/update body's custom_fields.", - "A field applies to ONE entity type (TestCase | TestResult | TestPlan | ExploratorySession | TestCaseStep) and cannot be moved later. TestCaseStep fields need the group's step-custom-fields feature, and cannot be url or nested_dropdown.", - "A field DEFINITION is workspace-wide; a DATASET is what makes it visible in a project, by pairing an option list with a set of projects. A field with no dataset, or a dataset with no linked projects, exists but appears nowhere.", - "A project may belong to only one dataset per field. Linking a project that a sibling dataset already holds is rejected \u2014 read the field's existing datasets before linking.", - "Editing a field definition is a full REPLACE: the label, the mandatory flag and the type must all be resent, and a placeholder or boolean default you leave out is cleared. Read the field first and send back everything you want to keep.", - "Three operations destroy stored values across every linked project and cannot be undone: deleting a field, deleting an option, and unlinking a project from a dataset. There is no bin. Count and name the affected projects before asking to proceed.", - "Renaming an option keeps its id, and stored values point at the id \u2014 so a rename loses nothing and is the safe way to correct a wrong label. Deleting and recreating is not.", - "Field types have two spellings. Definitions are written with the canonical names (string, text, user, dropdown, multi_dropdown, url, boolean, int, date, nested_dropdown); the project form-field and field-value reads return the legacy field_* forms (field_dropdown, field_string, \u2026). Translate between them, and expect null where a nested_dropdown has no legacy name.", - "Don't confuse a DEFINITION with a VALUE. A definition is which attributes exist; a value is what one case holds. Nothing here writes values \u2014 those are set when creating or editing the test case, result or plan itself.", - "A default value can only ever be set on a boolean field. On every other type the admin write calls force it to null, so a default cannot be created or preserved there." - ] - }, - "dataset": { - "entity": "dataset", - "title": "Dataset", - "description": "A named table of variables and rows that drives data-driven execution of a test case.", - "aliases": [ - "datasets", - "data-driven-data", - "dds", - "data driven testing", - "data-driven test data", - "test data table", - "dataset variables", - "dataset columns", - "parameterised test data", - "csv test data" - ], - "id_convention": "uuid", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "drives" - }, - { - "entity": "variable", - "via": "has columns" - } - ], - "capabilities": [ - "create_dataset", - "delete_dataset", - "get_dataset", - "list_dataset_linked_test_cases", - "get_dataset_variables", - "import_dataset_csv", - "list_datasets", - "update_dataset" - ], - "key_facts": [ - "Team Pro plan gated: without it EVERY dataset endpoint returns 422 \"Feature not available\". A 422 here means NOT-ENTITLED, not a bad payload \u2014 say so and stop, don't retry with a different body.", - "Datasets keep NO version history and there is no restore: an update replaces the whole table of columns and rows, and the previous contents are unrecoverable. Read a dataset before writing to it.", - "What you send is not what gets stored. Blank rows and columns that are empty in every row are dropped, cell values are trimmed, rows that end up identical are collapsed to one, and row numbers are reassigned from 1. Always read the stored result back.", - "Column names are restricted to letters, digits and underscores, must start with a letter, and must be unique within a dataset (compared case-insensitively).", - "Default size limits are 40 columns and 100 rows per dataset; groups on the raised-limit flag get 100 columns and 200 rows.", - "Deleting a dataset is permanent and is not blocked by test cases still bound to it \u2014 check what it drives first." - ] - }, - "duplicate": { - "entity": "duplicate", - "title": "Duplicate recommendation (AI dedupe)", - "description": "A machine-suggested pair of near-identical test cases, for a human to review and merge.", - "aliases": [ - "duplicate", - "duplicates", - "dedupe", - "deduplication", - "duplicate recommendation", - "ai duplicates", - "redundant test cases", - "duplicate test cases", - "near-duplicate", - "merge duplicates", - "duplicate review", - "duplicate suggestions", - "dedupe queue", - "similar test cases" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "links the pair it believes are duplicates" - } - ], - "capabilities": [ - "discard_duplicate", - "get_dedupe_status", - "get_duplicate_source_test_case", - "get_duplicate", - "list_duplicates", - "resolve_duplicate", - "search_duplicates" - ], - "key_facts": [ - "There is NO create. Duplicate suggestions are produced by the dedupe pipeline; a caller cannot author one.", - "Only status=suggested can be read or resolved; already-merged/archived/discarded suggestions return 404. A 404 here usually means 'already handled', not 'never existed'.", - "source_tc works ONLY for status=merged, and the snapshot may legitimately be absent (404 with error 'Source test case snapshot not available'). Treat that as 'not retained', not as a retryable error.", - "This list family pages with `page`, not the `p` used elsewhere in the profile.", - "Dedupe REVIEW is exposed here; TCG test-case GENERATION is not. Don't offer to generate cases.", - "Duplicate review can be paused entirely while the dedupe data is migrated \u2014 resolving or discarding then fails with a conflict and an explicit 'try again shortly' message. Nothing was changed; it is safe to retry later.", - "Resolving a duplicate requires the same permission as removing a test case, while discarding one needs only edit permission. A reviewer who can dismiss suggestions may still be unable to merge them.", - "Merging archives the source test case and tags it 'Archived as Duplicate', posts a comment on the surviving case, and stores a frozen snapshot of the source. Archived cases are recoverable; the comment and the status change are not undone by recovering one.", - "The review queue is served ten pairs at a time, ordered by model confidence rather than age, and the page size cannot be increased \u2014 the oldest suggestion is not reachable by asking for more rows.", - "A pair whose test case can no longer be resolved is dropped from the queue, so the reported total can exceed the pairs actually returned." - ] - }, - "exploratory_session": { - "entity": "exploratory_session", - "title": "Exploratory session", - "description": "An unscripted, charter-driven testing session and the observations recorded during it.", - "aliases": [ - "session", - "exploratory", - "et-session", - "exploratory testing", - "charter", - "session log", - "session note", - "bug bash" - ], - "id_convention": "integer", - "parents": [ - "project", - "folder" - ], - "relations": [ - { - "entity": "exploratory_log", - "via": "records" - }, - { - "entity": "issue", - "via": "links defects" - }, - { - "entity": "configuration", - "via": "runs against" - }, - { - "entity": "test_case", - "via": "notes become" - } - ], - "capabilities": [ - "clone_exploratory_session", - "close_exploratory_session", - "create_exploratory_session", - "create_exploratory_session_log", - "delete_exploratory_session", - "delete_exploratory_session_log", - "get_exploratory_session", - "list_exploratory_session_defects", - "list_exploratory_session_logs", - "list_exploratory_sessions", - "update_exploratory_session", - "update_exploratory_session_log" - ], - "key_facts": [ - "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1).", - "Logs hold rich-text HTML sanitised on write (off-project anchor/image links stripped); defects are the issues linked to the session.", - "Clone runs async when the session has many logs \u2192 re-fetch to confirm (see async-jobs).", - "No convert endpoint: turn session notes into test cases via the create-test-case flow.", - "session_id and log_id are plain INTEGERS; the parent project takes the INTEGER project id (v1). The display identifier (ES-NNN) is never accepted as an input.", - "A log entry belongs to exactly one session \u2014 both the session id and the log id are needed to change or remove a log, and a log id is not addressable under a different session.", - "Logs hold rich-text HTML sanitised on write (anchors and images pointing outside the project or group are stripped); screenshots are attached by uploading the image first and referencing its id inside the HTML, never by sending bytes.", - "Session-level 'defects' are the issue-tracker links recorded during the session \u2014 both links on the session and links made on individual logs; each row says which it is.", - "Closing a session is one-way: nothing accepts a state change and there is no reopen, so a closed session only leaves the default listing, it never comes back to it.", - "Neither sessions nor logs keep version history, and neither has a recycle bin \u2014 a deletion or an overwritten note cannot be recovered.", - "Listing sessions shows only ACTIVE ones unless the closed state is asked for explicitly.", - "Cloning a session with 50 or more logs is handled in the background and returns before the copy exists; re-read the session list to confirm. A clone does not carry over the charter or the folder.", - "Exploratory sessions are governed by the same permissions as test runs; deleting a closed session needs the closed-run delete permission specifically." - ] - }, - "filter": { - "entity": "filter", - "title": "Saved filter view", - "description": "A saved, named set of filter conditions a user can reapply to a list view.", - "aliases": [ - "filter", - "filters", - "saved view", - "saved filter", - "view", - "saved search", - "smart filter", - "filter view" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "filters" - }, - { - "entity": "test_run", - "via": "filters" - }, - { - "entity": "test_plan", - "via": "filters" - } - ], - "capabilities": [ - "create_filter", - "delete_filter", - "get_filter", - "list_filters", - "update_filter" - ], - "key_facts": [ - "A saved filter view persists a reusable filter: { name, entity, filters, is_project_level }. entity = testcase | testplan | testrun | exploratory_session; filter_id is an integer.", - "is_project_level scopes the view to the whole project vs. just the creator.", - "A saved filter view has no version history and no bin \u2014 an overwritten name or condition set, and a deleted view, cannot be restored. Read one before changing it if the conditions matter.", - "Reading a single saved view can change it: conditions on custom fields that no longer exist are pruned from the stored filter as a side effect of the read. The listing does not do this.", - "Saving a view as project-level publishes it to everyone on the project, and anyone with project access can then edit or delete it \u2014 there is no owner-only protection." - ] - }, - "folder": { - "entity": "folder", - "title": "Folder", - "description": "A container organising test cases inside a project; folders nest to form a tree.", - "aliases": [ - "dir", - "directory", - "test case folder", - "repository folder", - "test suite", - "suite", - "module" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "folder", - "via": "nests under" - }, - { - "entity": "test_case", - "via": "organises" - } - ], - "capabilities": [ - "copy_folder", - "create_root_folder", - "create_sub_folder", - "delete_folder_and_contents", - "get_folder_remove_summary", - "get_folder_tree", - "list_root_folders", - "list_folder_contents", - "move_folder_by_integer_id", - "rename_folder_by_integer_id", - "reorder_folders", - "delete_folder_and_subtree" - ], - "key_facts": [ - "Folder ids are plain INTEGERS (e.g. 54) \u2014 never FL-\u2026 strings. Nesting is via parent_id (integer, null = root).", - "mv cross-project and copy run ASYNC ({ success, async, unique_id }) \u2014 confirm by re-reading the destination (see async-jobs).", - "Deleting a folder is recursive and takes every subfolder and every test case beneath it. Nothing in this product restores a deleted folder \u2014 only the test cases can come back, and only from the recycle bin when that feature is switched on for the group.", - "Folders keep no version history, so a rename or a notes edit cannot be rolled back to a previous value.", - "Folder names must be unique among their siblings. Creating or renaming into a clash fails unless auto-rename is asked for; copying a folder resolves the clash silently by prefixing the copy's name.", - "Folder notes are stored as sanitised HTML with signed image links injected on read, so the text that comes back is not byte-identical to the text that was sent.", - "Copying a folder, and moving one to a different project, are background jobs: the call returns a tracking id and nothing else, and the only way to confirm the outcome is to re-read the destination." - ] - }, - "project": { - "entity": "project", - "title": "Project", - "description": "The top-level container that owns every folder, test case, run, plan and report.", - "aliases": [ - "pr", - "workspace", - "project id", - "pr-id", - "test project" - ], - "id_convention": "PR-NNN", - "parents": [], - "relations": [ - { - "entity": "folder" - }, - { - "entity": "test_case" - }, - { - "entity": "test_run" - }, - { - "entity": "test_plan" - }, - { - "entity": "configuration", - "via": "scopes" - }, - { - "entity": "custom_field", - "via": "scopes" - }, - { - "entity": "user", - "via": "grants access to" - } - ], - "capabilities": [ - "create_project", - "export_dashboard_analytics", - "get_active_test_runs_info", - "get_automation_stats", - "get_closed_test_runs_info", - "get_closed_test_runs_split", - "list_entity_filter_details", - "get_issues_count_info", - "get_project_by_integer_id", - "get_project_settings", - "list_project_users", - "get_projects_basic", - "get_projects_minify", - "get_test_case_count_trend", - "get_test_case_type_split", - "list_projects", - "search_project_entities", - "update_project_settings" - ], - "key_facts": [ - "Project ids come in two forms that are not interchangeable: an integer id and a PR-NNN identifier. Nearly every v1 project capability takes the integer; the v2 routes take PR-NNN. Read a project once to get both.", - "A project the caller cannot see is reported as 'not found' rather than 'forbidden', so a missing project and a permission gap look identical.", - "Projects are never deleted through the capabilities in this index \u2014 a project created by mistake can only be removed in the product UI.", - "Project settings are stored as free-form name/value pairs and keep no history: overwriting one loses the previous value, and a setting that was never written simply has no entry rather than a default.", - "Project-level analytics are aggregate counters only; none of the dashboard figures can be drilled down to the individual cases or runs behind them.", - "Listing projects and searching inside a project are different surfaces with different pagination: project listings cap at 300 per page, in-project search at 300 for test cases and 30 for test runs." - ] - }, - "report": { - "entity": "report", - "title": "Report", - "description": "A saved, optionally scheduled analytics view over a project's test data.", - "aliases": [ - "reports", - "scheduled-report", - "schedule", - "analytics-report", - "report-section", - "report-widget", - "report-drilldown", - "traceability-report", - "user-workload-report", - "exploratory-session-summary", - "report-export" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "summarises" - }, - { - "entity": "test_plan", - "via": "summarises" - }, - { - "entity": "test_case", - "via": "traces (traceability)" - }, - { - "entity": "user", - "via": "mailed to" - } - ], - "capabilities": [ - "create_report_by_integer_id", - "create_report_by_integer_id", - "delete_report_schedule", - "download_report", - "get_exploratory_session_summary", - "get_report_attachment_url", - "get_report_detail", - "get_report_section", - "get_report_summary_by_type", - "get_report", - "get_selected_report_testcases", - "list_reports", - "list_schedules", - "send_report_email_now", - "update_report", - "get_user_workload_drilldown" - ], - "key_facts": [ - "report_id and schedule_id are INTEGERS; the parent project takes the INTEGER project id (v1).", - "report_type on create/update is a DISPLAY NAME and the set is CLOSED \u2014 exactly these nine: Test Run Summary, Test Run Detailed Report, Test Plan Summary, Requirement Traceability Report, Test Case Activity, Exploratory Session Summary, User Workload Report, Defects Summary, Defects Detailed Report. Never invent one and never substitute a neighbouring type: if the user asks for activity, it is 'Test Case Activity', not 'Test Run Summary'.", - "Defects Summary and Defects Detailed Report are accepted on create but have no data branch \u2014 they always read back report_data: null. For defect counts use Test Run Summary's issues_by_priority / issues_by_status sections instead.", - "null inside report_data means NOT COMPUTED, not 'no data'. A Test Case Activity report fills its headline counts on a bare read; a Test Run Summary leaves issues_data / test_run_status / test_cases_status / issues_by_priority / issues_by_status null until you name them in sections. Never conclude 'there is no defect or status data' from a null \u2014 request the section; an empty payload like {total: 0} is a real finding, null is not. And never answer a 400 on sections by retreating to a bare read.", - "*_drilldown sections return PAGINATED ROWS, not aggregates \u2014 never tally rows into a count; read the aggregate section or report_data.", - "frequency + frequency_details travel together or not at all; omit both for an unscheduled on-demand report. weekly needs day as a lowercase name, monthly needs day as an integer 1-28. There is no 'once'. report_timeframe: custom REQUIRES custom_date_range.", - "\"Understand a report\" = fetch the report + its section/widget data and summarise agent-side; the API returns structured data, not narrative.", - "There is a tenth report type, Test Plan Detailed Report, hidden behind a per-group feature flag \u2014 creating, reading or exporting one without the flag is refused as not permitted.", - "A report is found by its id within your group, not within the project you name: the project you address it through does not narrow the lookup, so an id belonging to a sibling project still resolves (and still deletes).", - "Reports are not versioned and there is no undo. An edit replaces the entire definition \u2014 every field you leave out is cleared, and filters you leave out are deleted \u2014 and a delete is permanent, silent, and reports success even for an id that never existed.", - "Whether a report is scheduled is inferred, never declared: it is scheduled exactly when both a cadence and its details are present. Drop either one on an edit and the report stops emailing. Recipients are only stored on a scheduled report.", - "A report can span up to 60 projects, but only for Test Run Summary, Test Plan Summary, Test Run Detailed, Test Plan Detailed, Test Case Activity, Requirement Traceability and User Workload \u2014 and User Workload stops at 5.", - "A report file cannot be downloaded through the API: the export call only hands back a job channel, so it is withheld. To get the file, mail it with send_report_email_now (confirm the recipient first \u2014 it sends immediately); to work with the figures, read them with get_report_summary_by_type or get_report_detail.", - "Listing reports is fixed at 30 per page and always newest-first; the page size cannot be changed and asking for a page past the end is an error rather than an empty page.", - "Requirement Traceability reports have a grain \u2014 test case (default) or test plan \u2014 fixed when the report is created and refused on any later edit." - ] - }, - "result": { - "entity": "result", - "title": "Result", - "description": "The recorded outcome of one test case in one run: its status, and who set it.", - "aliases": [ - "outcome", - "execution-result", - "test-result" - ], - "id_convention": "integer", - "parents": [ - "test_run", - "test_case" - ], - "relations": [ - { - "entity": "test_case", - "via": "references" - }, - { - "entity": "test_run", - "via": "references" - } - ], - "capabilities": [ - "bulk_delete_test_results", - "bulk_set_test_result_status", - "create_step_result", - "create_test_result_for_test_case", - "create_test_results_for_test_run", - "delete_test_result", - "list_test_results_for_test_case_by_integer_id", - "list_test_results_for_test_case", - "search_test_results", - "update_latest_test_result_for_test_case" - ], - "key_facts": [ - "Bodies are wrapped in test_result; set status by NAME (status) or id (status_id) \u2014 use a real configured status (see statuses-and-states).", - "bulk-delete SUCCEEDS PARTIALLY and still returns 200. Authorisation is per id (author OR project admin); ids the caller may not delete come back in `skipped` \u2014 and BOTH lists are nested under a top-level `result` key, with `skipped` holding OBJECTS ({id, reason}) not bare ids, so read result.deleted_ids / result.skipped. Not as an error. ALWAYS compare deleted_ids against what you asked for and report the skipped ids \u2014 treating 200 as 'all deleted' will misreport.", - "bulk-delete needs a non-empty result_ids array (400 otherwise) of at most 300 ids (422 beyond) \u2014 batch bigger sets yourself.", - "On the v2 surface a result's status is given by NAME (a status configured for the project, matched case-insensitively); the numeric status_id the v1 surface takes is not accepted there.", - "Logging a result APPENDS to a test case's result history in that run, so the previous outcome stays readable. A bulk status set REPLACES the case's current outcome in place instead \u2014 the overwritten status is not recoverable.", - "Bulk result writes are queued, not applied inline: a success response means accepted for processing. Per-case outcomes come back as ACCEPTED/FAILED, a partly-valid batch still answers 200 with success:false and status PARTIAL, and a bulk status set reports nothing per case at all \u2014 read the results back to confirm.", - "A project can restrict which review statuses are allowed to log an execution result; cases outside that set are rejected with a REVIEW_STATUS_GATE error rather than being logged.", - "Result attachments only attach on a multipart upload that carries the files alongside the JSON body; naming filenames in a plain JSON request is accepted and does nothing. Limits are 10 files per result, 100 per request and 50 MB per file, and a result description over 16 KB is truncated, not rejected.", - "Amending the latest result edits it in place and keeps no prior copy of the amended values; logging a new result instead preserves the earlier outcome as its own entry in the case's history for that run.", - "Deleting a result is permanent and silently changes the case's standing in the run: its outcome rolls back to the previous attempt, or to unexecuted if that was the only one.", - "Only the person who logged a result may delete it, unless they are a project admin \u2014 a mixed batch comes back partly done rather than refused.", - "Result attachments are always referenced by the numeric id of a previously uploaded file; naming a filename in the body does nothing." - ] - }, - "shared_step": { - "entity": "shared_step", - "title": "Shared step", - "description": "A reusable block of test steps embedded by reference, held in one flat untyped list per project.", - "aliases": [ - "shared step", - "shared steps", - "reusable step", - "step template", - "shared field", - "step block", - "shared component" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "reused by" - } - ], - "capabilities": [ - "create_shared_step", - "delete_shared_step", - "get_shared_step", - "get_shared_steps", - "update_shared_step" - ], - "key_facts": [ - "A shared step is embedded by MANY test cases, so editing or deleting it changes every one of them at once. That is the point of shared steps, but it also means a delete is a wide blast radius \u2014 say how it is used before changing it.", - "Reference a shared step from a test case's steps via shared_step_id in test_case_steps \u2014 see test-case-authoring.", - "Shared steps keep no version history, unlike test cases and test plans, so an edit to one cannot be rolled back and a delete cannot be undone \u2014 there is no bin for them.", - "Each row inside a shared step has its own id, and a test case's step holds both the shared step's id and that row's id. Rebuilding the rows instead of updating them in place breaks every reference into the block.", - "An edit replaces the whole step list rather than merging: read the block, change the array, send it back whole. Rows left out are removed.", - "The count of test cases embedding a block is returned on every read of it \u2014 that count is the blast radius of any edit or delete, and it is the number to check first." - ] - }, - "tag": { - "entity": "tag", - "title": "Tag", - "description": "A free-text label on a test case, run or plan, used to group and filter them.", - "aliases": [ - "tags", - "label", - "labels", - "tag", - "keyword", - "keywords" - ], - "id_convention": "name", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "labels" - }, - { - "entity": "test_run", - "via": "labels" - }, - { - "entity": "test_plan", - "via": "labels" - } - ], - "capabilities": [ - "bulk_replace_test_case_fields", - "get_test_case_tags", - "list_test_case_tags", - "merge_tags", - "search_group_tags", - "search_test_case_tags" - ], - "key_facts": [ - "Tags are referenced by NAME in filters and on cases; the merge endpoint takes integer source_id/target_id, so resolve ids via the tag reads first.", - "That body MUST carry custom_fields (send {} when not changing any) \u2014 omitting it returns 500 'undefined method each for nil', even though the server's validator lists it as optional.", - "tags/merge is admin-gated (tags_management flag) \u2014 may be unavailable for a group; degrade gracefully and say so.", - "TAG-OBJECT WRITES ARE NOT EXPOSED. Renaming or deleting a tag project-wide is deliberately out of this profile: the endpoints exist but sit behind the tags_management flag that most groups do not have, and tagging is always driven through the ENTITY. If asked to rename or delete a tag, say it is not available here and point to the Test Management UI.", - "Tags are referenced by NAME on entities and in filters. Tag IDs exist but are only returned by the workspace-wide tag search; the project-level tag reads give names only, so resolve ids there when an operation needs them.", - "Tag vocabularies are per entity kind \u2014 test case, test run, test plan and shared step tags are separate sets. A name existing in one says nothing about the others, and an operation must name which vocabulary it means.", - "Merging one tag into another is not reversible and is not confined to a project: the losing tag stops existing and everything that carried it is re-pointed at the surviving tag, across the whole workspace. Capture what carries a tag before merging it \u2014 nothing afterwards tells you what changed.", - "Tag management \u2014 merging and renaming \u2014 is restricted to account owners and admins. A caller without that role is refused.", - "Renaming or deleting a tag as an object is deliberately not exposed here \u2014 tagging is driven through the entity. If asked to rename or delete a tag, say it is not available and point to the Test Management UI." - ] - }, - "template": { - "entity": "template", - "title": "Test case template", - "description": "A reusable field layout a test case is authored on \u2014 step or Gherkin style \u2014 setting which fields appear and in what order.", - "aliases": [ - "templates", - "test case template", - "case template", - "field template", - "test case layout", - "custom template", - "BDD template", - "Gherkin template", - "field layout" - ], - "id_convention": "integer, with no prefix. Discover ids through the template list call rather than guessing them.", - "parents": [], - "relations": [ - { - "entity": "test_case", - "via": "shapes the fields of" - }, - { - "entity": "project", - "via": "linked to" - }, - { - "entity": "custom_field", - "via": "arranges" - } - ], - "capabilities": [ - "create_template", - "delete_template", - "get_template", - "list_templates", - "list_test_case_templates", - "update_template" - ], - "key_facts": [ - "Templates belong to the workspace, not to a project \u2014 one template can be linked to many projects and keeps the same id everywhere. Do not look for them inside a project.", - "Templates exist for test cases, test results, test plans and exploratory sessions, but only test-case templates can be created or edited here; the older listing can enumerate the other three types and returns test-case templates unless asked for another kind.", - "Template ids are plain integers with no prefix (not PR- or TR- style). Discover an id from the template list rather than guessing it.", - "Templates have no version history and no recycle bin: an edit overwrites the previous field layout and a deletion cannot be undone from Test Management.", - "Disabling a template is the reversible alternative to deleting it \u2014 a disabled template stops being offered for new test cases but keeps its fields and project links and can be re-enabled.", - "Changing a template's field layout changes the fields shown on every test case using it, across every linked project. Say how many projects are affected before writing.", - "Creating, editing and deleting templates are paid-plan features; on a free plan the call reports failure in the body while still answering 200, and reading templates stays available.", - "The Review & Approve system fields (reviewers and review status) are hidden from reads on this surface and re-added automatically whenever the property fields are rewritten, so they can be neither seen nor removed here.", - "A template only affects a project once it is linked to it; creating a template links it to nothing unless linkage is requested.", - "Failures arrive as a single human-readable message next to a false success flag rather than as a structured error code.", - "Only test-case and test-result templates can be listed through the older settings surface. Asking it for test-plan or exploratory-session templates fails outright rather than returning an empty list, and the kind must always be stated \u2014 there is no default.", - "Only test-case templates exist today; there are no templates for runs, plans or results, so every template's entity reads TestCase.", - "A template's step style is fixed at creation: classic step rows or Gherkin (feature, background, scenario). A case authored on a Gherkin template cannot take step rows and vice versa, so choose the template before writing steps.", - "Templates belong to the workspace but apply only in the projects they are linked to. Naming a template that is unknown to, or not visible from, the caller's workspace is refused outright rather than falling back to the default.", - "Exactly one template is the default; a case created without naming a template lands on it. System templates ship with the workspace and cannot be deleted.", - "Disabled templates still appear in the listing (enabled false) but should not be offered for new cases.", - "Creating, editing, cloning, linking and deleting templates are paid-plan operations and are not exposed through these capabilities; only listing is." - ] - }, - "test_case": { - "entity": "test_case", - "title": "Test case", - "description": "The unit of test logic: title, preconditions, steps and expected results.", - "aliases": [ - "tc", - "case", - "test case", - "testcase", - "test-case", - "tc-id", - "scenario" - ], - "id_convention": "TC-NNN", - "parents": [ - "folder", - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "executed in" - }, - { - "entity": "result", - "via": "produces" - }, - { - "entity": "custom_field", - "via": "extended by" - }, - { - "entity": "attachment", - "via": "has" - }, - { - "entity": "tag", - "via": "labelled by" - }, - { - "entity": "shared_step", - "via": "reuses" - } - ], - "capabilities": [ - "apply_test_case_review_verdict", - "bulk_archive_test_cases_by_project", - "bulk_copy_test_cases", - "bulk_delete_test_cases", - "bulk_replace_test_case_fields", - "bulk_edit_test_cases_in_test_run", - "bulk_move_test_cases", - "bulk_restore_archived_test_cases", - "count_binned_test_cases", - "create_bulk_test_cases", - "create_test_case_by_integer_id", - "create_test_cases", - "edit_test_case_partial", - "edit_test_case", - "generate_test_case_automation", - "list_archived_test_cases", - "get_system_field_values", - "get_test_case_by_integer_id", - "get_test_case_detail", - "list_test_cases", - "list_binned_test_cases", - "list_folder_test_cases", - "move_test_case", - "reorder_test_cases_by_folder", - "search_project_entities_by_filter", - "search_test_cases_by_folder", - "submit_test_cases_for_review", - "update_test_case", - "validate_bdd_export_selection" - ], - "key_facts": [ - "A filter is a (key, value) pair and each half fails silently: an unknown KEY is dropped (you get the whole project with success: true), and a wrong-form VALUE matches nothing (count: 0, also success: true). So resolve both \u2014 never guess either \u2014 and see pagination-and-filtering for the key table and each value's source.", - "A surprising count means check the FORM, not try another value: 0 on a value the user plainly meant = wrong form, so go resolve ids; filtered count == unfiltered count = the key was dropped. Never take either count from a per_page: 1 call \u2014 it returns count: 1 regardless of the real total.", - "required.fields ADDS joined columns; it does NOT slim a row, so naming fields never buys more rows per page. required.fields=[\"count\"] ALONE is the only value that changes the response.", - "The compact response has a DIFFERENT envelope: no `success` key ({test_cases, info, filter_details} only \u2014 absence of success is NOT failure), info.page_size echoes the server ceiling not your per_page, and filter_details is {} so confirm filter intent before the call.", - "READING ROWS to show a user: leave per_page at its default and walk p until info.next is null. p advances by per_page and not by what came back, so paging over an incomplete read never recovers the gap.", - "Paths take INTEGER ids (project + folder + case). The case's TC-NNN comes back as data.test_case.identifier on any read; folder ids are plain integers.", - "Every successful edit to a test case writes a new version, and an earlier version can be restored \u2014 so an edit is recoverable even though nothing offers an undo. Deleting the case is not.", - "That safety net is plan-gated: below an Enterprise or Team-Pro plan only the last 30 days of versions are visible, and restoring one needs a paid plan \u2014 so an older edit may not be recoverable in practice.", - "The newer v2 project surface addresses a test case by its TC-NNN identifier \u2014 or by its third-party identifier in workspaces that issue project-level ids \u2014 and does not accept the internal integer id; the older v1 surface is the reverse.", - "Custom fields on a test case are addressed by their display NAME on the v2 surface and by numeric field id on the v1 surface; a name the project does not define is skipped without any error, so a typo reads back as a successful call that changed nothing.", - "Priority, status and case type are per-project configurable values, not a fixed vocabulary: send the project's own display value (or its numeric id) and resolve the set from the project's system field values before writing.", - "Priority, status, case type and automation state are per-project configurable option sets. The single-case write paths accept either the numeric option id or the project's display name, but every BULK write requires the numeric id \u2014 a display name there is stored as a broken id with no error. Resolve the ids from the project's system field values first.", - "`status` is the exception on every path, single and bulk alike: it is only ever taken as a numeric option id, never resolved from a display name.", - "A case's steps are only ever read from `test_case_steps`. A key called `steps` is legacy, is parsed as a JSON-encoded STRING, and turns a real JSON array into an empty step list \u2014 wiping the case's steps while still reporting success.", - "`estimate` is a dead field: it is accepted on the write paths and then never stored, and every read serialises it as null. The estimate that actually persists is `estimated_duration_seconds`, in seconds, and it is not settable through any bulk edit.", - "Bulk operations switch to a background job above a size threshold that differs per operation \u2014 around 30 for a copy, 100 for an edit or move, 300 for a delete or restore. Past it the call returns an acknowledgement with a unique id instead of the result, so anything that reads the response body as the outcome is wrong for large selections.", - "Two bulk operations cannot touch the same cases at once: the second is refused with a 'TestCases in progress with another operation' error. That is a retry, not bad input.", - "Archiving and un-archiving test cases, and listing the archive, are gated on a paid plan \u2014 and the refusal comes back as a SUCCESSFUL response carrying success:false, not as an error status.", - "Whether deleting a case is recoverable depends on the workspace: where the Bin is enabled the case is moved there and can be restored, otherwise the delete is permanent. Either way the case's results and schedule links do not come back.", - "Creating a case from a template is not automatic: when neither a template name nor a step type is sent, the write paths default to the plain-text template, which stores no steps at all and still reports success." - ] - }, - "test_plan": { - "entity": "test_plan", - "title": "Test plan (and sub-plan)", - "description": "A container grouping several test runs, typically for one release or milestone.", - "aliases": [ - "tp", - "plan", - "milestone", - "sub test plan", - "subplan", - "stp", - "release plan", - "test cycle", - "sub-plan", - "child plan", - "parent plan" - ], - "id_convention": "TP-NNN (sub-plan STP-NNN)", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "groups" - }, - { - "entity": "test_plan", - "via": "parent/child via parent_plan_id" - } - ], - "capabilities": [ - "bulk_archive_test_plans", - "bulk_restore_archived_test_plans", - "clone_test_plan", - "create_test_plan_by_integer_id", - "delete_test_plan", - "get_archived_test_plan_count", - "get_test_plan_by_integer_id", - "get_test_plan_execution_trend", - "get_test_plan_linked_sessions_chart_data", - "list_test_plan_test_runs_by_integer_id", - "list_archived_test_plans", - "list_test_plans_by_integer_id", - "unlink_test_runs_from_test_plan", - "update_test_plan_by_integer_id", - "list_test_plans", - "create_test_plan", - "get_test_plan", - "create_sub_test_plan", - "list_test_plan_test_runs", - "update_test_plan" - ], - "key_facts": [ - "Paths take the INTEGER plan id. The prefixed identifier (TP-NNN / STP-NNN) is for display; never put it in a v1 path.", - "The read detail has NO data wrapper: the plan sits under a top-level test_plan key, and test_plan.identifier is the TP-NNN / STP-NNN.", - "Sub-plans are HIDDEN by default in the list. Without include_sub_plans=true you see only top-level plans, so a sub-plan the user named will look missing.", - "name IS REQUIRED ON EVERY PLAN UPDATE, including one that only touches test_runs. Omit it and the call is rejected with 400 {success: false, error: 'Invalid Update Params'} however valid the rest of the body is \u2014 the server's edit branch is gated on name being present. Read the plan first and echo its current name back unchanged. Only a pure plan_status transition and a reviewers-only edit are exempt. For example, {test_plan: {test_runs: [198]}} is a 400, and the same call with name added succeeds.", - "Run ids in test_runs are the INTEGER uuid from a run read \u2014 NOT the run's id field, which is the TR-NNN string on every run endpoint (list, plan's run list, single detail alike). Identifiers are forwarded to the backing service uncoerced and fail upstream (observed 502 Upstream service error). Take uuid, never id.", - "Linking a run MOVES it: a run belongs to at most one plan, so linking silently unlinks it from the plan that held it, with nothing in the 200 to say so. Read the runs' current membership first and tell the user which plans will lose runs.", - "test_runs accepts an id array OR a bulk-selection object {select_all, de_selected_ids, search_query}. select_all means 'the newest active runs', NOT 'everything matching' \u2014 and `filters` is accepted but SILENTLY IGNORED, so a filtered select_all links the whole recent set. Narrow with search_query (name/identifier substring) or send explicit ids.", - "bulk-archive / bulk-retrieve go ASYNC above the bulk threshold and return {success, async: true, unique_id} \u2014 the plans are NOT changed yet. Re-read to confirm; see async-jobs.", - "execution-trend is scoped by EXACTLY ONE of test_plan_id / test_run_id. Both \u2192 400 'Provide either test_plan_id or test_run_id, not both'; neither \u2192 400 'test_plan_id or test_run_id is required'.", - "execution-trend and linked-sessions-chart-data return chart DATA. There is no insights-widget object to create, update, or delete in this profile.", - "A plan's status only moves forward, one step at a time: new -> started -> completed. Once completed the plan is FROZEN \u2014 even a rename is refused with 'Cannot update completed test plan' \u2014 so complete it last.", - "Plans keep no version history. An overwritten name, description or date is gone for good; there is no prior revision to restore, unlike a test case.", - "The same plan is addressed two different ways depending on which generation of the API you are on: the newer surface takes the prefixed identifier (TP-NNN / STP-NNN), the older one the numeric id. Carry whichever the capability you picked declares.", - "On the newer surface a run's plan membership is changed from the RUN, never from the plan: the plan's own edit ignores a list of runs entirely (the older surface accepts one). Linking still moves the run out of whatever plan held it.", - "On the newer surface a sub-plan's parent is fixed when it is created \u2014 there is no re-parenting or promotion to a top-level plan there, and the plan's owner is always whoever created it.", - "Creating a plan takes a name and nothing else; a name is mandatory on create, on clone, and on almost every edit. A status supplied at creation time is accepted and then ignored \u2014 a new plan always starts in the `new` state.", - "A status change and a content edit cannot travel together: when a request moves the plan's status, everything else it carries (name, description, tags, issues, owner) is silently dropped. Rename first, then move the status.", - "Starting a plan needs its start date in the same request and completing it needs its end date; the dates already stored on the plan are not used as a fallback.", - "Archiving is an optional feature for the account. When it is switched off, every archive, restore and archived read answers as though the address did not exist \u2014 that is a feature gap, not a missing plan.", - "Reading or restoring archived plans requires the same permission as DELETING a plan, so someone who can happily create and edit plans may still be refused the archived view.", - "Archived plans vanish from the normal plan listing entirely and have their own listing and their own count; archived sub-plans appear in neither.", - "An archived plan reads as missing unless the read explicitly opts in to archived records.", - "Cloning a plan is a background job: the reply confirms the job was queued and carries no id, name or identifier for the copy, and nothing except the runs and sub-plans is inherited \u2014 the new plan's description, dates, tags and owner all come from the clone request.", - "Owner is given as the person's test-management user id, not their BrowserStack id or email, and an id that does not resolve inside the account is discarded rather than rejected \u2014 leaving the plan with no owner.", - "Tags are all-or-nothing: if any entry in the list is not a plain string the entire tag list is dropped without an error." - ] - }, - "test_run": { - "entity": "test_run", - "title": "Test run", - "description": "One execution cycle over a fixed set of test cases.", - "aliases": [ - "tr", - "run", - "execution", - "test cycle", - "cycle", - "test execution" - ], - "id_convention": "TR-NNN", - "parents": [ - "test_plan", - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "includes" - }, - { - "entity": "result", - "via": "produces" - }, - { - "entity": "configuration", - "via": "runs against" - }, - { - "entity": "test_plan", - "via": "grouped by" - } - ], - "capabilities": [ - "get_test_run_edit_selection", - "assign_test_run_owner", - "assign_test_run_test_cases", - "bulk_set_test_case_assignee_in_run", - "bulk_update_test_run_test_cases", - "clone_test_run_by_integer_id", - "clone_test_run_by_integer_id", - "close_test_run", - "count_run_selection", - "create_test_run_by_integer_id", - "create_test_run", - "delete_test_run", - "edit_test_run", - "export_test_run_csv", - "list_closed_test_runs", - "list_test_run_test_cases_by_integer_id", - "get_test_run_by_integer_id", - "get_test_run_detail", - "get_test_run_progress", - "list_test_run_test_cases", - "get_test_run", - "get_test_runs_form_fields", - "list_test_runs_selection", - "list_test_runs_by_integer_id", - "list_test_runs", - "replace_test_run", - "update_test_run" - ], - "key_facts": [ - "Only test_case_ids / selection / shared_selection put cases in a run. `filters` and `dynamic_filter` are a forward-sync rule, NOT a selection: a create carrying only `filters` returns 200 with ZERO cases, and sets is_dynamic: true even though you did not ask.", - "Dynamic runs match on nine fields only: priority, status, category, owner, automation_status, automation_state, folder, tags, issues. created_at/updated_at are stored and echoed on read but NEVER matched, so a 'last 7 days' dynamic run drops the date window and over-collects forever. Create runs static unless the user explicitly asks for sync.", - "test_run.run_state is MANDATORY on create and edit (new_run | in_progress | under_review | rejected | done | closed). It is validated before the selection, so a bare 400 'invalid data' means a bad test_run field \u2014 NOT a bad selection shape.", - "A run's `id` FIELD is the TR-NNN STRING, not the integer \u2014 on the project list, a plan's linked-run list and the single-run detail alike. The integer lives in `uuid` ({id: 'TR-90', uuid: 198}). Paths tolerate either form, which hides this; bodies and UI paths do not. Carrying `id` into a test plan's test_runs array sends a string that reaches the backing service uncoerced and 502s there. Take `uuid` whenever a run id leaves a read.", - "Full lifecycle is exposed: create, edit, assign, bulk-assign cases, bulk-edit results, clone, close, delete.", - "A test run keeps NO version history and has no restore: unlike a test case, an edited name, cleared tag list or removed case cannot be recovered. Confirm before overwriting or removing.", - "Removing a case from a run deletes the results recorded against it unless the caller explicitly asks to preserve them \u2014 the default is to discard.", - "Changing which cases a run covers is always queued: the call returns an acceptance immediately and the run still shows the old case list for a moment. Re-read the run's cases to confirm, and do not retry on the acceptance.", - "Every way of editing a run only ever ADDS cases to it. Taking cases out is a separate operation, and only that one can shrink a run.", - "The newer API generation identifies a run and its project only by their prefixed ids (TR-14, PR-1) and rejects the plain integers the older generation still tolerates.", - "Listing a project's runs hides closed ones by default. Unless the caller asks to include closed runs, or filters on a lifecycle state, a run count or 'all our runs' answer silently omits every closed run and still looks complete.", - "Filtering a run listing by owner fails open: an email that matches nobody in the group is dropped from the filter rather than refused, so a misspelled name returns a wider list that reads as a correct answer. Filtering by plan or by date fails closed and errors instead.", - "Creating a run does not populate it synchronously. The run exists as soon as the call succeeds, but its test cases are attached by background work, so an immediate read of the run's cases can come back empty or short. Re-read rather than retrying the create, which makes a second run.", - "A run's name is optional at creation: leave it out and the run is named 'Test Run' plus today's date, which is how projects end up with several identically named runs.", - "Marking a run as an automation run is not something a caller can do at creation \u2014 the flag is accepted and discarded, but it still decides which permission is demanded, so asking for it can be refused outright.", - "Rows in a run are per case-and-configuration pair, not per test case: a case covered by three configurations is three rows with the same case id and three different mapping ids. Every bulk operation on a run addresses those mapping ids, and passing the test case's own id instead returns success and changes nothing.", - "Cloning a run copies NO test cases unless the caller explicitly asks for them. A clone requested without a case selection succeeds and produces an empty run, and the clone always starts in the new-run state with no configurations, owner or sync rule carried over.", - "Bulk changes inside a run switch from immediate to queued above 300 selected rows, and the queued form returns an acknowledgement with no data. The selected rows are locked while either form runs, so a second bulk operation touching any of them is refused as contention rather than queued behind it.", - "Deleting a finished run needs a different permission from deleting one still in progress, so a role that can clear up working runs can be refused on completed ones.", - "Discovering how many cases a selection really covers is a distinct read: it multiplies the cases by the configurations, so the number it returns is routinely far larger than the number of cases chosen.", - "Editing a run requires resending its lifecycle state on every call, including edits that only change the name. A rejected edit that reports nothing more than 'invalid data' is almost always that missing state, not a bad case selection." - ] - }, - "user": { - "entity": "user", - "title": "User", - "description": "A member with access to a project: who you assign a case to, or set as an owner.", - "aliases": [ - "users", - "member", - "assignee", - "owner", - "user", - "members", - "people", - "teammate" - ], - "id_convention": "integer", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "member of" - }, - { - "entity": "test_run", - "via": "assigned to" - }, - { - "entity": "test_case", - "via": "owns" - } - ], - "capabilities": [ - "list_group_users", - "search_project_users" - ], - "key_facts": [ - "WRITE BODIES take the integer: test_case.owner, test_run.owner/assignee_id \u2014 send 14.", - "An unrecognised q key is dropped, not rejected \u2014 the server silently omits that condition and returns an unfiltered set that looks like a successful filter. Use the documented key names verbatim.", - "Every person has two ids: a Test Management user id and a BrowserStack account id. Owner and assignee fields take the Test Management id; several listing filters take the BrowserStack one instead. Sending the wrong one usually matches nobody rather than failing, so check which the operation wants.", - "Both user reads here are scoped to the whole workspace, not to a project \u2014 neither can answer 'who has access to this project', and one of them says so in its path while doing the opposite.", - "Name search matches the full name only; there is no partial-email search. An exact-email lookup exists and returns at most one person.", - "Only active users are listed, so someone who has left the workspace cannot be resolved even when their name is still on a case or run.", - "An unrecognised search key is dropped rather than rejected \u2014 the condition silently disappears and an unfiltered set comes back looking like a successful filter. Use the documented key names verbatim." - ] - }, - "version": { - "entity": "version", - "title": "Test case version", - "description": "A point-in-time snapshot of a test case, for viewing or restoring an earlier revision.", - "aliases": [ - "history", - "histories", - "revision", - "version-history" - ], - "id_convention": "integer", - "parents": [ - "test_case", - "project" - ], - "relations": [ - { - "entity": "test_case", - "via": "versions" - }, - { - "entity": "user", - "via": "changed by" - } - ], - "capabilities": [ - "list_test_case_histories", - "get_test_case_history_diff", - "get_test_case_history", - "restore_history", - "get_test_case_histories" - ], - "key_facts": [ - "Each record carries source (create/update), modified_fields, a modified map (per-field old/new), user and created_at.", - "diff (compare two versions) and restore require a PAID plan; listing + single-version reads work otherwise. Degrade gracefully.", - "A plain-language diff summary is agent-side \u2014 read the field deltas and describe them; the API returns structured data, not prose.", - "A revision is addressed two different ways depending on the generation of the read: the older listing hands back a numeric history record id, the newer one identifies a revision only by its position label V1, V2, \u2026 with V1 the oldest revision still visible.", - "Without an Enterprise or Team Pro plan only the last 30 days of a test case's revisions are visible; older ones are absent with no flag saying so, and position labels count only what is visible, so they renumber as old revisions age out.", - "In the newer payload the editor arrives as updated_by, an email string that is null when the user cannot be resolved in this group, instead of the nested user object the older records carry.", - "A revision listing populates the per-field old/new detail only for the FIRST record on each page; every other record names the changed fields but not their values, so summarising a page of changes costs one extra read per revision.", - "Comparing two revisions only looks at a fixed set of fields (name, description, preconditions, automation status, type, status, priority, owner, assignee, folder, tags, defects, custom fields, steps, attachments, reviewers, review status). A change to anything else is invisible in a comparison.", - "Restoring is a full overwrite of the snapshotted fields, not a merge: a custom field that is empty in the snapshot is cleared, steps added since are removed, and attachments added since are unlinked. Say what will be lost before restoring.", - "A restore is applied as an ordinary edit, so the pre-restore state stays in the revision trail and can itself be restored \u2014 that is the only way back, and there is no undo action." - ] - }, - "comment": { - "entity": "comment", - "title": "Comment", - "description": "A rich-text note a team member posts on a test case, on its execution in a test run, or on a test plan, to discuss it.", - "aliases": [ - "comments", - "note", - "notes", - "remark", - "feedback", - "discussion", - "thread", - "review comment", - "review feedback", - "mention", - "@mention", - "annotation", - "message" - ], - "id_convention": "Plain integer, assigned on creation and returned as `id` on the posted comment. Existing comment ids are not guessable and must be discovered through the comment listing.", - "parents": [ - "test_run", - "test_case", - "test_plan" - ], - "relations": [ - { - "entity": "test_case", - "via": "posted on" - }, - { - "entity": "test_run", - "via": "scoped to the case's execution in" - }, - { - "entity": "user", - "via": "authored by / mentions" - } - ], - "capabilities": [ - "list_test_run_test_case_comments", - "create_test_run_test_case_comment" - ], - "key_facts": [ - "A comment can hang off a test case in its folder, off a test case's execution inside a specific test run, or off a test plan. Only the test-run variant is published in this pass; folder-scoped test-case comments and test-plan comments exist in the product but are not exposed here.", - "Comments are a paid-plan feature. On a free plan every comment call comes back HTTP 200 with success:false and the message 'This Feature is only for paid customers' \u2014 the body, not the status, carries the refusal.", - "Reading and posting comments both need the test-case VIEW permission; no separate comment or write permission exists, so anyone who can see the case can comment on it.", - "Comments cannot be posted on automation test runs (Automate/Observability-backed), and not on an archived test case; reading an archived case's comments still works.", - "Mentioning a colleague \u2014 via the mention span markup carrying data-lexical-mention, data-mention (their display name) and data-id (their BrowserStack user id) \u2014 notifies them immediately, and the run owner is alerted on every new comment. A mention of someone outside the group fails the whole post; a span missing data-mention is accepted but shows as plain text rather than a tag.", - "Display names are not unique within an account \u2014 two people can share one \u2014 so resolve the person to mention by email, not name, and take their BrowserStack user id from that row.", - "The body is HTML rich text. Whitespace-only or tag-only content is refused; an inline image counts as content. Stored HTML can differ from what was sent (links normalised, mentions rewritten).", - "Threads are per run/case/configuration cell: a case mapped to three configurations in a run has three separate comment threads, chosen by the mapping id.", - "Listings come newest first, 30 to a batch, and the size cannot be changed. Asking beyond the end returns the last batch again rather than an empty one, so stop when the next-batch marker is null.", - "Posting a comment is recoverable: the author can edit it or delete it afterwards, and deletion hides the comment rather than destroying it. Neither edit nor delete is exposed in this pass.", - "On each returned comment `edited` is true once it has been changed after posting; the author object's `id` is the internal TM user id (not the BrowserStack user id) and is null for imported comments, which show the original author's name instead.", - "Mentioning a colleague in a comment notifies them straight away, and the owner of the item commented on is notified of every new comment. Both are real messages that leave the product and cannot be recalled.", - "A person can only be mentioned if they belong to the same account; naming anyone else is refused rather than quietly ignored.", - "Only the person who wrote a comment can edit or delete it. Editing replaces the earlier text and keeps no previous version, and a deleted comment disappears from every listing with no self-serve way to restore it.", - "Comments are stored as rich text, so formatting, links and images survive, and a mention is stored as markup that displays as the person's name.", - "New comments cannot be added to an archived test case, although the comments already on it stay readable.", - "Comments are flat - each one sits on the item itself, and there is no way to reply beneath another comment.", - "Commenting is a paid-plan feature; on a plan without it the answer reports the feature as unavailable instead of failing." - ] - }, - "issue": { - "entity": "issue", - "title": "Linked issue", - "description": "A ticket in an external tracker such as Jira that test cases and runs are linked to.", - "aliases": [ - "requirement", - "ticket", - "jira issue", - "bug", - "work item" - ], - "id_convention": "The external tracker's own ticket key, as a string \u2014 for Jira an uppercase project key, a hyphen and digits, e.g. PROJ-123; other trackers use their own form. It is never a Test Management integer id and cannot be minted here: take it from the tracker itself, or read it from the linked-ticket list carried on a test case or test run. Test Management does not validate that the key exists, so a wrong key links silently.", - "parents": [], - "relations": [ - { - "entity": "test_case", - "via": "linked to" - }, - { - "entity": "test_run", - "via": "linked to" - }, - { - "entity": "project", - "via": "scoped by" - } - ], - "capabilities": [ - "link_entities_to_jira_issue", - "get_test_case_linked_issues", - "get_linked_test_case_selection", - "unlink_test_case", - "unlink_test_run" - ], - "key_facts": [ - "A linked issue is not stored by Test Management \u2014 only the link is. Unlinking removes the association and leaves the test case, the test run and the tracker ticket untouched, so every unlink here is reversible by linking again.", - "Linking and unlinking never write anything back into the external tracker; the ticket is not commented on, transitioned or modified.", - "One ticket can carry many test cases and many test runs, and one test case can be linked to several tickets, including tickets in different trackers.", - "The tracker a link belongs to is part of its identity: the same ticket key recorded under two different trackers is two independent links.", - "Unlinking identifies the link by the test case or test run plus the tracker, not by the ticket, so a case linked to several tickets in one tracker cannot be unlinked from a chosen one.", - "Test Management also has a separate Requirements feature of its own; a linked issue always lives in an external tracker and is a different thing, even though customers call both 'requirements'.", - "Ticket keys are never checked against the tracker, so a mistyped key produces a link to a ticket that does not exist and reports success." - ] - }, - "shared_field": { - "entity": "shared_field", - "title": "Shared field", - "description": "The foldered, typed form of a reusable content block: the same step rows plus a field type, a folder and tags.", - "aliases": [ - "shared component", - "shared field", - "reusable field", - "collection" - ], - "id_convention": "Plain integer, not guessable; discover ids by listing a project's shared fields. A shared step id comes from a different set and will not resolve as a shared field.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "belongs to" - }, - { - "entity": "test_case", - "via": "embedded by" - }, - { - "entity": "folder", - "via": "organised into collections" - }, - { - "entity": "tag", - "via": "labelled with" - } - ], - "capabilities": [ - "list_shared_components" - ], - "key_facts": [ - "A shared field is not a shared step. They are separate objects with separate listings and separate ids, and they are easy to confuse because both are reusable content - establish which one the user means before acting on an id.", - "The API calls this object a shared component while the product calls it a shared field; they are the same thing under two names.", - "Every shared field has a type - step, background or precondition - and the type decides where a test case can embed it.", - "Shared fields are organised into collections, which nest. A field placed in no collection sits in the Unassigned root.", - "A shared field is embedded by many test cases at once, so editing or deleting one changes every case that uses it; the usage count on the field is the measure of that blast radius.", - "Distinct from a shared step and stored separately: creating one does not make the other, and each is listed only by its own read.", - "Only the listing is available here. Creating, editing, deleting, copying and reading one by id all exist in the product but are not offered on this surface, so a block seen here cannot be changed here." - ] - } - }, - "version": "1.37" - } + }, + "version": "1.37" + } } diff --git a/capability/tra.capability-index.json b/capability/tra.capability-index.json index 220057af..ffbd4a76 100644 --- a/capability/tra.capability-index.json +++ b/capability/tra.capability-index.json @@ -1,31710 +1,31709 @@ { - "schema_version": 1, - "version": "1.7", - "build_id": "tra-v1.7-ai-failure-naming", - "tra": { - "title": "Test Reporting & Analytics", - "routing_terms": [ - "flaky", - "flakiness", - "workspace", - "configuration", - "schedule", - "suite", - "result" - ], - "summary": "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability): ingest, investigate and report on automated test runs. A build groups the test runs of one CI/SDK execution; each run carries status, logs, errors, hooks and AI root-cause analysis. Everything is scoped to a project. Read builds and runs; filter and search them; manage dashboards, custom views and saved reports; configure quality gates, webhooks, notifications, CI triggers and integrations; and read stability, performance, flakiness and unique-error analytics. Tests are REPORTED here, not executed here - execution happens in your runner or another BrowserStack product and is pushed in via the SDK. Access-key auth reaches only the /ext/v1 external API; everything else needs a session or service credential. Each run records its result on the browser, device, OS and environment it ran on, under the app version and the build tags it was pushed with, and a failure can be linked to an issue. Saved reports can be scheduled. Destructive capabilities need explicit human approval.", - "auth": { - "type": "oauth2" - }, - "base_urls": [ - "https://api-observability.browserstack.com", - "https://api-observability-eu.browserstack.com", - "https://api-observability-in.browserstack.com" - ], - "probe_path": "/api/v1/access", - "capabilities": [ - { - "name": "list_latest_build_summaries", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/buildSummaries", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - } - ], - "intent": "Return full build summaries (status, run counts, duration, CI/host info, user, failure stats) for up to the 5 latest builds of one project in a time window - the per-project body of the daily summary email.", - "returns": [ - "uuid", - "id", - "name", - "status", - "statusStats", - "issueTypeAggregate" - ], - "guidance": [ - "The body is a top-level ARRAY of build summaries, not a wrapped object.", - "Build selection is server-side: it returns at most the latest 5 builds in the window (hardcoded). To fetch specific builds instead, use list_build_summaries_by_ids.", - "This is an internal/job-facing endpoint - the handler does no group scoping or role check, so do not rely on it to enforce project access." - ], - "responses": { - "200": { - "$response": "BuildSummaryListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_name_status_aggregation", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/buildSummaryAggs", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "default": 11, - "description": "Caps the number of distinct build names returned (default 11, i.e. top 10 + 1)." - } - ], - "intent": "For one project and time window, return a per-build-name breakdown of run counts by display status ('for each distinct build name, how many runs passed/failed') - used to pick the noteworthy builds for the summary.", - "returns": [ - "count", - "passed", - "failed", - "skipped", - "unknown" - ], - "guidance": [ - "The body is a top-level MAP keyed by build name, each value `{count, passed, failed, skipped, unknown}`. There is no wrapper.", - "Only parent builds are counted, and builds whose usage quota status is 'locked' are excluded, so totals can be lower than the raw run count.", - "`limit` caps the number of distinct build names (default 11); it does not cap runs within a name." - ], - "responses": { - "200": { - "$response": "BuildAggregateMapResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_summaries_by_ids", - "method": "GET", - "path": "/api/v1/daily-summary/{projectId}/projectBuildSummaries", - "mode": "read", - "entity": "notification", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id." - } - ], - "query": [ - { - "name": "buildIds", - "type": "array", - "required": true, - "description": "The explicit build ids (integers) to summarize. Required." - } - ], - "intent": "Return full build summaries for an explicitly supplied list of build ids within one project - the follow-up call once you know which builds you want.", - "returns": [ - "uuid", - "id", - "name", - "status", - "statusStats", - "issueTypeAggregate" - ], - "guidance": [ - "The body is a top-level ARRAY of build summaries, same shape as list_latest_build_summaries.", - "The only difference from list_latest_build_summaries is that the CALLER chooses the builds by id here, instead of the server picking the latest 5 in a window.", - "Internal/job-facing endpoint - no group scoping or role check in the handler.", - "Result order does NOT follow the requested buildIds order - match rows by `id`." - ], - "responses": { - "200": { - "$response": "BuildSummaryListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_daily_summary_report", - "method": "GET", - "path": "/api/v1/daily-summary/projectBuildSummaries/v2", - "mode": "read", - "entity": "notification", - "query": [ - { - "name": "projectIds", - "type": "array", - "required": true, - "description": "Project ids (integers) to include in the report. Required." - }, - { - "name": "startTimeEpoc", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds." - }, - { - "name": "endTimeEpoc", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds." - } - ], - "intent": "Build the one-shot payload for the daily/periodic summary email: for a set of projects and a time window, per-project build counts, top failed builds, failed-test summary and failure-category breakdown, with the top failed build best-effort enriched with AI RCA, self-healing and test-authoring summaries.", - "returns": [ - "filterQueries", - "projects" - ], - "guidance": [ - "Despite the '/v2' path this is NOT another version of list_build_summaries_by_ids: that returns a flat array of build summaries, this returns the aggregated OverallDailySummary object `{filterQueries, projects:[...]}` and does its own build selection across the projects.", - "Heavy call: several aggregations plus three best-effort enrichment passes. The AI RCA / self-healing / authoring summary fields on the top failed build are swallowed on error, so they may be silently absent - do not treat a missing enrichment field as a failure.", - "`filterQueries` is a fixed set of canned query strings the email UI uses to deep-link into filtered views; it is not per-project data.", - "Internal/job-facing endpoint - no role check in the handler." - ], - "responses": { - "200": { - "$response": "OverallDailySummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_dashboard_public_link_setting", - "method": "GET", - "path": "/api/v1/setting/publicLink/dashboard/{dashboardId}", - "mode": "read", - "entity": "public_link", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "format": "uuid", - "required": true, - "description": "The custom dashboard's UUID." - } - ], - "intent": "Check whether public 'anyone with the link' sharing is currently turned on for a custom dashboard.", - "returns": [ - "publicLink" - ], - "guidance": [ - "The whole body is just `{publicLink: }`. A dashboard that has never been shared reads `false` (the default), which means sharing is off, not that the dashboard is missing.", - "The dashboard lookup is permission-aware: a dashboard the caller cannot see comes back 404 DASHBOARD_NOT_FOUND rather than a setting." - ], - "responses": { - "200": { - "$response": "PublicLinkSettingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_public_link_setting", - "method": "GET", - "path": "/api/v1/setting/publicLink/project/{projectId}", - "mode": "read", - "entity": "public_link", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The TestHub project id (NOT the observability project id)." - } - ], - "intent": "Check whether public link sharing is on for a project's report pages such as build insights.", - "returns": [ - "publicLink" - ], - "guidance": [ - "The whole body is just `{publicLink: }`. When the project has no token row yet the value is `false` (the default) \u2014 sharing off, not an error.", - "`projectId` is resolved as a TestHub project id, not the observability project id; an id that does not resolve is 404 PROJECT_NOT_FOUND." - ], - "responses": { - "200": { - "$response": "PublicLinkSettingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_quality_profile_filter_values", - "method": "GET", - "path": "/api/v1/projects/{projectName}/settings/quality-gates/filters", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "BUILD_NAME", - "BUILD_TAGS", - "TEST_TAGS", - "FOLDERS", - "ALERTS", - "FAILURE_CATEGORIES" - ], - "description": "Which value source to return. Bound from a query POJO \u2014 there is no request body despite the object-shaped parameters." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Search prefix/substring to filter values. Max 100 chars." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Cursor for the next page. Max 100 chars. Echoed back in the response as `afterKey` when more values remain." - }, - { - "name": "isGlobalProfile", - "type": "boolean", - "description": "Group-wide (true) or this project (false). REQUIRED in practice: the ES-backed types unbox it without a null check, so omitted -> 500.", - "required": true - } - ], - "intent": "Typeahead/value source for the filter builder inside a quality profile: return matching build names, build tags, test tags, folder paths, failure categories or alerts so a caller can pick which builds a gate applies to.", - "returns": [ - "data", - "afterKey", - "query" - ], - "responses": { - "200": { - "$response": "QualityProfileFilterValues" - } - }, - "guidance": [ - "Always send isGlobalProfile (false for a project profile): QGFilterESQuery does `if (!getIsGlobalProfile())` on a nullable Boolean, so omitting it makes BUILD_NAME/BUILD_TAGS/TEST_TAGS/FOLDERS return 500.", - "Page size is fixed PER TYPE, not caller-controlled: 10 for BUILD_NAME/BUILD_TAGS/TEST_TAGS/FAILURE_CATEGORIES/ALERTS, and 1000 for FOLDERS. There is no page-size parameter.", - "Paginate by passing the response's `afterKey` back as the next request's `afterKey`. afterKey is set even on the last page, so stop when a page returns fewer values than the page size (or none).", - "BUILD_NAME/BUILD_TAGS/TEST_TAGS values come from Elasticsearch aggregations over the project's builds/test runs; FAILURE_CATEGORIES and ALERTS come from the DB and honour `isGlobalProfile`." - ] - }, - { - "name": "get_project_quality_gate_settings", - "method": "GET", - "path": "/ext/v1/quality-gates/{project_name}/settings", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "intent": "Return a project's quality-gate configuration over the public (access-key) API: whether gates are enabled, whether the gate overrides build status, and the profiles attached to the project. Use this from CI/integrations authenticated with a username+access-key.", - "returns": [ - "enabled", - "shouldOverrideBuildStatus", - "qualityProfiles" - ], - "responses": { - "200": { - "$response": "QualityGateSettings" - } - }, - "guidance": [ - "Same response as the in-product `get_project_quality_gate_settings`; this is the external surface that accepts a username+access-key (CI/integration).", - "The action name reads like a plain profile list but it also carries the project-level enabled / shouldOverrideBuildStatus settings.", - "Plan-gated: refused as feature-inaccessible when the plan lacks Quality Gates.", - "For service-account callers this enforces the quality-gate project permission (fallback modify_project_settings); ordinary user-key callers skip that check." - ] - }, - { - "name": "get_quality_profile", - "method": "GET", - "path": "/ext/v1/quality-gates/{project_name}/profiles/{profile_id}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project's normalised name." - }, - { - "name": "profile_id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Profile UUID. A non-UUID value 400s before the handler runs." - } - ], - "intent": "Fetch one quality-gate profile in full \u2014 rules, thresholds and build filters \u2014 over the public (access-key) API, with failure-category operands resolved to the project's display names.", - "returns": [ - "uuid", - "name", - "ruleStatus", - "rules", - "applicableBuilds", - "isGlobalProfile", - "hooksVisibility" - ], - "responses": { - "200": { - "$response": "QualityProfileDetail" - } - }, - "guidance": [ - "`displayWith` is populated only for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules. Null fields are omitted (JSON include NON_NULL).", - "The profile is loaded by UUID and is not itself re-checked against the named project.", - "Plan-gated (feature-inaccessible when the plan lacks Quality Gates).", - "External surface accepting a username+access-key; for service-account callers enforces the quality-gate project permission, user-key callers skip it." - ] - }, - { - "name": "get_build_quality_gate_result", - "method": "GET", - "path": "/ext/v1/quality-gates/{build_id}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "The build uuid (40-char hashed id, not an RFC-4122 UUID), despite the name `build_id`. Looked up by UUID alone, not scoped to the caller's group." - } - ], - "intent": "The CI-facing quality gate check: given a build UUID, return whether the build passed or failed its quality gate (or that it is still running), so a pipeline can decide to proceed or fail.", - "returns": [ - "build_uuid", - "build_url", - "status", - "quality_gate_result", - "quality_profiles" - ], - "responses": { - "200": { - "$response": "CiQualityGateResult" - } - }, - "guidance": [ - "While the build is unfinished (or gate analysis is still in process) it returns status 'running' with `quality_gate_result` set to a 'try again in a few minutes' message \u2014 CI callers must POLL, not treat the first response as final.", - "`status`/`quality_gate_result` resolve to 'passed', 'failed' or 'skipped' once evaluation completes; null fields are omitted (JSON include NON_NULL).", - "Errors: build-not-found for an unknown UUID; forbidden-operation for a locked build; profile-not-found when quality gates are disabled for the project OR no profile results exist for the build.", - "The build is looked up by UUID alone and NOT scoped to the caller's group, unlike the profile endpoints on this surface. For service-account callers it enforces the quality-gate project permission; ordinary user-key callers skip that check.", - "Triggers configured Quality-Gate category integrations as a side effect when a result is produced." - ] - }, - { - "name": "get_build_quality_gate_summary", - "method": "GET", - "path": "/api/v1/builds/{uuid}/v2/quality-gate", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - } - ], - "intent": "The readiness-aware quality-gate summary for a build: return the per-profile results, or instead a status message when the build is still pending, the auto-analyser is running, or an evaluation state is cached. This is the endpoint to poll after triggering a recalculation.", - "returns": [ - "qgResult", - "message", - "state" - ], - "responses": { - "200": { - "$response": "BuildQualityGateSummary" - } - }, - "guidance": [ - "The 200 is one of several shapes and a caller MUST branch on it: `qgResult` (the per-profile results list) when ready; otherwise `message` = 'Quality Gate result will be available after your build finishes' (build pending) or 'Quality Gate evaluation will begin soon' (auto-analyser running); or a bare `state` string read from cache. Only one of qgResult/message/state is present per response.", - "Poll this after a recalculate to watch the gate finish; the message/state responses mean 'not ready yet', not an error.", - "Behavioural counterpart of `list_build_quality_profile_results`: that one returns the results as a bare array with no readiness handling; this one wraps them and can report still-evaluating/pending states instead. (They are the v1/v2 pair on this route.)", - "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface.", - "Ignore the extracted 'Check project permission' comment associated with this route \u2014 it belongs to a neighbouring close-build method; there is no extra permission check here." - ] - }, - { - "name": "list_build_quality_profile_results", - "method": "GET", - "path": "/api/v1/builds/{uuid}/quality-gate", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - } - ], - "intent": "Return the per-quality-profile results for a build as a flat list: each profile with its name, uuid and per-status rule counts \u2014 the quality gate breakdown on the build page.", - "returns": [ - "uuid", - "name", - "status", - "statusStats" - ], - "responses": { - "200": { - "$response": "BuildQualityProfileResults" - } - }, - "guidance": [ - "The 200 body is a BARE JSON ARRAY of per-profile result objects (no wrapper, no `data`, no readiness field); each item is {uuid, name, status, statusStats:{passed,failed,skipped}}.", - "This returns the raw results with NO readiness handling: if the build is still evaluating you get an empty/partial list rather than a 'still evaluating' signal. Use `get_build_quality_gate_summary` (the v2 route) when you need to distinguish 'not ready' from 'no results'.", - "For a split build's child, results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." - ] - }, - { - "name": "get_build_quality_profile_result", - "method": "GET", - "path": "/api/v1/builds/{uuid}/quality-gate/profile/{profileUuid}", - "mode": "read", - "entity": "quality_gate", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." - }, - { - "name": "profileUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "UUID of the quality profile. A non-UUID value 400s before the handler runs." - } - ], - "intent": "Drill into a single quality profile's result for a build: the individual rules, their thresholds and rendered conditions, and each rule's pass/fail outcome \u2014 for the quality-gate detail drawer.", - "returns": [ - "rules", - "ruleStatus" - ], - "responses": { - "200": { - "$response": "BuildQualityProfileResultDetail" - } - }, - "guidance": [ - "The body is an object {rules, ruleStatus}, where each rule entry is {rule, ruleStatus, ruleResult} \u2014 the rule definition plus that rule's per-build status and result.", - "Failure-category rules have their `displayWith` resolved to the project's human display names; other rule types do not.", - "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." - ] - }, - { - "name": "get_flakiness_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/flakiness", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at the plan retention floor. Rejected with 400 only if it is negative or greater than upperBound." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now. Silently clamped down to the plan retention ceiling." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name. Omit for all builds." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's runs are counted; repeat the param for a set." - } - ], - "intent": "Get the flakiness card for a project's Testing Trends page - the percentage and count of flaky tests over the window plus the sparkline series, to track whether suite flakiness is improving.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "ALWAYS pass lowerBound/upperBound: omitting them returns the full retention window (tens of thousands of characters) and blows the tool output limit. For a single build's flaky/new-failure/muted/re-run counts read the build row (historyAggregate) via list_project_builds_by_name or get_build_details instead; this card is per build NAME over a window, not per run.", - "Plan-feature-gated: if the flakiness widget is not in the caller's plan the call fails with 403 (FEATURE_INACCESSIBLE), NOT a 200 with empty data - do not treat that as 'no flaky tests'.", - "The window is clamped to plan retention silently, and the bucket interval is derived from the width of the window you asked for, so a wider range yields coarser buckets.", - "`insights.count` is the headline percentage as a STRING (one decimal), and `insights.meta` is a human sentence like 'N of M unique tests are flaky'; the per-bucket series is in `data`, each point's `y` being the flaky count for that bucket (null when no tests ran).", - "Both bounds are optional here; omit them to get the full retention window." - ] - }, - { - "name": "get_always_failing_tests_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/alwaysFailing", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor. 400 only if negative or greater than upperBound." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to the plan retention ceiling." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name. Omit for all builds." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the always-failing card for a project - the percentage and count of consistently broken (permanently red) tests over the window plus the sparkline series, to see whether they are growing.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-feature-gated: refused with 403 (FEATURE_INACCESSIBLE) when the always-failing widget is not in the caller's plan, rather than returning empty data.", - "Side effect: for non-superusers this records the Testing Trends view in the caller's recently-viewed list. It is harmless and does not change test data, but it means this read is not entirely free of writes.", - "Window is clamped to plan retention silently; bucket interval is auto-chosen from the width of the window. `insights.count` is a percentage string and `insights.meta` a summary sentence." - ] - }, - { - "name": "get_new_failures_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/newFailures", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow the card to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the new-failures card for a project - the percentage and count of tests failing for the first time in the window plus the series, the 'what did we just break' regression signal.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-feature-gated: 403 (FEATURE_INACCESSIBLE) when the new-failures widget is not in the caller's plan; not a 200 with empty data.", - "Window clamped to plan retention silently; bucket interval auto-chosen from window width. `insights.count` is a percentage string; per-bucket new-failure counts are in `data[].y` (null for buckets with no runs)." - ] - }, - { - "name": "get_test_growth_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testGrowth", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the test-suite growth card for a project - how the number of unique test cases changed over the window, with the timeline series, i.e. whether the suite is actually growing.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the `testGrowth` path this counts DISTINCT TEST CASES (suite size), not test runs - use get_test_executions_trend for run volume.", - "Unlike flakiness / new-failures / always-failing this card is NOT feature-gated, so it works on any plan.", - "`insights` is null on this card (no headline metric); read the series in `data`, where each point's `y` is the distinct-test-case count for that bucket. Window clamped to plan retention; bucket interval auto from window width." - ] - }, - { - "name": "get_test_executions_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testExecutions", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the test-execution volume card for a project - how many tests ran over the window, bucketed over time, to correlate suite growth or CI changes with other trends.", - "returns": [ - "data", - "insights" - ], - "responses": { - "200": { - "$response": "PercentTimelineCardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Counts executions (how many tests ran), not distinct cases; for suite size use get_test_growth_trend.", - "`insights.count` here is the raw total execution count as a string (not a percentage), and `insights.metric`/`insights.meta` are null. Per-bucket counts are in `data[].y` (0 for empty buckets); `data[].t` is null on this card.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_failure_categories_trend", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/failureCategories", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Break a project's failures down by failure category over time, so a team can see which classes of failure (defect types) are trending up and their share of the total.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FailureCategoriesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Response is `{data: [ {name, color, data:[{x,y}]} ]}` - one entry per defect/issue category, each with its own time series; `name` and `color` come from the group's defect-type mapping.", - "Within a category series, a point's `y` is null for a bucket in which no tests ran at all (as opposed to 0 failures) - distinguish 'no data' from 'no failures'.", - "Window clamped to plan retention; bucket granularity auto-chosen from the width of the window. A project with no failures in the window returns an empty `data` array, not an error." - ] - }, - { - "name": "get_cross_browser_coverage", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/cbt", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at epoch 0 rather than the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Narrow to a single build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the cross-browser-testing coverage breakdown for a project - the window's executions aggregated by browser, OS and device and grouped into primary/secondary platform buckets with counts and percentages.", - "returns": [ - "data", - "drillDownData" - ], - "responses": { - "200": { - "$response": "CbtResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`data` holds the top-level (primary) platform groups; `drillDownData` holds each group's secondary breakdown, addressed by the primary group's key (`drillDownData[].id` matches `data[].drilldown`).", - "`percentage` fields are formatted STRINGS (two decimals), and `value` is the raw execution count. Primary points carry os/browser/device as null; those are set only on secondary (drill-down) points. Runs without platform info land in an 'Unknown' group with 'NA' secondaries.", - "A project with no runs in the window returns empty groups, not an error. Window clamped to plan retention." - ] - }, - { - "name": "get_build_frequency", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildFrequency", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "required": true, - "description": "Window start, epoch milliseconds. REQUIRED on this card (unlike the others). Missing it is a 400." - }, - { - "name": "upperBound", - "type": "integer", - "required": true, - "description": "Window end, epoch milliseconds. REQUIRED. lowerBound > upperBound, or a negative bound, is a 400." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns the run count for just this one build name; when absent, the count per distinct build name." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get how often builds ran over a period - run counts per build name (or for one build name) - so a team can see CI cadence and gaps.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildFrequencyResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Unlike every other Testing Trends card, BOTH bounds are mandatory here and are validated as a range - omitting one, sending them reversed, or a negative value is a 400.", - "This card does NOT clamp the window to plan retention; the exact bounds you send are used against the relational build store, so counts can differ from the analytics-index cards.", - "Response is `{data: [ {id, buildName, runs} ]}`; `id` is just a positional index into the list, not a build id, and `runs` is the run count. There is no time series here - it is a per-build total for the window.", - "A `sourceFencingFlags` key exists on the model but is not populated on this path, so treat it as always absent." - ] - }, - { - "name": "list_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildNames", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's builds contribute names; repeat the param for a set." - } - ], - "intent": "List the distinct build names seen in a project within the plan's retention window - the source of the build-name values every other Testing Trends card filters by.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildNamesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes NO date range - it always scans the full plan-retention window, so build names from older builds simply disappear once they fall out of retention.", - "Response is wrapped as `{data: [ \"\", ... ]}` - a flat array of strings, not build objects.", - "An unknown project is a 404 (PROJECT_NOT_FOUND), unlike the analytics cards which resolve the project inside the aggregation." - ] - }, - { - "name": "list_stability_leaderboard", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the stability metric itself always uses the plan-retention window." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns just this one build's stability row (the 'All' aggregate row is then omitted)." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the stability leaderboard for a project's Testing Trends page - pass-rate / stability scoring per build name over the window, to rank the least reliable suites.", - "returns": [ - "data", - "pagingParam" - ], - "responses": { - "200": { - "$response": "StabilityLeaderboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The requested window is REPLACED by the plan-retention window for the stability computation, so an out-of-retention window silently returns retention-bounded scores; the bounds you send only affect which builds are listed on the page.", - "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false. Note the response key is `pagingParam` (SINGULAR), and `pagingParam.pageNumber` echoes back one-based (your pageNumber + 1).", - "On page 0 with no `buildName`, an extra project-wide row (`id:\"all\"`, buildName:\"All\") is prepended before the per-build rows; on later pages or with a build filter it is absent.", - "Each row's `percentage` is a formatted STRING and `chartData` is the per-bucket pass-rate series (entries may be null for empty buckets). For a full, higher-resolution series for one build use get_project_stability_chart / get_build_stability_chart." - ] - }, - { - "name": "get_project_stability_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the project-wide stability-over-time series for the Testing Trends stability chart - across all builds, or for one build selected by build name.", - "returns": [ - "median", - "data" - ], - "responses": { - "200": { - "$response": "StabilityChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the build-name-selected variant of the stability chart: it derives the series identifier from the `buildName` query param and falls back to the literal \"all\" when it is absent. To address a build by an identifier in the path instead, use get_build_stability_chart.", - "Response is `{median, data}`: `median` is the overall pass-rate percentage for the window (a number), and `data` is the per-bucket series where each point's `y` is a pass-rate percentage NUMBER (e.g. 55.5), null for empty buckets.", - "Window clamped to plan retention; bucket interval auto-chosen from the width of the window.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_build_stability_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/{identifier}/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - }, - { - "name": "identifier", - "type": "string", - "required": true, - "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the stability-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a stability leaderboard row is expanded.", - "returns": [ - "median", - "data" - ], - "responses": { - "200": { - "$response": "StabilityChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query instead, use get_project_stability_chart. Both return the identical `{median, data}` shape.", - "Window clamped to plan retention; bucket interval auto-chosen from window width. Point `y` values are pass-rate percentage strings, null for buckets with no runs." - ] - }, - { - "name": "list_performance_leaderboard", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the metric always uses the plan-retention window." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." - }, - { - "name": "buildName", - "type": "string", - "description": "When present, returns just this one build's performance row." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the performance leaderboard for a project's Testing Trends page - per-build execution-duration metrics over the window, to find which suites are slowing down.", - "returns": [ - "data", - "pagingParam" - ], - "responses": { - "200": { - "$response": "PerformanceLeaderboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The requested window is REPLACED by the plan-retention window for the duration metrics; the bounds you send only affect which builds appear on the page. So a window outside retention silently returns retention-bounded numbers.", - "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false (note the response key is `pagingParam`, SINGULAR). Builds with zero matching executions in the window are dropped from the page, so a page can contain fewer than 8 rows even when hasNext is true.", - "Each row: `avgDuration` (number, avg execution duration), `testCount` (avg test-run count), `barChartData` (per-bucket average durations) and `lineChartData` (per-bucket test counts) - note barChartData is durations and lineChartData is counts. Per-row full series also available via get_project_performance_chart / get_build_performance_chart." - ] - }, - { - "name": "get_project_performance_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "buildName", - "type": "string", - "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the project-wide duration-over-time series for the Testing Trends performance chart - across all builds, or for one build selected by build name.", - "returns": [ - "barData", - "lineData" - ], - "responses": { - "200": { - "$response": "PerformanceChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the build-name-selected variant: it derives the series identifier from the `buildName` query param, defaulting to \"all\" when absent. To address a build by an identifier in the path, use get_build_performance_chart.", - "Response is `{barData, lineData}`: `barData` is the per-bucket average duration series and `lineData` is the per-bucket test-count series. `lineData` points carry `pointRange: null` (only `x`/`y` are meaningful), whereas `barData` points carry the range.", - "Window clamped to plan retention; bucket interval auto-chosen from window width.", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "get_build_performance_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/{identifier}/chart", - "mode": "read", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - }, - { - "name": "identifier", - "type": "string", - "required": true, - "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." - } - ], - "query": [ - { - "name": "lowerBound", - "type": "integer", - "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values; repeat the param for a set." - } - ], - "intent": "Get the duration-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a performance leaderboard row is expanded.", - "returns": [ - "barData", - "lineData" - ], - "responses": { - "200": { - "$response": "PerformanceChartResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query, use get_project_performance_chart. Both return the identical `{barData, lineData}` shape.", - "Bucket interval auto-chosen from window width. `barData` is average duration per bucket, `lineData` is test count per bucket (lineData points carry pointRange:null and t:null).", - "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." - ] - }, - { - "name": "list_recent_runs", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns", - "mode": "write", - "entity": "testing_trends", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised (URL-safe) name." - } - ], - "query": [ - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. Each page returns a fixed 5 builds; there is no page-size param." - }, - { - "name": "source", - "type": "array", - "description": "Zero or more ProductSource values to restrict which product's runs are listed; repeat the param for a set." - } - ], - "intent": "List a project's most recent builds - one entry per build with its summary (name, number, status, counts) - the run picker / recent-runs list on the Testing Trends page.", - "returns": [ - "data", - "pagingParams", - "currentQGOverrideStatus" - ], - "responses": { - "200": { - "$response": "RecentRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SIDE EFFECT despite being a GET: this marks the project as subscribed for the calling user - a persisted user-preference write. Do not call it merely to peek at recent runs if you must avoid changing the user's subscriptions; there is no read-only variant on this route.", - "Takes NO date range; results are just the latest builds. Fixed page of 5 builds via the zero-based `pageNumber`; stop when `pagingParams.hasNext` is false. Here the response key is `pagingParams` (PLURAL) - unlike the leaderboards' singular `pagingParam`.", - "`data` is an array of full build summaries (not trend series), and `currentQGOverrideStatus` is the project's quality-gate override flag.", - "The subscription side effect is observable: when the caller has no email preference on the project, it creates their EMAIL notifier/preferences (daily summary ON by default), visible via list_project_notification_recipients type=EMAIL. Only fires once per user+project." - ] - }, - { - "name": "list_group_webhook_projects", - "method": "GET", - "path": "/api/v1/settings/webhooks/projects", - "mode": "read", - "entity": "webhook", - "intent": "List the group's TCM/TO projects for the webhook editor's project picker \u2014 a paginated, searchable, sortable list that marks the projects already linked to a given webhook.", - "returns": [ - "projectMapping" - ], - "query": [ - { - "name": "page", - "type": "integer", - "required": true, - "description": "Page number (required). Page size is fixed at 30 and is NOT caller-controllable \u2014 there is no perPage/pageSize parameter here." - }, - { - "name": "sortBy", - "type": "string", - "required": false, - "description": "Sort token for the project list." - }, - { - "name": "webhookId", - "type": "string", - "format": "uuid", - "required": false, - "description": "When supplied, each returned project's `isSelected` flags whether it is already linked to this rule; the rule must exist in the caller's group and product bucket." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Project name substring filter." - } - ], - "responses": { - "200": { - "$response": "WebhookProjectMappingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Subject to the controller-wide feature-entitlement check. When webhookId is passed, the rule must be in the caller's group/bucket or it is not found.", - "Page size is HARDCODED to 30 and the product source is HARDCODED to `TCM,TO` \u2014 neither can be changed by the caller; there is no perPage parameter. Walk pages via info.next.", - "Each project carries `isSelected`, true when it is already linked to the webhookId you passed \u2014 that is how the picker pre-checks linked projects.", - "Response is nested under projectMapping:{info, projects}; info here is the project-picker PaginationInfo (totalProjectsCount/linkedProjectsCount), distinct from the rule-list info block." - ] - }, - { - "name": "retrigger_rca_analysis", - "method": "POST", - "path": "/api/v1/ai/testRuns/{testRunId}/rcaContext/retrigger", - "mode": "write", - "entity": "ai_insight", - "intent": "'Improve analysis' \u2014 re-run the agentic AI RCA for a failed test with a user instruction and up to 3 previously presigned attachments, creating a new conversation turn. Returns the new message id (and trial quota used) so you can poll for the refreshed analysis.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true - } - ], - "body": [ - { - "name": "instruction", - "type": "string", - "required": false, - "description": "Free-text instruction for the improved analysis. Max 5000 characters." - }, - { - "name": "attachments", - "type": "array", - "required": false, - "description": "Up to 3 previously presigned attachments to include as context. Each item references a key returned by the RCA-context presign call. More than 3 is rejected.", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "description": "The key returned by the RCA-context presign call. Non-blank, max 512 chars." - }, - { - "name": "fileName", - "type": "string", - "required": true, - "description": "Non-blank, max 200 chars." - }, - { - "name": "contentType", - "type": "string", - "required": true, - "description": "Non-blank, max 100 chars." - }, - { - "name": "description", - "type": "string", - "required": false, - "description": "Optional note about the attachment. Max 500 chars." - } - ] - }, - { - "name": "usePreferences", - "type": "boolean", - "required": false, - "description": "Whether saved code-fix / analysis preferences should be applied to this run." - }, - { - "name": "webhook_url", - "type": "string", - "required": false, - "description": "Optional Atlas agent-callback URL. When present it rides the retrigger's metadata and the finished RCA is delivered to it. Max 2048 chars. Wire name is webhook_url.", - "json_path": "/webhook_url" - } - ], - "returns": [ - "messageId" - ], - "responses": { - "200": { - "$response": "RcaRetriggerResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: the response carries only messageId (and trialQuota.used for trial users) \u2014 the analysis itself arrives later when the AI service calls back. Poll the RCA conversation / that message id for the refreshed result.", - "trialQuota is present only for test-level trial users (their usage counter is incremented on success); it is omitted otherwise, so do not treat its absence as an error.", - "Attachments must first be presigned and uploaded via the RCA-context presign call \u2014 pass those returned s3Keys here; at most 3.", - "Only agentic RCAs on RCA-v2-eligible builds are accepted, the test run must be within the allowed age, and a run owned by the MCP-driven agent is refused.", - "There is no undo for a retrigger \u2014 it appends a new conversation turn; approve or discard the resulting turn separately." - ] - }, - { - "name": "get_build_accessibility_meta", - "method": "GET", - "path": "/api/v1/builds/{buildId}/a11y-meta", - "mode": "read", - "entity": "build", - "intent": "Get the build-level accessibility summary header \u2014 whether auto-scan was enabled, the severity breakdown (critical/serious/moderate/minor) and the total issue count \u2014 for the strip above a build's per-test accessibility listing.", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Build UUID. Can be silently overridden by the `run` query param \u2014 see guidance." - } - ], - "query": [ - { - "name": "run", - "type": "array", - "description": "Optional test-run ids (bound from `A11yFilterParams.run`). If supplied, the FIRST entry is treated as a test-run id and the build UUID is resolved from it, OVERRIDING the path `buildId`. Send this empty/absent to honour the path build.", - "fields": [ - { - "name": "run[]", - "type": "integer" - } - ] - }, - { - "name": "status", - "type": "string", - "description": "Accessibility status filter (bound from A11yFilterParams). Not used to compute the meta totals on this route." - }, - { - "name": "searchQuery", - "type": "string", - "description": "Search token (bound from A11yFilterParams). Not used to compute the meta totals on this route." - } - ], - "returns": [ - "totalIssue", - "severityBreakdown", - "autoEnabledBuild", - "autoEnabled", - "reportUrl" - ], - "guidance": [ - "This is the web/Automate accessibility shape. For App-Automate builds that have App Accessibility enabled for the group, the request is proxied to the App Accessibility service and returns a DIFFERENT body \u2014 do not assume the fields below when the build is an app build.", - "The `run` query param silently overrides the path `buildId`: if you pass a run id, the meta is for the build that run belongs to, not the build in the path.", - "The build must have finished (status FINISHED or TIMEOUT) and carry accessibility data; otherwise you get a classified accessibility error (build-not-completed / build-not-found / results-not-found), not an empty 200.", - "`autoEnabledBuild` and `autoEnabled` are the same value duplicated, read from the build's accessibility settings.auto_enable (the auto-scan config), so they can be false on a build whose SDK run did scan. `severityBreakdown` carries each severity the build summary reports (zero counts included), and `totalIssue` is their sum.", - "`reportUrl` is a weak deep link into the Accessibility dashboard: its path uses the literal placeholder segments Project/builds/Build/Number and only the thBuildId query is real. It is not an API endpoint.", - "The central-auth JWT (cookie or Bearer) is extracted and forwarded to the App-Accessibility proxy on the app path; a missing token there surfaces as an accessibility-service auth error." - ], - "responses": { - "200": { - "$response": "BuildAccessibilityMetaResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_similar_test_runs", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/analyzer-similar", - "mode": "read", - "entity": "build", - "intent": "For the auto-analyser / unique-errors view: given a failed test run (or a set of error-cluster ids) in a build, list the other test runs with a similar failure so you can judge blast radius and treat the cluster as one issue.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "mode", - "type": "string", - "required": true, - "description": "Scope of the similarity search. Required \u2014 a missing/invalid value is a 400.", - "values": [ - "CURRENT_BUILD", - "LAST_10_BUILDS", - "LAST_10_DAYS" - ] - }, - { - "name": "testRunId", - "type": "integer", - "description": "Numeric test-run id to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present." - }, - { - "name": "clusterIds", - "type": "array", - "description": "Error-cluster ids to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present.", - "fields": [ - { - "name": "clusterIds[]", - "type": "string" - } - ] - } - ], - "returns": [ - "similar", - "current", - "message" - ], - "guidance": [ - "Send EITHER `testRunId` OR `clusterIds` (or both) \u2014 if neither is provided the call is a 400 (\"Either clusterIds or TestRunId is required\").", - "403 (plan-feature-not-allowed) when the build is locked by plan fencing; this is a plan-fenced analyser feature.", - "`current` describes the reference run and `similar` the matched runs; each carries the run uuid plus project/build normalised names and build number so you can deep-link. `message` may explain why the list is empty.", - "Available on public-link-token auth (public dashboards), unlike most write routes." - ], - "responses": { - "200": { - "$response": "SimilarTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_always_failing_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/alwaysFailingStats", - "mode": "read", - "entity": "build", - "intent": "Get the 'always failing' tile for a build \u2014 how many of the build's tests have been failing consistently (out of the total), plus the tile's comment count \u2014 for the build insights page.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text filter over the build's tests (part of the standard build filter set). The whole FilterParamsV2 filter set is accepted here; it narrows which tests the metric counts." - }, - { - "name": "status", - "type": "array", - "description": "Test status filter (standard build filter set).", - "fields": [ - { - "name": "status[]", - "type": "string" - } - ] - }, - { - "name": "folders", - "type": "array", - "description": "Folder-path filter (standard build filter set).", - "fields": [ - { - "name": "folders[]", - "type": "string" - } - ] - }, - { - "name": "testTags", - "type": "array", - "description": "Tag filter (standard build filter set)." - }, - { - "name": "customParams", - "type": "object", - "description": "Any extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded. Unknown keys are not rejected." - } - ], - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "guidance": [ - "`count` is the always-failing test count and `meta` typically carries the total it is out of; `commentCount` is the number of comments on this tile.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "One of a family of near-identical build tiles (flaky, new-failure, muted, defect, stability) that share this shape and differ only in the metric computed \u2014 pick the endpoint for the metric you want rather than re-filtering this one.", - "This is the standard build filter set: the same query params work across the tiles." - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_ai_failure_summary", - "method": "GET", - "path": "/api/v1/builds/{id}/aiSummary", - "mode": "read", - "entity": "build", - "intent": "Get the AI-generated narrative of why a build failed \u2014 a summary paragraph plus the top failure themes (name, description, affected test count, failure %) and the top affected workflows.", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The NUMERIC internal build id \u2014 NOT the UUID used by most other build routes. A UUID here will not resolve." - } - ], - "returns": [ - "summary", - "topFailureThemes", - "topAffectedWorkflows", - "status" - ], - "guidance": [ - "Takes the numeric build id, so it will NOT accept the UUID the sibling build endpoints use.", - "Fails soft: any upstream error or missing theme data returns 200 with `status: \"NOT_AVAILABLE\"`, `summary: null` and empty theme/workflow lists \u2014 check `status`, do not treat it as an error.", - "`topFailureThemes` and `topAffectedWorkflows` are each capped at 5 items.", - "Ownership-checked: if the build's group does not match the caller it reads as build-not-found." - ], - "responses": { - "200": { - "$response": "BuildAISummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_attempt_run_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildAttemptRunStats", - "mode": "read", - "entity": "build", - "intent": "Break a build's test results down per re-run attempt \u2014 pass/fail/skipped/unknown and workflow-status counts for attempt 1, 2, 3\u2026 (including manual runs) \u2014 so you can see how results changed across retries.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "attempts", - "summary", - "count", - "commentCount" - ], - "guidance": [ - "`attempts` is one entry per attempt (label, attemptNumber, totalAttempts and status counts); `summary` is the aggregate across attempts.", - "The project's hooks visibility is resolved first and changes which test-run documents are counted \u2014 the same build can report different totals depending on hooks visibility.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID." - ], - "responses": { - "200": { - "$response": "BuildAttemptRunStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_history", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildHistory", - "mode": "read", - "entity": "build", - "intent": "Get the trend of the recent builds of the same build name leading up to this one (per-build pass/fail counts, keys, lock state) so the build page can show how this build compares with previous runs.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text filter (standard build filter set). The whole FilterParamsV2 filter set is accepted and narrows what the trend counts." - }, - { - "name": "status", - "type": "array", - "description": "Test status filter (standard build filter set).", - "fields": [ - { - "name": "status[]", - "type": "string" - } - ] - }, - { - "name": "customParams", - "type": "object", - "description": "Extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded; unknown keys are not rejected." - } - ], - "returns": [ - "data", - "keys", - "commentCount" - ], - "guidance": [ - "The window is hard-coded to the ~10 most recent builds of the same build name \u2014 there is no page size or count parameter to widen it.", - "`data` is a per-status series and `keys` the build labels/order along the x-axis; builds locked by plan fencing are flagged within the mapping.", - "403 (plan-feature-not-allowed) when the CURRENT build is plan-locked; build-not-found for an unknown UUID." - ], - "responses": { - "200": { - "$response": "BuildHistoryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_id_from_build_coordinates", - "method": "GET", - "path": "/api/v1/builds/getBuildIdFromBuildInfo", - "mode": "read", - "entity": "build", - "intent": "Resolve a human-readable build coordinate (project name + build name + build serial number, i.e. what appears in a build URL) into the internal build id/UUID, so deep links and CI references can be turned into an API-usable handle.", - "query": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name as it appears in the dashboard URL." - }, - { - "name": "buildNormalisedName", - "type": "string", - "required": true, - "description": "Build normalised name as it appears in the dashboard URL." - }, - { - "name": "buildSerialId", - "type": "integer", - "required": true, - "description": "Build serial number (the #N shown in the UI)." - } - ], - "returns": [ - "buildId" - ], - "guidance": [ - "All three query params are required.", - "When no build matches you get a bare HTTP 404 with an EMPTY body (not a structured error object) \u2014 handle the status code, there is nothing to parse.", - "The lookup is scoped to the caller's group, so a build in another group reads as not-found.", - "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse.", - "`buildSerialId` is the UI #N only. If the user's number does not match (404) it may be the CI run id (`buildRunId`): resolve that with search_entities (entity Build) or list_project_builds_by_name, which return both ids." - ], - "responses": { - "200": { - "$response": "BuildIdFromCoordinatesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "No build matched the coordinates. Empty response body.", - "schema": { - "type": "object" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_coordinates_from_uuid", - "method": "GET", - "path": "/api/v1/builds/getBuildInfoFromUuid/{uuid}", - "mode": "read", - "entity": "build", - "intent": "The inverse of the coordinate lookup: given a build UUID, get the URL-safe coordinates for it (project & build normalised names, serial id, project id/name, finished/created timestamps) so a caller holding only a UUID can construct the build's dashboard URL.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "buildNormalisedName", - "buildSerialId", - "projectNormalisedName", - "userId", - "groupId", - "projectName", - "projectFinishedAt", - "projectCreatedAt", - "toProjectId", - "thProjectId" - ], - "guidance": [ - "The normalised names are URI-encoded with spaces turned into '+', so use them verbatim when building a URL \u2014 do not re-encode.", - "If the build's group does not match the caller the call is refused with a forbidden-operation error.", - "An unknown UUID returns a build-not-found error (note: an older description claimed an empty JSON object here; the current handler throws not-found \u2014 do not rely on an empty-object sentinel).", - "`toProjectId` is the Observability project id and `thProjectId` the TestHub project id \u2014 they are different id spaces." - ], - "responses": { - "200": { - "$response": "BuildCoordinatesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_rerun_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildRerunStats", - "mode": "read", - "entity": "build", - "intent": "Get the flat re-run summary for a build \u2014 a list of re-run legs with per-leg pass/fail/skipped/unknown counts and totals \u2014 for the re-run section of the build page.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "retryData", - "count", - "commentCount" - ], - "guidance": [ - "This is the flat/legacy shape, computed straight from the DB by build id + group id; `retryData` is a flat list of legs with no split-build grouping and there is no aggregate `summary`.", - "For split or parent/child builds, or when you need the aggregate summary, use `get_split_build_rerun_stats` instead \u2014 its response shape is different and NOT a drop-in swap.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "Hooks visibility for the project is resolved first and affects which runs are counted." - ], - "responses": { - "200": { - "$response": "BuildReRunResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_split_build_rerun_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildRerunStats/v2", - "mode": "read", - "entity": "build", - "intent": "Get the split-build-aware re-run summary for a build \u2014 re-run legs grouped (with per-split breakdowns) plus an overall aggregate summary \u2014 resolved against the whole build and the Observability project id so split/parent-child builds are handled correctly.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "returns": [ - "retryData", - "count", - "commentCount", - "summary" - ], - "guidance": [ - "Use this for split or parent/child builds and project-scoped configuration \u2014 it resolves against the whole build object and the Observability project id.", - "The response shape differs from `get_build_rerun_stats`: `retryData` is a list of GROUPS (each with `splits` and its own `summary` and build metadata) and there is a top-level aggregate `summary`. It is NOT a drop-in swap for the flat version.", - "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", - "Hooks visibility for the project is resolved first and affects which runs are counted." - ], - "responses": { - "200": { - "$response": "BuildReRunV2Response" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_test_filters", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/filters", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "The build's UUID. If it is a leg of a split build, the parent is resolved automatically and the filter set is aggregated over the parent plus every child leg." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "description": "Free-text search echoed back into the resolved `applied` block; part of the currently-applied test filter, not a facet type-ahead." - }, - { - "name": "status", - "type": "array", - "description": "Currently-applied test status values, echoed back resolved. All the FilterParamsV2 test-listing fields are accepted here (status, priority, caseType, automationStatus, testTags, folders, owner, assignee, deviceList, osList, browserList, failureCategories, dateRange, isFlaky, isNewFailure, ...) and are returned normalised under `applied`." - }, - { - "name": "priority", - "type": "array", - "description": "Applied priority ids. When any of priority / caseType / automationStatus is present the response rewrites their labels in `applied` to match the static catalogue, so read labels from the response rather than assuming them." - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Test-management user ids; max 500 entries (enforced server-side)." - } - ], - "intent": "Get the filter panel for a build's TEST listing \u2014 the currently applied test filters resolved with labels, the static catalogue of every selectable test filter, hooks visibility, and which filters are pro-only for this workspace.", - "returns": [ - "applied", - "staticFilters", - "hooksVisibility", - "proFilters" - ], - "guidance": [ - "This is the filter set for the TESTS inside one build, not for the build listing \u2014 use get_build_list_filters / get_build_sidebar_filters for the builds-list facets.", - "`applied` is your own query echoed back in resolved form (ids turned into user/label objects); `staticFilters` is the full catalogue of selectable values keyed by filter name. Read available option values from `staticFilters`, not from `applied`.", - "`proFilters` lists the filter keys locked behind a paid plan; it is NON-EMPTY only for a freemium workspace (then it names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted) and empty otherwise. Treat a key that appears here as disabled, not missing.", - "`hooksVisibility` is one of all / failed / beforeFailed / none and tells you how before/after hook tests are surfaced for this project.", - "For a split build you may pass any leg's UUID; the parent is resolved and the static filters are unioned across all legs, so the result is the same whichever leg you name." - ], - "responses": { - "200": { - "$response": "BuildTestFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_list_filters", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/filters", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised-name slug. Resolved within the caller's group first, so a project in another group (or an unknown slug) returns 404." - } - ], - "query": [ - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Applied build-name selection, echoed back resolved. Any of the GetBuildFilterRqV2 build-list fields may be supplied as query params." - }, - { - "name": "users", - "type": "array", - "description": "Applied build-owner user ids (integers), echoed back as resolved user objects (id + name)." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team ids (integers), echoed back as resolved team objects." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values \u2014 branded SDK names and pass-through values like 'appium'/'selenium-4'. Sent as strings (not an enum), matched case-insensitively; use the exact value the static catalogue offers." - }, - { - "name": "statuses", - "type": "array", - "description": "Applied build display-status values." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag values (build-level tags)." - }, - { - "name": "branches", - "type": "array", - "description": "Applied git branch values." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end] \u2014 a size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names (distinct from buildTags); max 500 entries, and gated by a per-group flag \u2014 ignored downstream when the flag is off.", - "maxItems": 500 - }, - { - "name": "state", - "type": "string", - "description": "Build state scope; defaults to `active` when omitted.", - "default": "active" - } - ], - "intent": "Populate the build-listing filter bar for a project in one call \u2014 the static catalogue of selectable build filters (statuses, frameworks, build types, product sources, creation types) plus the caller's currently applied filter echoed back resolved.", - "returns": [ - "applied", - "staticFilters" - ], - "guidance": [ - "`staticFilters` carries the option lists: statusList, frameworkList, types, sources, creationType \u2014 each a list of {id, value, label} option objects. `applied` is your query resolved (user/team ids to names, date range, tags, branches).", - "This is the PER-PROJECT filter bar (scoped by the path project) \u2014 it returns the catalogue for that project. For a cross-project, filter-narrowed facet list use the sidebar capabilities instead.", - "On a demo-sandbox tenant this short-circuits before any project lookup and returns a hardcoded framework/static list, so the option set there is the global master list rather than what the project actually contains.", - "An unknown or foreign project slug is a 404 \u2014 the project is resolved inside the caller's group." - ], - "responses": { - "200": { - "$response": "BuildListFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_branch_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/branches", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the branch name. Optional \u2014 omit to get the leading matches." - } - ], - "intent": "Type-ahead options for the 'Branch' filter on a project's build listing \u2014 git branch names seen in the project's builds, as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Per-project, prefix-search only \u2014 it does not narrow by any other currently-applied filter. For branches present under a full applied filter across a project set, use list_sidebar_branch_filter_options.", - "Each option's `id`/`value` IS the branch string; send that value straight back as the branch filter selection." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_tag_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/tags", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the tag value. Optional." - } - ], - "intent": "Type-ahead options for the 'Build tag' filter on a project's build listing \u2014 matching build-tag values as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Per-project, prefix-search only \u2014 no other applied filter narrows the result. This handler always does a real project lookup (there is no demo-sandbox short-circuit here).", - "These are build-level tags. For test-execution tags, or for a tag list narrowed by an applied cross-project filter, use the sidebar tag facet." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_team_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/teams", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the team name. Optional." - } - ], - "intent": "Type-ahead options for the 'Team' filter on a project's build listing \u2014 BrowserStack teams that have builds in the project, as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Each option's `id` is the numeric team id (as a string) and `value`/`label` are the team name \u2014 send the id back as the team filter selection, not the name.", - "Per-project only; it is not narrowed by any other applied filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_user_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/users", - "mode": "read", - "entity": "build_filter", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." - } - ], - "query": [ - { - "name": "q", - "type": "string", - "description": "Case-insensitive search prefix for the user name. Optional." - } - ], - "intent": "Type-ahead options for the 'User' (build owner) filter on a project's build listing \u2014 matching users as {id, value, label} option objects.", - "returns": [ - "data" - ], - "guidance": [ - "Each option's `id` is the numeric user id (as a string) and `value`/`label` are the user's name \u2014 send the id back as the user/owner filter selection.", - "Per-project only; it is not narrowed by any other applied filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_sidebar_filters", - "method": "POST", - "path": "/api/v2/builds/filters", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids the filter spans. When omitted the server falls back to a default project set for the group, so send it explicitly to scope the view." - }, - { - "name": "query", - "type": "string", - "description": "Type-ahead text for the sidebar sub-searches (users/tags/runGroups). NOT applied or echoed by this call - use `search` for the free-text build search." - }, - { - "name": "users", - "type": "array", - "description": "Applied build-owner user ids (integers), returned resolved to {id, name}." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team ids (integers), returned resolved to team objects." - }, - { - "name": "statuses", - "type": "array", - "description": "Applied build display-status values." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag values." - }, - { - "name": "branches", - "type": "array", - "description": "Applied git branch values." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values (branded SDK names and pass-through values like 'appium'/'selenium-4'); strings, matched case-insensitively." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end]; a size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - }, - { - "name": "assignees", - "type": "array", - "description": "Assignee tokens, each matching ^(\\$none|[0-9]+)$ \u2014 the literal '$none' selects unassigned, otherwise a numeric user id. A value that does not match the pattern is a 400.", - "pattern": "^(\\$none|[0-9]+)$" - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names; max 500 entries, gated by a per-group flag (ignored when off).", - "maxItems": 500 - }, - { - "name": "state", - "type": "string", - "description": "Build state scope; defaults to `active`.", - "default": "active" - }, - { - "name": "search", - "type": "string", - "description": "Free-text build search; echoed back as applied.search." - } - ], - "intent": "Hydrate the build-listing sidebar filter panel across a set of projects \u2014 the caller's applied selection resolved into display objects (user/team ids to names, date range, tags, branches, statuses, build names) alongside the static option catalogue.", - "returns": [ - "applied", - "staticFilters" - ], - "guidance": [ - "POST but purely read \u2014 the body is only a filter payload and nothing is written.", - "`applied` (a SidebarFilter: the resolved build filter plus the `projects` it was scoped to) echoes your selection resolved; `staticFilters` is the option catalogue (statusList, frameworkList, types, sources, creationType).", - "This is the GROUP-WIDE variant: the static framework list is the hardcoded master list, NOT the frameworks actually present in the selected projects. If you need the frameworks that really occur, treat frameworkList here as the full menu, not a presence list.", - "Scope with `projects`; if you leave it out you get the group's default project set rather than an error.", - "`applied` echoes users (resolved), teams, dateRange, statuses, buildTags (as `tags`), frameworks, branches, search and the boolean/enum toggles; `assignees` and `query` are NOT echoed, so do not read their absence as rejection." - ], - "responses": { - "200": { - "$response": "BuildSidebarFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_branch_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/branches", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive search text for the branch name. Optional." - }, - { - "name": "statuses", - "type": "array", - "description": "Part of the currently-applied build filter that narrows which builds are considered; the full GetBuildFilterRqV2 build-list field set is accepted (users, teams, buildTags, frameworks, dateRange, state, ...)." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs [start, end]; size other than 2 is rejected.", - "minItems": 2, - "maxItems": 2 - } - ], - "intent": "Type-ahead options for the 'Branch' facet of the build-list sidebar \u2014 git branch names present in the builds matching the rest of the currently applied filter, optionally narrowed by a search string.", - "returns": [ - "data" - ], - "guidance": [ - "POST but read-only \u2014 the body is a filter, no state change.", - "Unlike the per-project branch list, this narrows to branches that actually occur under the whole applied filter across `projects`, and returns BARE STRINGS in `data` (not {id,value,label} objects).", - "Values come from an Elasticsearch terms aggregation capped at 100, so the list is a suggestion set, not a complete enumeration \u2014 narrow with `query` rather than expecting every branch back." - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_build_name_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/buildNames", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive search text for the build name. Optional." - }, - { - "name": "statuses", - "type": "array", - "description": "Part of the applied build filter that narrows which builds are considered; the full build-list field set (users, teams, buildTags, branches, frameworks, dateRange, state, ...) is accepted." - } - ], - "intent": "Type-ahead options for the 'Build name' facet of the build-list sidebar \u2014 the distinct build names present under the current filter/projects, optionally matched against a search string.", - "returns": [ - "data" - ], - "guidance": [ - "POST but read-only \u2014 body is a filter payload only.", - "Returns BARE STRINGS in `data`. Distinct from the shipped list_build_names capability, which is a different (paged) listing \u2014 this one is the sidebar facet's aggregation.", - "ES terms aggregation capped at 100 distinct names, so results are bounded, not exhaustive \u2014 use `query` to find a specific name." - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_run_group_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/runGroups", - "mode": "read", - "entity": "build_filter", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Integer project ids whose run groups to list. Omitted \u2192 the group's default project set." - }, - { - "name": "query", - "type": "string", - "description": "Case-insensitive name search for the run group. Optional." - } - ], - "intent": "Type-ahead options for the 'Run group' (build group) facet of the build-list sidebar \u2014 the first 100 run groups of the selected projects, name-searchable.", - "returns": [ - "data", - "info" - ], - "guidance": [ - "POST but read-only. ONLY `projects` and `query` are read from the body \u2014 every other build-filter field is ignored here, so do not expect statuses/tags/dateRange to narrow the result.", - "Pagination is hardcoded to page 1, page size 100 and CANNOT be driven from the request: you always get at most the first 100 run groups. `info` reports paging but there is no way to fetch page 2 through this endpoint \u2014 use the per-project build-groups listing if you need to page.", - "`data` items are SLIM build-group rows {id, name, description, isClosed, state, updated_at} (note snake_case updated_at), not bare strings. For projectId/createdBy/closedAt use the per-project build-groups listing.", - "This facet is not available on a public dashboard link (no public-link-token auth); it needs an authenticated session or service key." - ], - "responses": { - "200": { - "$response": "RunGroupFilterOptionsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_widget_custom_field_filters", - "method": "GET", - "path": "/api/v1/dashboards/filters/{widgetType}", - "mode": "read", - "entity": "dashboard", - "intent": "List the Test Management custom fields usable as extra filters on a test-case widget, so the widget-config panel can offer them.", - "path_params": [ - { - "name": "widgetType", - "type": "string", - "required": true, - "values": [ - "TEST_CASE_TREND", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "AUTOMATION_COVERAGE" - ], - "description": "Widget type. ONLY these four are valid; any other widget type returns a 400 (BAD_REQUEST_ERROR)." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "required": false, - "description": "Page number. Presence of ANY of page/per_page/search switches the response to the paginated shape (see guidance)." - }, - { - "name": "per_page", - "type": "integer", - "required": false, - "description": "Page size. Passed through to Test Management's custom-fields API unchanged \u2014 no ceiling is enforced by this service." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Substring filter over custom-field names." - } - ], - "returns": [ - "data", - "info" - ], - "responses": { - "200": { - "$response": "WidgetCustomFieldFiltersResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "TWO response shapes from one route: omit all of page/per_page/search and you get a bare JSON ARRAY of {id, label, type, fieldType}; pass any one of them and you get the paginated object {data:[...], info:{...}} instead.", - "Only the four listed widget types are accepted; anything else is a 400, not an empty list.", - "This is a pass-through to Test Management's custom-fields API; `per_page` is forwarded without clamping, so a page ceiling cannot be assumed." - ] - }, - { - "name": "resolve_dashboard_filter_names", - "method": "POST", - "path": "/api/v1/dashboards/meta", - "mode": "read", - "entity": "dashboard", - "intent": "Batch-resolve project ids and user ids to their display names, so a saved dashboard's stored filter ids can be rendered as readable chips.", - "body": [ - { - "name": "projectIds", - "type": "array", - "required": true, - "description": "TRA project ids (Long) to resolve to names. Required key, but may be an empty array. Converted to Test Management project ids before lookup." - }, - { - "name": "userIds", - "type": "array", - "required": true, - "description": "User ids (Long) to resolve to names. Required key, but may be an empty array." - } - ], - "returns": [ - "projects", - "users" - ], - "responses": { - "200": { - "$response": "DashboardMetaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only \u2014 it changes nothing, it only looks names up.", - "Both `projectIds` and `userIds` are required keys (may be empty arrays); omitting either fails validation.", - "Returns {projects, users}, each a list of {id, name}. Project names come from the project repository (ids are mapped to Test Management project ids first); user names come from the user-details service." - ] - }, - { - "name": "search_unique_errors", - "method": "GET", - "path": "/api/v1/dashboards/errors", - "mode": "read", - "entity": "dashboard", - "intent": "Autocomplete over unique error signatures \u2014 given a partial error string, return the top matching unique errors so a user can pick one while configuring an error-focused dashboard widget.", - "query": [ - { - "name": "q", - "type": "string", - "required": false, - "description": "Partial error string to search for. Matched against the errors search index." - } - ], - "returns": [ - "errorClusterId", - "error" - ], - "responses": { - "200": { - "$response": "UniqueErrorSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The body is a top-level JSON ARRAY of {errorClusterId, error} \u2014 there is no wrapper object.", - "The result count is hardcoded to 10 and cannot be paged or raised; refine `q` to narrow rather than expecting more rows.", - "Backed by an Elasticsearch aggregation over the errors index; `errorClusterId` is the stable id of the unique-error cluster." - ] - }, - { - "name": "list_reports", - "method": "GET", - "path": "/api/v1/reports/list", - "mode": "read", - "entity": "dashboard", - "intent": "Power the Reports listing page: a paginated, searchable, sortable list of saved dashboards/reports across Test Observability and Test Management, each with owner, linked projects, type and schedule frequency, plus 'my reports' and total counts.", - "query": [ - { - "name": "projectIds", - "type": "array", - "required": false, - "description": "TestHub project ids (a project's thProjectId, e.g. from list_projects), NOT the TRA project id - a TRA id matches nothing and returns an empty 200. UDAC project filtering is applied on top; if UDAC blocks every project the page comes back empty." - }, - { - "name": "source", - "type": "string", - "required": false, - "values": [ - "TO", - "TCM" - ], - "description": "Product source. An INVALID value does not 4xx \u2014 it returns HTTP 200 with an `error` string and empty data." - }, - { - "name": "type", - "type": "string", - "required": false, - "description": "Report/dashboard type filter." - }, - { - "name": "userId", - "type": "integer", - "required": false, - "description": "Filter to a specific owner's reports." - }, - { - "name": "frequency", - "type": "string", - "required": false, - "description": "Schedule frequency filter." - }, - { - "name": "p", - "type": "integer", - "required": false, - "default": 1, - "description": "Page number (1-based)." - }, - { - "name": "size", - "type": "integer", - "required": false, - "default": 30, - "maximum": 100, - "description": "Page size. Clamped server-side to the range 1-100." - }, - { - "name": "sortBy", - "type": "string", - "required": false, - "default": "modifiedAt", - "description": "Sort column. Defaults to modifiedAt." - }, - { - "name": "sortOrder", - "type": "string", - "required": false, - "default": "desc", - "description": "Sort direction. Defaults to desc." - }, - { - "name": "q[query]", - "type": "string", - "required": false, - "description": "Free-text search. The query-parameter name is literally `q[query]`." - } - ], - "returns": [ - "data", - "my_report_count", - "total_report_count", - "info", - "error" - ], - "responses": { - "200": { - "$response": "ReportListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } + "schema_version": 1, + "version": "1.7", + "build_id": "tra-v1.7-ai-failure-naming", + "tra": { + "routing_terms": [ + "flaky", + "flakiness", + "workspace", + "configuration", + "schedule", + "suite", + "result" + ], + "summary": "BrowserStack Test Reporting & Analytics (TRA, aka Test Observability): ingest, investigate and report on automated test runs. A build groups the test runs of one CI/SDK execution; each run carries status, logs, errors, hooks and AI root-cause analysis. Everything is scoped to a project. Read builds and runs; filter and search them; manage dashboards, custom views and saved reports; configure quality gates, webhooks, notifications, CI triggers and integrations; and read stability, performance, flakiness and unique-error analytics. Tests are REPORTED here, not executed here - execution happens in your runner or another BrowserStack product and is pushed in via the SDK. Access-key auth reaches only the /ext/v1 external API; everything else needs a session or service credential. Each run records its result on the browser, device, OS and environment it ran on, under the app version and the build tags it was pushed with, and a failure can be linked to an issue. Saved reports can be scheduled. Destructive capabilities need explicit human approval.", + "auth": { + "type": "oauth2" }, - "guidance": [ - "An invalid `source` returns HTTP 200 with a top-level `error` string and empty `data` \u2014 check `error`, not the status code.", - "TRA report types (Build Comparison, Tabular Test Runs) are hidden unless the group has the Custom Dashboard entitlement AND the TM-TO-reports rollout flag is on; other TM reports stay visible.", - "`size` is clamped to a maximum of 100 (and a minimum of 1); asking for more returns at most 100 rows.", - "The search parameter is spelled `q[query]`, not `q` or `query`.", - "`my_report_count` / `total_report_count` are separate top-level counts; pagination cursors are under `info` (page/next/prev/count/page_size)." - ] - }, - { - "name": "list_dashboards", - "method": "GET", - "path": "/ext/v1/dashboards", - "mode": "read", - "entity": "dashboard", - "intent": "Public-API listing of the custom dashboards visible to the caller's group (used by the Atlas AI dashboard builder and API clients) to discover dashboard ids/names/widgets before reading or cloning one.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source to list. Defaults to TO." - } + "base_urls": [ + "https://api-observability.browserstack.com", + "https://api-observability-eu.browserstack.com", + "https://api-observability-in.browserstack.com" ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardsListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Same underlying service as the internal listing: scheduled-report dashboards are excluded and there is no pagination. Rows are summaries - widgets, filters and reports are null (totalWidget counts them); read one dashboard by id for its definition." - ] - }, - { - "name": "get_dashboard", - "method": "GET", - "path": "/ext/v1/dashboards/{id}", - "mode": "read", - "entity": "dashboard", - "intent": "Fetch one dashboard's full definition (name, type, level and the widget list with each widget's config) via the public API, so a client can render or edit it.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Dashboard UUID (from list_dashboards_via_public_api)." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source. Defaults to TO." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Resolves the id as a dashboard (not a scheduled report)." - ] - }, - { - "name": "create_dashboard", - "method": "POST", - "path": "/ext/v1/dashboards", - "mode": "write", - "entity": "dashboard", - "intent": "Create a new custom dashboard with its widget layout via the public API, so a client (or the AI dashboard builder) can provision a dashboard programmatically instead of building it in the UI.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Product source for the new dashboard. Defaults to TO." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 100, - "description": "Dashboard name, 1-100 chars, unique in the group." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "values": [ - "Dashboard", - "View", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON" - ], - "description": "Dashboard type; use `Dashboard` for an ordinary dashboard." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Optional description, max 100 chars." - }, - { - "name": "filters", - "type": "object", - "description": "Global filters (same shape as the internal create; note buildName / branches / tcmTestPlanIds wire names)." - }, - { - "name": "widgets", - "type": "array", - "description": "Widget definitions at the body root. Each widget is {id, name, type, config, layout, globalFilters}. See guidance for the treemap normalization this surface applies.", - "fields": [ - { - "name": "id", - "type": "string", - "description": "Client widget id." - }, - { - "name": "name", - "type": "string", - "description": "Widget title." - }, - { - "name": "type", - "type": "string", - "description": "Widget type enum." - }, - { - "name": "config", - "type": "object", - "description": "Widget config; validated for render-safety before persist." - }, - { - "name": "layout", - "type": "object", - "description": "Grid layout." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "This surface silently DROPS a `visualization` override on a treemap widget (FAILURE_RATE_BY_FOLDERS) rather than rejecting it \u2014 don't rely on that override being honoured.", - "It also runs render-safety validation up front, so a widget config that would 5xx at render is rejected here. The internal create does neither of these." - ] - }, - { - "name": "update_dashboard", - "method": "PUT", - "path": "/ext/v1/dashboards/{id}", - "mode": "write", - "entity": "dashboard", - "intent": "Update an existing dashboard's name/type/level and replace its widget set via the public API.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "Dashboard UUID to update." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "New name, 1-100 chars. May be omitted \u2014 it is backfilled from the stored dashboard (see guidance)." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2. Backfilled from the stored dashboard if omitted." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "values": [ - "Dashboard", - "View", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON" - ], - "description": "Dashboard type. Backfilled from the stored dashboard if omitted." - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget set at the body root. Same treemap-visualization stripping and render-safety validation as create." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters (same shape as create)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "Unlike the internal PUT this TOLERATES a partial body: omitted name/type/level are backfilled from the stored dashboard (an extra read) so a null is not mistaken for a change. The widget set, however, still fully replaces the stored one \u2014 there is no undo.", - "Same normalization as create: a treemap `visualization` override is silently dropped and widget configs are validated up front.", - "In demo-sandbox environments updating the default dashboard is rejected with 400." - ] - }, - { - "name": "clone_dashboard", - "method": "POST", - "path": "/ext/v1/dashboards/{id}/clone", - "mode": "write", - "entity": "dashboard", - "intent": "Copy an existing dashboard (its persisted widgets) into a new dashboard via the public API \u2014 typically to fork a template dashboard under a new name/level.", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "SOURCE dashboard UUID to copy from." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "minLength": 1, - "maxLength": 100, - "description": "Name for the copy, 1-100 chars, unique in the group." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "description": "Visibility level 1 or 2 for the copy." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "description": "Dashboard type for the copy." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", - "The widgets in the request body are IGNORED \u2014 the clone copies the SOURCE dashboard's stored widgets; only name/type/level are taken from the body.", - "Render-safety validation runs against the resolved source dashboard (inside the service), not the request body, so a broken source can fail the clone." - ] - }, - { - "name": "get_project_automation_coverage", - "method": "GET", - "path": "/ext/v1/projects/automation-coverage", - "mode": "read", - "entity": "project", - "intent": "Get one project's automation-coverage figures - total test cases, automated count and coverage percent over all time - to chart automation progress from outside the UI.", - "query": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Exact project display NAME (not the normalised slug and not the id), matched within the caller's group. Blank or missing is a 400; a name that does not resolve in the group is a 404." - } - ], - "returns": [ - "project_id", - "project_name", - "total_test_case_count", - "automated_test_case_count", - "automation_coverage_percent", - "calculated_at" - ], - "responses": { - "200": { - "$response": "ProjectAutomationCoverageResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public REST API route - callable with a username + access key as well as service credentials; scoping is by the authenticated group only, with no project-level permission check on this route.", - "Lookup is by EXACT project name, case- and space-sensitive, within your group. The normalised slug used elsewhere and the numeric id are both rejected here - use the display name.", - "This reuses the Automation Coverage dashboard widget over an all-time window, so it is an aggregation query, not a cheap key lookup - expect it to be slower than a plain read.", - "Fields are serialised only when non-null (NON_NULL). `calculated_at` is when this figure was computed, not a project timestamp; treat it as the freshness of the number." - ] - }, - { - "name": "list_projects", - "method": "GET", - "path": "/ext/v1/projects", - "mode": "read", - "entity": "project", - "intent": "List the calling account's Test Observability projects (public REST API) - the entry point for scripted/CI consumers that then drill into a project's builds.", - "query": [ - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 cursor from the previous response's pagination.next_page (it encodes a page index, not a row offset). Omit for the first page. A malformed value is a 400 'Incorrect pagination params'." - } - ], - "returns": [ - "projects", - "pagination" - ], - "responses": { - "200": { - "$response": "ProjectListingExternalResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pages are tiny and uneven in practice (2, 5, 6 rows) and there is no name filter, so this is the WRONG route for 'find project X' or 'what projects do I have' on a large group: use list_projects_lite_paginated (`search`, 100 per page, newest activity first) or list_projects_full (whole group, capped at 30 rows).", - "Requires the PROJECT_VIEW permission; a credential without it is refused. A user OAuth credential that holds it is accepted - a service account is not required.", - "Up to 10 projects per page, and a page can hold fewer while has_next is still true - a short page is not the last page. There is no page-size parameter. Paginate only with the returned base64 next_page cursor, not a numeric page.", - "pagination.has_next is computed as 'this page was non-empty', so the last full page still reports has_next=true and you get one extra EMPTY page. Stop when projects comes back empty, not when has_next turns false.", - "There is no total count in the response. Each row carries the project's id, name and a ready-made observability_url into its builds page." - ] - }, - { - "name": "list_home_unique_builds", - "method": "GET", - "path": "/api/v1/home/uniqueBuilds", - "mode": "read", - "entity": "project", - "intent": "List the Home page's latest builds across the caller's visible projects - one row per build with its status and counts - optionally narrowed to 'my builds' and quick filters.", - "query": [ - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Only affects has_next (true while pageNumber*10 <= 99); it does NOT offset the search, so pageNumber=1 returns the same first page. Page with afterKey instead. The response's pagingParams.pageNumber echoes pageNumber+1." - }, - { - "name": "afterKey", - "type": "string", - "description": "Cursor (the last row's build id) for the next page. This is the ONLY real pagination: carry pagingParams.afterKey from the previous response." - }, - { - "name": "myBuilds", - "type": "boolean", - "default": false, - "description": "Restrict to builds owned by the calling user." - }, - { - "name": "applyQuickFilters", - "type": "boolean", - "description": "Turn the per-request quick filters below ON. When false (default) the endpoint applies only the user's persisted Home filters and ignores the quick-filter params." - }, - { - "name": "statuses", - "type": "array", - "description": "Quick-filter build statuses. Only applied when applyQuickFilters=true; invalid values are skipped rather than 400ing." - }, - { - "name": "users", - "type": "array", - "description": "Quick-filter user ids. Only applied when applyQuickFilters=true." - }, - { - "name": "frameworks", - "type": "array", - "description": "Quick-filter framework values. Only applied when applyQuickFilters=true." - }, - { - "name": "buildTags", - "type": "array", - "description": "Quick-filter build tags. Only applied when applyQuickFilters=true." - }, - { - "name": "projects", - "type": "array", - "description": "Quick-filter project ids. Only applied when applyQuickFilters=true." - }, - { - "name": "isRealDevice", - "type": "array", - "description": "Quick-filter device type (string, not boolean, so a stale value degrades instead of erroring). Only applied when applyQuickFilters=true." - } - ], - "returns": [ - "data", - "pagingParams" - ], - "responses": { - "200": { - "$response": "HomeUniqueBuildsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The user's saved Home filters (build tags, projects, users, teams, unique build names, statuses) are merged into the query server-side even when applyQuickFilters is off - results are always scoped by the caller's persisted personalisation and sub-group.", - "Pagination: pageNumber is not applied to the search (only to the has_next cap); page with afterKey = pagingParams.afterKey. pagingParams.hasNext is 'this page was full AND pageNumber*10 <= 99'.", - "Quick-filter params are inert unless applyQuickFilters=true. Bad/stale quick-filter values are silently dropped (mapSkippingInvalid), never 400.", - "Heavy on large groups (emits per-stage timing telemetry); each row is a BuildSummary - use displayStatus for the pass/fail verdict (status is the lifecycle state, e.g. finished).", - "Rows are the most recent builds, one row PER BUILD (not deduplicated by build name: several runs of one pipeline appear as separate rows), in the caller's Home scope. Page size is 10." - ] - }, - { - "name": "list_home_recent_views", - "method": "GET", - "path": "/api/v1/home/recents", - "mode": "read", - "entity": "project", - "intent": "Get the user's last few visited items (dashboards, builds, views) for the Home page 'Recently viewed' strip.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "HomeRecentViewsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read from a per-user Redis sorted set, capped at the 5 most recent entries; there are no parameters and no pagination.", - "Entries are written as a side effect of the user opening a dashboard - this endpoint never writes. A fresh user gets an empty data array.", - "Unparseable stored entries are silently skipped, so the list can be shorter than 5 even when 5 were recorded. Response is {\"data\": [ ... ]}; each item has type, link, id, name." - ] - }, - { - "name": "get_home_takeaways", - "method": "GET", - "path": "/api/v1/home/takeaway", - "mode": "read", - "entity": "project", - "intent": "Get the Home page 'key takeaways' cards with the aggregate time-saved metric - stability, pass-rate, always-failing/flaky/new-failure counts and a single time-savings percentage, versus the previous 7-day window.", - "returns": [ - "timeSavingsData", - "takeAwayData" - ], - "responses": { - "200": { - "$response": "HomeTakeawaysResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the older takeaways variant. It returns the aggregate `timeSavingsData` object (timeSavings %, testToVerify, uniqueErrors, flakiness, alwaysFailing, autoTagged) and sends the v2 `timeSavingV2Metric` key as null. For the shape the current Home page uses, pick get_home_takeaways_with_noise_breakdown instead.", - "Scoped implicitly by the caller's saved Home filters, sub-group and visible projects; no parameters.", - "Cached in Redis per USER id for ~1 hour, so a filter change is not reflected until the cache expires. All metrics compare the current 7 days to the prior 7 days (each TakeAwayMetric carries value and prev)." - ] - }, - { - "name": "get_home_takeaways_with_noise_breakdown", - "method": "GET", - "path": "/api/v1/home/v2/takeaway", - "mode": "read", - "entity": "project", - "intent": "Get the Home page 'key takeaways' cards with the failure-noise breakdown - the same stability/pass-rate story plus per-category (flaky, always-failing, auto-analyzed, unique-error) failure counts and their percentage reductions.", - "returns": [ - "timeSavingV2Metric", - "takeAwayData" - ], - "responses": { - "200": { - "$response": "HomeTakeawaysNoiseBreakdownResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the variant the current Home page uses. It returns the `timeSavingV2Metric` object (totalFailures plus flakyFailing/alwaysFailing/autoAnalyzedFailure/uniqueErrors, each a count + reduction%) and explicitly sets the legacy `timeSavingsData` to null - do not expect a single time-savings percentage here.", - "Same takeAwayData block (stability, unique executions, new failures, always-failing, flakiness, failures; each value vs prev 7-day) as the older variant.", - "Cached in Redis per (filter-hash + CAD product + sub-group), so the cache is SHARED across users with identical filters - unlike the per-user cache of the older variant. TTL ~1 hour.", - "When there are zero failures in the window, timeSavingV2Metric collapses to just {totalFailures: 0} with the per-category objects omitted." - ] - }, - { - "name": "list_project_builds_by_group", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/build-groups-listing", - "mode": "read", - "entity": "project", - "intent": "List a project's builds rolled up by build group (consolidated re-runs/shards shown as one row), with the full v2 build-listing filter set.", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL-safe slug), not its numeric id." - } - ], - "query": [ - { - "name": "buildNameIds", - "type": "array", - "description": "Filter by build name ids." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Filter by unique build names." - }, - { - "name": "users", - "type": "array", - "description": "Filter by user ids (numeric)." - }, - { - "name": "assignees", - "type": "array", - "description": "Assignee ids; each must match ^(\\$none|[0-9]+)$ ($none = unassigned)." - }, - { - "name": "teams", - "type": "array", - "description": "Filter by team ids." - }, - { - "name": "buildGroupIds", - "type": "array", - "description": "Filter by build group ids." - }, - { - "name": "frameworks", - "type": "array", - "description": "Raw framework values (branded enum names and pass-through values like 'appium'); matched case-insensitively." - }, - { - "name": "statuses", - "type": "array", - "description": "Build display statuses." - }, - { - "name": "sources", - "type": "array", - "description": "Product sources." - }, - { - "name": "types", - "type": "array", - "description": "Build types." - }, - { - "name": "buildTags", - "type": "array", - "description": "Filter by build tags." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names (max 500); AND/OR selected via `match`." - }, - { - "name": "branches", - "type": "array", - "description": "Filter by branch names." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millis longs [from, to] (size must be 2)." - }, - { - "name": "search", - "type": "string", - "description": "Free-text build search." - }, - { - "name": "searchAfter", - "type": "string", - "description": "ES search_after cursor; must match ^[0-9a-zA-Z]{40}$ (a 40-char id)." - }, - { - "name": "state", - "type": "string", - "values": [ - "active", - "closed" - ], - "description": "Build state; defaults to active." - }, - { - "name": "isArchived", - "type": "boolean", - "description": "Restrict to archived builds." - }, - { - "name": "selfHealed", - "type": "boolean", - "description": "Only self-healed builds." - }, - { - "name": "aiAuthored", - "type": "boolean", - "description": "Only AI-authored builds." - }, - { - "name": "isRealDevice", - "type": "array", - "description": "Device-type multi-select (true=real, false=virtual); empty/both = no filter." - } - ], - "returns": [ - "id", - "name", - "buildGroupAggResult", - "activeCount", - "closedCount" - ], - "responses": { - "200": { - "$response": "ProjectBuildGroupListingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Sibling of the ungrouped v2 build listing: this one consolidates re-runs/shards into build groups (see buildGroupAggResult). Filters are the same rich v2 set, passed as query params (there is no request body).", - "Returns per-group AGGREGATES only (buildGroupAggResult: counts plus the TestHub group record) - no build rows: `builds` and `pagingParams` are always null here, and there is no pagination. To list the builds themselves use the ungrouped v2 build listing.", - "Side effect: reading this marks the project as SUBSCRIBED for the caller (a user-preference write) - a benign, recoverable effect, but it is not a pure read.", - "Unlike the ungrouped v2 listing, this route does NOT apply the group's custom-retention window, so it can surface builds older than that window." - ] - }, - { - "name": "get_test_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug), not its numeric id." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to], for the window the filters resolve within. Required; a null or non-2-element range is a 400." - }, - { - "name": "search", - "type": "string", - "description": "Free-text test-name search that is echoed back under `applied`." - }, - { - "name": "buildTags", - "type": "array", - "description": "Applied build-tag filter values (echoed back resolved under `applied.buildTags`)." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Applied unique-build-name filter values." - }, - { - "name": "testTags", - "type": "array", - "description": "Applied test-tag filter values." - }, - { - "name": "folders", - "type": "array", - "description": "Applied folder filter values." - }, - { - "name": "hostNames", - "type": "array", - "description": "Applied host-name (machine/agent) filter values." - }, - { - "name": "status", - "type": "array", - "description": "Applied test-status filter values." - }, - { - "name": "sources", - "type": "array", - "description": "Applied product-source filter values." - }, - { - "name": "users", - "type": "array", - "description": "Applied user-id filter values (longs)." - }, - { - "name": "lastExecutedBy", - "type": "array", - "maxItems": 500, - "description": "User ids (longs) whose executions to include. Capped at 500 by validation; exceeding it is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Applied team-id filter values (longs)." - }, - { - "name": "failureCategories", - "type": "array", - "description": "Applied failure-category filter values." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state: set true/false to filter by flakiness, omit to not filter." - }, - { - "name": "isNewFailure", - "type": "boolean", - "description": "Tri-state new-failure filter; omit to not filter." - }, - { - "name": "isAlwaysFailing", - "type": "boolean", - "description": "Tri-state always-failing filter; omit to not filter." - }, - { - "name": "hasPmToolDefects", - "type": "boolean", - "description": "Tri-state filter for tests that have linked PM-tool defects; omit to not filter." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden-status filter; omit to not filter." - }, - { - "name": "osList", - "type": "array", - "description": "Applied OS filter values." - }, - { - "name": "browserList", - "type": "array", - "description": "Applied browser filter values." - }, - { - "name": "deviceList", - "type": "array", - "description": "Applied device filter values." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-test visibility. Accepts all / true (muted only) / false (unmuted only); the identifiers ALL / MUTED / UNMUTED are also accepted, case-insensitively." - }, - { - "name": "hooksVisibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Applied hooks visibility. The response also returns the project's own default hooks visibility separately under `hooksVisibility`." - } - ], - "intent": "Get the whole filter sidebar for the Suite Health test list: the currently applied filters resolved into display fields, the available static filter options (failure categories, OS/browser/device, sources, run-level options, etc.), and the project's default hooks visibility.", - "returns": [ - "applied", - "staticFilters", - "hooksVisibility" - ], - "guidance": [ - "This echoes back the filters you sent (resolved into {id,value,label} fields under `applied`) AND the catalogue of choosable options under `staticFilters` \u2014 send an empty query (just dateRange) to fetch the full option catalogue for building a filter UI.", - "It degrades open: the run-level option lists inside `staticFilters` (typeOfRun, assignedTo, state, testPlan) are fetched from an upstream service under a timeout, and on timeout/failure (or when there is nothing) they come back null or [] rather than erroring. An empty or null run-level dropdown does not mean there are no options.", - "`dateRange` is mandatory and must be exactly two millisecond timestamps; there are no lowerBound/upperBound scalars on this route.", - "Scoped to project visibility only; no plan gate." - ], - "responses": { - "200": { - "$response": "SPTestFilterOptions" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_branches", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/branches", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wrapped in wildcards for the Elasticsearch match, and additionally applied as a case-SENSITIVE literal `contains` post-filter \u2014 a lowercase query will not surface an upper-case branch. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 it is only consulted by the build-names suggestion route, not this one." - } - ], - "intent": "Type-ahead the branch filter on the Suite Health test list: get git branch names seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the Elasticsearch terms aggregation is capped at 100 branches, so it is not exhaustive; narrow with `query` rather than expecting the full set.", - "The `query` match is applied twice: a wildcard ES match AND a case-sensitive literal `contains` filter on the returned values, so casing matters when narrowing.", - "Each suggestion is a Field object; use its `value` (the branch name) when applying the branch filter elsewhere.", - "Omitting `query` returns an EMPTY `data` list, not the top branches - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildNames", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match against unique build names. Omit/blank to list the top build names in the window." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Whether manual runs are included. Unlike the sibling type-aheads this route honours it (it is carried into the build-level filter request)." - } - ], - "intent": "Type-ahead the build-name filter on the Suite Health test list: get unique build names for the project in the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "This route is the odd one out: it converts the request into a build-sidebar filter and resolves against the build-level index (BuildFilterServiceV2), NOT the test-level filter service the other type-aheads use \u2014 so its matching and result limits can differ from the branches/tags/hostNames suggestions.", - "Because it runs through the build path it honours `includeManualRuns`, which the other suggestion routes ignore.", - "Each suggestion is a Field object; use its `value` (the build name) when applying the uniqueBuildNames filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_build_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildTags", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the build-tag filter on the Suite Health test list: get build tags used in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results, so it is not exhaustive; narrow with `query`.", - "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", - "Each suggestion is a Field object; use its `value` (the tag) when applying the buildTags filter.", - "Omitting `query` returns an EMPTY `data` list, not the top build tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_host_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/hostNames", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match against host names, wrapped in wildcards for the ES match. Omit/blank to list the top host names in the window." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the host-name (machine/agent) filter on the Suite Health test list: get host names seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", - "Unlike the other type-aheads this route does NOT accept `isManuallyOverridden` (that filter is not applied here), and it does not apply a client-side literal `contains` filter \u2014 it relies purely on the wildcard ES match.", - "Each suggestion is a Field object; use its `value` (the host name) when applying the hostNames filter." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "search_test_filter_test_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/testTags", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." - }, - { - "name": "query", - "type": "string", - "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "description": "Tri-state manually-overridden filter; omit to not filter." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." - } - ], - "intent": "Type-ahead the test-tag filter on the Suite Health test list: get test tags seen in the project within the date window that match a substring.", - "returns": [ - "data" - ], - "guidance": [ - "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", - "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", - "Each suggestion is a Field object; use its `value` (the tag) when applying the testTags filter.", - "Omitting `query` returns an EMPTY `data` list, not the top test tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." - ], - "responses": { - "200": { - "$response": "FieldSearchResults" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_metrics_chart", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/tests/metrics/chart", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Exactly two epoch-millisecond longs, [from, to]. Required \u2014 the chart interval is derived from it; a null or non-2-element range is a 400." - }, - { - "name": "search", - "type": "string", - "description": "Free-text test-name search to scope the charts." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build-tag filter values." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Unique-build-name filter values." - }, - { - "name": "testTags", - "type": "array", - "description": "Test-tag filter values." - }, - { - "name": "folders", - "type": "array", - "description": "Folder filter values." - }, - { - "name": "hostNames", - "type": "array", - "description": "Host-name filter values." - }, - { - "name": "status", - "type": "array", - "description": "Test-status filter values." - }, - { - "name": "sources", - "type": "array", - "description": "Product-source filter values." - }, - { - "name": "users", - "type": "array", - "description": "User-id filter values (longs)." - }, - { - "name": "lastExecutedBy", - "type": "array", - "maxItems": 500, - "description": "User ids (longs). Capped at 500 by validation; exceeding it is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Team-id filter values (longs)." - }, - { - "name": "failureCategories", - "type": "array", - "description": "Failure-category filter values." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state flakiness filter; omit to not filter." - }, - { - "name": "isNewFailure", - "type": "boolean", - "description": "Tri-state new-failure filter; omit to not filter." - }, - { - "name": "isAlwaysFailing", - "type": "boolean", - "description": "Tri-state always-failing filter; omit to not filter." - }, - { - "name": "osList", - "type": "array", - "description": "OS filter values." - }, - { - "name": "browserList", - "type": "array", - "description": "Browser filter values." - }, - { - "name": "deviceList", - "type": "array", - "description": "Device filter values." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-test visibility (ALL / MUTED / UNMUTED identifiers also accepted, case-insensitively)." - }, - { - "name": "hooksVisibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Hooks visibility scope." - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, failure counts are sourced from the recorded status keyword (SDK-recorded automation status). When not true AND the account has the new build listing flag on, counts instead come from workflow status, which can change the numbers for the same window." - } - ], - "intent": "Get the Suite Health page chart data in one call: time-bucketed failures-over-executions, average duration, and average failure-rate series for the project's tests over a window, filtered by the full Suite Health filter set.", - "returns": [ - "failureChart", - "durationChart", - "failureRateChart" - ], - "guidance": [ - "Response is a top-level map of three charts \u2014 `failureChart`, `durationChart`, `failureRateChart` \u2014 each an SPChart with a `data` map of named series (arrays of {x,t,y,pointRange} points) and an `insights` summary. There is no `data` envelope wrapper.", - "Series keys inside `data`: failureChart carries `totalExecutions` and `totalFailures`; durationChart carries `averageDuration`; failureRateChart carries `averageFailureRates`.", - "Archived builds are excluded from every counter.", - "The same query can return DIFFERENT failure numbers depending on account config: with the new build listing flag on and `isAutomationLatest` not true, failures are counted from workflow status rather than the raw status keyword. Set `isAutomationLatest=true` to force the raw-status counts.", - "The failure series can come back with fewer buckets than the executions series when a slice has zero matches; the failure-rate series is computed by aligning on the `x` timestamp, so do not assume the two arrays are index-aligned." - ], - "responses": { - "200": { - "$response": "SPTestMetricsChart" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_base_info", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/info", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash, discovered from the Suite Health test listing." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ], - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start as an epoch-millisecond long. Optional on this route (there is no dateRange array here)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end as an epoch-millisecond long. Optional." - }, - { - "name": "buildName", - "type": "string", - "description": "Single build name to scope to (wrapped into a one-element list server-side); this route takes at most one, not a list." - } - ], - "intent": "Get the identifying header info for one test in the Test Health detail view \u2014 its name and the environment (OS/browser/device) combinations it ran on \u2014 for the selected window.", - "returns": [ - "name", - "combinations" - ], - "guidance": [ - "Response is a flat object `{name, combinations}` with NO data wrapper. `name` is the test's display name (empty string when the hash matches nothing in the window, still 200), and `combinations` is the list of OS/browser/device environments it ran on.", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Narrower filter set than the test listing: no browser/os/device/tag filters, and only a single `buildName`.", - "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 (no structured error body), so treat a 500 as opaque rather than parsing it." - ], - "responses": { - "200": { - "$response": "SPTestBaseInfo" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_environment_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/breakdown", - "mode": "read", - "entity": "sp_test", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug)." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash, discovered from the Suite Health test listing." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ], - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start as an epoch-millisecond long. Optional." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end as an epoch-millisecond long. Optional." - }, - { - "name": "buildName", - "type": "string", - "description": "Single build name to scope to (wrapped into a one-element list server-side); at most one, not a list." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "Tri-state flakiness filter; omit to not filter." - }, - { - "name": "orderKey", - "type": "string", - "values": [ - "reliability", - "average", - "totalFailures" - ], - "description": "Sort key for the returned rows. Unrecognised/absent falls back to reliability." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ], - "description": "Sort direction. Unrecognised/absent falls back to descending." - } - ], - "intent": "Break one test's results down by environment (OS / browser / device) over the window, so you can see whether its instability is platform-specific.", - "returns": [ - "id", - "reliability", - "duration", - "os", - "browser", - "browserDevice", - "totalFailures" - ], - "guidance": [ - "The body is a BARE JSON ARRAY of environment rows (no object wrapper, no `data` key). Each row: `id` (browser-platform key), `reliability` (fail %), `duration` (avg), `os`/`browser`/`browserDevice` (formatted platform objects), and `totalFailures` {failed,total}.", - "Uses the older explicit-argument service overload, so it accepts only a SINGLE `buildName` and a much smaller filter set than the test listing (no os/browser/device/tag filters).", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Rows are sorted server-side (default reliability, descending) and platform rows that carry no displayable browser or device are dropped, so the array may be shorter than the number of distinct environments.", - "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 with no structured body." - ], - "responses": { - "200": { - "$response": "SPTestEnvironmentBreakdown" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_app_logs", - "method": "GET", - "path": "/api/v1/appLogs/query", - "mode": "read", - "entity": "test_detail", - "intent": "Get a test run's application (app-under-test / SDK) log lines, parsed and flattened for display \u2014 use this for the app-logs tab when you want normalised rows rather than raw Loki JSON.", - "query": [ - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "The test run's UUID (not its integer id and not the TR-NNN identifier). A missing or foreign UUID collapses into an empty 500 with no body." - } - ], - "returns": [ - "status", - "appLogQuery", - "logs" - ], - "responses": { - "200": { - "description": "Parsed app logs. `status` is 'success', or 'failure' with an empty `logs` list (still HTTP 200) when Loki reports a non-success status.", - "schema": { - "$schema": "AppLogResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns ONLY abnormal (error-level) app-log lines \u2014 the Loki query hard-codes an abnormal filter \u2014 capped at 5000 lines and bounded to the run's own start/finish window (\u00b15s). A passing run with no error logs legitimately comes back with an empty `logs` list.", - "Two failure shapes, neither is an ordinary HTTP error: when Loki answers non-success you get HTTP 200 with `status:\"failure\"` and `logs:[]`; when anything throws you get a bare HTTP 500 with no body. Check `status`, and do not read an empty 500 as a specific cause \u2014 a missing/foreign run and a Loki outage look identical.", - "Each log line is split on a ' cSep=S ' separator and only the text after it is kept; a line that lacks the separator is returned raw and unsplit.", - "For the untouched Loki payload use the raw variant instead; this one is the parsed, UI-normalised form.", - "`level` on each line falls back to 'unknown' when Loki did not tag the stream with a level." - ] - }, - { - "name": "get_test_run_app_logs_raw", - "method": "GET", - "path": "/api/v1/appLogs/nativeQuery", - "mode": "read", - "entity": "test_detail", - "intent": "Get a test run's application logs as Loki's raw, unparsed JSON response \u2014 use this only when you need the native Loki shape; for displayable rows use the parsed app-logs capability instead.", - "query": [ - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "The test run's UUID (not its integer id and not the TR-NNN identifier)." - } - ], - "returns": [ - "status", - "data" - ], - "responses": { - "200": { - "description": "Loki's native query_range JSON body, relayed verbatim as an opaque JSON document (top-level `status` and `data.result[]` streams). This is NOT an app model \u2014 the shape is Loki's, not the service's.", - "schema": { - "type": "object", - "description": "Opaque pass-through of Loki's /query_range response. Typically `{status, data:{resultType, result:[{stream, values:[[epochNano, line]]}]}}`, but treat it as an untyped Loki document." - } - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pure pass-through of Loki's body \u2014 the same abnormal-only filter, 5000-line cap and run-time-window bounding as the parsed variant apply, but nothing is normalised. Log lines still carry the ' cSep=S ' separator and their level pattern; you must split them yourself.", - "The handler swallows every exception into a bare HTTP 500 with no body, so a missing or foreign testRunUuid is indistinguishable from a Loki outage.", - "Prefer the parsed app-logs capability unless you specifically need Loki's native envelope; this one returns a JSON string, not the service's AppLogResponse." - ] - }, - { - "name": "get_test_run_hooks", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/hooks", - "mode": "read", - "entity": "test_detail", - "intent": "Public API: list the before/after setup and teardown hook executions relevant to a test run, so a failure can be attributed to a hook rather than the test body.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [ - "testCode", - "testMetadata", - "hooksCode" - ], - "responses": { - "200": { - "description": "A MAP keyed by hook test-run id (numeric string keys), each value a TestDetailResponse for that hook. An empty object means no hooks matched.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestDetailResponse" - }, - "description": "Keyed by matched hook run id." - } - }, - "404": { - "$response": "NotFound" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", - "The result is a MAP keyed by hook test-run id, not a list; iterate the values.", - "Matching is heuristic, not a stored parent/child link: hooks are matched on the SAME build id + filename + OS + browser + device, latest only. A hook that ran in a differently-configured session will not appear here even though it belongs to the same build.", - "At most 100 hook runs are returned (the underlying search is size-capped); there is no paging to fetch more.", - "An unknown test_run_id returns TEST_RUN_NOT_FOUND (404). A run with no matching hooks returns an empty object with 200." - ] - }, - { - "name": "get_test_run_log_bundle", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/logs", - "mode": "read", - "entity": "test_detail", - "intent": "Public API: get the full log bundle for a test run \u2014 BrowserStack session logs (console/network/selenium/appium/video), SDK logs, hook-run logs, failure and step logs \u2014 as URLs plus the test start/finish timestamps and framework.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [ - "testStartedAt", - "testFinishedAt", - "browserstackLogs", - "sdkLogs", - "hookRunLogs", - "failureLogs", - "stepLogs", - "framework", - "sessionId" - ], - "responses": { - "200": { - "description": "Log URLs and metadata. `browserstackLogs` is a SessionLogs object of per-type log URLs; `sdkLogs` and `hookRunLogs` are lists of URL strings; `failureLogs` and `stepLogs` are inline log-entry objects.", - "schema": { - "$schema": "LogUrls" - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", - "Returns log URLs to fetch separately, NOT log content. The caller downloads each artefact from the URLs in `browserstackLogs`, `sdkLogs` and `hookRunLogs`; only `failureLogs` and `stepLogs` are inline.", - "The logs are always the TRIMMED variant \u2014 the trim flag is hard-coded true on this route, so there is no way to ask for the untrimmed logs here.", - "Plan-locked / retention-expired builds are rejected with FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", - "Several log sources are fetched concurrently, so latency tracks the slowest source; expect a slow response for large sessions." - ] - }, - { - "name": "get_session_network_log_url", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "intent": "Get the URL of a session's HAR network log for the Test Details network tab \u2014 returns a link to the HAR file plus its fetch status, not the parsed entries.", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "The session's HASHED id (not a numeric id)." - } - ], - "returns": [ - "networkLogs", - "status_code", - "message" - ], - "responses": { - "200": { - "description": "The network-log URL envelope. `networkLogs` is the HAR file URL, `status_code` is the upstream fetch status, `message` any upstream message.", - "schema": { - "$schema": "SessionNetworkLogUrlResponse" - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Despite the name, this returns the HAR file URL in `networkLogs`, NOT the parsed HAR entries \u2014 the handler requests the unparsed form. Fetch the HAR yourself from that URL; it can be large.", - "On the demo-sandbox environment it short-circuits to an empty HAR (`{log:{entries:[]}, responseOrigin:\"demo\"}`) before any lookup.", - "An unknown sessionId returns SESSION_NOT_FOUND. Note the id here is the session's hashed id, the same id the sibling session log-URLs capability takes." - ] - }, - { - "name": "get_test_run_behaviour", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/behaviour", - "mode": "read", - "entity": "test_detail", - "intent": "Get the BDD/behaviour payload stored on a test run (the Gherkin feature/scenario structure in the run's meta) that backs the Behaviour tab for Cucumber-style frameworks.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "returns": [], - "responses": { - "200": { - "description": "The run's meta JSON (the Gherkin/behaviour structure) as an opaque object, or an empty object `{}` when the run has no meta.", - "schema": { - "type": "object", - "description": "Opaque BDD/behaviour meta object stored on the run. Empty object when no meta is present." - } - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Returns the run's raw meta object; only meaningful for BDD (Cucumber-style) builds. A run with no meta returns an empty object `{}` with HTTP 200, not a 404.", - "A MISSING test run does not give a typed error \u2014 the handler checks lock status before the null check, so a bad id throws a NullPointerException (HTTP 500), not TEST_RUN_NOT_FOUND.", - "Locked (retention-expired) runs are rejected with FORBIDDEN_OPERATION.", - "The Behaviour tab is only offered for BDD builds; for a non-BDD run this payload is not useful even when present." - ] - }, - { - "name": "get_test_run_merged_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/consolidatedLogs", - "mode": "read", - "entity": "test_detail", - "intent": "Get a single merged, time-ordered log stream for a test run \u2014 BrowserStack session logs, SDK logs, hook-run logs, failure and step logs combined into one list, with content merged server-side \u2014 for the Logs tab.", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "required": false, - "default": false, - "description": "When true, restrict the merged logs to the test's own time window. Defaults to false (full session)." - } - ], - "returns": [ - "logLevel", - "logType", - "timeStamp", - "content", - "url", - "duration" - ], - "responses": { - "200": { - "description": "A top-level JSON ARRAY of merged, time-ordered log entries (GenericLog objects). Content is downloaded and merged server-side, so entries carry inline `content`, not just URLs.", - "schema": { - "type": "array", - "items": { - "$schema": "GenericLog" - } - } - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This form downloads and MERGES every log source server-side and returns the content inline as one time-ordered array, so it is slow for long sessions. A sibling capability returns log URLs instead (content fetched client-side) \u2014 prefer that one for large sessions.", - "The body is a bare JSON array of log entries, not an object wrapper \u2014 iterate it directly.", - "`trim=true` narrows the stream to the test's own time window; the default (false) includes the whole session.", - "Bad ids give TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND (404); locked builds give FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", - "On the demo-sandbox environment canned logs are returned, chosen by the test's name.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ] - }, - { - "name": "get_test_run_details", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/details", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test run id." - } - ], - "intent": "Get the full detail of one test run \u2014 its metadata plus the captured test code and the code of the before/after hooks that ran around it, i.e. everything needed to understand what the test did.", - "guidance": [ - "This is the heaviest of the detail reads: it assembles the run, its captured code, its meta and the related hook code from several sources, so prefer a lighter read when you only need status or metadata.", - "testCode (and each entry of hooksCode) can have null code/language when no code was captured for that run \u2014 the row is still returned; do not treat a null code as an error.", - "A run that does not belong to the caller's group, or does not exist, comes back as TEST_RUN_NOT_FOUND \u2014 the same answer for 'wrong group' and 'no such run'.", - "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ], - "returns": [ - "testCode", - "testMetadata", - "hooksCode" - ], - "responses": { - "200": { - "$response": "TestRunDetailResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_baseline", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/baseLine", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric id of the (typically failing) run whose baseline you want." - } - ], - "intent": "Find a failing run's baseline \u2014 the most recent passing run of the same test in the last 30 days \u2014 returned with full test detail, so you can diff a failure against the last known-good execution.", - "guidance": [ - "Returns a MAP keyed by test run id, not a single object. Priority 1 is the newest passing run with the same test hash (one entry); if there is none it falls back to the newest passing run per device/os/browser combination (several entries). An EMPTY map means no baseline was found, not an error.", - "The look-back window is hardcoded to 30 days before this run's start time \u2014 an older last-good run will not appear, so an empty result does not prove the test has never passed.", - "Each map value is { testCode, testMetadata, hooksCode } - hooksCode is present but null on this route, and testCode is null when no code was captured. Read the passing run's numeric id from the map key." - ], - "returns": [ - " (map key)", - "testCode", - "testMetadata" - ], - "responses": { - "200": { - "$response": "TestRunDetailMapResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_build_insights", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/buildInsights", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test run id; its parent build is resolved from it." - } - ], - "intent": "Get the build-level insights that surround a failing run \u2014 the parent build's failure/flakiness summary, failure categorisation and top-errors analysis, plus this run's own file/module failure context \u2014 so a failure can be reasoned about in context.", - "guidance": [ - "The run's parent build is resolved first; a run whose build cannot be found returns BUILD_NOT_FOUND (distinct from TEST_RUN_NOT_FOUND when the run itself is missing).", - "topErrorsAnalysis.status is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED \u2014 when not COMPLETE, uniqueErrors may be empty or partial, so check the status before treating the error list as final.", - "failureContext (file and parentModule failure rates) is the per-run slice; the rest of the payload is aggregated across the whole build. A build-scoped variant that omits this per-run context exists internally but is not this capability." - ], - "returns": [ - "buildId", - "buildName", - "status", - "summary", - "failureCategorization", - "failureContext", - "topErrorsAnalysis", - "defectStats" - ], - "responses": { - "200": { - "$response": "BuildInsightsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_runs_sharing_error", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/similarError/{clusterId}", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric id of the failing run whose error cluster you are matching against. Excluded from the returned set." - }, - { - "name": "clusterId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). Unique-error cluster id. It sits in the PATH but is bound onto the filter object's clusterId property by Spring's URI-template data binding \u2014 you do NOT also pass it as a query param.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Project the error breakdown is scoped to. Required \u2014 omitting it is a binding error, not an empty result." - }, - { - "name": "dateRange", - "type": "array", - "description": "Two epoch-millisecond bounds [from, to] scoping the error search. Bound onto the filter but NOT validated at request binding on this route (no @Valid), so a call without it will not 400 here yet may return nothing \u2014 supply it to get a meaningful window.", - "fields": [ - { - "name": "from", - "type": "integer" - }, - { - "name": "to", - "type": "integer" - } - ] - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true, search the error across all builds rather than only the run's own build." - }, - { - "name": "searchAfter", - "type": "array", - "description": "Elasticsearch search_after cursor echoed back from a previous page to continue paging the affected-runs scan." - } - ], - "intent": "List the other test runs that failed with the same unique-error cluster as this run \u2014 the 'tests affected by this error' list, each returned with full test detail.", - "guidance": [ - "Returns a MAP keyed by test run id (each value is { testCode, testMetadata }); the querying run itself is always excluded, and an EMPTY map means no other run shares the cluster.", - "projectId is a REQUIRED query param; the cluster is taken from the {clusterId} path segment via data binding, so do not duplicate it as a query param.", - "The affected-run scan also accepts the wider GetSPError filter set (test tags, statuses, devices/os/browsers, folders, users, etc.) as query params bound onto the filter object \u2014 use them to narrow which affected runs come back." - ], - "returns": [ - " (map key)", - "testCode", - "testMetadata" - ], - "responses": { - "200": { - "$response": "TestRunDetailMapResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/testRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id). The listing is scoped to this build." - } - ], - "query": [ - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 pagination cursor. Echo back the pagination.nextPage from the previous response verbatim; do not construct it. Stop when pagination.hasNext is false." - }, - { - "name": "sort", - "type": "string", - "values": [ - "EXECUTION_ORDER", - "TOP_LEVEL_NAME", - "DURATION", - "FAILED_TEST", - "PLATFORM", - "STATUS" - ], - "description": "Sort column." - }, - { - "name": "order", - "type": "string", - "values": [ - "asc", - "desc" - ], - "description": "Sort direction. Required alongside sort \u2014 the two are read together." - }, - { - "name": "test_statuses", - "type": "array", - "description": "Filter by status strings (e.g. passed, failed, skipped)." - }, - { - "name": "re_runs", - "type": "array", - "description": "Filter by re-run numbers (list of integers)." - }, - { - "name": "ci_build_numbers", - "type": "array" - }, - { - "name": "test_tags", - "type": "array" - }, - { - "name": "host_names", - "type": "array" - }, - { - "name": "is_flaky", - "type": "boolean", - "description": "Smart-tag filter. On a freemium or source-fenced build this (and is_always_failing / is_new_failure / has_performance_anomaly) is REFUSED with a plan-restriction 403, not silently ignored." - }, - { - "name": "is_always_failing", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "is_new_failure", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "has_performance_anomaly", - "type": "boolean", - "description": "Smart-tag filter; plan-gated as above." - }, - { - "name": "failure_categories", - "type": "array" - }, - { - "name": "devices", - "type": "array" - }, - { - "name": "os", - "type": "array" - }, - { - "name": "browsers", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hooks_visibility", - "type": "string", - "values": [ - "all", - "failed", - "beforeFailed", - "none" - ], - "description": "Which hooks to include in the tree. Defaults to failed." - }, - { - "name": "is_muted", - "type": "string", - "values": [ - "all", - "true", - "false" - ], - "description": "Muted-tests visibility: all, true (only muted), false (only unmuted). Also accepts ALL/MUTED/UNMUTED. An unrecognised value is rejected." - } - ], - "intent": "List a build's test runs as the UI suite/folder tree, with the full filter set (status, flaky, muted, re-runs, tags, failure category, device/os/browser, folders, performance anomaly, new/always-failing), sorted and cursor-paginated, with deep-link URLs back to the BrowserStack UI.", - "guidance": [ - "The response is a NESTED hierarchy (suites/folders/tests), each node carrying an external deep-link URL \u2014 it is not a flat array. For a flat list use the flat build listing instead.", - "Plan- and source-aware: on a freemium or source-fenced build the analysis keys are stripped from every node, and using any smart-tag filter (is_flaky / is_always_failing / is_new_failure / has_performance_anomaly) is refused with a 403 telling you to upgrade \u2014 so gate those filters on plan.", - "Service-account callers are additionally checked for the TEST_RUN_AUTOMATED_VIEW permission on the build's project; lacking it is a 403.", - "Pagination is an opaque base64 next_page token: pass `pagination.next_page` back unchanged and stop when `pagination.has_next` is false. Keys are snake_case. The envelope differs by an account flag: newer accounts add `is_manually_overridden` and `test_summary` carries workflow-status counts, older ones plain status counts." - ], - "returns": [ - "hierarchy", - "is_archived", - "build_name", - "build_id", - "original_name", - "build_number", - "project_id", - "name", - "group_id", - "test_summary", - "app_details", - "pagination" - ], - "responses": { - "200": { - "$response": "BuildTestRunListingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_with_attachments", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/testRuns/withAttachments", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id)." - } - ], - "query": [ - { - "name": "page_size", - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 30, - "description": "Page size. Must be 1..100; outside that range is a 400 ('Limit should be between 1 and 100')." - }, - { - "name": "cursor", - "type": "integer", - "description": "NUMERIC cursor from the previous response's next_cursor (this route uses a numeric cursor, unlike the base64 cursor on the plain build test-run listing). Omit on the first page." - } - ], - "intent": "List only the test runs in a build that actually have attachments or screenshots, with per-run counts \u2014 the discovery step before downloading artefacts run by run.", - "guidance": [ - "Returns a TRIMMED row per run \u2014 id, name, uuid, build_id, attachments_count, screenshots_count \u2014 not the full test-run object; use the per-run attachments read to get the actual download URLs.", - "Pagination is a numeric cursor: pass the previous response's next_cursor back as cursor; a null next_cursor means the last page.", - "The build is resolved by (uuid, group); an unknown uuid for the caller's group is BUILD_NOT_FOUND." - ], - "returns": [ - "test_runs", - "next_cursor" - ], - "responses": { - "200": { - "$response": "BuildTestRunsWithAttachmentsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_bug_report_draft", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetails", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler \u2014 the draft is composed from testRunId alone (group-scoped). Any placeholder value is accepted." - }, - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Numeric test run id whose failure the draft describes." - } - ], - "intent": "Build the pre-filled bug-report draft for a failed test \u2014 test name/status/duration, platform/browser, failure category, CI and host info, build name, retries, screenshot/log attachments and the AI RCA snippet \u2014 i.e. everything the 'Report a bug to Jira/Azure' modal needs to compose a ticket.", - "guidance": [ - "Read-only despite the name: it COMPOSES a new draft description, it does not fetch a bug that was already filed. To record a filed bug use link_bug_to_test_run.", - "It requires screenshot/retry data to exist for the run \u2014 a run with no such data throws 'retry doesn't exist' (a 4xx), so treat that as 'nothing to report on yet', not an outage.", - "logStruct carries image/log attachment references and aiRca carries the AI RCA snippet; both can be empty/absent when the run has no screenshots or no RCA." - ], - "returns": [ - "buildName", - "buildId", - "ciInfo", - "testDetails", - "testUrl", - "videoUrl", - "status", - "platform", - "browser", - "hostInfo", - "logStruct", - "retries", - "issueType", - "isAutoAnalyzed", - "duration", - "aiRca" - ], - "responses": { - "200": { - "$response": "BugReportDraftResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "link_bug_to_test_run", - "method": "POST", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetailsCallback", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler. Any placeholder value is accepted; the run is resolved from testRunId." - }, - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Numeric test run id to attach the bug to." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "The external issue KEY (e.g. the Jira/Azure ticket id). Used for the idempotency check \u2014 a key already tracked on the run makes the call a no-op." - }, - { - "name": "type", - "type": "string", - "description": "The PM-tool name the issue belongs to (e.g. Jira, Azure Boards)." - }, - { - "name": "url", - "type": "string", - "description": "Deep link to the created issue, shown in the UI." - }, - { - "name": "status", - "type": "string", - "description": "The issue's status to display against the run." - }, - { - "name": "uniqueUserId", - "type": "integer", - "description": "Id of the user credited with filing the bug." - } - ], - "intent": "After a bug has been created in a PM tool (Jira, Azure Boards, \u2026), link that external issue to the test run \u2014 recording it in the run's integration tables and marking the test as bug-reported so the UI shows the issue key/link.", - "guidance": [ - "Idempotent on the issue key: if that bug name is already tracked on the run (checked in both quoted-legacy and unquoted forms) you get 204 No Content with no body \u2014 treat that as 'already linked', not a failure.", - "On a fresh link it returns 200 with { id } \u2014 the created integration id. The caller must have permission to create defects on the run's project (403 otherwise).", - "Linking also fires the PM-Tools integration trigger as a side effect; if that trigger fails it is only logged and does not fail the link.", - "Reversible: an unlink route removes the association, so this is a write, not a destructive op. Send the issue KEY in `name` (not in `url`).", - "Read-back: the link is written to the test run's search document and appears as details.pm_tool_details[] ({id,name,url,type,status}) in list_build_test_runs \u2014 the returned id is that entry's id." - ], - "returns": [ - "id" - ], - "responses": { - "200": { - "$response": "LinkBugToTestRunResponse" - }, - "204": { - "description": "The bug key is already tracked on this run; nothing was changed and there is no body." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_flat", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/testRuns/flat", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build UUID (not a numeric id). Resolved by (uuid, group); unknown \u2192 BUILD_NOT_FOUND." - } - ], - "query": [ - { - "name": "searchAfter", - "type": "string", - "description": "Elasticsearch search_after cursor \u2014 pass the previous page's cursor back to continue. Omit on the first page." - }, - { - "name": "testRunIds", - "type": "array", - "description": "Restrict to specific numeric run ids. Send this OR clusterIds: with neither the handler 500s. Takes precedence over clusterIds." - }, - { - "name": "clusterIds", - "type": "array", - "description": "Restrict to runs in these unique-error clusters (latest run per test). Send this OR testRunIds: with neither the handler 500s." - }, - { - "name": "status", - "type": "array" - }, - { - "name": "issueType", - "type": "array" - }, - { - "name": "tags", - "type": "array" - }, - { - "name": "browser", - "type": "array" - }, - { - "name": "os", - "type": "array" - }, - { - "name": "folder", - "type": "array" - }, - { - "name": "flaky", - "type": "string" - }, - { - "name": "isMuted", - "type": "string" - }, - { - "name": "search", - "type": "array" - }, - { - "name": "virtualDevice", - "type": "boolean", - "description": "true restricts to virtual-device runs; false includes explicit-false AND runs missing the field." - } - ], - "intent": "List a build's test runs as a flat, filterable list (rather than the nested suite/folder hierarchy) \u2014 the data source for the flat test-listing view of a build.", - "guidance": [ - "Send testRunIds or clusterIds. With neither, the handler builds an Elasticsearch terms filter from a null clusterIds list and returns 500 Internal System Exception. The other filter params (status, issueType, tags, browser, os, folder, flaky, isMuted, search) are bound but NOT applied by this handler; only testRunIds, clusterIds and virtualDevice narrow the result.", - "Flat counterpart of the hierarchical build listing \u2014 returns a plain array under testRuns, no tree. Filters arrive as QUERY params bound onto a POJO, not as a JSON body.", - "In each row the top-level `id` is the test's HASH (a string); the numeric run id you need for other run reads is `details.id`. Do not pass the top-level id where a numeric run id is expected.", - "Pagination is Elasticsearch search_after: pass the prior page's cursor back as `searchAfter`. Note the underlying query fetches up to 10,000 hits, so on very large builds narrow with filters rather than relying on paging alone.", - "The ES indices queried are chosen from the build, so very old builds may resolve to different indices; results are limited to the latest run per test unless you pass explicit testRunIds." - ], - "returns": [ - "testRuns", - "pagingParams" - ], - "responses": { - "200": { - "$response": "FlatTestRunListingResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_ai_failures", - "method": "GET", - "path": "/ext/v1/ai/failures/{buildUUID}", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "intent": "Public API: get a build's AI failure analysis (clustered failure themes and their workflows, sorted by failure share), the summary a build-RCA agent uses to explain a build failure.", - "returns": [ - "buildId", - "buildThemes", - "buildWorkflows", - "buildThemeWorkflow", - "stats", - "trialQuota" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Read-only: returns whatever has already been computed and never triggers analysis; use trigger_build_ai_failure_analysis to start it.", - "Themes and workflows are sorted by failure share, highest first.", - "trialQuota appears only for build-level trial accounts.", - "The build is resolved within the caller's group; a build in another group reads as not found." - ], - "responses": { - "200": { - "$response": "BuildAIFailuresResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "trigger_build_ai_failure_analysis", - "method": "POST", - "path": "/ext/v1/ai/failures/{buildUUID}", - "mode": "write", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "intent": "Public API: start build-level AI failure clustering for a build (get-or-trigger). Returns existing analysis if ready, otherwise enqueues it and flags that it was triggered.", - "returns": [ - "triggered", - "buildId", - "buildThemes", - "buildWorkflows", - "buildThemeWorkflow", - "stats", - "trialQuota" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Get-or-trigger and asynchronous: triggered=true means analysis was enqueued and results are NOT in the response yet; poll get_build_ai_failures.", - "Gated by the AI-TFA plan fence plus AI consent, the build-level RCA feature, source-fencing and a trial quota; a blocked account gets a 403 plan-not-allowed error.", - "Runs only the theme/workflow clustering pipeline; this is NOT per-test RCA.", - "Takes no request body.", - "Produces data only, never removes any; there is no undo for the analysis run it starts." - ], - "responses": { - "200": { - "$response": "BuildAIFailureTriggerResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_ai_failure_theme_test_runs", - "method": "GET", - "path": "/ext/v1/ai/failures/{buildUUID}/flat", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "query": [ - { - "name": "buildFailureThemeId", - "type": "integer", - "required": false, - "description": "Integer id of the failure theme to drill into, from a public AI-failures read. Give this OR buildFailureWorkflowId to scope to one cluster. One of theme/workflow id is required." - }, - { - "name": "buildFailureWorkflowId", - "type": "integer", - "required": false, - "description": "Integer id of the failure workflow to drill into. One of theme/workflow id is required." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend default; use searchAfter to page." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Opaque search-after cursor from the previous response's pagingParams.searchAfter." - } - ], - "intent": "Drill into one AI failure theme or workflow of a build and list the flat, paginated test runs behind it. Takes a theme or workflow id only \u2014 to fetch an explicit set of test-run ids instead, use list_build_ai_failure_cluster_test_runs, which accepts testRunIds.", - "returns": [ - "testRuns", - "pagingParams" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Send buildFailureThemeId OR buildFailureWorkflowId (integer ids from get_build_ai_failures). One of them is REQUIRED: with neither, the TestHub lookup 400s 'At least one of build_failure_theme_id or build_failure_workflow_id must be provided' (there is no whole-build mode).", - "There is no explicit-testRunIds variant on the public flat endpoint.", - "limit is ignored; page with searchAfter until pagingParams.hasNext is false." - ], - "responses": { - "200": { - "$response": "FlatAIFailureTestRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_queued_sessions", - "method": "GET", - "path": "/api/v1/queued-sessions", - "mode": "read", - "entity": "session", - "query": [ - { - "name": "userId", - "type": "integer", - "required": true, - "description": "The user whose queued sessions to list. Taken from the query, NOT the caller's identity, so any user id can be asked about." - }, - { - "name": "product", - "type": "string", - "required": true, - "description": "Product to filter the queue by (e.g. automate / app-automate)." - } - ], - "intent": "List the automation sessions currently queued for a user and product, each with session/build/project name, resolved capabilities, why it is queued and how long it has waited.", - "returns": [ - "sessions" - ], - "guidance": [ - "Both userId and product are required (400 otherwise).", - "userId comes from the query, so it is NOT scoped to the caller; only the build/project name enrichment is group-scoped (names resolve to null for builds outside your group).", - "queueReason explains the wait (e.g. no_terminal_available); duration is computed at read time.", - "The capabilities field's shape depends on the session's caps format (selenium / appium / default)." - ], - "responses": { - "200": { - "$response": "QueuedSessionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_app_profiling", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/appProfiling", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get app-profiling data (CPU/memory/etc. captured during an App Automate session) for the Test Details app-performance tab.", - "returns": [], - "guidance": [ - "App profiling is App Automate only; on an Automate session this is a 400 (\"App Profiling is only supported for App Automate sessions\").", - "An empty object ({}) is a normal 200 meaning the report is still generating (upstream 202) or was never captured (upstream 404); it is NOT an error, so re-poll rather than treating it as failure.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "The body is the opaque upstream app-profiling JSON.", - "On the demo sandbox environment this returns an empty object." - ], - "responses": { - "200": { - "$response": "SessionAppProfilingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_capabilities", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/capabilities", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get the raw desired/actual capabilities a session was started with (Automate or App Automate), so a user can see exactly how the run was configured.", - "returns": [], - "guidance": [ - "Product-dependent: the session's origin (automate / app-automate) is resolved and the caps are fetched from that product's API.", - "The body is opaque JSON: normally the upstream sessionParams object, but if the upstream fetch fails it falls back to {outputCapabilities:{capabilities:...}} built from the stored session capabilities. Do not assume a fixed key.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "On the demo sandbox environment this returns dummy capabilities." - ], - "responses": { - "200": { - "$response": "SessionCapabilitiesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_details", - "method": "GET", - "path": "/api/v1/sessions/{sessionId}/sessionDetails", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "sessionId", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Get the header/metadata block for a session on the Test Details page: video/audio log handles, session URL, platform/device, framework and timings the tabs are built from.", - "returns": [ - "videoLogs", - "audioLogs", - "browserstackSessionUrl", - "percyBuildUrl", - "isSdk", - "device", - "framework", - "customMetadata", - "testStartedAt", - "buildId" - ], - "guidance": [ - "While the session is still being linked to its build the call returns a RETRYABLE 425 (BUILD_LINK_NOT_READY); retry shortly rather than treating it as fatal.", - "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", - "customMetadata and buildId are always null through this path today (buildId is not populated here despite the field name), so do not depend on them.", - "If the upstream media fetch fails, videoLogs/audioLogs come back null but the rest of the metadata is still returned with a 200.", - "On the demo sandbox environment this returns synthetic details with a sample video URL." - ], - "responses": { - "200": { - "$response": "SessionDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "425": { - "$response": "BuildLinkNotReady" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_session_test_code", - "method": "GET", - "path": "/ext/v1/sessions/{session_id}/testCode", - "mode": "read", - "entity": "session", - "path_params": [ - { - "name": "session_id", - "type": "string", - "required": true, - "description": "Session hashed id." - } - ], - "intent": "Public API: get the test source code captured for an Automate session, one entry per test executed in that session, so external tooling (e.g. AI agents) can read the code behind a session.", - "returns": [ - "session_id", - "build_uuid", - "tests" - ], - "guidance": [ - "Public endpoint: authenticate with your BrowserStack username + access key.", - "Cross-group safe: the resolved build's group is re-checked against the caller's group and a mismatch is reported as SESSION_NOT_FOUND (not a distinct 403).", - "A session that exists but captured NO code is reported as SESSION_NOT_FOUND, indistinguishable from a bad id, so not-found does not prove the id is wrong.", - "TestHub caps and truncates very large responses server-side; the truncation is only logged, never surfaced, so a returned code body may be incomplete without any flag.", - "A plan-locked build returns a forbidden-operation error." - ], - "responses": { - "200": { - "$response": "SessionTestCodeResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "create_checks_mapping", - "entity": "check_config", - "method": "POST", - "path": "/api/v1/checks-mapping", - "mode": "write", - "intent": "Register a pull request's VCS checks (e.g. GitHub check runs) against existing BrowserStack check configurations, so the PR-checks / quality-gate pipeline can evaluate them and call back with results.", - "path_params": [], - "body": [ - { - "name": "event_name", - "type": "string", - "required": true, - "json_path": "/event_name", - "description": "VCS event name that triggered the mapping. Mandatory and must be non-blank." - }, - { - "name": "tool", - "type": "string", - "required": true, - "json_path": "/tool", - "description": "Source tool (e.g. the VCS provider). Mandatory and must be non-blank." - }, - { - "name": "callback_url", - "type": "string", - "required": true, - "json_path": "/callback_url", - "description": "URL the evaluation pipeline calls back once each mapped check is evaluated. Mandatory and must be non-blank. Stored verbatim on every created ingestion row." - }, - { - "name": "pr_data", - "type": "object", - "required": true, - "json_path": "/pr_data", - "description": "Pull-request metadata. Mandatory. The whole object is also stored verbatim as the ingestion row's PR metadata blob (with githubCheckId merged in per check).", - "fields": [ - { - "name": "organisationId", - "type": "string", - "description": "VCS organisation/owner NAME as stored on the check configuration (vcs_org_name, e.g. 'my-org'), NOT GitHub's numeric org id. Matched with repositoryId + group; a mismatch means no config is found and the whole call is a 400." - }, - { - "name": "repositoryId", - "type": "string", - "description": "Repository NAME as stored on the check configuration (repo_name, e.g. 'my-repo'), NOT GitHub's numeric repository id. Matched with organisationId + group." - }, - { - "name": "number", - "type": "integer", - "description": "Pull request number, stored as pr_number on every created row." - }, - { - "name": "commitSHA", - "type": "string", - "description": "Head commit SHA, stored as commit_sha on every created row." - }, - { - "name": "date", - "type": "string", - "format": "date-time", - "description": "Commit timestamp as an ISO-8601 instant (parsed with Instant.parse). An unparseable value fails the whole request with a 400 naming the check; when omitted, commit_timestamp is left unset." - }, - { - "name": "githubCheckId", - "type": "string", - "description": "Optional; note the per-check GitHub id is normally supplied inside pr_checks, not here." - }, - { - "name": "ticketId", - "type": "string" - }, - { - "name": "installationId", - "type": "string" - }, - { - "name": "autoMerge", - "type": "boolean" - }, - { - "name": "fullName", - "type": "string" - } - ] - }, - { - "name": "pr_checks", - "type": "array", - "required": true, - "json_path": "/pr_checks", - "description": "Non-empty list of the PR's checks to register. Each item's id is the CHECK CONFIGURATION id, and it must match an existing check configuration for the caller's group + this repository + organisation, or the whole call returns 400 'Check Configurations not found...'.", - "fields": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Check CONFIGURATION id as a string. Parsed with Long.parseLong, so a non-numeric value throws before validation and fails the call. NOT the GitHub check id." - }, - { - "name": "githubCheckId", - "type": "integer", - "required": true, - "description": "The GitHub check-run id for this check. Mandatory. Merged into the stored PR metadata blob for the matching ingestion row." - }, - { - "name": "name", - "type": "string", - "required": true, - "description": "Check name. Mandatory and must be non-blank." - } - ] - } - ], - "returns": [ - "id", - "prNumber", - "commitSha", - "callbackUrl", - "checkStates", - "checkType", - "retryCount", - "splitRepos", - "prMetadataJson", - "thProjectId", - "checkConfiguration" - ], - "responses": { - "201": { - "description": "Array of the created vcs_ingestion rows (one per matched check configuration), each in state 'Not Started'. On success but with no matching configs the flow instead returns 400.", - "schema": { - "type": "array", - "items": { - "$schema": "VcsCheckMapping" - } - } - }, - "400": { - "description": "Plain-text (not JSON) body. Covers validation failures, the CHECKS_EVALUATION-not-accessible case, an unparseable commit date, a non-empty-but-unmatched pr_checks set ('Check Configurations not found...'), and any other exception whose message is echoed as the body.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement; when it is off the call still comes back as a 400 with the exception message as a PLAIN-TEXT body, so errors here are untyped strings, not JSON.", - "pr_checks[].id is the BrowserStack check-CONFIGURATION id, not the GitHub check id and not the ingestion id. Send the GitHub id separately as pr_checks[].githubCheckId.", - "A non-numeric pr_checks[].id throws (Long.parseLong) before bean validation, so it fails the whole request rather than returning a clean per-field error.", - "If none of the pr_checks ids match an existing check configuration for the caller's group + repositoryId + organisationId, the whole call is 400 'Check Configurations not found...' and nothing is created; register the check configuration first.", - "One vcs_ingestion row is created per MATCHED configuration; ids that match nothing are silently skipped rather than erroring individually. Read the returned array to see exactly which were registered, and take each row's integer id to look the mapping up later.", - "pr_data.date must be an ISO-8601 instant; an unparseable value fails the call with a 400 naming the check, and omitting it just leaves the commit timestamp unset.", - "Live 2026-09-30: organisationId/repositoryId are compared to the configuration's stored owner/repo NAMES (VcsIngestionService findByIdInAndRepoNameAndVcsOrgNameAndGroupId); numeric GitHub ids never match. Creating a mapping only stores a 'Not Started' ingestion row \u2014 it posts nothing to GitHub itself." - ] - }, - { - "name": "get_checks_mapping", - "entity": "check_config", - "method": "GET", - "path": "/api/v1/checks-mapping/{id}", - "mode": "read", - "intent": "Fetch a single previously-registered PR-check mapping (a vcs_ingestion row plus its joined check configuration) to see its current evaluation state, retry count, PR metadata and callback URL.", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The vcs_ingestion row id returned by create_checks_mapping. NOT the GitHub check id and NOT the check-configuration id (the service calls it checkId, which is misleading)." - } - ], - "returns": [ - "id", - "prNumber", - "commitSha", - "callbackUrl", - "checkStates", - "checkType", - "retryCount", - "splitRepos", - "prMetadataJson", - "thProjectId", - "checkConfiguration" - ], - "responses": { - "200": { - "$response": "VcsCheckMappingResponse" - }, - "404": { - "description": "Plain-text (not JSON) body: 'No check mapping found for checkId: N'. Returned when no row matches the id within the caller's group (another group's mapping reads as not-found).", - "schema": { - "type": "string" - } - }, - "500": { - "description": "Plain-text (not JSON) body: 'Error fetching check mapping: ...'.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "The path id is the INGESTION row id from create_checks_mapping's response, not the githubCheckId or the check-configuration id.", - "Scoped to the caller's group: a mapping owned by another group returns 404, so a 404 does not prove the id never existed.", - "The 404 and 500 bodies are PLAIN TEXT, not JSON error objects, so do not try to parse them as {error:...}.", - "checkStates carries the evaluation status (created rows start at 'Not Started'); the joined checkConfiguration object gives the underlying check's name, project and type." - ] - }, - { - "name": "list_check_configurations", - "entity": "check_config", - "method": "GET", - "path": "/api/v1/repos/{owner}/{repo}/check-runs", - "mode": "read", - "intent": "List the SCM check-run configurations set up for a repository \u2014 the rules deciding which BrowserStack build/project a commit or PR check reports on, and whether it posts quality-gate or build status.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner. Matched against the stored vcs_org_name." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name. Matched against the stored repo_name." - } - ], - "query": [ - { - "name": "filter", - "type": "string", - "required": false, - "description": "Only the value 'enabled' (case-insensitive) is honoured: it restricts the listing to enabled checks. ANY other value is silently ignored and all checks are returned \u2014 this is not a general-purpose filter.", - "values": [ - "enabled" - ] - } - ], - "returns": [ - "repoName", - "ownerName", - "checks" - ], - "responses": { - "200": { - "$response": "CheckConfigurationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement (contrary to what one might assume for a read); without it the call is refused as feature-inaccessible.", - "Rows are scoped to the caller's group AND user, so you only see check configs you created \u2014 not the whole repository's.", - "When nothing is configured you get HTTP 200 with an empty checks list, not a 404; and if the lookup throws internally it also returns 200 with an EMPTY response object (null repoName/ownerName and no checks), so treat an all-null body as an error, not as 'no checks'.", - "filter accepts only 'enabled'; any other token is ignored rather than rejected, so a mistyped filter silently returns everything.", - "PRODUCT BUG (live 2026-09-30): when the repository has NO checks, the empty response swaps the envelope \u2014 repoName carries the OWNER and ownerName carries the REPO (controller builds CheckConfigurationResponse(owner, repo, \u2026) against a (repoName, ownerName, \u2026) constructor). Non-empty listings are correct. Trust the path you sent, not the envelope, on an empty list." - ] - }, - { - "name": "create_check_configurations", - "entity": "check_config", - "method": "POST", - "path": "/api/v1/repos/{owner}/{repo}/check-runs", - "mode": "write", - "intent": "Register up to 10 check-run configurations on a repository so BrowserStack posts a named check (quality-gate or build status) back to the VCS for commits/PRs, bound to a given project, build name and branch.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner; stored as the check's vcs_org_name." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name; stored as the check's repo_name." - } - ], - "body": [ - { - "name": "checks", - "type": "array", - "required": true, - "json_path": "/checks", - "minItems": 1, - "maxItems": 10, - "description": "The check configurations to create (1..10). Empty is rejected (400 'Checks list cannot be empty'); more than 10 is rejected. This is also capped against the repository's EXISTING count \u2014 max 10 per repository in total.", - "fields": [ - { - "name": "checkName", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Display name of the check. Mandatory, non-blank. Part of the uniqueness key (checkName + repo + group + buildName + project)." - }, - { - "name": "checkType", - "type": "string", - "required": true, - "values": [ - "QG_STATUS", - "BUILD_STATUS" - ], - "description": "What the check reports: QG_STATUS (quality-gate status) or BUILD_STATUS (build status). Mandatory." - }, - { - "name": "tool", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Source tool / VCS provider. Mandatory, non-blank." - }, - { - "name": "splitRepos", - "type": "boolean", - "required": true, - "json_path": "/checks/splitRepos", - "description": "Split-repos flag. Mandatory (@NotNull) \u2014 omitting it is a 400 even though it looks optional." - }, - { - "name": "buildName", - "type": "string", - "required": true, - "json_path": "/checks/buildName", - "maxLength": 1024, - "description": "Target build name the check reports on. Mandatory, non-blank. Part of the uniqueness key." - }, - { - "name": "projectName", - "type": "string", - "required": true, - "json_path": "/checks/projectName", - "maxLength": 256, - "description": "Target project name. Mandatory, non-blank. Must resolve to an EXISTING project in the workspace, or the write is refused as project-not-found." - }, - { - "name": "branchName", - "type": "string", - "required": false, - "json_path": "/checks/branchName", - "maxLength": 256, - "description": "Branch to scope the check to (optional)." - }, - { - "name": "buildTags", - "type": "string", - "required": false, - "json_path": "/checks/buildTags", - "maxLength": 256, - "description": "Build tags used to select the build (optional)." - }, - { - "name": "commitCheck", - "type": "boolean", - "required": false, - "json_path": "/checks/commitCheck", - "description": "Whether the check reports per-commit (optional)." - }, - { - "name": "isEnabled", - "type": "boolean", - "required": false, - "json_path": "/checks/isEnabled", - "description": "Whether the check is active (optional)." - } - ] - } - ], - "returns": [ - "repoName", - "ownerName", - "checks" - ], - "responses": { - "201": { - "$response": "CheckConfigurationResponse" - }, - "200": { - "$response": "CheckConfigurationResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Gated by the CHECKS_EVALUATION entitlement; without it the write is refused as feature-inaccessible.", - "Success is HTTP 201, but the SAME route returns HTTP 200 when nothing was actually saved (empty or all-dropped) \u2014 check the returned checks list, not just the status code.", - "At most 10 checks per repository IN TOTAL (existing + new), not just 10 per request; exceeding it is a 400 'Maximum 10 checks per repository'.", - "splitRepos is required despite reading like a flag \u2014 omit it and the call is a 400.", - "projectName must name a project that already exists in the workspace, otherwise the whole request fails with project-not-found.", - "Uniqueness is (checkName, repo, group, buildName, project); a duplicate collides on that key.", - "Checks are stored against the caller's group and user, so only you will see and be able to manage them afterwards." - ] - }, - { - "name": "update_check_configuration", - "entity": "check_config", - "method": "PUT", - "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}", - "mode": "write", - "intent": "Edit an existing check-run configuration \u2014 its name, check type, tool, target project/build name, branch, tags, commit-check or enabled flag.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name." - }, - { - "name": "checkId", - "type": "string", - "required": true, - "description": "Numeric check-configuration id (as a string in the path). A non-numeric value is a 400 'Invalid check ID format'." - } - ], - "body": [ - { - "name": "checkName", - "type": "string", - "required": true, - "maxLength": 256, - "description": "Check name. Mandatory, non-blank." - }, - { - "name": "checkType", - "type": "string", - "required": true, - "values": [ - "QG_STATUS", - "BUILD_STATUS" - ], - "description": "QG_STATUS or BUILD_STATUS. Mandatory." - }, - { - "name": "tool", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Source tool. Mandatory, non-blank." - }, - { - "name": "splitRepos", - "type": "boolean", - "required": true, - "json_path": "/splitRepos", - "description": "Split-repos flag. Mandatory (@NotNull)." - }, - { - "name": "buildName", - "type": "string", - "required": true, - "json_path": "/buildName", - "maxLength": 1024, - "description": "Target build name. Mandatory, non-blank." - }, - { - "name": "projectName", - "type": "string", - "required": true, - "json_path": "/projectName", - "maxLength": 256, - "description": "Target project name. Mandatory, non-blank; must resolve to an existing project or the update is refused as project-not-found." - }, - { - "name": "branchName", - "type": "string", - "required": false, - "json_path": "/branchName", - "maxLength": 256, - "description": "Branch scope (optional)." - }, - { - "name": "buildTags", - "type": "string", - "required": false, - "json_path": "/buildTags", - "maxLength": 256, - "description": "Build tags (optional)." - }, - { - "name": "commitCheck", - "type": "boolean", - "required": false, - "json_path": "/commitCheck", - "description": "Per-commit reporting flag (optional)." - }, - { - "name": "isEnabled", - "type": "boolean", - "required": false, - "json_path": "/isEnabled", - "description": "Enabled flag (optional)." - } - ], - "returns": [ - "checkId", - "checkName", - "repoName", - "vcsOrgName", - "vcsName", - "checkType", - "splitRepos", - "projectId", - "projectName", - "branchName", - "buildName", - "buildTags", - "groupId", - "userId", - "createdAt", - "updatedAt", - "commitCheck", - "isEnabled" - ], - "responses": { - "200": { - "$response": "CheckConfigurationItemResponseWrap" - }, - "204": { - "description": "Empty body. Returned when the service resolves no updatable check (used INSTEAD of a 404) \u2014 the caller cannot tell 'not found' from 'no-op' by status alone." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "This is a FULL replace, not a patch: all mandatory fields (checkName, checkType, tool, splitRepos, buildName, projectName) must be present on every update, so read the check first and echo unchanged values back.", - "Gated by CHECKS_EVALUATION; scoped to the caller's group and user \u2014 you cannot edit a teammate's check.", - "A missing/foreign check comes back as HTTP 204 with an empty body, NOT a 404 \u2014 do not read 204 as success without re-reading.", - "A non-numeric checkId is a 400 'Invalid check ID format'; projectName must resolve to an existing project.", - "To only flip the enabled flag, prefer toggle_check_configuration \u2014 it takes a single query param instead of the whole body." - ] - }, - { - "name": "toggle_check_configuration", - "entity": "check_config", - "method": "PATCH", - "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}/toggle", - "mode": "write", - "intent": "Enable or disable a single check-run configuration without editing the rest of it \u2014 the quick on/off switch in the repo's checks settings.", - "path_params": [ - { - "name": "owner", - "type": "string", - "required": true, - "description": "VCS organisation / owner." - }, - { - "name": "repo", - "type": "string", - "required": true, - "description": "VCS repository name." - }, - { - "name": "checkId", - "type": "string", - "required": true, - "description": "Numeric check-configuration id (string in path); non-numeric is a 400 'Invalid check ID format'." - } - ], - "query": [ - { - "name": "isEnabled", - "type": "boolean", - "required": true, - "description": "New enabled state. This is a REQUIRED QUERY parameter, not a body field \u2014 omitting it is a 400 for a missing parameter." - } - ], - "returns": [ - "checkId", - "checkName", - "repoName", - "vcsOrgName", - "vcsName", - "checkType", - "splitRepos", - "projectId", - "projectName", - "branchName", - "buildName", - "buildTags", - "groupId", - "userId", - "createdAt", - "updatedAt", - "commitCheck", - "isEnabled" - ], - "responses": { - "200": { - "$response": "CheckConfigurationItemResponseWrap" - }, - "404": { - "description": "Empty body. Returned when no check matches the id for the caller's group/user." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "isEnabled is a REQUIRED QUERY parameter (?isEnabled=true|false), not a body field \u2014 this is the most common mistake on this route.", - "Gated by CHECKS_EVALUATION and scoped to the caller's group and user.", - "A check that is not found comes back as HTTP 404 with an EMPTY body (unlike update, which uses 204 for the same case).", - "On success it returns the full updated check configuration, so you can confirm the new isEnabled value from the body." - ] - }, - { - "name": "update_view", - "entity": "view", - "method": "PUT", - "path": "/api/v1/views/{viewType}/{id}", - "mode": "write", - "intent": "Rename a saved view, replace its stored filter query, or change its private/public visibility.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view belongs to; sets the type used for the name-uniqueness check. An unrecognised value is a 400." - }, - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "View UUID. A view that does not exist is a 400 'View does not exists', not a 404." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "json_path": "/name", - "description": "New name. Omit to keep the stored name. Despite @NotNull on the model, an omitted field falls back to the stored value (partial update). A clash returns VIEW_NAME_EXISTS." - }, - { - "name": "query", - "type": "string", - "required": false, - "json_path": "/query", - "description": "New serialised filter. Omit to keep the stored query." - }, - { - "name": "level", - "type": "integer", - "required": false, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "New visibility (1 = private, 2 = public). Omit to keep the stored level." - } - ], - "returns": [], - "responses": { - "200": { - "description": "Empty body \u2014 the updated view is NOT echoed back (unlike create_view). Re-list to see the result." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Requires the manage-custom-views permission (BUILD_MANAGE_VIEWS).", - "PARTIAL update despite being a PUT: omitted name/query/level fall back to the view's stored values.", - "For a PRIVATE view the caller must be the owner ('Not allowed to update this view'); the SQL also restricts updates to your own views OR public views in your group. A non-existent id is a 400 'View does not exists', not a 404.", - "A name clash returns VIEW_NAME_EXISTS (per screen type: within group for public, within creator for private).", - "Returns an empty 200 body on success \u2014 do not expect the updated view back; re-list to confirm.", - "There is no revision history: an overwritten name, query or level cannot be restored." - ] - }, - { - "name": "list_views", - "entity": "view", - "method": "GET", - "path": "/ext/v1/views/{viewType}", - "mode": "read", - "intent": "External/public API (used by the Atlas agent) to list the caller's saved views for a screen, so an integration can pick a saved view instead of rebuilding a filter query.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the views belong to. An unrecognised value is a 400." - } - ], - "query": [], - "returns": [ - "id", - "type", - "query", - "level", - "name", - "description", - "createdBy", - "groupId" - ], - "responses": { - "200": { - "$response": "ViewListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: authenticated with a BrowserStack username + access key over HTTP Basic, not the internal session token.", - "Returns a bare JSON ARRAY of view objects, scoped to the caller's user/group; not paginated. No public-link-token path here (unlike the internal list).", - "Thin adapter over the same view service as the internal list \u2014 results are the same set for the same caller. There is intentionally NO external delete operation.", - "An unrecognised viewType is a 400; valid values are builds, testRuns, testHealth, uniqueErrors, buildInsights." - ] - }, - { - "name": "create_view", - "entity": "view", - "method": "POST", - "path": "/ext/v1/views/{viewType}", - "mode": "write", - "intent": "External/public API to save a filter query as a named view (private or shared with the org) for a screen.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view is for; sets the view's type. An unrecognised value is a 400." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/name", - "minLength": 1, - "maxLength": 100, - "description": "View name. Enforced at this public boundary to be non-blank and at most 100 characters (stricter than the internal surface). Must be unique per screen type within the group (public) or creator (private)." - }, - { - "name": "query", - "type": "string", - "required": true, - "json_path": "/query", - "minLength": 1, - "maxLength": 4000, - "description": "Serialised filter to store, as a string. Enforced non-blank and at most 4000 characters here." - }, - { - "name": "level", - "type": "integer", - "required": true, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = shared with the org. Required; any other value is rejected with 'level must be 1 (private) or 2 (shared)'." - } - ], - "returns": [ - "id", - "type", - "query", - "level", - "name", - "description", - "createdBy", - "groupId" - ], - "responses": { - "200": { - "$response": "ViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: username + access-key HTTP Basic auth. Requires the manage-custom-views permission (works for both globalRBAC-on and -off orgs).", - "Stricter validation than the internal create: name must be non-blank and <=100 chars, query non-blank and <=4000 chars, level exactly 1 or 2 \u2014 each violation is its own 400 message.", - "type is set from the path and id/createdBy/groupId in the body are ignored; the creator and group come from the authenticated caller.", - "Name uniqueness is per screen type (group for public, creator for private); a clash returns VIEW_NAME_EXISTS.", - "Returns the stored view (with its UUID id) as a single object." - ] - }, - { - "name": "replace_view", - "entity": "view", - "method": "PUT", - "path": "/ext/v1/views/{viewType}/{id}", - "mode": "write", - "intent": "Rename a saved view or replace its stored filter query and visibility level. Name, query and level are all REQUIRED here \u2014 this replaces the view rather than patching it, so send the whole object; update_view is the partial-update route.", - "path_params": [ - { - "name": "viewType", - "type": "string", - "required": true, - "values": [ - "builds", - "testRuns", - "testHealth", - "uniqueErrors", - "buildInsights" - ], - "description": "Screen the view belongs to; used for the name-uniqueness check and the ownership guard. An unrecognised value is a 400." - }, - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "View UUID. A non-UUID is a 400 'Invalid view id.'; an id not in the caller's own scoped view list is a 400 'View does not exist.' (closes a cross-tenant existence oracle)." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "json_path": "/name", - "minLength": 1, - "maxLength": 100, - "description": "View name. Validated non-blank and <=100 chars at this boundary." - }, - { - "name": "query", - "type": "string", - "required": true, - "json_path": "/query", - "minLength": 1, - "maxLength": 4000, - "description": "Serialised filter to store. Validated non-blank and <=4000 chars." - }, - { - "name": "level", - "type": "integer", - "required": true, - "json_path": "/level", - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = shared. Required; other values are a 400." - } - ], - "returns": [], - "responses": { - "200": { - "description": "Empty body on success \u2014 the updated view is not echoed back." - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - } - }, - "guidance": [ - "Public /ext/v1 surface: username + access-key HTTP Basic auth, plus the manage-custom-views permission.", - "Unlike the internal update, this validates the payload the same way create does (name <=100, query <=4000, level 1|2) \u2014 all three are effectively required here.", - "Explicit ownership guard: the id must appear in the caller's own scoped view list, otherwise a 400 'View does not exist.' \u2014 this deliberately hides whether some other tenant's view id exists. A non-UUID is a 400 'Invalid view id.'.", - "Returns an empty 200 body; re-list to confirm. No revision history, so overwritten values cannot be restored." - ] - }, - { - "name": "list_dashboard_views", - "method": "GET", - "path": "/api/v1/dashboards/views", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the dashboard views the current user can see (their own private ones plus group/shared ones) with name, visibility level, widget count and owner \u2014 the dashboard picker / dashboard list.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED even though this listing does not use the project to filter results \u2014 the controller resolves a project before every handler and 400s with 'One of `project_id`, `project_name` or `project_identifier` is required' when none is given." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which dashboard surface to resolve the project against. TO = Test Observability, TCM = Test Management." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "Dashboard views the caller can see, as a flat list under `data` (no success flag on this route).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "DashboardView" - } - } - } + "probe_path": "/api/v1/access", + "capabilities": [ + { + "name": "list_latest_build_summaries", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/buildSummaries", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + } + ], + "intent": "Return full build summaries (status, run counts, duration, CI/host info, user, failure stats) for up to the 5 latest builds of one project in a time window - the per-project body of the daily summary email.", + "returns": [ + "uuid", + "id", + "name", + "status", + "statusStats", + "issueTypeAggregate" + ], + "guidance": [ + "The body is a top-level ARRAY of build summaries, not a wrapped object.", + "Build selection is server-side: it returns at most the latest 5 builds in the window (hardcoded). To fetch specific builds instead, use list_build_summaries_by_ids.", + "This is an internal/job-facing endpoint - the handler does no group scoping or role check, so do not rely on it to enforce project access." + ], + "responses": { + "200": { + "$response": "BuildSummaryListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_name_status_aggregation", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/buildSummaryAggs", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "default": 11, + "description": "Caps the number of distinct build names returned (default 11, i.e. top 10 + 1)." + } + ], + "intent": "For one project and time window, return a per-build-name breakdown of run counts by display status ('for each distinct build name, how many runs passed/failed') - used to pick the noteworthy builds for the summary.", + "returns": [ + "count", + "passed", + "failed", + "skipped", + "unknown" + ], + "guidance": [ + "The body is a top-level MAP keyed by build name, each value `{count, passed, failed, skipped, unknown}`. There is no wrapper.", + "Only parent builds are counted, and builds whose usage quota status is 'locked' are excluded, so totals can be lower than the raw run count.", + "`limit` caps the number of distinct build names (default 11); it does not cap runs within a name." + ], + "responses": { + "200": { + "$response": "BuildAggregateMapResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_summaries_by_ids", + "method": "GET", + "path": "/api/v1/daily-summary/{projectId}/projectBuildSummaries", + "mode": "read", + "entity": "notification", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id." + } + ], + "query": [ + { + "name": "buildIds", + "type": "array", + "required": true, + "description": "The explicit build ids (integers) to summarize. Required." + } + ], + "intent": "Return full build summaries for an explicitly supplied list of build ids within one project - the follow-up call once you know which builds you want.", + "returns": [ + "uuid", + "id", + "name", + "status", + "statusStats", + "issueTypeAggregate" + ], + "guidance": [ + "The body is a top-level ARRAY of build summaries, same shape as list_latest_build_summaries.", + "The only difference from list_latest_build_summaries is that the CALLER chooses the builds by id here, instead of the server picking the latest 5 in a window.", + "Internal/job-facing endpoint - no group scoping or role check in the handler.", + "Result order does NOT follow the requested buildIds order - match rows by `id`." + ], + "responses": { + "200": { + "$response": "BuildSummaryListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_daily_summary_report", + "method": "GET", + "path": "/api/v1/daily-summary/projectBuildSummaries/v2", + "mode": "read", + "entity": "notification", + "query": [ + { + "name": "projectIds", + "type": "array", + "required": true, + "description": "Project ids (integers) to include in the report. Required." + }, + { + "name": "startTimeEpoc", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds." + }, + { + "name": "endTimeEpoc", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds." + } + ], + "intent": "Build the one-shot payload for the daily/periodic summary email: for a set of projects and a time window, per-project build counts, top failed builds, failed-test summary and failure-category breakdown, with the top failed build best-effort enriched with AI RCA, self-healing and test-authoring summaries.", + "returns": [ + "filterQueries", + "projects" + ], + "guidance": [ + "Despite the '/v2' path this is NOT another version of list_build_summaries_by_ids: that returns a flat array of build summaries, this returns the aggregated OverallDailySummary object `{filterQueries, projects:[...]}` and does its own build selection across the projects.", + "Heavy call: several aggregations plus three best-effort enrichment passes. The AI RCA / self-healing / authoring summary fields on the top failed build are swallowed on error, so they may be silently absent - do not treat a missing enrichment field as a failure.", + "`filterQueries` is a fixed set of canned query strings the email UI uses to deep-link into filtered views; it is not per-project data.", + "Internal/job-facing endpoint - no role check in the handler." + ], + "responses": { + "200": { + "$response": "OverallDailySummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_dashboard_public_link_setting", + "method": "GET", + "path": "/api/v1/setting/publicLink/dashboard/{dashboardId}", + "mode": "read", + "entity": "public_link", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "format": "uuid", + "required": true, + "description": "The custom dashboard's UUID." + } + ], + "intent": "Check whether public 'anyone with the link' sharing is currently turned on for a custom dashboard.", + "returns": [ + "publicLink" + ], + "guidance": [ + "The whole body is just `{publicLink: }`. A dashboard that has never been shared reads `false` (the default), which means sharing is off, not that the dashboard is missing.", + "The dashboard lookup is permission-aware: a dashboard the caller cannot see comes back 404 DASHBOARD_NOT_FOUND rather than a setting." + ], + "responses": { + "200": { + "$response": "PublicLinkSettingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_public_link_setting", + "method": "GET", + "path": "/api/v1/setting/publicLink/project/{projectId}", + "mode": "read", + "entity": "public_link", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The TestHub project id (NOT the observability project id)." + } + ], + "intent": "Check whether public link sharing is on for a project's report pages such as build insights.", + "returns": [ + "publicLink" + ], + "guidance": [ + "The whole body is just `{publicLink: }`. When the project has no token row yet the value is `false` (the default) \u2014 sharing off, not an error.", + "`projectId` is resolved as a TestHub project id, not the observability project id; an id that does not resolve is 404 PROJECT_NOT_FOUND." + ], + "responses": { + "200": { + "$response": "PublicLinkSettingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_quality_profile_filter_values", + "method": "GET", + "path": "/api/v1/projects/{projectName}/settings/quality-gates/filters", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project's normalised name." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "BUILD_NAME", + "BUILD_TAGS", + "TEST_TAGS", + "FOLDERS", + "ALERTS", + "FAILURE_CATEGORIES" + ], + "description": "Which value source to return. Bound from a query POJO \u2014 there is no request body despite the object-shaped parameters." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Search prefix/substring to filter values. Max 100 chars." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Cursor for the next page. Max 100 chars. Echoed back in the response as `afterKey` when more values remain." + }, + { + "name": "isGlobalProfile", + "type": "boolean", + "description": "Group-wide (true) or this project (false). REQUIRED in practice: the ES-backed types unbox it without a null check, so omitted -> 500.", + "required": true + } + ], + "intent": "Typeahead/value source for the filter builder inside a quality profile: return matching build names, build tags, test tags, folder paths, failure categories or alerts so a caller can pick which builds a gate applies to.", + "returns": [ + "data", + "afterKey", + "query" + ], + "responses": { + "200": { + "$response": "QualityProfileFilterValues" + } + }, + "guidance": [ + "Always send isGlobalProfile (false for a project profile): QGFilterESQuery does `if (!getIsGlobalProfile())` on a nullable Boolean, so omitting it makes BUILD_NAME/BUILD_TAGS/TEST_TAGS/FOLDERS return 500.", + "Page size is fixed PER TYPE, not caller-controlled: 10 for BUILD_NAME/BUILD_TAGS/TEST_TAGS/FAILURE_CATEGORIES/ALERTS, and 1000 for FOLDERS. There is no page-size parameter.", + "Paginate by passing the response's `afterKey` back as the next request's `afterKey`. afterKey is set even on the last page, so stop when a page returns fewer values than the page size (or none).", + "BUILD_NAME/BUILD_TAGS/TEST_TAGS values come from Elasticsearch aggregations over the project's builds/test runs; FAILURE_CATEGORIES and ALERTS come from the DB and honour `isGlobalProfile`." + ] + }, + { + "name": "get_project_quality_gate_settings", + "method": "GET", + "path": "/ext/v1/quality-gates/{project_name}/settings", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project's normalised name." + } + ], + "intent": "Return a project's quality-gate configuration over the public (access-key) API: whether gates are enabled, whether the gate overrides build status, and the profiles attached to the project. Use this from CI/integrations authenticated with a username+access-key.", + "returns": [ + "enabled", + "shouldOverrideBuildStatus", + "qualityProfiles" + ], + "responses": { + "200": { + "$response": "QualityGateSettings" + } + }, + "guidance": [ + "Same response as the in-product `get_project_quality_gate_settings`; this is the external surface that accepts a username+access-key (CI/integration).", + "The action name reads like a plain profile list but it also carries the project-level enabled / shouldOverrideBuildStatus settings.", + "Plan-gated: refused as feature-inaccessible when the plan lacks Quality Gates.", + "For service-account callers this enforces the quality-gate project permission (fallback modify_project_settings); ordinary user-key callers skip that check." + ] + }, + { + "name": "get_quality_profile", + "method": "GET", + "path": "/ext/v1/quality-gates/{project_name}/profiles/{profile_id}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project's normalised name." + }, + { + "name": "profile_id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Profile UUID. A non-UUID value 400s before the handler runs." + } + ], + "intent": "Fetch one quality-gate profile in full \u2014 rules, thresholds and build filters \u2014 over the public (access-key) API, with failure-category operands resolved to the project's display names.", + "returns": [ + "uuid", + "name", + "ruleStatus", + "rules", + "applicableBuilds", + "isGlobalProfile", + "hooksVisibility" + ], + "responses": { + "200": { + "$response": "QualityProfileDetail" + } + }, + "guidance": [ + "`displayWith` is populated only for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules. Null fields are omitted (JSON include NON_NULL).", + "The profile is loaded by UUID and is not itself re-checked against the named project.", + "Plan-gated (feature-inaccessible when the plan lacks Quality Gates).", + "External surface accepting a username+access-key; for service-account callers enforces the quality-gate project permission, user-key callers skip it." + ] + }, + { + "name": "get_build_quality_gate_result", + "method": "GET", + "path": "/ext/v1/quality-gates/{build_id}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "The build uuid (40-char hashed id, not an RFC-4122 UUID), despite the name `build_id`. Looked up by UUID alone, not scoped to the caller's group." + } + ], + "intent": "The CI-facing quality gate check: given a build UUID, return whether the build passed or failed its quality gate (or that it is still running), so a pipeline can decide to proceed or fail.", + "returns": [ + "build_uuid", + "build_url", + "status", + "quality_gate_result", + "quality_profiles" + ], + "responses": { + "200": { + "$response": "CiQualityGateResult" + } + }, + "guidance": [ + "While the build is unfinished (or gate analysis is still in process) it returns status 'running' with `quality_gate_result` set to a 'try again in a few minutes' message \u2014 CI callers must POLL, not treat the first response as final.", + "`status`/`quality_gate_result` resolve to 'passed', 'failed' or 'skipped' once evaluation completes; null fields are omitted (JSON include NON_NULL).", + "Errors: build-not-found for an unknown UUID; forbidden-operation for a locked build; profile-not-found when quality gates are disabled for the project OR no profile results exist for the build.", + "The build is looked up by UUID alone and NOT scoped to the caller's group, unlike the profile endpoints on this surface. For service-account callers it enforces the quality-gate project permission; ordinary user-key callers skip that check.", + "Triggers configured Quality-Gate category integrations as a side effect when a result is produced." + ] + }, + { + "name": "get_build_quality_gate_summary", + "method": "GET", + "path": "/api/v1/builds/{uuid}/v2/quality-gate", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + } + ], + "intent": "The readiness-aware quality-gate summary for a build: return the per-profile results, or instead a status message when the build is still pending, the auto-analyser is running, or an evaluation state is cached. This is the endpoint to poll after triggering a recalculation.", + "returns": [ + "qgResult", + "message", + "state" + ], + "responses": { + "200": { + "$response": "BuildQualityGateSummary" + } + }, + "guidance": [ + "The 200 is one of several shapes and a caller MUST branch on it: `qgResult` (the per-profile results list) when ready; otherwise `message` = 'Quality Gate result will be available after your build finishes' (build pending) or 'Quality Gate evaluation will begin soon' (auto-analyser running); or a bare `state` string read from cache. Only one of qgResult/message/state is present per response.", + "Poll this after a recalculate to watch the gate finish; the message/state responses mean 'not ready yet', not an error.", + "Behavioural counterpart of `list_build_quality_profile_results`: that one returns the results as a bare array with no readiness handling; this one wraps them and can report still-evaluating/pending states instead. (They are the v1/v2 pair on this route.)", + "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface.", + "Ignore the extracted 'Check project permission' comment associated with this route \u2014 it belongs to a neighbouring close-build method; there is no extra permission check here." + ] + }, + { + "name": "list_build_quality_profile_results", + "method": "GET", + "path": "/api/v1/builds/{uuid}/quality-gate", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + } + ], + "intent": "Return the per-quality-profile results for a build as a flat list: each profile with its name, uuid and per-status rule counts \u2014 the quality gate breakdown on the build page.", + "returns": [ + "uuid", + "name", + "status", + "statusStats" + ], + "responses": { + "200": { + "$response": "BuildQualityProfileResults" + } + }, + "guidance": [ + "The 200 body is a BARE JSON ARRAY of per-profile result objects (no wrapper, no `data`, no readiness field); each item is {uuid, name, status, statusStats:{passed,failed,skipped}}.", + "This returns the raw results with NO readiness handling: if the build is still evaluating you get an empty/partial list rather than a 'still evaluating' signal. Use `get_build_quality_gate_summary` (the v2 route) when you need to distinguish 'not ready' from 'no results'.", + "For a split build's child, results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." + ] + }, + { + "name": "get_build_quality_profile_result", + "method": "GET", + "path": "/api/v1/builds/{uuid}/quality-gate/profile/{profileUuid}", + "mode": "read", + "entity": "quality_gate", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid: the 40-char hashed build id from a build read (not an RFC-4122 UUID)." + }, + { + "name": "profileUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "UUID of the quality profile. A non-UUID value 400s before the handler runs." + } + ], + "intent": "Drill into a single quality profile's result for a build: the individual rules, their thresholds and rendered conditions, and each rule's pass/fail outcome \u2014 for the quality-gate detail drawer.", + "returns": [ + "rules", + "ruleStatus" + ], + "responses": { + "200": { + "$response": "BuildQualityProfileResultDetail" + } + }, + "guidance": [ + "The body is an object {rules, ruleStatus}, where each rule entry is {rule, ruleStatus, ruleResult} \u2014 the rule definition plus that rule's per-build status and result.", + "Failure-category rules have their `displayWith` resolved to the project's human display names; other rule types do not.", + "For a split build's child the results are read from the parent build. 403 when the build is plan-locked; build-not-found for an unknown UUID. No username+access-key on this surface." + ] + }, + { + "name": "get_flakiness_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/flakiness", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at the plan retention floor. Rejected with 400 only if it is negative or greater than upperBound." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now. Silently clamped down to the plan retention ceiling." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name. Omit for all builds." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's runs are counted; repeat the param for a set." + } + ], + "intent": "Get the flakiness card for a project's Testing Trends page - the percentage and count of flaky tests over the window plus the sparkline series, to track whether suite flakiness is improving.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "ALWAYS pass lowerBound/upperBound: omitting them returns the full retention window (tens of thousands of characters) and blows the tool output limit. For a single build's flaky/new-failure/muted/re-run counts read the build row (historyAggregate) via list_project_builds_by_name or get_build_details instead; this card is per build NAME over a window, not per run.", + "Plan-feature-gated: if the flakiness widget is not in the caller's plan the call fails with 403 (FEATURE_INACCESSIBLE), NOT a 200 with empty data - do not treat that as 'no flaky tests'.", + "The window is clamped to plan retention silently, and the bucket interval is derived from the width of the window you asked for, so a wider range yields coarser buckets.", + "`insights.count` is the headline percentage as a STRING (one decimal), and `insights.meta` is a human sentence like 'N of M unique tests are flaky'; the per-bucket series is in `data`, each point's `y` being the flaky count for that bucket (null when no tests ran).", + "Both bounds are optional here; omit them to get the full retention window." + ] + }, + { + "name": "get_always_failing_tests_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/alwaysFailing", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor. 400 only if negative or greater than upperBound." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to the plan retention ceiling." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name. Omit for all builds." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the always-failing card for a project - the percentage and count of consistently broken (permanently red) tests over the window plus the sparkline series, to see whether they are growing.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-feature-gated: refused with 403 (FEATURE_INACCESSIBLE) when the always-failing widget is not in the caller's plan, rather than returning empty data.", + "Side effect: for non-superusers this records the Testing Trends view in the caller's recently-viewed list. It is harmless and does not change test data, but it means this read is not entirely free of writes.", + "Window is clamped to plan retention silently; bucket interval is auto-chosen from the width of the window. `insights.count` is a percentage string and `insights.meta` a summary sentence." + ] + }, + { + "name": "get_new_failures_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/newFailures", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow the card to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the new-failures card for a project - the percentage and count of tests failing for the first time in the window plus the series, the 'what did we just break' regression signal.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-feature-gated: 403 (FEATURE_INACCESSIBLE) when the new-failures widget is not in the caller's plan; not a 200 with empty data.", + "Window clamped to plan retention silently; bucket interval auto-chosen from window width. `insights.count` is a percentage string; per-bucket new-failure counts are in `data[].y` (null for buckets with no runs)." + ] + }, + { + "name": "get_test_growth_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testGrowth", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the test-suite growth card for a project - how the number of unique test cases changed over the window, with the timeline series, i.e. whether the suite is actually growing.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the `testGrowth` path this counts DISTINCT TEST CASES (suite size), not test runs - use get_test_executions_trend for run volume.", + "Unlike flakiness / new-failures / always-failing this card is NOT feature-gated, so it works on any plan.", + "`insights` is null on this card (no headline metric); read the series in `data`, where each point's `y` is the distinct-test-case count for that bucket. Window clamped to plan retention; bucket interval auto from window width." + ] + }, + { + "name": "get_test_executions_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/testExecutions", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the test-execution volume card for a project - how many tests ran over the window, bucketed over time, to correlate suite growth or CI changes with other trends.", + "returns": [ + "data", + "insights" + ], + "responses": { + "200": { + "$response": "PercentTimelineCardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Counts executions (how many tests ran), not distinct cases; for suite size use get_test_growth_trend.", + "`insights.count` here is the raw total execution count as a string (not a percentage), and `insights.metric`/`insights.meta` are null. Per-bucket counts are in `data[].y` (0 for empty buckets); `data[].t` is null on this card.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_failure_categories_trend", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/failureCategories", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Break a project's failures down by failure category over time, so a team can see which classes of failure (defect types) are trending up and their share of the total.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FailureCategoriesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Response is `{data: [ {name, color, data:[{x,y}]} ]}` - one entry per defect/issue category, each with its own time series; `name` and `color` come from the group's defect-type mapping.", + "Within a category series, a point's `y` is null for a bucket in which no tests ran at all (as opposed to 0 failures) - distinguish 'no data' from 'no failures'.", + "Window clamped to plan retention; bucket granularity auto-chosen from the width of the window. A project with no failures in the window returns an empty `data` array, not an error." + ] + }, + { + "name": "get_cross_browser_coverage", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/cbt", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; when omitted the window starts at epoch 0 rather than the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; defaults to now, clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Narrow to a single build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the cross-browser-testing coverage breakdown for a project - the window's executions aggregated by browser, OS and device and grouped into primary/secondary platform buckets with counts and percentages.", + "returns": [ + "data", + "drillDownData" + ], + "responses": { + "200": { + "$response": "CbtResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`data` holds the top-level (primary) platform groups; `drillDownData` holds each group's secondary breakdown, addressed by the primary group's key (`drillDownData[].id` matches `data[].drilldown`).", + "`percentage` fields are formatted STRINGS (two decimals), and `value` is the raw execution count. Primary points carry os/browser/device as null; those are set only on secondary (drill-down) points. Runs without platform info land in an 'Unknown' group with 'NA' secondaries.", + "A project with no runs in the window returns empty groups, not an error. Window clamped to plan retention." + ] + }, + { + "name": "get_build_frequency", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildFrequency", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "required": true, + "description": "Window start, epoch milliseconds. REQUIRED on this card (unlike the others). Missing it is a 400." + }, + { + "name": "upperBound", + "type": "integer", + "required": true, + "description": "Window end, epoch milliseconds. REQUIRED. lowerBound > upperBound, or a negative bound, is a 400." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns the run count for just this one build name; when absent, the count per distinct build name." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get how often builds ran over a period - run counts per build name (or for one build name) - so a team can see CI cadence and gaps.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildFrequencyResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Unlike every other Testing Trends card, BOTH bounds are mandatory here and are validated as a range - omitting one, sending them reversed, or a negative value is a 400.", + "This card does NOT clamp the window to plan retention; the exact bounds you send are used against the relational build store, so counts can differ from the analytics-index cards.", + "Response is `{data: [ {id, buildName, runs} ]}`; `id` is just a positional index into the list, not a build id, and `runs` is the run count. There is no time series here - it is a per-build total for the window.", + "A `sourceFencingFlags` key exists on the model but is not populated on this path, so treat it as always absent." + ] + }, + { + "name": "list_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/buildNames", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's builds contribute names; repeat the param for a set." + } + ], + "intent": "List the distinct build names seen in a project within the plan's retention window - the source of the build-name values every other Testing Trends card filters by.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildNamesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes NO date range - it always scans the full plan-retention window, so build names from older builds simply disappear once they fall out of retention.", + "Response is wrapped as `{data: [ \"\", ... ]}` - a flat array of strings, not build objects.", + "An unknown project is a 404 (PROJECT_NOT_FOUND), unlike the analytics cards which resolve the project inside the aggregation." + ] + }, + { + "name": "list_stability_leaderboard", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the stability metric itself always uses the plan-retention window." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns just this one build's stability row (the 'All' aggregate row is then omitted)." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the stability leaderboard for a project's Testing Trends page - pass-rate / stability scoring per build name over the window, to rank the least reliable suites.", + "returns": [ + "data", + "pagingParam" + ], + "responses": { + "200": { + "$response": "StabilityLeaderboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The requested window is REPLACED by the plan-retention window for the stability computation, so an out-of-retention window silently returns retention-bounded scores; the bounds you send only affect which builds are listed on the page.", + "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false. Note the response key is `pagingParam` (SINGULAR), and `pagingParam.pageNumber` echoes back one-based (your pageNumber + 1).", + "On page 0 with no `buildName`, an extra project-wide row (`id:\"all\"`, buildName:\"All\") is prepended before the per-build rows; on later pages or with a build filter it is absent.", + "Each row's `percentage` is a formatted STRING and `chartData` is the per-bucket pass-rate series (entries may be null for empty buckets). For a full, higher-resolution series for one build use get_project_stability_chart / get_build_stability_chart." + ] + }, + { + "name": "get_project_stability_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the project-wide stability-over-time series for the Testing Trends stability chart - across all builds, or for one build selected by build name.", + "returns": [ + "median", + "data" + ], + "responses": { + "200": { + "$response": "StabilityChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the build-name-selected variant of the stability chart: it derives the series identifier from the `buildName` query param and falls back to the literal \"all\" when it is absent. To address a build by an identifier in the path instead, use get_build_stability_chart.", + "Response is `{median, data}`: `median` is the overall pass-rate percentage for the window (a number), and `data` is the per-bucket series where each point's `y` is a pass-rate percentage NUMBER (e.g. 55.5), null for empty buckets.", + "Window clamped to plan retention; bucket interval auto-chosen from the width of the window.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_build_stability_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/stability/{identifier}/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + }, + { + "name": "identifier", + "type": "string", + "required": true, + "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; defaults to the plan retention floor." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the stability-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a stability leaderboard row is expanded.", + "returns": [ + "median", + "data" + ], + "responses": { + "200": { + "$response": "StabilityChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query instead, use get_project_stability_chart. Both return the identical `{median, data}` shape.", + "Window clamped to plan retention; bucket interval auto-chosen from window width. Point `y` values are pass-rate percentage strings, null for buckets with no runs." + ] + }, + { + "name": "list_performance_leaderboard", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Optional; used only to pick which builds are on the page - the metric always uses the plan-retention window." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 8 builds; there is no page-size param." + }, + { + "name": "buildName", + "type": "string", + "description": "When present, returns just this one build's performance row." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the performance leaderboard for a project's Testing Trends page - per-build execution-duration metrics over the window, to find which suites are slowing down.", + "returns": [ + "data", + "pagingParam" + ], + "responses": { + "200": { + "$response": "PerformanceLeaderboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The requested window is REPLACED by the plan-retention window for the duration metrics; the bounds you send only affect which builds appear on the page. So a window outside retention silently returns retention-bounded numbers.", + "Fixed page of 8 builds; page with the zero-based `pageNumber` and stop when `pagingParam.hasNext` is false (note the response key is `pagingParam`, SINGULAR). Builds with zero matching executions in the window are dropped from the page, so a page can contain fewer than 8 rows even when hasNext is true.", + "Each row: `avgDuration` (number, avg execution duration), `testCount` (avg test-run count), `barChartData` (per-bucket average durations) and `lineChartData` (per-bucket test counts) - note barChartData is durations and lineChartData is counts. Per-row full series also available via get_project_performance_chart / get_build_performance_chart." + ] + }, + { + "name": "get_project_performance_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "buildName", + "type": "string", + "description": "Select one build's series; when omitted the identifier defaults to the literal \"all\" and the series is project-wide across every build." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the project-wide duration-over-time series for the Testing Trends performance chart - across all builds, or for one build selected by build name.", + "returns": [ + "barData", + "lineData" + ], + "responses": { + "200": { + "$response": "PerformanceChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the build-name-selected variant: it derives the series identifier from the `buildName` query param, defaulting to \"all\" when absent. To address a build by an identifier in the path, use get_build_performance_chart.", + "Response is `{barData, lineData}`: `barData` is the per-bucket average duration series and `lineData` is the per-bucket test-count series. `lineData` points carry `pointRange: null` (only `x`/`y` are meaningful), whereas `barData` points carry the range.", + "Window clamped to plan retention; bucket interval auto-chosen from window width.", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "get_build_performance_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/performance/{identifier}/chart", + "mode": "read", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + }, + { + "name": "identifier", + "type": "string", + "required": true, + "description": "The build to chart, addressed by build name, or the literal \"all\" for the project-wide series. A leaderboard row id of the form \"buildName-\" is also accepted - the leading \"buildName-\" prefix is stripped for you." + } + ], + "query": [ + { + "name": "lowerBound", + "type": "integer", + "description": "Window start, epoch milliseconds. Pass it: when omitted the window starts at epoch 0 (not the plan retention floor), giving ~690 monthly buckets that are almost all empty (a 50-90 KB response)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end, epoch milliseconds. Optional; clamped to plan retention." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values; repeat the param for a set." + } + ], + "intent": "Get the duration-over-time series for one specific build, addressed by its identifier in the path - what the UI calls when a performance leaderboard row is expanded.", + "returns": [ + "barData", + "lineData" + ], + "responses": { + "200": { + "$response": "PerformanceChartResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the path-addressed variant: the build is named by the `identifier` path segment (build name, \"all\", or a \"buildName-\" row id). It takes NO `buildName` query param - to select by build name in the query, use get_project_performance_chart. Both return the identical `{barData, lineData}` shape.", + "Bucket interval auto-chosen from window width. `barData` is average duration per bucket, `lineData` is test count per bucket (lineData points carry pointRange:null and t:null).", + "Always pass lowerBound (and upperBound): without it the series starts at epoch 0 (negative-x first bucket), producing ~690 mostly-empty monthly buckets and a very large response." + ] + }, + { + "name": "list_recent_runs", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns", + "mode": "write", + "entity": "testing_trends", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised (URL-safe) name." + } + ], + "query": [ + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. Each page returns a fixed 5 builds; there is no page-size param." + }, + { + "name": "source", + "type": "array", + "description": "Zero or more ProductSource values to restrict which product's runs are listed; repeat the param for a set." + } + ], + "intent": "List a project's most recent builds - one entry per build with its summary (name, number, status, counts) - the run picker / recent-runs list on the Testing Trends page.", + "returns": [ + "data", + "pagingParams", + "currentQGOverrideStatus" + ], + "responses": { + "200": { + "$response": "RecentRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SIDE EFFECT despite being a GET: this marks the project as subscribed for the calling user - a persisted user-preference write. Do not call it merely to peek at recent runs if you must avoid changing the user's subscriptions; there is no read-only variant on this route.", + "Takes NO date range; results are just the latest builds. Fixed page of 5 builds via the zero-based `pageNumber`; stop when `pagingParams.hasNext` is false. Here the response key is `pagingParams` (PLURAL) - unlike the leaderboards' singular `pagingParam`.", + "`data` is an array of full build summaries (not trend series), and `currentQGOverrideStatus` is the project's quality-gate override flag.", + "The subscription side effect is observable: when the caller has no email preference on the project, it creates their EMAIL notifier/preferences (daily summary ON by default), visible via list_project_notification_recipients type=EMAIL. Only fires once per user+project." + ] + }, + { + "name": "list_group_webhook_projects", + "method": "GET", + "path": "/api/v1/settings/webhooks/projects", + "mode": "read", + "entity": "webhook", + "intent": "List the group's TCM/TO projects for the webhook editor's project picker \u2014 a paginated, searchable, sortable list that marks the projects already linked to a given webhook.", + "returns": [ + "projectMapping" + ], + "query": [ + { + "name": "page", + "type": "integer", + "required": true, + "description": "Page number (required). Page size is fixed at 30 and is NOT caller-controllable \u2014 there is no perPage/pageSize parameter here." + }, + { + "name": "sortBy", + "type": "string", + "required": false, + "description": "Sort token for the project list." + }, + { + "name": "webhookId", + "type": "string", + "format": "uuid", + "required": false, + "description": "When supplied, each returned project's `isSelected` flags whether it is already linked to this rule; the rule must exist in the caller's group and product bucket." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Project name substring filter." + } + ], + "responses": { + "200": { + "$response": "WebhookProjectMappingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Subject to the controller-wide feature-entitlement check. When webhookId is passed, the rule must be in the caller's group/bucket or it is not found.", + "Page size is HARDCODED to 30 and the product source is HARDCODED to `TCM,TO` \u2014 neither can be changed by the caller; there is no perPage parameter. Walk pages via info.next.", + "Each project carries `isSelected`, true when it is already linked to the webhookId you passed \u2014 that is how the picker pre-checks linked projects.", + "Response is nested under projectMapping:{info, projects}; info here is the project-picker PaginationInfo (totalProjectsCount/linkedProjectsCount), distinct from the rule-list info block." + ] + }, + { + "name": "retrigger_rca_analysis", + "method": "POST", + "path": "/api/v1/ai/testRuns/{testRunId}/rcaContext/retrigger", + "mode": "write", + "entity": "ai_insight", + "intent": "'Improve analysis' \u2014 re-run the agentic AI RCA for a failed test with a user instruction and up to 3 previously presigned attachments, creating a new conversation turn. Returns the new message id (and trial quota used) so you can poll for the refreshed analysis.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true + } + ], + "body": [ + { + "name": "instruction", + "type": "string", + "required": false, + "description": "Free-text instruction for the improved analysis. Max 5000 characters." + }, + { + "name": "attachments", + "type": "array", + "required": false, + "description": "Up to 3 previously presigned attachments to include as context. Each item references a key returned by the RCA-context presign call. More than 3 is rejected.", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "description": "The key returned by the RCA-context presign call. Non-blank, max 512 chars." + }, + { + "name": "fileName", + "type": "string", + "required": true, + "description": "Non-blank, max 200 chars." + }, + { + "name": "contentType", + "type": "string", + "required": true, + "description": "Non-blank, max 100 chars." + }, + { + "name": "description", + "type": "string", + "required": false, + "description": "Optional note about the attachment. Max 500 chars." + } + ] + }, + { + "name": "usePreferences", + "type": "boolean", + "required": false, + "description": "Whether saved code-fix / analysis preferences should be applied to this run." + }, + { + "name": "webhook_url", + "type": "string", + "required": false, + "description": "Optional Atlas agent-callback URL. When present it rides the retrigger's metadata and the finished RCA is delivered to it. Max 2048 chars. Wire name is webhook_url.", + "json_path": "/webhook_url" + } + ], + "returns": [ + "messageId" + ], + "responses": { + "200": { + "$response": "RcaRetriggerResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: the response carries only messageId (and trialQuota.used for trial users) \u2014 the analysis itself arrives later when the AI service calls back. Poll the RCA conversation / that message id for the refreshed result.", + "trialQuota is present only for test-level trial users (their usage counter is incremented on success); it is omitted otherwise, so do not treat its absence as an error.", + "Attachments must first be presigned and uploaded via the RCA-context presign call \u2014 pass those returned s3Keys here; at most 3.", + "Only agentic RCAs on RCA-v2-eligible builds are accepted, the test run must be within the allowed age, and a run owned by the MCP-driven agent is refused.", + "There is no undo for a retrigger \u2014 it appends a new conversation turn; approve or discard the resulting turn separately." + ] + }, + { + "name": "get_build_accessibility_meta", + "method": "GET", + "path": "/api/v1/builds/{buildId}/a11y-meta", + "mode": "read", + "entity": "build", + "intent": "Get the build-level accessibility summary header \u2014 whether auto-scan was enabled, the severity breakdown (critical/serious/moderate/minor) and the total issue count \u2014 for the strip above a build's per-test accessibility listing.", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Build UUID. Can be silently overridden by the `run` query param \u2014 see guidance." + } + ], + "query": [ + { + "name": "run", + "type": "array", + "description": "Optional test-run ids (bound from `A11yFilterParams.run`). If supplied, the FIRST entry is treated as a test-run id and the build UUID is resolved from it, OVERRIDING the path `buildId`. Send this empty/absent to honour the path build.", + "fields": [ + { + "name": "run[]", + "type": "integer" + } + ] + }, + { + "name": "status", + "type": "string", + "description": "Accessibility status filter (bound from A11yFilterParams). Not used to compute the meta totals on this route." + }, + { + "name": "searchQuery", + "type": "string", + "description": "Search token (bound from A11yFilterParams). Not used to compute the meta totals on this route." + } + ], + "returns": [ + "totalIssue", + "severityBreakdown", + "autoEnabledBuild", + "autoEnabled", + "reportUrl" + ], + "guidance": [ + "This is the web/Automate accessibility shape. For App-Automate builds that have App Accessibility enabled for the group, the request is proxied to the App Accessibility service and returns a DIFFERENT body \u2014 do not assume the fields below when the build is an app build.", + "The `run` query param silently overrides the path `buildId`: if you pass a run id, the meta is for the build that run belongs to, not the build in the path.", + "The build must have finished (status FINISHED or TIMEOUT) and carry accessibility data; otherwise you get a classified accessibility error (build-not-completed / build-not-found / results-not-found), not an empty 200.", + "`autoEnabledBuild` and `autoEnabled` are the same value duplicated, read from the build's accessibility settings.auto_enable (the auto-scan config), so they can be false on a build whose SDK run did scan. `severityBreakdown` carries each severity the build summary reports (zero counts included), and `totalIssue` is their sum.", + "`reportUrl` is a weak deep link into the Accessibility dashboard: its path uses the literal placeholder segments Project/builds/Build/Number and only the thBuildId query is real. It is not an API endpoint.", + "The central-auth JWT (cookie or Bearer) is extracted and forwarded to the App-Accessibility proxy on the app path; a missing token there surfaces as an accessibility-service auth error." + ], + "responses": { + "200": { + "$response": "BuildAccessibilityMetaResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_similar_test_runs", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/analyzer-similar", + "mode": "read", + "entity": "build", + "intent": "For the auto-analyser / unique-errors view: given a failed test run (or a set of error-cluster ids) in a build, list the other test runs with a similar failure so you can judge blast radius and treat the cluster as one issue.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "mode", + "type": "string", + "required": true, + "description": "Scope of the similarity search. Required \u2014 a missing/invalid value is a 400.", + "values": [ + "CURRENT_BUILD", + "LAST_10_BUILDS", + "LAST_10_DAYS" + ] + }, + { + "name": "testRunId", + "type": "integer", + "description": "Numeric test-run id to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present." + }, + { + "name": "clusterIds", + "type": "array", + "description": "Error-cluster ids to find similars for. Optional, but at least one of `testRunId` or `clusterIds` must be present.", + "fields": [ + { + "name": "clusterIds[]", + "type": "string" + } + ] + } + ], + "returns": [ + "similar", + "current", + "message" + ], + "guidance": [ + "Send EITHER `testRunId` OR `clusterIds` (or both) \u2014 if neither is provided the call is a 400 (\"Either clusterIds or TestRunId is required\").", + "403 (plan-feature-not-allowed) when the build is locked by plan fencing; this is a plan-fenced analyser feature.", + "`current` describes the reference run and `similar` the matched runs; each carries the run uuid plus project/build normalised names and build number so you can deep-link. `message` may explain why the list is empty.", + "Available on public-link-token auth (public dashboards), unlike most write routes." + ], + "responses": { + "200": { + "$response": "SimilarTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_always_failing_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/alwaysFailingStats", + "mode": "read", + "entity": "build", + "intent": "Get the 'always failing' tile for a build \u2014 how many of the build's tests have been failing consistently (out of the total), plus the tile's comment count \u2014 for the build insights page.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text filter over the build's tests (part of the standard build filter set). The whole FilterParamsV2 filter set is accepted here; it narrows which tests the metric counts." + }, + { + "name": "status", + "type": "array", + "description": "Test status filter (standard build filter set).", + "fields": [ + { + "name": "status[]", + "type": "string" + } + ] + }, + { + "name": "folders", + "type": "array", + "description": "Folder-path filter (standard build filter set).", + "fields": [ + { + "name": "folders[]", + "type": "string" + } + ] + }, + { + "name": "testTags", + "type": "array", + "description": "Tag filter (standard build filter set)." + }, + { + "name": "customParams", + "type": "object", + "description": "Any extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded. Unknown keys are not rejected." + } + ], + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "guidance": [ + "`count` is the always-failing test count and `meta` typically carries the total it is out of; `commentCount` is the number of comments on this tile.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "One of a family of near-identical build tiles (flaky, new-failure, muted, defect, stability) that share this shape and differ only in the metric computed \u2014 pick the endpoint for the metric you want rather than re-filtering this one.", + "This is the standard build filter set: the same query params work across the tiles." + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_ai_failure_summary", + "method": "GET", + "path": "/api/v1/builds/{id}/aiSummary", + "mode": "read", + "entity": "build", + "intent": "Get the AI-generated narrative of why a build failed \u2014 a summary paragraph plus the top failure themes (name, description, affected test count, failure %) and the top affected workflows.", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The NUMERIC internal build id \u2014 NOT the UUID used by most other build routes. A UUID here will not resolve." + } + ], + "returns": [ + "summary", + "topFailureThemes", + "topAffectedWorkflows", + "status" + ], + "guidance": [ + "Takes the numeric build id, so it will NOT accept the UUID the sibling build endpoints use.", + "Fails soft: any upstream error or missing theme data returns 200 with `status: \"NOT_AVAILABLE\"`, `summary: null` and empty theme/workflow lists \u2014 check `status`, do not treat it as an error.", + "`topFailureThemes` and `topAffectedWorkflows` are each capped at 5 items.", + "Ownership-checked: if the build's group does not match the caller it reads as build-not-found." + ], + "responses": { + "200": { + "$response": "BuildAISummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_attempt_run_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildAttemptRunStats", + "mode": "read", + "entity": "build", + "intent": "Break a build's test results down per re-run attempt \u2014 pass/fail/skipped/unknown and workflow-status counts for attempt 1, 2, 3\u2026 (including manual runs) \u2014 so you can see how results changed across retries.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "attempts", + "summary", + "count", + "commentCount" + ], + "guidance": [ + "`attempts` is one entry per attempt (label, attemptNumber, totalAttempts and status counts); `summary` is the aggregate across attempts.", + "The project's hooks visibility is resolved first and changes which test-run documents are counted \u2014 the same build can report different totals depending on hooks visibility.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID." + ], + "responses": { + "200": { + "$response": "BuildAttemptRunStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_history", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildHistory", + "mode": "read", + "entity": "build", + "intent": "Get the trend of the recent builds of the same build name leading up to this one (per-build pass/fail counts, keys, lock state) so the build page can show how this build compares with previous runs.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text filter (standard build filter set). The whole FilterParamsV2 filter set is accepted and narrows what the trend counts." + }, + { + "name": "status", + "type": "array", + "description": "Test status filter (standard build filter set).", + "fields": [ + { + "name": "status[]", + "type": "string" + } + ] + }, + { + "name": "customParams", + "type": "object", + "description": "Extra query params not bound to a named FilterParamsV2 field are collected into a customParams map and forwarded; unknown keys are not rejected." + } + ], + "returns": [ + "data", + "keys", + "commentCount" + ], + "guidance": [ + "The window is hard-coded to the ~10 most recent builds of the same build name \u2014 there is no page size or count parameter to widen it.", + "`data` is a per-status series and `keys` the build labels/order along the x-axis; builds locked by plan fencing are flagged within the mapping.", + "403 (plan-feature-not-allowed) when the CURRENT build is plan-locked; build-not-found for an unknown UUID." + ], + "responses": { + "200": { + "$response": "BuildHistoryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_id_from_build_coordinates", + "method": "GET", + "path": "/api/v1/builds/getBuildIdFromBuildInfo", + "mode": "read", + "entity": "build", + "intent": "Resolve a human-readable build coordinate (project name + build name + build serial number, i.e. what appears in a build URL) into the internal build id/UUID, so deep links and CI references can be turned into an API-usable handle.", + "query": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name as it appears in the dashboard URL." + }, + { + "name": "buildNormalisedName", + "type": "string", + "required": true, + "description": "Build normalised name as it appears in the dashboard URL." + }, + { + "name": "buildSerialId", + "type": "integer", + "required": true, + "description": "Build serial number (the #N shown in the UI)." + } + ], + "returns": [ + "buildId" + ], + "guidance": [ + "All three query params are required.", + "When no build matches you get a bare HTTP 404 with an EMPTY body (not a structured error object) \u2014 handle the status code, there is nothing to parse.", + "The lookup is scoped to the caller's group, so a build in another group reads as not-found.", + "Returns just `{buildId}` (the resolved UUID); use `get_build_coordinates_from_uuid` for the inverse.", + "`buildSerialId` is the UI #N only. If the user's number does not match (404) it may be the CI run id (`buildRunId`): resolve that with search_entities (entity Build) or list_project_builds_by_name, which return both ids." + ], + "responses": { + "200": { + "$response": "BuildIdFromCoordinatesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "No build matched the coordinates. Empty response body.", + "schema": { + "type": "object" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_coordinates_from_uuid", + "method": "GET", + "path": "/api/v1/builds/getBuildInfoFromUuid/{uuid}", + "mode": "read", + "entity": "build", + "intent": "The inverse of the coordinate lookup: given a build UUID, get the URL-safe coordinates for it (project & build normalised names, serial id, project id/name, finished/created timestamps) so a caller holding only a UUID can construct the build's dashboard URL.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "buildNormalisedName", + "buildSerialId", + "projectNormalisedName", + "userId", + "groupId", + "projectName", + "projectFinishedAt", + "projectCreatedAt", + "toProjectId", + "thProjectId" + ], + "guidance": [ + "The normalised names are URI-encoded with spaces turned into '+', so use them verbatim when building a URL \u2014 do not re-encode.", + "If the build's group does not match the caller the call is refused with a forbidden-operation error.", + "An unknown UUID returns a build-not-found error (note: an older description claimed an empty JSON object here; the current handler throws not-found \u2014 do not rely on an empty-object sentinel).", + "`toProjectId` is the Observability project id and `thProjectId` the TestHub project id \u2014 they are different id spaces." + ], + "responses": { + "200": { + "$response": "BuildCoordinatesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_rerun_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildRerunStats", + "mode": "read", + "entity": "build", + "intent": "Get the flat re-run summary for a build \u2014 a list of re-run legs with per-leg pass/fail/skipped/unknown counts and totals \u2014 for the re-run section of the build page.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "retryData", + "count", + "commentCount" + ], + "guidance": [ + "This is the flat/legacy shape, computed straight from the DB by build id + group id; `retryData` is a flat list of legs with no split-build grouping and there is no aggregate `summary`.", + "For split or parent/child builds, or when you need the aggregate summary, use `get_split_build_rerun_stats` instead \u2014 its response shape is different and NOT a drop-in swap.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "Hooks visibility for the project is resolved first and affects which runs are counted." + ], + "responses": { + "200": { + "$response": "BuildReRunResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_split_build_rerun_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildRerunStats/v2", + "mode": "read", + "entity": "build", + "intent": "Get the split-build-aware re-run summary for a build \u2014 re-run legs grouped (with per-split breakdowns) plus an overall aggregate summary \u2014 resolved against the whole build and the Observability project id so split/parent-child builds are handled correctly.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "returns": [ + "retryData", + "count", + "commentCount", + "summary" + ], + "guidance": [ + "Use this for split or parent/child builds and project-scoped configuration \u2014 it resolves against the whole build object and the Observability project id.", + "The response shape differs from `get_build_rerun_stats`: `retryData` is a list of GROUPS (each with `splits` and its own `summary` and build metadata) and there is a top-level aggregate `summary`. It is NOT a drop-in swap for the flat version.", + "403 (plan-feature-not-allowed) when the build is plan-locked; build-not-found for an unknown UUID.", + "Hooks visibility for the project is resolved first and affects which runs are counted." + ], + "responses": { + "200": { + "$response": "BuildReRunV2Response" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_test_filters", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/filters", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "The build's UUID. If it is a leg of a split build, the parent is resolved automatically and the filter set is aggregated over the parent plus every child leg." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "description": "Free-text search echoed back into the resolved `applied` block; part of the currently-applied test filter, not a facet type-ahead." + }, + { + "name": "status", + "type": "array", + "description": "Currently-applied test status values, echoed back resolved. All the FilterParamsV2 test-listing fields are accepted here (status, priority, caseType, automationStatus, testTags, folders, owner, assignee, deviceList, osList, browserList, failureCategories, dateRange, isFlaky, isNewFailure, ...) and are returned normalised under `applied`." + }, + { + "name": "priority", + "type": "array", + "description": "Applied priority ids. When any of priority / caseType / automationStatus is present the response rewrites their labels in `applied` to match the static catalogue, so read labels from the response rather than assuming them." + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Test-management user ids; max 500 entries (enforced server-side)." + } + ], + "intent": "Get the filter panel for a build's TEST listing \u2014 the currently applied test filters resolved with labels, the static catalogue of every selectable test filter, hooks visibility, and which filters are pro-only for this workspace.", + "returns": [ + "applied", + "staticFilters", + "hooksVisibility", + "proFilters" + ], + "guidance": [ + "This is the filter set for the TESTS inside one build, not for the build listing \u2014 use get_build_list_filters / get_build_sidebar_filters for the builds-list facets.", + "`applied` is your own query echoed back in resolved form (ids turned into user/label objects); `staticFilters` is the full catalogue of selectable values keyed by filter name. Read available option values from `staticFilters`, not from `applied`.", + "`proFilters` lists the filter keys locked behind a paid plan; it is NON-EMPTY only for a freemium workspace (then it names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted) and empty otherwise. Treat a key that appears here as disabled, not missing.", + "`hooksVisibility` is one of all / failed / beforeFailed / none and tells you how before/after hook tests are surfaced for this project.", + "For a split build you may pass any leg's UUID; the parent is resolved and the static filters are unioned across all legs, so the result is the same whichever leg you name." + ], + "responses": { + "200": { + "$response": "BuildTestFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_list_filters", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/filters", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised-name slug. Resolved within the caller's group first, so a project in another group (or an unknown slug) returns 404." + } + ], + "query": [ + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Applied build-name selection, echoed back resolved. Any of the GetBuildFilterRqV2 build-list fields may be supplied as query params." + }, + { + "name": "users", + "type": "array", + "description": "Applied build-owner user ids (integers), echoed back as resolved user objects (id + name)." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team ids (integers), echoed back as resolved team objects." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values \u2014 branded SDK names and pass-through values like 'appium'/'selenium-4'. Sent as strings (not an enum), matched case-insensitively; use the exact value the static catalogue offers." + }, + { + "name": "statuses", + "type": "array", + "description": "Applied build display-status values." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag values (build-level tags)." + }, + { + "name": "branches", + "type": "array", + "description": "Applied git branch values." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end] \u2014 a size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names (distinct from buildTags); max 500 entries, and gated by a per-group flag \u2014 ignored downstream when the flag is off.", + "maxItems": 500 + }, + { + "name": "state", + "type": "string", + "description": "Build state scope; defaults to `active` when omitted.", + "default": "active" + } + ], + "intent": "Populate the build-listing filter bar for a project in one call \u2014 the static catalogue of selectable build filters (statuses, frameworks, build types, product sources, creation types) plus the caller's currently applied filter echoed back resolved.", + "returns": [ + "applied", + "staticFilters" + ], + "guidance": [ + "`staticFilters` carries the option lists: statusList, frameworkList, types, sources, creationType \u2014 each a list of {id, value, label} option objects. `applied` is your query resolved (user/team ids to names, date range, tags, branches).", + "This is the PER-PROJECT filter bar (scoped by the path project) \u2014 it returns the catalogue for that project. For a cross-project, filter-narrowed facet list use the sidebar capabilities instead.", + "On a demo-sandbox tenant this short-circuits before any project lookup and returns a hardcoded framework/static list, so the option set there is the global master list rather than what the project actually contains.", + "An unknown or foreign project slug is a 404 \u2014 the project is resolved inside the caller's group." + ], + "responses": { + "200": { + "$response": "BuildListFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_branch_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/branches", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the branch name. Optional \u2014 omit to get the leading matches." + } + ], + "intent": "Type-ahead options for the 'Branch' filter on a project's build listing \u2014 git branch names seen in the project's builds, as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Per-project, prefix-search only \u2014 it does not narrow by any other currently-applied filter. For branches present under a full applied filter across a project set, use list_sidebar_branch_filter_options.", + "Each option's `id`/`value` IS the branch string; send that value straight back as the branch filter selection." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_tag_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/tags", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the tag value. Optional." + } + ], + "intent": "Type-ahead options for the 'Build tag' filter on a project's build listing \u2014 matching build-tag values as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Per-project, prefix-search only \u2014 no other applied filter narrows the result. This handler always does a real project lookup (there is no demo-sandbox short-circuit here).", + "These are build-level tags. For test-execution tags, or for a tag list narrowed by an applied cross-project filter, use the sidebar tag facet." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_team_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/teams", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the team name. Optional." + } + ], + "intent": "Type-ahead options for the 'Team' filter on a project's build listing \u2014 BrowserStack teams that have builds in the project, as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Each option's `id` is the numeric team id (as a string) and `value`/`label` are the team name \u2014 send the id back as the team filter selection, not the name.", + "Per-project only; it is not narrowed by any other applied filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_user_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/builds/v2/users", + "mode": "read", + "entity": "build_filter", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised-name slug, resolved within the caller's group; unknown/foreign slug returns 404." + } + ], + "query": [ + { + "name": "q", + "type": "string", + "description": "Case-insensitive search prefix for the user name. Optional." + } + ], + "intent": "Type-ahead options for the 'User' (build owner) filter on a project's build listing \u2014 matching users as {id, value, label} option objects.", + "returns": [ + "data" + ], + "guidance": [ + "Each option's `id` is the numeric user id (as a string) and `value`/`label` are the user's name \u2014 send the id back as the user/owner filter selection.", + "Per-project only; it is not narrowed by any other applied filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_sidebar_filters", + "method": "POST", + "path": "/api/v2/builds/filters", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids the filter spans. When omitted the server falls back to a default project set for the group, so send it explicitly to scope the view." + }, + { + "name": "query", + "type": "string", + "description": "Type-ahead text for the sidebar sub-searches (users/tags/runGroups). NOT applied or echoed by this call - use `search` for the free-text build search." + }, + { + "name": "users", + "type": "array", + "description": "Applied build-owner user ids (integers), returned resolved to {id, name}." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team ids (integers), returned resolved to team objects." + }, + { + "name": "statuses", + "type": "array", + "description": "Applied build display-status values." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag values." + }, + { + "name": "branches", + "type": "array", + "description": "Applied git branch values." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values (branded SDK names and pass-through values like 'appium'/'selenium-4'); strings, matched case-insensitively." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end]; a size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + }, + { + "name": "assignees", + "type": "array", + "description": "Assignee tokens, each matching ^(\\$none|[0-9]+)$ \u2014 the literal '$none' selects unassigned, otherwise a numeric user id. A value that does not match the pattern is a 400.", + "pattern": "^(\\$none|[0-9]+)$" + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names; max 500 entries, gated by a per-group flag (ignored when off).", + "maxItems": 500 + }, + { + "name": "state", + "type": "string", + "description": "Build state scope; defaults to `active`.", + "default": "active" + }, + { + "name": "search", + "type": "string", + "description": "Free-text build search; echoed back as applied.search." + } + ], + "intent": "Hydrate the build-listing sidebar filter panel across a set of projects \u2014 the caller's applied selection resolved into display objects (user/team ids to names, date range, tags, branches, statuses, build names) alongside the static option catalogue.", + "returns": [ + "applied", + "staticFilters" + ], + "guidance": [ + "POST but purely read \u2014 the body is only a filter payload and nothing is written.", + "`applied` (a SidebarFilter: the resolved build filter plus the `projects` it was scoped to) echoes your selection resolved; `staticFilters` is the option catalogue (statusList, frameworkList, types, sources, creationType).", + "This is the GROUP-WIDE variant: the static framework list is the hardcoded master list, NOT the frameworks actually present in the selected projects. If you need the frameworks that really occur, treat frameworkList here as the full menu, not a presence list.", + "Scope with `projects`; if you leave it out you get the group's default project set rather than an error.", + "`applied` echoes users (resolved), teams, dateRange, statuses, buildTags (as `tags`), frameworks, branches, search and the boolean/enum toggles; `assignees` and `query` are NOT echoed, so do not read their absence as rejection." + ], + "responses": { + "200": { + "$response": "BuildSidebarFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_branch_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/branches", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive search text for the branch name. Optional." + }, + { + "name": "statuses", + "type": "array", + "description": "Part of the currently-applied build filter that narrows which builds are considered; the full GetBuildFilterRqV2 build-list field set is accepted (users, teams, buildTags, frameworks, dateRange, state, ...)." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs [start, end]; size other than 2 is rejected.", + "minItems": 2, + "maxItems": 2 + } + ], + "intent": "Type-ahead options for the 'Branch' facet of the build-list sidebar \u2014 git branch names present in the builds matching the rest of the currently applied filter, optionally narrowed by a search string.", + "returns": [ + "data" + ], + "guidance": [ + "POST but read-only \u2014 the body is a filter, no state change.", + "Unlike the per-project branch list, this narrows to branches that actually occur under the whole applied filter across `projects`, and returns BARE STRINGS in `data` (not {id,value,label} objects).", + "Values come from an Elasticsearch terms aggregation capped at 100, so the list is a suggestion set, not a complete enumeration \u2014 narrow with `query` rather than expecting every branch back." + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_build_name_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/buildNames", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids to search across. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive search text for the build name. Optional." + }, + { + "name": "statuses", + "type": "array", + "description": "Part of the applied build filter that narrows which builds are considered; the full build-list field set (users, teams, buildTags, branches, frameworks, dateRange, state, ...) is accepted." + } + ], + "intent": "Type-ahead options for the 'Build name' facet of the build-list sidebar \u2014 the distinct build names present under the current filter/projects, optionally matched against a search string.", + "returns": [ + "data" + ], + "guidance": [ + "POST but read-only \u2014 body is a filter payload only.", + "Returns BARE STRINGS in `data`. Distinct from the shipped list_build_names capability, which is a different (paged) listing \u2014 this one is the sidebar facet's aggregation.", + "ES terms aggregation capped at 100 distinct names, so results are bounded, not exhaustive \u2014 use `query` to find a specific name." + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_run_group_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/runGroups", + "mode": "read", + "entity": "build_filter", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Integer project ids whose run groups to list. Omitted \u2192 the group's default project set." + }, + { + "name": "query", + "type": "string", + "description": "Case-insensitive name search for the run group. Optional." + } + ], + "intent": "Type-ahead options for the 'Run group' (build group) facet of the build-list sidebar \u2014 the first 100 run groups of the selected projects, name-searchable.", + "returns": [ + "data", + "info" + ], + "guidance": [ + "POST but read-only. ONLY `projects` and `query` are read from the body \u2014 every other build-filter field is ignored here, so do not expect statuses/tags/dateRange to narrow the result.", + "Pagination is hardcoded to page 1, page size 100 and CANNOT be driven from the request: you always get at most the first 100 run groups. `info` reports paging but there is no way to fetch page 2 through this endpoint \u2014 use the per-project build-groups listing if you need to page.", + "`data` items are SLIM build-group rows {id, name, description, isClosed, state, updated_at} (note snake_case updated_at), not bare strings. For projectId/createdBy/closedAt use the per-project build-groups listing.", + "This facet is not available on a public dashboard link (no public-link-token auth); it needs an authenticated session or service key." + ], + "responses": { + "200": { + "$response": "RunGroupFilterOptionsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_widget_custom_field_filters", + "method": "GET", + "path": "/api/v1/dashboards/filters/{widgetType}", + "mode": "read", + "entity": "dashboard", + "intent": "List the Test Management custom fields usable as extra filters on a test-case widget, so the widget-config panel can offer them.", + "path_params": [ + { + "name": "widgetType", + "type": "string", + "required": true, + "values": [ + "TEST_CASE_TREND", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "AUTOMATION_COVERAGE" + ], + "description": "Widget type. ONLY these four are valid; any other widget type returns a 400 (BAD_REQUEST_ERROR)." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number. Presence of ANY of page/per_page/search switches the response to the paginated shape (see guidance)." + }, + { + "name": "per_page", + "type": "integer", + "required": false, + "description": "Page size. Passed through to Test Management's custom-fields API unchanged \u2014 no ceiling is enforced by this service." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Substring filter over custom-field names." + } + ], + "returns": [ + "data", + "info" + ], + "responses": { + "200": { + "$response": "WidgetCustomFieldFiltersResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "TWO response shapes from one route: omit all of page/per_page/search and you get a bare JSON ARRAY of {id, label, type, fieldType}; pass any one of them and you get the paginated object {data:[...], info:{...}} instead.", + "Only the four listed widget types are accepted; anything else is a 400, not an empty list.", + "This is a pass-through to Test Management's custom-fields API; `per_page` is forwarded without clamping, so a page ceiling cannot be assumed." + ] + }, + { + "name": "resolve_dashboard_filter_names", + "method": "POST", + "path": "/api/v1/dashboards/meta", + "mode": "read", + "entity": "dashboard", + "intent": "Batch-resolve project ids and user ids to their display names, so a saved dashboard's stored filter ids can be rendered as readable chips.", + "body": [ + { + "name": "projectIds", + "type": "array", + "required": true, + "description": "TRA project ids (Long) to resolve to names. Required key, but may be an empty array. Converted to Test Management project ids before lookup." + }, + { + "name": "userIds", + "type": "array", + "required": true, + "description": "User ids (Long) to resolve to names. Required key, but may be an empty array." + } + ], + "returns": [ + "projects", + "users" + ], + "responses": { + "200": { + "$response": "DashboardMetaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only \u2014 it changes nothing, it only looks names up.", + "Both `projectIds` and `userIds` are required keys (may be empty arrays); omitting either fails validation.", + "Returns {projects, users}, each a list of {id, name}. Project names come from the project repository (ids are mapped to Test Management project ids first); user names come from the user-details service." + ] + }, + { + "name": "search_unique_errors", + "method": "GET", + "path": "/api/v1/dashboards/errors", + "mode": "read", + "entity": "dashboard", + "intent": "Autocomplete over unique error signatures \u2014 given a partial error string, return the top matching unique errors so a user can pick one while configuring an error-focused dashboard widget.", + "query": [ + { + "name": "q", + "type": "string", + "required": false, + "description": "Partial error string to search for. Matched against the errors search index." + } + ], + "returns": [ + "errorClusterId", + "error" + ], + "responses": { + "200": { + "$response": "UniqueErrorSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The body is a top-level JSON ARRAY of {errorClusterId, error} \u2014 there is no wrapper object.", + "The result count is hardcoded to 10 and cannot be paged or raised; refine `q` to narrow rather than expecting more rows.", + "Backed by an Elasticsearch aggregation over the errors index; `errorClusterId` is the stable id of the unique-error cluster." + ] + }, + { + "name": "list_reports", + "method": "GET", + "path": "/api/v1/reports/list", + "mode": "read", + "entity": "dashboard", + "intent": "Power the Reports listing page: a paginated, searchable, sortable list of saved dashboards/reports across Test Observability and Test Management, each with owner, linked projects, type and schedule frequency, plus 'my reports' and total counts.", + "query": [ + { + "name": "projectIds", + "type": "array", + "required": false, + "description": "TestHub project ids (a project's thProjectId, e.g. from list_projects), NOT the TRA project id - a TRA id matches nothing and returns an empty 200. UDAC project filtering is applied on top; if UDAC blocks every project the page comes back empty." + }, + { + "name": "source", + "type": "string", + "required": false, + "values": [ + "TO", + "TCM" + ], + "description": "Product source. An INVALID value does not 4xx \u2014 it returns HTTP 200 with an `error` string and empty data." + }, + { + "name": "type", + "type": "string", + "required": false, + "description": "Report/dashboard type filter." + }, + { + "name": "userId", + "type": "integer", + "required": false, + "description": "Filter to a specific owner's reports." + }, + { + "name": "frequency", + "type": "string", + "required": false, + "description": "Schedule frequency filter." + }, + { + "name": "p", + "type": "integer", + "required": false, + "default": 1, + "description": "Page number (1-based)." + }, + { + "name": "size", + "type": "integer", + "required": false, + "default": 30, + "maximum": 100, + "description": "Page size. Clamped server-side to the range 1-100." + }, + { + "name": "sortBy", + "type": "string", + "required": false, + "default": "modifiedAt", + "description": "Sort column. Defaults to modifiedAt." + }, + { + "name": "sortOrder", + "type": "string", + "required": false, + "default": "desc", + "description": "Sort direction. Defaults to desc." + }, + { + "name": "q[query]", + "type": "string", + "required": false, + "description": "Free-text search. The query-parameter name is literally `q[query]`." + } + ], + "returns": [ + "data", + "my_report_count", + "total_report_count", + "info", + "error" + ], + "responses": { + "200": { + "$response": "ReportListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An invalid `source` returns HTTP 200 with a top-level `error` string and empty `data` \u2014 check `error`, not the status code.", + "TRA report types (Build Comparison, Tabular Test Runs) are hidden unless the group has the Custom Dashboard entitlement AND the TM-TO-reports rollout flag is on; other TM reports stay visible.", + "`size` is clamped to a maximum of 100 (and a minimum of 1); asking for more returns at most 100 rows.", + "The search parameter is spelled `q[query]`, not `q` or `query`.", + "`my_report_count` / `total_report_count` are separate top-level counts; pagination cursors are under `info` (page/next/prev/count/page_size)." + ] + }, + { + "name": "list_dashboards", + "method": "GET", + "path": "/ext/v1/dashboards", + "mode": "read", + "entity": "dashboard", + "intent": "Public-API listing of the custom dashboards visible to the caller's group (used by the Atlas AI dashboard builder and API clients) to discover dashboard ids/names/widgets before reading or cloning one.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source to list. Defaults to TO." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardsListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Same underlying service as the internal listing: scheduled-report dashboards are excluded and there is no pagination. Rows are summaries - widgets, filters and reports are null (totalWidget counts them); read one dashboard by id for its definition." + ] + }, + { + "name": "get_dashboard", + "method": "GET", + "path": "/ext/v1/dashboards/{id}", + "mode": "read", + "entity": "dashboard", + "intent": "Fetch one dashboard's full definition (name, type, level and the widget list with each widget's config) via the public API, so a client can render or edit it.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Dashboard UUID (from list_dashboards_via_public_api)." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source. Defaults to TO." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Resolves the id as a dashboard (not a scheduled report)." + ] + }, + { + "name": "create_dashboard", + "method": "POST", + "path": "/ext/v1/dashboards", + "mode": "write", + "entity": "dashboard", + "intent": "Create a new custom dashboard with its widget layout via the public API, so a client (or the AI dashboard builder) can provision a dashboard programmatically instead of building it in the UI.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Product source for the new dashboard. Defaults to TO." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 100, + "description": "Dashboard name, 1-100 chars, unique in the group." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "values": [ + "Dashboard", + "View", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON" + ], + "description": "Dashboard type; use `Dashboard` for an ordinary dashboard." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Optional description, max 100 chars." + }, + { + "name": "filters", + "type": "object", + "description": "Global filters (same shape as the internal create; note buildName / branches / tcmTestPlanIds wire names)." + }, + { + "name": "widgets", + "type": "array", + "description": "Widget definitions at the body root. Each widget is {id, name, type, config, layout, globalFilters}. See guidance for the treemap normalization this surface applies.", + "fields": [ + { + "name": "id", + "type": "string", + "description": "Client widget id." + }, + { + "name": "name", + "type": "string", + "description": "Widget title." + }, + { + "name": "type", + "type": "string", + "description": "Widget type enum." + }, + { + "name": "config", + "type": "object", + "description": "Widget config; validated for render-safety before persist." + }, + { + "name": "layout", + "type": "object", + "description": "Grid layout." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "This surface silently DROPS a `visualization` override on a treemap widget (FAILURE_RATE_BY_FOLDERS) rather than rejecting it \u2014 don't rely on that override being honoured.", + "It also runs render-safety validation up front, so a widget config that would 5xx at render is rejected here. The internal create does neither of these." + ] + }, + { + "name": "update_dashboard", + "method": "PUT", + "path": "/ext/v1/dashboards/{id}", + "mode": "write", + "entity": "dashboard", + "intent": "Update an existing dashboard's name/type/level and replace its widget set via the public API.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "Dashboard UUID to update." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "New name, 1-100 chars. May be omitted \u2014 it is backfilled from the stored dashboard (see guidance)." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2. Backfilled from the stored dashboard if omitted." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "values": [ + "Dashboard", + "View", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON" + ], + "description": "Dashboard type. Backfilled from the stored dashboard if omitted." + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget set at the body root. Same treemap-visualization stripping and render-safety validation as create." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters (same shape as create)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "Unlike the internal PUT this TOLERATES a partial body: omitted name/type/level are backfilled from the stored dashboard (an extra read) so a null is not mistaken for a change. The widget set, however, still fully replaces the stored one \u2014 there is no undo.", + "Same normalization as create: a treemap `visualization` override is silently dropped and widget configs are validated up front.", + "In demo-sandbox environments updating the default dashboard is rejected with 400." + ] + }, + { + "name": "clone_dashboard", + "method": "POST", + "path": "/ext/v1/dashboards/{id}/clone", + "mode": "write", + "entity": "dashboard", + "intent": "Copy an existing dashboard (its persisted widgets) into a new dashboard via the public API \u2014 typically to fork a template dashboard under a new name/level.", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "SOURCE dashboard UUID to copy from." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "minLength": 1, + "maxLength": 100, + "description": "Name for the copy, 1-100 chars, unique in the group." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "description": "Visibility level 1 or 2 for the copy." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "description": "Dashboard type for the copy." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public API surface \u2014 authenticate with a BrowserStack username + access key over HTTP Basic.", + "The widgets in the request body are IGNORED \u2014 the clone copies the SOURCE dashboard's stored widgets; only name/type/level are taken from the body.", + "Render-safety validation runs against the resolved source dashboard (inside the service), not the request body, so a broken source can fail the clone." + ] + }, + { + "name": "get_project_automation_coverage", + "method": "GET", + "path": "/ext/v1/projects/automation-coverage", + "mode": "read", + "entity": "project", + "intent": "Get one project's automation-coverage figures - total test cases, automated count and coverage percent over all time - to chart automation progress from outside the UI.", + "query": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Exact project display NAME (not the normalised slug and not the id), matched within the caller's group. Blank or missing is a 400; a name that does not resolve in the group is a 404." + } + ], + "returns": [ + "project_id", + "project_name", + "total_test_case_count", + "automated_test_case_count", + "automation_coverage_percent", + "calculated_at" + ], + "responses": { + "200": { + "$response": "ProjectAutomationCoverageResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public REST API route - callable with a username + access key as well as service credentials; scoping is by the authenticated group only, with no project-level permission check on this route.", + "Lookup is by EXACT project name, case- and space-sensitive, within your group. The normalised slug used elsewhere and the numeric id are both rejected here - use the display name.", + "This reuses the Automation Coverage dashboard widget over an all-time window, so it is an aggregation query, not a cheap key lookup - expect it to be slower than a plain read.", + "Fields are serialised only when non-null (NON_NULL). `calculated_at` is when this figure was computed, not a project timestamp; treat it as the freshness of the number." + ] + }, + { + "name": "list_projects", + "method": "GET", + "path": "/ext/v1/projects", + "mode": "read", + "entity": "project", + "intent": "List the calling account's Test Observability projects (public REST API) - the entry point for scripted/CI consumers that then drill into a project's builds.", + "query": [ + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 cursor from the previous response's pagination.next_page (it encodes a page index, not a row offset). Omit for the first page. A malformed value is a 400 'Incorrect pagination params'." + } + ], + "returns": [ + "projects", + "pagination" + ], + "responses": { + "200": { + "$response": "ProjectListingExternalResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pages are tiny and uneven in practice (2, 5, 6 rows) and there is no name filter, so this is the WRONG route for 'find project X' or 'what projects do I have' on a large group: use list_projects_lite_paginated (`search`, 100 per page, newest activity first) or list_projects_full (whole group, capped at 30 rows).", + "Requires the PROJECT_VIEW permission; a credential without it is refused. A user OAuth credential that holds it is accepted - a service account is not required.", + "Up to 10 projects per page, and a page can hold fewer while has_next is still true - a short page is not the last page. There is no page-size parameter. Paginate only with the returned base64 next_page cursor, not a numeric page.", + "pagination.has_next is computed as 'this page was non-empty', so the last full page still reports has_next=true and you get one extra EMPTY page. Stop when projects comes back empty, not when has_next turns false.", + "There is no total count in the response. Each row carries the project's id, name and a ready-made observability_url into its builds page." + ] + }, + { + "name": "list_home_unique_builds", + "method": "GET", + "path": "/api/v1/home/uniqueBuilds", + "mode": "read", + "entity": "project", + "intent": "List the Home page's latest builds across the caller's visible projects - one row per build with its status and counts - optionally narrowed to 'my builds' and quick filters.", + "query": [ + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Only affects has_next (true while pageNumber*10 <= 99); it does NOT offset the search, so pageNumber=1 returns the same first page. Page with afterKey instead. The response's pagingParams.pageNumber echoes pageNumber+1." + }, + { + "name": "afterKey", + "type": "string", + "description": "Cursor (the last row's build id) for the next page. This is the ONLY real pagination: carry pagingParams.afterKey from the previous response." + }, + { + "name": "myBuilds", + "type": "boolean", + "default": false, + "description": "Restrict to builds owned by the calling user." + }, + { + "name": "applyQuickFilters", + "type": "boolean", + "description": "Turn the per-request quick filters below ON. When false (default) the endpoint applies only the user's persisted Home filters and ignores the quick-filter params." + }, + { + "name": "statuses", + "type": "array", + "description": "Quick-filter build statuses. Only applied when applyQuickFilters=true; invalid values are skipped rather than 400ing." + }, + { + "name": "users", + "type": "array", + "description": "Quick-filter user ids. Only applied when applyQuickFilters=true." + }, + { + "name": "frameworks", + "type": "array", + "description": "Quick-filter framework values. Only applied when applyQuickFilters=true." + }, + { + "name": "buildTags", + "type": "array", + "description": "Quick-filter build tags. Only applied when applyQuickFilters=true." + }, + { + "name": "projects", + "type": "array", + "description": "Quick-filter project ids. Only applied when applyQuickFilters=true." + }, + { + "name": "isRealDevice", + "type": "array", + "description": "Quick-filter device type (string, not boolean, so a stale value degrades instead of erroring). Only applied when applyQuickFilters=true." + } + ], + "returns": [ + "data", + "pagingParams" + ], + "responses": { + "200": { + "$response": "HomeUniqueBuildsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The user's saved Home filters (build tags, projects, users, teams, unique build names, statuses) are merged into the query server-side even when applyQuickFilters is off - results are always scoped by the caller's persisted personalisation and sub-group.", + "Pagination: pageNumber is not applied to the search (only to the has_next cap); page with afterKey = pagingParams.afterKey. pagingParams.hasNext is 'this page was full AND pageNumber*10 <= 99'.", + "Quick-filter params are inert unless applyQuickFilters=true. Bad/stale quick-filter values are silently dropped (mapSkippingInvalid), never 400.", + "Heavy on large groups (emits per-stage timing telemetry); each row is a BuildSummary - use displayStatus for the pass/fail verdict (status is the lifecycle state, e.g. finished).", + "Rows are the most recent builds, one row PER BUILD (not deduplicated by build name: several runs of one pipeline appear as separate rows), in the caller's Home scope. Page size is 10." + ] + }, + { + "name": "list_home_recent_views", + "method": "GET", + "path": "/api/v1/home/recents", + "mode": "read", + "entity": "project", + "intent": "Get the user's last few visited items (dashboards, builds, views) for the Home page 'Recently viewed' strip.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "HomeRecentViewsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read from a per-user Redis sorted set, capped at the 5 most recent entries; there are no parameters and no pagination.", + "Entries are written as a side effect of the user opening a dashboard - this endpoint never writes. A fresh user gets an empty data array.", + "Unparseable stored entries are silently skipped, so the list can be shorter than 5 even when 5 were recorded. Response is {\"data\": [ ... ]}; each item has type, link, id, name." + ] + }, + { + "name": "get_home_takeaways", + "method": "GET", + "path": "/api/v1/home/takeaway", + "mode": "read", + "entity": "project", + "intent": "Get the Home page 'key takeaways' cards with the aggregate time-saved metric - stability, pass-rate, always-failing/flaky/new-failure counts and a single time-savings percentage, versus the previous 7-day window.", + "returns": [ + "timeSavingsData", + "takeAwayData" + ], + "responses": { + "200": { + "$response": "HomeTakeawaysResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the older takeaways variant. It returns the aggregate `timeSavingsData` object (timeSavings %, testToVerify, uniqueErrors, flakiness, alwaysFailing, autoTagged) and sends the v2 `timeSavingV2Metric` key as null. For the shape the current Home page uses, pick get_home_takeaways_with_noise_breakdown instead.", + "Scoped implicitly by the caller's saved Home filters, sub-group and visible projects; no parameters.", + "Cached in Redis per USER id for ~1 hour, so a filter change is not reflected until the cache expires. All metrics compare the current 7 days to the prior 7 days (each TakeAwayMetric carries value and prev)." + ] + }, + { + "name": "get_home_takeaways_with_noise_breakdown", + "method": "GET", + "path": "/api/v1/home/v2/takeaway", + "mode": "read", + "entity": "project", + "intent": "Get the Home page 'key takeaways' cards with the failure-noise breakdown - the same stability/pass-rate story plus per-category (flaky, always-failing, auto-analyzed, unique-error) failure counts and their percentage reductions.", + "returns": [ + "timeSavingV2Metric", + "takeAwayData" + ], + "responses": { + "200": { + "$response": "HomeTakeawaysNoiseBreakdownResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the variant the current Home page uses. It returns the `timeSavingV2Metric` object (totalFailures plus flakyFailing/alwaysFailing/autoAnalyzedFailure/uniqueErrors, each a count + reduction%) and explicitly sets the legacy `timeSavingsData` to null - do not expect a single time-savings percentage here.", + "Same takeAwayData block (stability, unique executions, new failures, always-failing, flakiness, failures; each value vs prev 7-day) as the older variant.", + "Cached in Redis per (filter-hash + CAD product + sub-group), so the cache is SHARED across users with identical filters - unlike the per-user cache of the older variant. TTL ~1 hour.", + "When there are zero failures in the window, timeSavingV2Metric collapses to just {totalFailures: 0} with the per-category objects omitted." + ] + }, + { + "name": "list_project_builds_by_group", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/build-groups-listing", + "mode": "read", + "entity": "project", + "intent": "List a project's builds rolled up by build group (consolidated re-runs/shards shown as one row), with the full v2 build-listing filter set.", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL-safe slug), not its numeric id." + } + ], + "query": [ + { + "name": "buildNameIds", + "type": "array", + "description": "Filter by build name ids." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Filter by unique build names." + }, + { + "name": "users", + "type": "array", + "description": "Filter by user ids (numeric)." + }, + { + "name": "assignees", + "type": "array", + "description": "Assignee ids; each must match ^(\\$none|[0-9]+)$ ($none = unassigned)." + }, + { + "name": "teams", + "type": "array", + "description": "Filter by team ids." + }, + { + "name": "buildGroupIds", + "type": "array", + "description": "Filter by build group ids." + }, + { + "name": "frameworks", + "type": "array", + "description": "Raw framework values (branded enum names and pass-through values like 'appium'); matched case-insensitively." + }, + { + "name": "statuses", + "type": "array", + "description": "Build display statuses." + }, + { + "name": "sources", + "type": "array", + "description": "Product sources." + }, + { + "name": "types", + "type": "array", + "description": "Build types." + }, + { + "name": "buildTags", + "type": "array", + "description": "Filter by build tags." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names (max 500); AND/OR selected via `match`." + }, + { + "name": "branches", + "type": "array", + "description": "Filter by branch names." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millis longs [from, to] (size must be 2)." + }, + { + "name": "search", + "type": "string", + "description": "Free-text build search." + }, + { + "name": "searchAfter", + "type": "string", + "description": "ES search_after cursor; must match ^[0-9a-zA-Z]{40}$ (a 40-char id)." + }, + { + "name": "state", + "type": "string", + "values": [ + "active", + "closed" + ], + "description": "Build state; defaults to active." + }, + { + "name": "isArchived", + "type": "boolean", + "description": "Restrict to archived builds." + }, + { + "name": "selfHealed", + "type": "boolean", + "description": "Only self-healed builds." + }, + { + "name": "aiAuthored", + "type": "boolean", + "description": "Only AI-authored builds." + }, + { + "name": "isRealDevice", + "type": "array", + "description": "Device-type multi-select (true=real, false=virtual); empty/both = no filter." + } + ], + "returns": [ + "id", + "name", + "buildGroupAggResult", + "activeCount", + "closedCount" + ], + "responses": { + "200": { + "$response": "ProjectBuildGroupListingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Sibling of the ungrouped v2 build listing: this one consolidates re-runs/shards into build groups (see buildGroupAggResult). Filters are the same rich v2 set, passed as query params (there is no request body).", + "Returns per-group AGGREGATES only (buildGroupAggResult: counts plus the TestHub group record) - no build rows: `builds` and `pagingParams` are always null here, and there is no pagination. To list the builds themselves use the ungrouped v2 build listing.", + "Side effect: reading this marks the project as SUBSCRIBED for the caller (a user-preference write) - a benign, recoverable effect, but it is not a pure read.", + "Unlike the ungrouped v2 listing, this route does NOT apply the group's custom-retention window, so it can surface builds older than that window." + ] + }, + { + "name": "get_test_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug), not its numeric id." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to], for the window the filters resolve within. Required; a null or non-2-element range is a 400." + }, + { + "name": "search", + "type": "string", + "description": "Free-text test-name search that is echoed back under `applied`." + }, + { + "name": "buildTags", + "type": "array", + "description": "Applied build-tag filter values (echoed back resolved under `applied.buildTags`)." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Applied unique-build-name filter values." + }, + { + "name": "testTags", + "type": "array", + "description": "Applied test-tag filter values." + }, + { + "name": "folders", + "type": "array", + "description": "Applied folder filter values." + }, + { + "name": "hostNames", + "type": "array", + "description": "Applied host-name (machine/agent) filter values." + }, + { + "name": "status", + "type": "array", + "description": "Applied test-status filter values." + }, + { + "name": "sources", + "type": "array", + "description": "Applied product-source filter values." + }, + { + "name": "users", + "type": "array", + "description": "Applied user-id filter values (longs)." + }, + { + "name": "lastExecutedBy", + "type": "array", + "maxItems": 500, + "description": "User ids (longs) whose executions to include. Capped at 500 by validation; exceeding it is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Applied team-id filter values (longs)." + }, + { + "name": "failureCategories", + "type": "array", + "description": "Applied failure-category filter values." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state: set true/false to filter by flakiness, omit to not filter." + }, + { + "name": "isNewFailure", + "type": "boolean", + "description": "Tri-state new-failure filter; omit to not filter." + }, + { + "name": "isAlwaysFailing", + "type": "boolean", + "description": "Tri-state always-failing filter; omit to not filter." + }, + { + "name": "hasPmToolDefects", + "type": "boolean", + "description": "Tri-state filter for tests that have linked PM-tool defects; omit to not filter." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden-status filter; omit to not filter." + }, + { + "name": "osList", + "type": "array", + "description": "Applied OS filter values." + }, + { + "name": "browserList", + "type": "array", + "description": "Applied browser filter values." + }, + { + "name": "deviceList", + "type": "array", + "description": "Applied device filter values." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-test visibility. Accepts all / true (muted only) / false (unmuted only); the identifiers ALL / MUTED / UNMUTED are also accepted, case-insensitively." + }, + { + "name": "hooksVisibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Applied hooks visibility. The response also returns the project's own default hooks visibility separately under `hooksVisibility`." + } + ], + "intent": "Get the whole filter sidebar for the Suite Health test list: the currently applied filters resolved into display fields, the available static filter options (failure categories, OS/browser/device, sources, run-level options, etc.), and the project's default hooks visibility.", + "returns": [ + "applied", + "staticFilters", + "hooksVisibility" + ], + "guidance": [ + "This echoes back the filters you sent (resolved into {id,value,label} fields under `applied`) AND the catalogue of choosable options under `staticFilters` \u2014 send an empty query (just dateRange) to fetch the full option catalogue for building a filter UI.", + "It degrades open: the run-level option lists inside `staticFilters` (typeOfRun, assignedTo, state, testPlan) are fetched from an upstream service under a timeout, and on timeout/failure (or when there is nothing) they come back null or [] rather than erroring. An empty or null run-level dropdown does not mean there are no options.", + "`dateRange` is mandatory and must be exactly two millisecond timestamps; there are no lowerBound/upperBound scalars on this route.", + "Scoped to project visibility only; no plan gate." + ], + "responses": { + "200": { + "$response": "SPTestFilterOptions" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_branches", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/branches", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wrapped in wildcards for the Elasticsearch match, and additionally applied as a case-SENSITIVE literal `contains` post-filter \u2014 a lowercase query will not surface an upper-case branch. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 it is only consulted by the build-names suggestion route, not this one." + } + ], + "intent": "Type-ahead the branch filter on the Suite Health test list: get git branch names seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the Elasticsearch terms aggregation is capped at 100 branches, so it is not exhaustive; narrow with `query` rather than expecting the full set.", + "The `query` match is applied twice: a wildcard ES match AND a case-sensitive literal `contains` filter on the returned values, so casing matters when narrowing.", + "Each suggestion is a Field object; use its `value` (the branch name) when applying the branch filter elsewhere.", + "Omitting `query` returns an EMPTY `data` list, not the top branches - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildNames", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match against unique build names. Omit/blank to list the top build names in the window." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Whether manual runs are included. Unlike the sibling type-aheads this route honours it (it is carried into the build-level filter request)." + } + ], + "intent": "Type-ahead the build-name filter on the Suite Health test list: get unique build names for the project in the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "This route is the odd one out: it converts the request into a build-sidebar filter and resolves against the build-level index (BuildFilterServiceV2), NOT the test-level filter service the other type-aheads use \u2014 so its matching and result limits can differ from the branches/tags/hostNames suggestions.", + "Because it runs through the build path it honours `includeManualRuns`, which the other suggestion routes ignore.", + "Each suggestion is a Field object; use its `value` (the build name) when applying the uniqueBuildNames filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_build_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/buildTags", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the build-tag filter on the Suite Health test list: get build tags used in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results, so it is not exhaustive; narrow with `query`.", + "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", + "Each suggestion is a Field object; use its `value` (the tag) when applying the buildTags filter.", + "Omitting `query` returns an EMPTY `data` list, not the top build tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_host_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/hostNames", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match against host names, wrapped in wildcards for the ES match. Omit/blank to list the top host names in the window." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the host-name (machine/agent) filter on the Suite Health test list: get host names seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", + "Unlike the other type-aheads this route does NOT accept `isManuallyOverridden` (that filter is not applied here), and it does not apply a client-side literal `contains` filter \u2014 it relies purely on the wildcard ES match.", + "Each suggestion is a Field object; use its `value` (the host name) when applying the hostNames filter." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "search_test_filter_test_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/tests/filters/testTags", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required; a null or non-2-element range is a 400." + }, + { + "name": "query", + "type": "string", + "description": "Substring to match. Wildcard ES match plus a case-SENSITIVE literal `contains` post-filter on the returned values. Effectively REQUIRED: when omitted, the case-sensitive post-filter (StringUtils.contains(value, null)) drops every value, so the response is an empty list." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "description": "Tri-state manually-overridden filter; omit to not filter." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Accepted but INERT on this route \u2014 only the build-names route consults it." + } + ], + "intent": "Type-ahead the test-tag filter on the Suite Health test list: get test tags seen in the project within the date window that match a substring.", + "returns": [ + "data" + ], + "guidance": [ + "Suggestion list only \u2014 the terms aggregation is capped at 10 results.", + "The `query` narrowing is a case-sensitive literal `contains` on top of the wildcard match, so casing matters.", + "Each suggestion is a Field object; use its `value` (the tag) when applying the testTags filter.", + "Omitting `query` returns an EMPTY `data` list, not the top test tags - the post-filter drops every value when query is null. Send at least one character (e.g. a common prefix)." + ], + "responses": { + "200": { + "$response": "FieldSearchResults" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_metrics_chart", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/tests/metrics/chart", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Exactly two epoch-millisecond longs, [from, to]. Required \u2014 the chart interval is derived from it; a null or non-2-element range is a 400." + }, + { + "name": "search", + "type": "string", + "description": "Free-text test-name search to scope the charts." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build-tag filter values." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Unique-build-name filter values." + }, + { + "name": "testTags", + "type": "array", + "description": "Test-tag filter values." + }, + { + "name": "folders", + "type": "array", + "description": "Folder filter values." + }, + { + "name": "hostNames", + "type": "array", + "description": "Host-name filter values." + }, + { + "name": "status", + "type": "array", + "description": "Test-status filter values." + }, + { + "name": "sources", + "type": "array", + "description": "Product-source filter values." + }, + { + "name": "users", + "type": "array", + "description": "User-id filter values (longs)." + }, + { + "name": "lastExecutedBy", + "type": "array", + "maxItems": 500, + "description": "User ids (longs). Capped at 500 by validation; exceeding it is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Team-id filter values (longs)." + }, + { + "name": "failureCategories", + "type": "array", + "description": "Failure-category filter values." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state flakiness filter; omit to not filter." + }, + { + "name": "isNewFailure", + "type": "boolean", + "description": "Tri-state new-failure filter; omit to not filter." + }, + { + "name": "isAlwaysFailing", + "type": "boolean", + "description": "Tri-state always-failing filter; omit to not filter." + }, + { + "name": "osList", + "type": "array", + "description": "OS filter values." + }, + { + "name": "browserList", + "type": "array", + "description": "Browser filter values." + }, + { + "name": "deviceList", + "type": "array", + "description": "Device filter values." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-test visibility (ALL / MUTED / UNMUTED identifiers also accepted, case-insensitively)." + }, + { + "name": "hooksVisibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Hooks visibility scope." + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, failure counts are sourced from the recorded status keyword (SDK-recorded automation status). When not true AND the account has the new build listing flag on, counts instead come from workflow status, which can change the numbers for the same window." + } + ], + "intent": "Get the Suite Health page chart data in one call: time-bucketed failures-over-executions, average duration, and average failure-rate series for the project's tests over a window, filtered by the full Suite Health filter set.", + "returns": [ + "failureChart", + "durationChart", + "failureRateChart" + ], + "guidance": [ + "Response is a top-level map of three charts \u2014 `failureChart`, `durationChart`, `failureRateChart` \u2014 each an SPChart with a `data` map of named series (arrays of {x,t,y,pointRange} points) and an `insights` summary. There is no `data` envelope wrapper.", + "Series keys inside `data`: failureChart carries `totalExecutions` and `totalFailures`; durationChart carries `averageDuration`; failureRateChart carries `averageFailureRates`.", + "Archived builds are excluded from every counter.", + "The same query can return DIFFERENT failure numbers depending on account config: with the new build listing flag on and `isAutomationLatest` not true, failures are counted from workflow status rather than the raw status keyword. Set `isAutomationLatest=true` to force the raw-status counts.", + "The failure series can come back with fewer buckets than the executions series when a slice has zero matches; the failure-rate series is computed by aligning on the `x` timestamp, so do not assume the two arrays are index-aligned." + ], + "responses": { + "200": { + "$response": "SPTestMetricsChart" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_base_info", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/info", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash, discovered from the Suite Health test listing." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ], + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start as an epoch-millisecond long. Optional on this route (there is no dateRange array here)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end as an epoch-millisecond long. Optional." + }, + { + "name": "buildName", + "type": "string", + "description": "Single build name to scope to (wrapped into a one-element list server-side); this route takes at most one, not a list." + } + ], + "intent": "Get the identifying header info for one test in the Test Health detail view \u2014 its name and the environment (OS/browser/device) combinations it ran on \u2014 for the selected window.", + "returns": [ + "name", + "combinations" + ], + "guidance": [ + "Response is a flat object `{name, combinations}` with NO data wrapper. `name` is the test's display name (empty string when the hash matches nothing in the window, still 200), and `combinations` is the list of OS/browser/device environments it ran on.", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Narrower filter set than the test listing: no browser/os/device/tag filters, and only a single `buildName`.", + "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 (no structured error body), so treat a 500 as opaque rather than parsing it." + ], + "responses": { + "200": { + "$response": "SPTestBaseInfo" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_environment_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/breakdown", + "mode": "read", + "entity": "sp_test", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug)." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash, discovered from the Suite Health test listing." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ], + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start as an epoch-millisecond long. Optional." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end as an epoch-millisecond long. Optional." + }, + { + "name": "buildName", + "type": "string", + "description": "Single build name to scope to (wrapped into a one-element list server-side); at most one, not a list." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "Tri-state flakiness filter; omit to not filter." + }, + { + "name": "orderKey", + "type": "string", + "values": [ + "reliability", + "average", + "totalFailures" + ], + "description": "Sort key for the returned rows. Unrecognised/absent falls back to reliability." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ], + "description": "Sort direction. Unrecognised/absent falls back to descending." + } + ], + "intent": "Break one test's results down by environment (OS / browser / device) over the window, so you can see whether its instability is platform-specific.", + "returns": [ + "id", + "reliability", + "duration", + "os", + "browser", + "browserDevice", + "totalFailures" + ], + "guidance": [ + "The body is a BARE JSON ARRAY of environment rows (no object wrapper, no `data` key). Each row: `id` (browser-platform key), `reliability` (fail %), `duration` (avg), `os`/`browser`/`browserDevice` (formatted platform objects), and `totalFailures` {failed,total}.", + "Uses the older explicit-argument service overload, so it accepts only a SINGLE `buildName` and a much smaller filter set than the test listing (no os/browser/device/tag filters).", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Rows are sorted server-side (default reliability, descending) and platform rows that carry no displayable browser or device are dropped, so the array may be shorter than the number of distinct environments.", + "Any internal error is rethrown as a bare RuntimeException and surfaces as a plain 500 with no structured body." + ], + "responses": { + "200": { + "$response": "SPTestEnvironmentBreakdown" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_app_logs", + "method": "GET", + "path": "/api/v1/appLogs/query", + "mode": "read", + "entity": "test_detail", + "intent": "Get a test run's application (app-under-test / SDK) log lines, parsed and flattened for display \u2014 use this for the app-logs tab when you want normalised rows rather than raw Loki JSON.", + "query": [ + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "The test run's UUID (not its integer id and not the TR-NNN identifier). A missing or foreign UUID collapses into an empty 500 with no body." + } + ], + "returns": [ + "status", + "appLogQuery", + "logs" + ], + "responses": { + "200": { + "description": "Parsed app logs. `status` is 'success', or 'failure' with an empty `logs` list (still HTTP 200) when Loki reports a non-success status.", + "schema": { + "$schema": "AppLogResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns ONLY abnormal (error-level) app-log lines \u2014 the Loki query hard-codes an abnormal filter \u2014 capped at 5000 lines and bounded to the run's own start/finish window (\u00b15s). A passing run with no error logs legitimately comes back with an empty `logs` list.", + "Two failure shapes, neither is an ordinary HTTP error: when Loki answers non-success you get HTTP 200 with `status:\"failure\"` and `logs:[]`; when anything throws you get a bare HTTP 500 with no body. Check `status`, and do not read an empty 500 as a specific cause \u2014 a missing/foreign run and a Loki outage look identical.", + "Each log line is split on a ' cSep=S ' separator and only the text after it is kept; a line that lacks the separator is returned raw and unsplit.", + "For the untouched Loki payload use the raw variant instead; this one is the parsed, UI-normalised form.", + "`level` on each line falls back to 'unknown' when Loki did not tag the stream with a level." + ] + }, + { + "name": "get_test_run_app_logs_raw", + "method": "GET", + "path": "/api/v1/appLogs/nativeQuery", + "mode": "read", + "entity": "test_detail", + "intent": "Get a test run's application logs as Loki's raw, unparsed JSON response \u2014 use this only when you need the native Loki shape; for displayable rows use the parsed app-logs capability instead.", + "query": [ + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "The test run's UUID (not its integer id and not the TR-NNN identifier)." + } + ], + "returns": [ + "status", + "data" + ], + "responses": { + "200": { + "description": "Loki's native query_range JSON body, relayed verbatim as an opaque JSON document (top-level `status` and `data.result[]` streams). This is NOT an app model \u2014 the shape is Loki's, not the service's.", + "schema": { + "type": "object", + "description": "Opaque pass-through of Loki's /query_range response. Typically `{status, data:{resultType, result:[{stream, values:[[epochNano, line]]}]}}`, but treat it as an untyped Loki document." + } + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pure pass-through of Loki's body \u2014 the same abnormal-only filter, 5000-line cap and run-time-window bounding as the parsed variant apply, but nothing is normalised. Log lines still carry the ' cSep=S ' separator and their level pattern; you must split them yourself.", + "The handler swallows every exception into a bare HTTP 500 with no body, so a missing or foreign testRunUuid is indistinguishable from a Loki outage.", + "Prefer the parsed app-logs capability unless you specifically need Loki's native envelope; this one returns a JSON string, not the service's AppLogResponse." + ] + }, + { + "name": "get_test_run_hooks", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/hooks", + "mode": "read", + "entity": "test_detail", + "intent": "Public API: list the before/after setup and teardown hook executions relevant to a test run, so a failure can be attributed to a hook rather than the test body.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [ + "testCode", + "testMetadata", + "hooksCode" + ], + "responses": { + "200": { + "description": "A MAP keyed by hook test-run id (numeric string keys), each value a TestDetailResponse for that hook. An empty object means no hooks matched.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestDetailResponse" + }, + "description": "Keyed by matched hook run id." + } + }, + "404": { + "$response": "NotFound" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", + "The result is a MAP keyed by hook test-run id, not a list; iterate the values.", + "Matching is heuristic, not a stored parent/child link: hooks are matched on the SAME build id + filename + OS + browser + device, latest only. A hook that ran in a differently-configured session will not appear here even though it belongs to the same build.", + "At most 100 hook runs are returned (the underlying search is size-capped); there is no paging to fetch more.", + "An unknown test_run_id returns TEST_RUN_NOT_FOUND (404). A run with no matching hooks returns an empty object with 200." + ] + }, + { + "name": "get_test_run_log_bundle", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/logs", + "mode": "read", + "entity": "test_detail", + "intent": "Public API: get the full log bundle for a test run \u2014 BrowserStack session logs (console/network/selenium/appium/video), SDK logs, hook-run logs, failure and step logs \u2014 as URLs plus the test start/finish timestamps and framework.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [ + "testStartedAt", + "testFinishedAt", + "browserstackLogs", + "sdkLogs", + "hookRunLogs", + "failureLogs", + "stepLogs", + "framework", + "sessionId" + ], + "responses": { + "200": { + "description": "Log URLs and metadata. `browserstackLogs` is a SessionLogs object of per-type log URLs; `sdkLogs` and `hookRunLogs` are lists of URL strings; `failureLogs` and `stepLogs` are inline log-entry objects.", + "schema": { + "$schema": "LogUrls" + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Public /ext/v1 API \u2014 authenticate with your BrowserStack username + access key.", + "Returns log URLs to fetch separately, NOT log content. The caller downloads each artefact from the URLs in `browserstackLogs`, `sdkLogs` and `hookRunLogs`; only `failureLogs` and `stepLogs` are inline.", + "The logs are always the TRIMMED variant \u2014 the trim flag is hard-coded true on this route, so there is no way to ask for the untrimmed logs here.", + "Plan-locked / retention-expired builds are rejected with FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", + "Several log sources are fetched concurrently, so latency tracks the slowest source; expect a slow response for large sessions." + ] + }, + { + "name": "get_session_network_log_url", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "intent": "Get the URL of a session's HAR network log for the Test Details network tab \u2014 returns a link to the HAR file plus its fetch status, not the parsed entries.", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "The session's HASHED id (not a numeric id)." + } + ], + "returns": [ + "networkLogs", + "status_code", + "message" + ], + "responses": { + "200": { + "description": "The network-log URL envelope. `networkLogs` is the HAR file URL, `status_code` is the upstream fetch status, `message` any upstream message.", + "schema": { + "$schema": "SessionNetworkLogUrlResponse" + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Despite the name, this returns the HAR file URL in `networkLogs`, NOT the parsed HAR entries \u2014 the handler requests the unparsed form. Fetch the HAR yourself from that URL; it can be large.", + "On the demo-sandbox environment it short-circuits to an empty HAR (`{log:{entries:[]}, responseOrigin:\"demo\"}`) before any lookup.", + "An unknown sessionId returns SESSION_NOT_FOUND. Note the id here is the session's hashed id, the same id the sibling session log-URLs capability takes." + ] + }, + { + "name": "get_test_run_behaviour", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/behaviour", + "mode": "read", + "entity": "test_detail", + "intent": "Get the BDD/behaviour payload stored on a test run (the Gherkin feature/scenario structure in the run's meta) that backs the Behaviour tab for Cucumber-style frameworks.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "returns": [], + "responses": { + "200": { + "description": "The run's meta JSON (the Gherkin/behaviour structure) as an opaque object, or an empty object `{}` when the run has no meta.", + "schema": { + "type": "object", + "description": "Opaque BDD/behaviour meta object stored on the run. Empty object when no meta is present." + } + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Returns the run's raw meta object; only meaningful for BDD (Cucumber-style) builds. A run with no meta returns an empty object `{}` with HTTP 200, not a 404.", + "A MISSING test run does not give a typed error \u2014 the handler checks lock status before the null check, so a bad id throws a NullPointerException (HTTP 500), not TEST_RUN_NOT_FOUND.", + "Locked (retention-expired) runs are rejected with FORBIDDEN_OPERATION.", + "The Behaviour tab is only offered for BDD builds; for a non-BDD run this payload is not useful even when present." + ] + }, + { + "name": "get_test_run_merged_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/consolidatedLogs", + "mode": "read", + "entity": "test_detail", + "intent": "Get a single merged, time-ordered log stream for a test run \u2014 BrowserStack session logs, SDK logs, hook-run logs, failure and step logs combined into one list, with content merged server-side \u2014 for the Logs tab.", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "required": false, + "default": false, + "description": "When true, restrict the merged logs to the test's own time window. Defaults to false (full session)." + } + ], + "returns": [ + "logLevel", + "logType", + "timeStamp", + "content", + "url", + "duration" + ], + "responses": { + "200": { + "description": "A top-level JSON ARRAY of merged, time-ordered log entries (GenericLog objects). Content is downloaded and merged server-side, so entries carry inline `content`, not just URLs.", + "schema": { + "type": "array", + "items": { + "$schema": "GenericLog" + } + } + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This form downloads and MERGES every log source server-side and returns the content inline as one time-ordered array, so it is slow for long sessions. A sibling capability returns log URLs instead (content fetched client-side) \u2014 prefer that one for large sessions.", + "The body is a bare JSON array of log entries, not an object wrapper \u2014 iterate it directly.", + "`trim=true` narrows the stream to the test's own time window; the default (false) includes the whole session.", + "Bad ids give TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND (404); locked builds give FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED (403).", + "On the demo-sandbox environment canned logs are returned, chosen by the test's name.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ] + }, + { + "name": "get_test_run_details", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/details", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test run id." + } + ], + "intent": "Get the full detail of one test run \u2014 its metadata plus the captured test code and the code of the before/after hooks that ran around it, i.e. everything needed to understand what the test did.", + "guidance": [ + "This is the heaviest of the detail reads: it assembles the run, its captured code, its meta and the related hook code from several sources, so prefer a lighter read when you only need status or metadata.", + "testCode (and each entry of hooksCode) can have null code/language when no code was captured for that run \u2014 the row is still returned; do not treat a null code as an error.", + "A run that does not belong to the caller's group, or does not exist, comes back as TEST_RUN_NOT_FOUND \u2014 the same answer for 'wrong group' and 'no such run'.", + "testMetadata here is the internal test-meta object and is shaped differently from the flatter meta returned by the baseline/similar-error map reads; read its keys from this response rather than assuming they match.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ], + "returns": [ + "testCode", + "testMetadata", + "hooksCode" + ], + "responses": { + "200": { + "$response": "TestRunDetailResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_baseline", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/baseLine", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric id of the (typically failing) run whose baseline you want." + } + ], + "intent": "Find a failing run's baseline \u2014 the most recent passing run of the same test in the last 30 days \u2014 returned with full test detail, so you can diff a failure against the last known-good execution.", + "guidance": [ + "Returns a MAP keyed by test run id, not a single object. Priority 1 is the newest passing run with the same test hash (one entry); if there is none it falls back to the newest passing run per device/os/browser combination (several entries). An EMPTY map means no baseline was found, not an error.", + "The look-back window is hardcoded to 30 days before this run's start time \u2014 an older last-good run will not appear, so an empty result does not prove the test has never passed.", + "Each map value is { testCode, testMetadata, hooksCode } - hooksCode is present but null on this route, and testCode is null when no code was captured. Read the passing run's numeric id from the map key." + ], + "returns": [ + " (map key)", + "testCode", + "testMetadata" + ], + "responses": { + "200": { + "$response": "TestRunDetailMapResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_build_insights", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/buildInsights", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test run id; its parent build is resolved from it." + } + ], + "intent": "Get the build-level insights that surround a failing run \u2014 the parent build's failure/flakiness summary, failure categorisation and top-errors analysis, plus this run's own file/module failure context \u2014 so a failure can be reasoned about in context.", + "guidance": [ + "The run's parent build is resolved first; a run whose build cannot be found returns BUILD_NOT_FOUND (distinct from TEST_RUN_NOT_FOUND when the run itself is missing).", + "topErrorsAnalysis.status is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED \u2014 when not COMPLETE, uniqueErrors may be empty or partial, so check the status before treating the error list as final.", + "failureContext (file and parentModule failure rates) is the per-run slice; the rest of the payload is aggregated across the whole build. A build-scoped variant that omits this per-run context exists internally but is not this capability." + ], + "returns": [ + "buildId", + "buildName", + "status", + "summary", + "failureCategorization", + "failureContext", + "topErrorsAnalysis", + "defectStats" + ], + "responses": { + "200": { + "$response": "BuildInsightsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_runs_sharing_error", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/similarError/{clusterId}", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric id of the failing run whose error cluster you are matching against. Excluded from the returned set." + }, + { + "name": "clusterId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). Unique-error cluster id. It sits in the PATH but is bound onto the filter object's clusterId property by Spring's URI-template data binding \u2014 you do NOT also pass it as a query param.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Project the error breakdown is scoped to. Required \u2014 omitting it is a binding error, not an empty result." + }, + { + "name": "dateRange", + "type": "array", + "description": "Two epoch-millisecond bounds [from, to] scoping the error search. Bound onto the filter but NOT validated at request binding on this route (no @Valid), so a call without it will not 400 here yet may return nothing \u2014 supply it to get a meaningful window.", + "fields": [ + { + "name": "from", + "type": "integer" + }, + { + "name": "to", + "type": "integer" + } + ] + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true, search the error across all builds rather than only the run's own build." + }, + { + "name": "searchAfter", + "type": "array", + "description": "Elasticsearch search_after cursor echoed back from a previous page to continue paging the affected-runs scan." + } + ], + "intent": "List the other test runs that failed with the same unique-error cluster as this run \u2014 the 'tests affected by this error' list, each returned with full test detail.", + "guidance": [ + "Returns a MAP keyed by test run id (each value is { testCode, testMetadata }); the querying run itself is always excluded, and an EMPTY map means no other run shares the cluster.", + "projectId is a REQUIRED query param; the cluster is taken from the {clusterId} path segment via data binding, so do not duplicate it as a query param.", + "The affected-run scan also accepts the wider GetSPError filter set (test tags, statuses, devices/os/browsers, folders, users, etc.) as query params bound onto the filter object \u2014 use them to narrow which affected runs come back." + ], + "returns": [ + " (map key)", + "testCode", + "testMetadata" + ], + "responses": { + "200": { + "$response": "TestRunDetailMapResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/testRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id). The listing is scoped to this build." + } + ], + "query": [ + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 pagination cursor. Echo back the pagination.nextPage from the previous response verbatim; do not construct it. Stop when pagination.hasNext is false." + }, + { + "name": "sort", + "type": "string", + "values": [ + "EXECUTION_ORDER", + "TOP_LEVEL_NAME", + "DURATION", + "FAILED_TEST", + "PLATFORM", + "STATUS" + ], + "description": "Sort column." + }, + { + "name": "order", + "type": "string", + "values": [ + "asc", + "desc" + ], + "description": "Sort direction. Required alongside sort \u2014 the two are read together." + }, + { + "name": "test_statuses", + "type": "array", + "description": "Filter by status strings (e.g. passed, failed, skipped)." + }, + { + "name": "re_runs", + "type": "array", + "description": "Filter by re-run numbers (list of integers)." + }, + { + "name": "ci_build_numbers", + "type": "array" + }, + { + "name": "test_tags", + "type": "array" + }, + { + "name": "host_names", + "type": "array" + }, + { + "name": "is_flaky", + "type": "boolean", + "description": "Smart-tag filter. On a freemium or source-fenced build this (and is_always_failing / is_new_failure / has_performance_anomaly) is REFUSED with a plan-restriction 403, not silently ignored." + }, + { + "name": "is_always_failing", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "is_new_failure", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "has_performance_anomaly", + "type": "boolean", + "description": "Smart-tag filter; plan-gated as above." + }, + { + "name": "failure_categories", + "type": "array" + }, + { + "name": "devices", + "type": "array" + }, + { + "name": "os", + "type": "array" + }, + { + "name": "browsers", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hooks_visibility", + "type": "string", + "values": [ + "all", + "failed", + "beforeFailed", + "none" + ], + "description": "Which hooks to include in the tree. Defaults to failed." + }, + { + "name": "is_muted", + "type": "string", + "values": [ + "all", + "true", + "false" + ], + "description": "Muted-tests visibility: all, true (only muted), false (only unmuted). Also accepts ALL/MUTED/UNMUTED. An unrecognised value is rejected." + } + ], + "intent": "List a build's test runs as the UI suite/folder tree, with the full filter set (status, flaky, muted, re-runs, tags, failure category, device/os/browser, folders, performance anomaly, new/always-failing), sorted and cursor-paginated, with deep-link URLs back to the BrowserStack UI.", + "guidance": [ + "The response is a NESTED hierarchy (suites/folders/tests), each node carrying an external deep-link URL \u2014 it is not a flat array. For a flat list use the flat build listing instead.", + "Plan- and source-aware: on a freemium or source-fenced build the analysis keys are stripped from every node, and using any smart-tag filter (is_flaky / is_always_failing / is_new_failure / has_performance_anomaly) is refused with a 403 telling you to upgrade \u2014 so gate those filters on plan.", + "Service-account callers are additionally checked for the TEST_RUN_AUTOMATED_VIEW permission on the build's project; lacking it is a 403.", + "Pagination is an opaque base64 next_page token: pass `pagination.next_page` back unchanged and stop when `pagination.has_next` is false. Keys are snake_case. The envelope differs by an account flag: newer accounts add `is_manually_overridden` and `test_summary` carries workflow-status counts, older ones plain status counts." + ], + "returns": [ + "hierarchy", + "is_archived", + "build_name", + "build_id", + "original_name", + "build_number", + "project_id", + "name", + "group_id", + "test_summary", + "app_details", + "pagination" + ], + "responses": { + "200": { + "$response": "BuildTestRunListingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_with_attachments", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/testRuns/withAttachments", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id)." + } + ], + "query": [ + { + "name": "page_size", + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30, + "description": "Page size. Must be 1..100; outside that range is a 400 ('Limit should be between 1 and 100')." + }, + { + "name": "cursor", + "type": "integer", + "description": "NUMERIC cursor from the previous response's next_cursor (this route uses a numeric cursor, unlike the base64 cursor on the plain build test-run listing). Omit on the first page." + } + ], + "intent": "List only the test runs in a build that actually have attachments or screenshots, with per-run counts \u2014 the discovery step before downloading artefacts run by run.", + "guidance": [ + "Returns a TRIMMED row per run \u2014 id, name, uuid, build_id, attachments_count, screenshots_count \u2014 not the full test-run object; use the per-run attachments read to get the actual download URLs.", + "Pagination is a numeric cursor: pass the previous response's next_cursor back as cursor; a null next_cursor means the last page.", + "The build is resolved by (uuid, group); an unknown uuid for the caller's group is BUILD_NOT_FOUND." + ], + "returns": [ + "test_runs", + "next_cursor" + ], + "responses": { + "200": { + "$response": "BuildTestRunsWithAttachmentsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_bug_report_draft", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetails", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler \u2014 the draft is composed from testRunId alone (group-scoped). Any placeholder value is accepted." + }, + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Numeric test run id whose failure the draft describes." + } + ], + "intent": "Build the pre-filled bug-report draft for a failed test \u2014 test name/status/duration, platform/browser, failure category, CI and host info, build name, retries, screenshot/log attachments and the AI RCA snippet \u2014 i.e. everything the 'Report a bug to Jira/Azure' modal needs to compose a ticket.", + "guidance": [ + "Read-only despite the name: it COMPOSES a new draft description, it does not fetch a bug that was already filed. To record a filed bug use link_bug_to_test_run.", + "It requires screenshot/retry data to exist for the run \u2014 a run with no such data throws 'retry doesn't exist' (a 4xx), so treat that as 'nothing to report on yet', not an outage.", + "logStruct carries image/log attachment references and aiRca carries the AI RCA snippet; both can be empty/absent when the run has no screenshots or no RCA." + ], + "returns": [ + "buildName", + "buildId", + "ciInfo", + "testDetails", + "testUrl", + "videoUrl", + "status", + "platform", + "browser", + "hostInfo", + "logStruct", + "retries", + "issueType", + "isAutoAnalyzed", + "duration", + "aiRca" + ], + "responses": { + "200": { + "$response": "BugReportDraftResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "link_bug_to_test_run", + "method": "POST", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunId}/bugDetailsCallback", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler. Any placeholder value is accepted; the run is resolved from testRunId." + }, + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Numeric test run id to attach the bug to." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "The external issue KEY (e.g. the Jira/Azure ticket id). Used for the idempotency check \u2014 a key already tracked on the run makes the call a no-op." + }, + { + "name": "type", + "type": "string", + "description": "The PM-tool name the issue belongs to (e.g. Jira, Azure Boards)." + }, + { + "name": "url", + "type": "string", + "description": "Deep link to the created issue, shown in the UI." + }, + { + "name": "status", + "type": "string", + "description": "The issue's status to display against the run." + }, + { + "name": "uniqueUserId", + "type": "integer", + "description": "Id of the user credited with filing the bug." + } + ], + "intent": "After a bug has been created in a PM tool (Jira, Azure Boards, \u2026), link that external issue to the test run \u2014 recording it in the run's integration tables and marking the test as bug-reported so the UI shows the issue key/link.", + "guidance": [ + "Idempotent on the issue key: if that bug name is already tracked on the run (checked in both quoted-legacy and unquoted forms) you get 204 No Content with no body \u2014 treat that as 'already linked', not a failure.", + "On a fresh link it returns 200 with { id } \u2014 the created integration id. The caller must have permission to create defects on the run's project (403 otherwise).", + "Linking also fires the PM-Tools integration trigger as a side effect; if that trigger fails it is only logged and does not fail the link.", + "Reversible: an unlink route removes the association, so this is a write, not a destructive op. Send the issue KEY in `name` (not in `url`).", + "Read-back: the link is written to the test run's search document and appears as details.pm_tool_details[] ({id,name,url,type,status}) in list_build_test_runs \u2014 the returned id is that entry's id." + ], + "returns": [ + "id" + ], + "responses": { + "200": { + "$response": "LinkBugToTestRunResponse" + }, + "204": { + "description": "The bug key is already tracked on this run; nothing was changed and there is no body." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_flat", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/testRuns/flat", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build UUID (not a numeric id). Resolved by (uuid, group); unknown \u2192 BUILD_NOT_FOUND." + } + ], + "query": [ + { + "name": "searchAfter", + "type": "string", + "description": "Elasticsearch search_after cursor \u2014 pass the previous page's cursor back to continue. Omit on the first page." + }, + { + "name": "testRunIds", + "type": "array", + "description": "Restrict to specific numeric run ids. Send this OR clusterIds: with neither the handler 500s. Takes precedence over clusterIds." + }, + { + "name": "clusterIds", + "type": "array", + "description": "Restrict to runs in these unique-error clusters (latest run per test). Send this OR testRunIds: with neither the handler 500s." + }, + { + "name": "status", + "type": "array" + }, + { + "name": "issueType", + "type": "array" + }, + { + "name": "tags", + "type": "array" + }, + { + "name": "browser", + "type": "array" + }, + { + "name": "os", + "type": "array" + }, + { + "name": "folder", + "type": "array" + }, + { + "name": "flaky", + "type": "string" + }, + { + "name": "isMuted", + "type": "string" + }, + { + "name": "search", + "type": "array" + }, + { + "name": "virtualDevice", + "type": "boolean", + "description": "true restricts to virtual-device runs; false includes explicit-false AND runs missing the field." + } + ], + "intent": "List a build's test runs as a flat, filterable list (rather than the nested suite/folder hierarchy) \u2014 the data source for the flat test-listing view of a build.", + "guidance": [ + "Send testRunIds or clusterIds. With neither, the handler builds an Elasticsearch terms filter from a null clusterIds list and returns 500 Internal System Exception. The other filter params (status, issueType, tags, browser, os, folder, flaky, isMuted, search) are bound but NOT applied by this handler; only testRunIds, clusterIds and virtualDevice narrow the result.", + "Flat counterpart of the hierarchical build listing \u2014 returns a plain array under testRuns, no tree. Filters arrive as QUERY params bound onto a POJO, not as a JSON body.", + "In each row the top-level `id` is the test's HASH (a string); the numeric run id you need for other run reads is `details.id`. Do not pass the top-level id where a numeric run id is expected.", + "Pagination is Elasticsearch search_after: pass the prior page's cursor back as `searchAfter`. Note the underlying query fetches up to 10,000 hits, so on very large builds narrow with filters rather than relying on paging alone.", + "The ES indices queried are chosen from the build, so very old builds may resolve to different indices; results are limited to the latest run per test unless you pass explicit testRunIds." + ], + "returns": [ + "testRuns", + "pagingParams" + ], + "responses": { + "200": { + "$response": "FlatTestRunListingResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_ai_failures", + "method": "GET", + "path": "/ext/v1/ai/failures/{buildUUID}", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "intent": "Public API: get a build's AI failure analysis (clustered failure themes and their workflows, sorted by failure share), the summary a build-RCA agent uses to explain a build failure.", + "returns": [ + "buildId", + "buildThemes", + "buildWorkflows", + "buildThemeWorkflow", + "stats", + "trialQuota" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Read-only: returns whatever has already been computed and never triggers analysis; use trigger_build_ai_failure_analysis to start it.", + "Themes and workflows are sorted by failure share, highest first.", + "trialQuota appears only for build-level trial accounts.", + "The build is resolved within the caller's group; a build in another group reads as not found." + ], + "responses": { + "200": { + "$response": "BuildAIFailuresResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "trigger_build_ai_failure_analysis", + "method": "POST", + "path": "/ext/v1/ai/failures/{buildUUID}", + "mode": "write", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "intent": "Public API: start build-level AI failure clustering for a build (get-or-trigger). Returns existing analysis if ready, otherwise enqueues it and flags that it was triggered.", + "returns": [ + "triggered", + "buildId", + "buildThemes", + "buildWorkflows", + "buildThemeWorkflow", + "stats", + "trialQuota" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Get-or-trigger and asynchronous: triggered=true means analysis was enqueued and results are NOT in the response yet; poll get_build_ai_failures.", + "Gated by the AI-TFA plan fence plus AI consent, the build-level RCA feature, source-fencing and a trial quota; a blocked account gets a 403 plan-not-allowed error.", + "Runs only the theme/workflow clustering pipeline; this is NOT per-test RCA.", + "Takes no request body.", + "Produces data only, never removes any; there is no undo for the analysis run it starts." + ], + "responses": { + "200": { + "$response": "BuildAIFailureTriggerResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_ai_failure_theme_test_runs", + "method": "GET", + "path": "/ext/v1/ai/failures/{buildUUID}/flat", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "query": [ + { + "name": "buildFailureThemeId", + "type": "integer", + "required": false, + "description": "Integer id of the failure theme to drill into, from a public AI-failures read. Give this OR buildFailureWorkflowId to scope to one cluster. One of theme/workflow id is required." + }, + { + "name": "buildFailureWorkflowId", + "type": "integer", + "required": false, + "description": "Integer id of the failure workflow to drill into. One of theme/workflow id is required." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend default; use searchAfter to page." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Opaque search-after cursor from the previous response's pagingParams.searchAfter." + } + ], + "intent": "Drill into one AI failure theme or workflow of a build and list the flat, paginated test runs behind it. Takes a theme or workflow id only \u2014 to fetch an explicit set of test-run ids instead, use list_build_ai_failure_cluster_test_runs, which accepts testRunIds.", + "returns": [ + "testRuns", + "pagingParams" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Send buildFailureThemeId OR buildFailureWorkflowId (integer ids from get_build_ai_failures). One of them is REQUIRED: with neither, the TestHub lookup 400s 'At least one of build_failure_theme_id or build_failure_workflow_id must be provided' (there is no whole-build mode).", + "There is no explicit-testRunIds variant on the public flat endpoint.", + "limit is ignored; page with searchAfter until pagingParams.hasNext is false." + ], + "responses": { + "200": { + "$response": "FlatAIFailureTestRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_queued_sessions", + "method": "GET", + "path": "/api/v1/queued-sessions", + "mode": "read", + "entity": "session", + "query": [ + { + "name": "userId", + "type": "integer", + "required": true, + "description": "The user whose queued sessions to list. Taken from the query, NOT the caller's identity, so any user id can be asked about." + }, + { + "name": "product", + "type": "string", + "required": true, + "description": "Product to filter the queue by (e.g. automate / app-automate)." + } + ], + "intent": "List the automation sessions currently queued for a user and product, each with session/build/project name, resolved capabilities, why it is queued and how long it has waited.", + "returns": [ + "sessions" + ], + "guidance": [ + "Both userId and product are required (400 otherwise).", + "userId comes from the query, so it is NOT scoped to the caller; only the build/project name enrichment is group-scoped (names resolve to null for builds outside your group).", + "queueReason explains the wait (e.g. no_terminal_available); duration is computed at read time.", + "The capabilities field's shape depends on the session's caps format (selenium / appium / default)." + ], + "responses": { + "200": { + "$response": "QueuedSessionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_app_profiling", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/appProfiling", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get app-profiling data (CPU/memory/etc. captured during an App Automate session) for the Test Details app-performance tab.", + "returns": [], + "guidance": [ + "App profiling is App Automate only; on an Automate session this is a 400 (\"App Profiling is only supported for App Automate sessions\").", + "An empty object ({}) is a normal 200 meaning the report is still generating (upstream 202) or was never captured (upstream 404); it is NOT an error, so re-poll rather than treating it as failure.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "The body is the opaque upstream app-profiling JSON.", + "On the demo sandbox environment this returns an empty object." + ], + "responses": { + "200": { + "$response": "SessionAppProfilingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_capabilities", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/capabilities", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get the raw desired/actual capabilities a session was started with (Automate or App Automate), so a user can see exactly how the run was configured.", + "returns": [], + "guidance": [ + "Product-dependent: the session's origin (automate / app-automate) is resolved and the caps are fetched from that product's API.", + "The body is opaque JSON: normally the upstream sessionParams object, but if the upstream fetch fails it falls back to {outputCapabilities:{capabilities:...}} built from the stored session capabilities. Do not assume a fixed key.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "On the demo sandbox environment this returns dummy capabilities." + ], + "responses": { + "200": { + "$response": "SessionCapabilitiesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_details", + "method": "GET", + "path": "/api/v1/sessions/{sessionId}/sessionDetails", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Get the header/metadata block for a session on the Test Details page: video/audio log handles, session URL, platform/device, framework and timings the tabs are built from.", + "returns": [ + "videoLogs", + "audioLogs", + "browserstackSessionUrl", + "percyBuildUrl", + "isSdk", + "device", + "framework", + "customMetadata", + "testStartedAt", + "buildId" + ], + "guidance": [ + "While the session is still being linked to its build the call returns a RETRYABLE 425 (BUILD_LINK_NOT_READY); retry shortly rather than treating it as fatal.", + "A missing session is SESSION_NOT_FOUND; a plan-locked build is 403 plan-not-allowed.", + "customMetadata and buildId are always null through this path today (buildId is not populated here despite the field name), so do not depend on them.", + "If the upstream media fetch fails, videoLogs/audioLogs come back null but the rest of the metadata is still returned with a 200.", + "On the demo sandbox environment this returns synthetic details with a sample video URL." + ], + "responses": { + "200": { + "$response": "SessionDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "425": { + "$response": "BuildLinkNotReady" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_session_test_code", + "method": "GET", + "path": "/ext/v1/sessions/{session_id}/testCode", + "mode": "read", + "entity": "session", + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "description": "Session hashed id." + } + ], + "intent": "Public API: get the test source code captured for an Automate session, one entry per test executed in that session, so external tooling (e.g. AI agents) can read the code behind a session.", + "returns": [ + "session_id", + "build_uuid", + "tests" + ], + "guidance": [ + "Public endpoint: authenticate with your BrowserStack username + access key.", + "Cross-group safe: the resolved build's group is re-checked against the caller's group and a mismatch is reported as SESSION_NOT_FOUND (not a distinct 403).", + "A session that exists but captured NO code is reported as SESSION_NOT_FOUND, indistinguishable from a bad id, so not-found does not prove the id is wrong.", + "TestHub caps and truncates very large responses server-side; the truncation is only logged, never surfaced, so a returned code body may be incomplete without any flag.", + "A plan-locked build returns a forbidden-operation error." + ], + "responses": { + "200": { + "$response": "SessionTestCodeResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "create_checks_mapping", + "entity": "check_config", + "method": "POST", + "path": "/api/v1/checks-mapping", + "mode": "write", + "intent": "Register a pull request's VCS checks (e.g. GitHub check runs) against existing BrowserStack check configurations, so the PR-checks / quality-gate pipeline can evaluate them and call back with results.", + "path_params": [], + "body": [ + { + "name": "event_name", + "type": "string", + "required": true, + "json_path": "/event_name", + "description": "VCS event name that triggered the mapping. Mandatory and must be non-blank." + }, + { + "name": "tool", + "type": "string", + "required": true, + "json_path": "/tool", + "description": "Source tool (e.g. the VCS provider). Mandatory and must be non-blank." + }, + { + "name": "callback_url", + "type": "string", + "required": true, + "json_path": "/callback_url", + "description": "URL the evaluation pipeline calls back once each mapped check is evaluated. Mandatory and must be non-blank. Stored verbatim on every created ingestion row." + }, + { + "name": "pr_data", + "type": "object", + "required": true, + "json_path": "/pr_data", + "description": "Pull-request metadata. Mandatory. The whole object is also stored verbatim as the ingestion row's PR metadata blob (with githubCheckId merged in per check).", + "fields": [ + { + "name": "organisationId", + "type": "string", + "description": "VCS organisation/owner NAME as stored on the check configuration (vcs_org_name, e.g. 'my-org'), NOT GitHub's numeric org id. Matched with repositoryId + group; a mismatch means no config is found and the whole call is a 400." + }, + { + "name": "repositoryId", + "type": "string", + "description": "Repository NAME as stored on the check configuration (repo_name, e.g. 'my-repo'), NOT GitHub's numeric repository id. Matched with organisationId + group." + }, + { + "name": "number", + "type": "integer", + "description": "Pull request number, stored as pr_number on every created row." + }, + { + "name": "commitSHA", + "type": "string", + "description": "Head commit SHA, stored as commit_sha on every created row." + }, + { + "name": "date", + "type": "string", + "format": "date-time", + "description": "Commit timestamp as an ISO-8601 instant (parsed with Instant.parse). An unparseable value fails the whole request with a 400 naming the check; when omitted, commit_timestamp is left unset." + }, + { + "name": "githubCheckId", + "type": "string", + "description": "Optional; note the per-check GitHub id is normally supplied inside pr_checks, not here." + }, + { + "name": "ticketId", + "type": "string" + }, + { + "name": "installationId", + "type": "string" + }, + { + "name": "autoMerge", + "type": "boolean" + }, + { + "name": "fullName", + "type": "string" + } + ] + }, + { + "name": "pr_checks", + "type": "array", + "required": true, + "json_path": "/pr_checks", + "description": "Non-empty list of the PR's checks to register. Each item's id is the CHECK CONFIGURATION id, and it must match an existing check configuration for the caller's group + this repository + organisation, or the whole call returns 400 'Check Configurations not found...'.", + "fields": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Check CONFIGURATION id as a string. Parsed with Long.parseLong, so a non-numeric value throws before validation and fails the call. NOT the GitHub check id." + }, + { + "name": "githubCheckId", + "type": "integer", + "required": true, + "description": "The GitHub check-run id for this check. Mandatory. Merged into the stored PR metadata blob for the matching ingestion row." + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "Check name. Mandatory and must be non-blank." + } + ] + } + ], + "returns": [ + "id", + "prNumber", + "commitSha", + "callbackUrl", + "checkStates", + "checkType", + "retryCount", + "splitRepos", + "prMetadataJson", + "thProjectId", + "checkConfiguration" + ], + "responses": { + "201": { + "description": "Array of the created vcs_ingestion rows (one per matched check configuration), each in state 'Not Started'. On success but with no matching configs the flow instead returns 400.", + "schema": { + "type": "array", + "items": { + "$schema": "VcsCheckMapping" + } + } + }, + "400": { + "description": "Plain-text (not JSON) body. Covers validation failures, the CHECKS_EVALUATION-not-accessible case, an unparseable commit date, a non-empty-but-unmatched pr_checks set ('Check Configurations not found...'), and any other exception whose message is echoed as the body.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement; when it is off the call still comes back as a 400 with the exception message as a PLAIN-TEXT body, so errors here are untyped strings, not JSON.", + "pr_checks[].id is the BrowserStack check-CONFIGURATION id, not the GitHub check id and not the ingestion id. Send the GitHub id separately as pr_checks[].githubCheckId.", + "A non-numeric pr_checks[].id throws (Long.parseLong) before bean validation, so it fails the whole request rather than returning a clean per-field error.", + "If none of the pr_checks ids match an existing check configuration for the caller's group + repositoryId + organisationId, the whole call is 400 'Check Configurations not found...' and nothing is created; register the check configuration first.", + "One vcs_ingestion row is created per MATCHED configuration; ids that match nothing are silently skipped rather than erroring individually. Read the returned array to see exactly which were registered, and take each row's integer id to look the mapping up later.", + "pr_data.date must be an ISO-8601 instant; an unparseable value fails the call with a 400 naming the check, and omitting it just leaves the commit timestamp unset.", + "Live 2026-09-30: organisationId/repositoryId are compared to the configuration's stored owner/repo NAMES (VcsIngestionService findByIdInAndRepoNameAndVcsOrgNameAndGroupId); numeric GitHub ids never match. Creating a mapping only stores a 'Not Started' ingestion row \u2014 it posts nothing to GitHub itself." + ] + }, + { + "name": "get_checks_mapping", + "entity": "check_config", + "method": "GET", + "path": "/api/v1/checks-mapping/{id}", + "mode": "read", + "intent": "Fetch a single previously-registered PR-check mapping (a vcs_ingestion row plus its joined check configuration) to see its current evaluation state, retry count, PR metadata and callback URL.", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The vcs_ingestion row id returned by create_checks_mapping. NOT the GitHub check id and NOT the check-configuration id (the service calls it checkId, which is misleading)." + } + ], + "returns": [ + "id", + "prNumber", + "commitSha", + "callbackUrl", + "checkStates", + "checkType", + "retryCount", + "splitRepos", + "prMetadataJson", + "thProjectId", + "checkConfiguration" + ], + "responses": { + "200": { + "$response": "VcsCheckMappingResponse" + }, + "404": { + "description": "Plain-text (not JSON) body: 'No check mapping found for checkId: N'. Returned when no row matches the id within the caller's group (another group's mapping reads as not-found).", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Plain-text (not JSON) body: 'Error fetching check mapping: ...'.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "The path id is the INGESTION row id from create_checks_mapping's response, not the githubCheckId or the check-configuration id.", + "Scoped to the caller's group: a mapping owned by another group returns 404, so a 404 does not prove the id never existed.", + "The 404 and 500 bodies are PLAIN TEXT, not JSON error objects, so do not try to parse them as {error:...}.", + "checkStates carries the evaluation status (created rows start at 'Not Started'); the joined checkConfiguration object gives the underlying check's name, project and type." + ] + }, + { + "name": "list_check_configurations", + "entity": "check_config", + "method": "GET", + "path": "/api/v1/repos/{owner}/{repo}/check-runs", + "mode": "read", + "intent": "List the SCM check-run configurations set up for a repository \u2014 the rules deciding which BrowserStack build/project a commit or PR check reports on, and whether it posts quality-gate or build status.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner. Matched against the stored vcs_org_name." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name. Matched against the stored repo_name." + } + ], + "query": [ + { + "name": "filter", + "type": "string", + "required": false, + "description": "Only the value 'enabled' (case-insensitive) is honoured: it restricts the listing to enabled checks. ANY other value is silently ignored and all checks are returned \u2014 this is not a general-purpose filter.", + "values": [ + "enabled" + ] + } + ], + "returns": [ + "repoName", + "ownerName", + "checks" + ], + "responses": { + "200": { + "$response": "CheckConfigurationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement (contrary to what one might assume for a read); without it the call is refused as feature-inaccessible.", + "Rows are scoped to the caller's group AND user, so you only see check configs you created \u2014 not the whole repository's.", + "When nothing is configured you get HTTP 200 with an empty checks list, not a 404; and if the lookup throws internally it also returns 200 with an EMPTY response object (null repoName/ownerName and no checks), so treat an all-null body as an error, not as 'no checks'.", + "filter accepts only 'enabled'; any other token is ignored rather than rejected, so a mistyped filter silently returns everything.", + "PRODUCT BUG (live 2026-09-30): when the repository has NO checks, the empty response swaps the envelope \u2014 repoName carries the OWNER and ownerName carries the REPO (controller builds CheckConfigurationResponse(owner, repo, \u2026) against a (repoName, ownerName, \u2026) constructor). Non-empty listings are correct. Trust the path you sent, not the envelope, on an empty list." + ] + }, + { + "name": "create_check_configurations", + "entity": "check_config", + "method": "POST", + "path": "/api/v1/repos/{owner}/{repo}/check-runs", + "mode": "write", + "intent": "Register up to 10 check-run configurations on a repository so BrowserStack posts a named check (quality-gate or build status) back to the VCS for commits/PRs, bound to a given project, build name and branch.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner; stored as the check's vcs_org_name." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name; stored as the check's repo_name." + } + ], + "body": [ + { + "name": "checks", + "type": "array", + "required": true, + "json_path": "/checks", + "minItems": 1, + "maxItems": 10, + "description": "The check configurations to create (1..10). Empty is rejected (400 'Checks list cannot be empty'); more than 10 is rejected. This is also capped against the repository's EXISTING count \u2014 max 10 per repository in total.", + "fields": [ + { + "name": "checkName", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Display name of the check. Mandatory, non-blank. Part of the uniqueness key (checkName + repo + group + buildName + project)." + }, + { + "name": "checkType", + "type": "string", + "required": true, + "values": [ + "QG_STATUS", + "BUILD_STATUS" + ], + "description": "What the check reports: QG_STATUS (quality-gate status) or BUILD_STATUS (build status). Mandatory." + }, + { + "name": "tool", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Source tool / VCS provider. Mandatory, non-blank." + }, + { + "name": "splitRepos", + "type": "boolean", + "required": true, + "json_path": "/checks/splitRepos", + "description": "Split-repos flag. Mandatory (@NotNull) \u2014 omitting it is a 400 even though it looks optional." + }, + { + "name": "buildName", + "type": "string", + "required": true, + "json_path": "/checks/buildName", + "maxLength": 1024, + "description": "Target build name the check reports on. Mandatory, non-blank. Part of the uniqueness key." + }, + { + "name": "projectName", + "type": "string", + "required": true, + "json_path": "/checks/projectName", + "maxLength": 256, + "description": "Target project name. Mandatory, non-blank. Must resolve to an EXISTING project in the workspace, or the write is refused as project-not-found." + }, + { + "name": "branchName", + "type": "string", + "required": false, + "json_path": "/checks/branchName", + "maxLength": 256, + "description": "Branch to scope the check to (optional)." + }, + { + "name": "buildTags", + "type": "string", + "required": false, + "json_path": "/checks/buildTags", + "maxLength": 256, + "description": "Build tags used to select the build (optional)." + }, + { + "name": "commitCheck", + "type": "boolean", + "required": false, + "json_path": "/checks/commitCheck", + "description": "Whether the check reports per-commit (optional)." + }, + { + "name": "isEnabled", + "type": "boolean", + "required": false, + "json_path": "/checks/isEnabled", + "description": "Whether the check is active (optional)." + } + ] + } + ], + "returns": [ + "repoName", + "ownerName", + "checks" + ], + "responses": { + "201": { + "$response": "CheckConfigurationResponse" + }, + "200": { + "$response": "CheckConfigurationResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Gated by the CHECKS_EVALUATION entitlement; without it the write is refused as feature-inaccessible.", + "Success is HTTP 201, but the SAME route returns HTTP 200 when nothing was actually saved (empty or all-dropped) \u2014 check the returned checks list, not just the status code.", + "At most 10 checks per repository IN TOTAL (existing + new), not just 10 per request; exceeding it is a 400 'Maximum 10 checks per repository'.", + "splitRepos is required despite reading like a flag \u2014 omit it and the call is a 400.", + "projectName must name a project that already exists in the workspace, otherwise the whole request fails with project-not-found.", + "Uniqueness is (checkName, repo, group, buildName, project); a duplicate collides on that key.", + "Checks are stored against the caller's group and user, so only you will see and be able to manage them afterwards." + ] + }, + { + "name": "update_check_configuration", + "entity": "check_config", + "method": "PUT", + "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}", + "mode": "write", + "intent": "Edit an existing check-run configuration \u2014 its name, check type, tool, target project/build name, branch, tags, commit-check or enabled flag.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name." + }, + { + "name": "checkId", + "type": "string", + "required": true, + "description": "Numeric check-configuration id (as a string in the path). A non-numeric value is a 400 'Invalid check ID format'." + } + ], + "body": [ + { + "name": "checkName", + "type": "string", + "required": true, + "maxLength": 256, + "description": "Check name. Mandatory, non-blank." + }, + { + "name": "checkType", + "type": "string", + "required": true, + "values": [ + "QG_STATUS", + "BUILD_STATUS" + ], + "description": "QG_STATUS or BUILD_STATUS. Mandatory." + }, + { + "name": "tool", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Source tool. Mandatory, non-blank." + }, + { + "name": "splitRepos", + "type": "boolean", + "required": true, + "json_path": "/splitRepos", + "description": "Split-repos flag. Mandatory (@NotNull)." + }, + { + "name": "buildName", + "type": "string", + "required": true, + "json_path": "/buildName", + "maxLength": 1024, + "description": "Target build name. Mandatory, non-blank." + }, + { + "name": "projectName", + "type": "string", + "required": true, + "json_path": "/projectName", + "maxLength": 256, + "description": "Target project name. Mandatory, non-blank; must resolve to an existing project or the update is refused as project-not-found." + }, + { + "name": "branchName", + "type": "string", + "required": false, + "json_path": "/branchName", + "maxLength": 256, + "description": "Branch scope (optional)." + }, + { + "name": "buildTags", + "type": "string", + "required": false, + "json_path": "/buildTags", + "maxLength": 256, + "description": "Build tags (optional)." + }, + { + "name": "commitCheck", + "type": "boolean", + "required": false, + "json_path": "/commitCheck", + "description": "Per-commit reporting flag (optional)." + }, + { + "name": "isEnabled", + "type": "boolean", + "required": false, + "json_path": "/isEnabled", + "description": "Enabled flag (optional)." + } + ], + "returns": [ + "checkId", + "checkName", + "repoName", + "vcsOrgName", + "vcsName", + "checkType", + "splitRepos", + "projectId", + "projectName", + "branchName", + "buildName", + "buildTags", + "groupId", + "userId", + "createdAt", + "updatedAt", + "commitCheck", + "isEnabled" + ], + "responses": { + "200": { + "$response": "CheckConfigurationItemResponseWrap" + }, + "204": { + "description": "Empty body. Returned when the service resolves no updatable check (used INSTEAD of a 404) \u2014 the caller cannot tell 'not found' from 'no-op' by status alone." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "This is a FULL replace, not a patch: all mandatory fields (checkName, checkType, tool, splitRepos, buildName, projectName) must be present on every update, so read the check first and echo unchanged values back.", + "Gated by CHECKS_EVALUATION; scoped to the caller's group and user \u2014 you cannot edit a teammate's check.", + "A missing/foreign check comes back as HTTP 204 with an empty body, NOT a 404 \u2014 do not read 204 as success without re-reading.", + "A non-numeric checkId is a 400 'Invalid check ID format'; projectName must resolve to an existing project.", + "To only flip the enabled flag, prefer toggle_check_configuration \u2014 it takes a single query param instead of the whole body." + ] + }, + { + "name": "toggle_check_configuration", + "entity": "check_config", + "method": "PATCH", + "path": "/api/v1/repos/{owner}/{repo}/check-runs/{checkId}/toggle", + "mode": "write", + "intent": "Enable or disable a single check-run configuration without editing the rest of it \u2014 the quick on/off switch in the repo's checks settings.", + "path_params": [ + { + "name": "owner", + "type": "string", + "required": true, + "description": "VCS organisation / owner." + }, + { + "name": "repo", + "type": "string", + "required": true, + "description": "VCS repository name." + }, + { + "name": "checkId", + "type": "string", + "required": true, + "description": "Numeric check-configuration id (string in path); non-numeric is a 400 'Invalid check ID format'." + } + ], + "query": [ + { + "name": "isEnabled", + "type": "boolean", + "required": true, + "description": "New enabled state. This is a REQUIRED QUERY parameter, not a body field \u2014 omitting it is a 400 for a missing parameter." + } + ], + "returns": [ + "checkId", + "checkName", + "repoName", + "vcsOrgName", + "vcsName", + "checkType", + "splitRepos", + "projectId", + "projectName", + "branchName", + "buildName", + "buildTags", + "groupId", + "userId", + "createdAt", + "updatedAt", + "commitCheck", + "isEnabled" + ], + "responses": { + "200": { + "$response": "CheckConfigurationItemResponseWrap" + }, + "404": { + "description": "Empty body. Returned when no check matches the id for the caller's group/user." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "isEnabled is a REQUIRED QUERY parameter (?isEnabled=true|false), not a body field \u2014 this is the most common mistake on this route.", + "Gated by CHECKS_EVALUATION and scoped to the caller's group and user.", + "A check that is not found comes back as HTTP 404 with an EMPTY body (unlike update, which uses 204 for the same case).", + "On success it returns the full updated check configuration, so you can confirm the new isEnabled value from the body." + ] + }, + { + "name": "update_view", + "entity": "view", + "method": "PUT", + "path": "/api/v1/views/{viewType}/{id}", + "mode": "write", + "intent": "Rename a saved view, replace its stored filter query, or change its private/public visibility.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view belongs to; sets the type used for the name-uniqueness check. An unrecognised value is a 400." + }, + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "View UUID. A view that does not exist is a 400 'View does not exists', not a 404." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "json_path": "/name", + "description": "New name. Omit to keep the stored name. Despite @NotNull on the model, an omitted field falls back to the stored value (partial update). A clash returns VIEW_NAME_EXISTS." + }, + { + "name": "query", + "type": "string", + "required": false, + "json_path": "/query", + "description": "New serialised filter. Omit to keep the stored query." + }, + { + "name": "level", + "type": "integer", + "required": false, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "New visibility (1 = private, 2 = public). Omit to keep the stored level." + } + ], + "returns": [], + "responses": { + "200": { + "description": "Empty body \u2014 the updated view is NOT echoed back (unlike create_view). Re-list to see the result." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Requires the manage-custom-views permission (BUILD_MANAGE_VIEWS).", + "PARTIAL update despite being a PUT: omitted name/query/level fall back to the view's stored values.", + "For a PRIVATE view the caller must be the owner ('Not allowed to update this view'); the SQL also restricts updates to your own views OR public views in your group. A non-existent id is a 400 'View does not exists', not a 404.", + "A name clash returns VIEW_NAME_EXISTS (per screen type: within group for public, within creator for private).", + "Returns an empty 200 body on success \u2014 do not expect the updated view back; re-list to confirm.", + "There is no revision history: an overwritten name, query or level cannot be restored." + ] + }, + { + "name": "list_views", + "entity": "view", + "method": "GET", + "path": "/ext/v1/views/{viewType}", + "mode": "read", + "intent": "External/public API (used by the Atlas agent) to list the caller's saved views for a screen, so an integration can pick a saved view instead of rebuilding a filter query.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the views belong to. An unrecognised value is a 400." + } + ], + "query": [], + "returns": [ + "id", + "type", + "query", + "level", + "name", + "description", + "createdBy", + "groupId" + ], + "responses": { + "200": { + "$response": "ViewListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: authenticated with a BrowserStack username + access key over HTTP Basic, not the internal session token.", + "Returns a bare JSON ARRAY of view objects, scoped to the caller's user/group; not paginated. No public-link-token path here (unlike the internal list).", + "Thin adapter over the same view service as the internal list \u2014 results are the same set for the same caller. There is intentionally NO external delete operation.", + "An unrecognised viewType is a 400; valid values are builds, testRuns, testHealth, uniqueErrors, buildInsights." + ] + }, + { + "name": "create_view", + "entity": "view", + "method": "POST", + "path": "/ext/v1/views/{viewType}", + "mode": "write", + "intent": "External/public API to save a filter query as a named view (private or shared with the org) for a screen.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view is for; sets the view's type. An unrecognised value is a 400." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/name", + "minLength": 1, + "maxLength": 100, + "description": "View name. Enforced at this public boundary to be non-blank and at most 100 characters (stricter than the internal surface). Must be unique per screen type within the group (public) or creator (private)." + }, + { + "name": "query", + "type": "string", + "required": true, + "json_path": "/query", + "minLength": 1, + "maxLength": 4000, + "description": "Serialised filter to store, as a string. Enforced non-blank and at most 4000 characters here." + }, + { + "name": "level", + "type": "integer", + "required": true, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = shared with the org. Required; any other value is rejected with 'level must be 1 (private) or 2 (shared)'." + } + ], + "returns": [ + "id", + "type", + "query", + "level", + "name", + "description", + "createdBy", + "groupId" + ], + "responses": { + "200": { + "$response": "ViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: username + access-key HTTP Basic auth. Requires the manage-custom-views permission (works for both globalRBAC-on and -off orgs).", + "Stricter validation than the internal create: name must be non-blank and <=100 chars, query non-blank and <=4000 chars, level exactly 1 or 2 \u2014 each violation is its own 400 message.", + "type is set from the path and id/createdBy/groupId in the body are ignored; the creator and group come from the authenticated caller.", + "Name uniqueness is per screen type (group for public, creator for private); a clash returns VIEW_NAME_EXISTS.", + "Returns the stored view (with its UUID id) as a single object." + ] + }, + { + "name": "replace_view", + "entity": "view", + "method": "PUT", + "path": "/ext/v1/views/{viewType}/{id}", + "mode": "write", + "intent": "Rename a saved view or replace its stored filter query and visibility level. Name, query and level are all REQUIRED here \u2014 this replaces the view rather than patching it, so send the whole object; update_view is the partial-update route.", + "path_params": [ + { + "name": "viewType", + "type": "string", + "required": true, + "values": [ + "builds", + "testRuns", + "testHealth", + "uniqueErrors", + "buildInsights" + ], + "description": "Screen the view belongs to; used for the name-uniqueness check and the ownership guard. An unrecognised value is a 400." + }, + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "View UUID. A non-UUID is a 400 'Invalid view id.'; an id not in the caller's own scoped view list is a 400 'View does not exist.' (closes a cross-tenant existence oracle)." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "json_path": "/name", + "minLength": 1, + "maxLength": 100, + "description": "View name. Validated non-blank and <=100 chars at this boundary." + }, + { + "name": "query", + "type": "string", + "required": true, + "json_path": "/query", + "minLength": 1, + "maxLength": 4000, + "description": "Serialised filter to store. Validated non-blank and <=4000 chars." + }, + { + "name": "level", + "type": "integer", + "required": true, + "json_path": "/level", + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = shared. Required; other values are a 400." + } + ], + "returns": [], + "responses": { + "200": { + "description": "Empty body on success \u2014 the updated view is not echoed back." + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + } + }, + "guidance": [ + "Public /ext/v1 surface: username + access-key HTTP Basic auth, plus the manage-custom-views permission.", + "Unlike the internal update, this validates the payload the same way create does (name <=100, query <=4000, level 1|2) \u2014 all three are effectively required here.", + "Explicit ownership guard: the id must appear in the caller's own scoped view list, otherwise a 400 'View does not exist.' \u2014 this deliberately hides whether some other tenant's view id exists. A non-UUID is a 400 'Invalid view id.'.", + "Returns an empty 200 body; re-list to confirm. No revision history, so overwritten values cannot be restored." + ] + }, + { + "name": "list_dashboard_views", + "method": "GET", + "path": "/api/v1/dashboards/views", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the dashboard views the current user can see (their own private ones plus group/shared ones) with name, visibility level, widget count and owner \u2014 the dashboard picker / dashboard list.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED even though this listing does not use the project to filter results \u2014 the controller resolves a project before every handler and 400s with 'One of `project_id`, `project_name` or `project_identifier` is required' when none is given." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which dashboard surface to resolve the project against. TO = Test Observability, TCM = Test Management." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "Dashboard views the caller can see, as a flat list under `data` (no success flag on this route).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "DashboardView" + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A project query param is mandatory here purely to satisfy the controller-wide project resolver; the returned list is not scoped by it, so passing any project the caller can access is enough.", + "The response is a bare `{data: [...]}` envelope with no `success` flag and no pagination \u2014 the whole visible set comes back in one call.", + "Each row carries the dashboard UUID under `data[].id`; carry that forward to get/update/delete/clone. The built-in views may or may not appear here, so address those by their `default` / `automation-trend` aliases on the read call rather than expecting them in this list." + ] + }, + { + "name": "create_dashboard_view", + "method": "POST", + "path": "/api/v1/dashboards/views", + "mode": "write", + "entity": "dashboard_view", + "intent": "Create a new custom dashboard view \u2014 its name, visibility level, global filters and widget layout \u2014 for the Test Observability (TO) or Test Management (TCM) dashboard page.", + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which surface the dashboard belongs to. TO = Test Observability, TCM = Test Management." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it both resolves the project and is the resource the DASHBOARD_CREATE permission check runs against." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "Dashboard name, 1-100 characters. Must be unique within the project \u2014 a duplicate name is rejected. Not annotated @NotNull, but a dashboard is expected to carry a name." + }, + { + "name": "level", + "type": "integer", + "default": 2, + "description": "Visibility level. Defaults to 2 (group / shared). Controls who else can see the dashboard." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Optional description. Capped at 100 characters (the validation message misstates the cap as 200)." + }, + { + "name": "filters", + "type": "object", + "description": "Global dashboard filters (DashboardFilters): dateRange, buildTags, tags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields. Applied across all widgets on the dashboard.", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Default time window for the dashboard (a DateRangeFilter with a `key` such as days30, or a custom range)." + }, + { + "name": "projects", + "type": "array", + "description": "Project ids the dashboard spans." + }, + { + "name": "buildName", + "type": "array", + "description": "Build-name filter values (wire key is `buildName`, not `buildNames`)." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names." + }, + { + "name": "tcmTestPlanIds", + "type": "array", + "description": "Test Plan ids (wire key `tcmTestPlanIds`; legacy `testPlanIds` is accepted as an alias)." + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "The widget layout to save. Each item is a Widgets object; see the widget-data capability for the per-widget field shape (id, name, type, globalFilters, desc, config, layout).", + "fields": [ + { + "name": "id", + "type": "string", + "description": "Client widget id, up to 100 chars." + }, + { + "name": "name", + "type": "string", + "description": "Widget title, 1-100 chars." + }, + { + "name": "type", + "type": "string", + "description": "Widget type (WidgetTypeEnum)." + }, + { + "name": "globalFilters", + "type": "object", + "description": "Per-widget filter overrides (DashboardFilters shape)." + }, + { + "name": "config", + "type": "object", + "description": "Widget configuration (display type, segments, filters, etc.)." + }, + { + "name": "layout", + "type": "object", + "description": "Grid position/size of the widget." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The created dashboard is returned under `data`, with the assigned UUID at `data.id` \u2014 take that for subsequent get/update/delete/clone.", + "Name must be unique in the project; a clash is rejected rather than silently de-duplicated.", + "`level` defaults to 2 (group/shared); set it explicitly if you need a private dashboard.", + "`source` (TO vs TCM) decides which dashboard surface the view belongs to and is not part of the body \u2014 pass it as a query param (defaults to TO)." + ] + }, + { + "name": "get_dashboard_view", + "method": "GET", + "path": "/api/v1/dashboards/views/{id}", + "mode": "read", + "entity": "dashboard_view", + "intent": "Load one dashboard view for rendering \u2014 its name, global filters and full widget configuration, with filter values injected for the built-in dashboards.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "A dashboard UUID, OR one of the read-only aliases `default` / `automation-trend`. `default` resolves to the TCM view when source=TCM and to the TO 'testing trends' view when source=TO; `automation-trend` resolves to the automation-trend view. Custom (non-built-in) UUIDs are scoped to the caller's user/group and read as not-found if they belong to someone else." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "TO or TCM. Also disambiguates which view the `default` alias resolves to." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide project resolver)." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "SIDE EFFECT: reading a dashboard pushes it onto the caller's Redis 'recent views' list (capped at the most recent few), which is what the home recent-views listing returns. This write happens for ordinary users and is skipped only for super-users (except super-users in group 2). There is no read that avoids it.", + "For the built-in default dashboard the response is doctored: its date range is forced to the last 30 days regardless of what was stored, and widget filter values are injected \u2014 so what you read back is not necessarily what was persisted.", + "The aliases `default` and `automation-trend` work ONLY here (and other reads). Update, delete and clone-source require the raw UUID and reject the aliases.", + "The dashboard is under `data`; `data.isDefault` / `data.isAutomation` flag the built-in views, and `data.totalWidget` is the widget count." + ] + }, + { + "name": "update_dashboard_view", + "method": "PUT", + "path": "/api/v1/dashboards/views/{id}", + "mode": "write", + "entity": "dashboard_view", + "intent": "Save changes to an existing dashboard view \u2014 rename it, change its visibility level, change its global filters, or persist an edited widget layout.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The dashboard UUID (raw UUID only \u2014 the `default` / `automation-trend` aliases are NOT accepted on this route). The update is additionally scoped to the caller's user/group." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_UPDATE permission check." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "New name, 1-100 characters. Must stay unique within the project." + }, + { + "name": "level", + "type": "integer", + "description": "New visibility level." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "New description (capped at 100 characters)." + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters (DashboardFilters shape \u2014 same as create)." + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget layout (array of Widgets objects \u2014 same shape as create)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "NO UNDO: dashboards keep no version history, so an overwritten name, filter set or widget layout is gone for good \u2014 read the current dashboard first if you need to preserve anything.", + "The three built-in dashboards (default TCM, default TO/testing-trends, automation-trend) cannot be updated \u2014 the call returns 400 'Cannot update default dashboard'.", + "The body replaces the fields it carries; send the full intended state (e.g. the complete widgets array) rather than a partial patch.", + "The path takes a raw UUID only. Unlike the read, `default` / `automation-trend` are not accepted here." + ] + }, + { + "name": "clone_dashboard_view", + "method": "POST", + "path": "/api/v1/dashboards/views/{id}/clone", + "mode": "write", + "entity": "dashboard_view", + "intent": "Duplicate an existing dashboard \u2014 copy its filters, widgets, source and type into a new dashboard owned by the caller, under a new name.", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "UUID of the SOURCE dashboard to copy (raw UUID only \u2014 aliases not accepted). The source is read with the caller's permission, so you can only clone a dashboard you can see." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_CREATE permission check." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": false, + "minLength": 1, + "maxLength": 100, + "description": "Name for the NEW cloned dashboard, 1-100 chars, unique within the project. This is the only field of the body that is used \u2014 level, filters and widgets are copied verbatim from the source, not taken from the body." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "DashboardViewResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only `name` from the body matters. Any level/filters/widgets you send are ignored \u2014 the clone copies the source dashboard's filters, widgets, source and type verbatim, so edit the copy afterwards to change them.", + "The new name must be unique in the project; a duplicate is rejected.", + "The clone is owned by the calling user; the new dashboard (with its own UUID at `data.id`) is returned synchronously in `data`.", + "The source id must be a raw UUID \u2014 the `default` / `automation-trend` aliases are not accepted here even though the read accepts them." + ] + }, + { + "name": "list_dashboard_view_widget_types", + "method": "GET", + "path": "/api/v1/dashboards/views/widgets", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the catalogue of widget types that can be added to a dashboard view (the 'add widget' palette) with their display names.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED by the controller-wide resolver even though this static catalogue does not use it." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "name", + "key" + ], + "responses": { + "200": { + "description": "Static catalogue of addable widget types, returned as a BARE JSON ARRAY (no envelope).", + "schema": { + "type": "array", + "items": { + "$schema": "WidgetCatalogueEntry" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a bare array `[{name, key}]`, not wrapped in `data` or `success`.", + "This is static, hard-coded data (Test Case Statistics, Test Case Type, Test Case Trend) \u2014 it makes no DB or downstream call and does not vary by project, despite the mandatory project query param.", + "`key` is the WidgetTypeEnum value to send as a widget's `type` when building a dashboard layout or requesting widget data." + ] + }, + { + "name": "list_dashboard_view_custom_field_filters", + "method": "GET", + "path": "/api/v1/dashboards/views/filters/{widgetType}", + "mode": "read", + "entity": "dashboard_view", + "intent": "List the project's custom (user-defined) test-case fields that can be used as filters when configuring a test-case dashboard widget.", + "path_params": [ + { + "name": "widgetType", + "type": "string", + "required": true, + "values": [ + "TEST_CASE_TREND", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE" + ], + "description": "A WidgetTypeEnum value. Only TEST_CASE_TREND, TEST_CASE_STATISTICS_CARD and TEST_CASE_TYPE are valid here; any other widget type returns 400 'Custom Filters are not available for widget type: X'." + } + ], + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide resolver); the custom fields returned are this project's." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "returns": [ + "id", + "label", + "type", + "fieldType" + ], + "responses": { + "200": { + "description": "Custom-field filters for the project, as a BARE JSON ARRAY (no envelope).", + "schema": { + "type": "array", + "items": { + "$schema": "CustomFieldFilterDetail" + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only the three test-case widget types are accepted; anything else is a 400 naming the widget type, not an empty list.", + "The response is a bare array `[{id, label, type, fieldType}]` \u2014 each entry is a custom field usable as a widget filter (type is 'Custom').", + "Distinct from the group-level `/api/v1/dashboards/filters/{widgetType}` route; this one is scoped to a single dashboard-view project." + ] + }, + { + "name": "list_dashboard_widget_filter_values", + "method": "POST", + "path": "/api/v1/dashboards/views/widgets/filters", + "mode": "read", + "entity": "dashboard_view", + "intent": "Fetch the selectable values for one filter inside a dashboard widget's filter editor (the type-ahead / value picker) \u2014 e.g. the build names, tags, users, teams or test-case field values available for a project, given the currently applied filters.", + "query": [ + { + "name": "page", + "type": "integer", + "required": false, + "default": 1, + "description": "Page number for the value list. Note the param is `page` (not `p`)." + }, + { + "name": "per_page", + "type": "integer", + "required": false, + "default": 30, + "description": "Values per page (default 30). No server-side upper bound was found in the resolved code \u2014 see guidance." + }, + { + "name": "search", + "type": "string", + "required": false, + "description": "Type-ahead substring to narrow the returned values." + }, + { + "name": "source", + "type": "string", + "required": false, + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "TO or TCM. With source=TCM the request is forced to includeManualAndAutomated=true." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED. The resolved project OVERWRITES whatever `filters.projects` is in the body." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "widgetKey", + "type": "string", + "required": true, + "description": "REQUIRED (@NotNull). The widget type (WidgetTypeEnum) the filter belongs to. Note the wire key is `widgetKey`, not `widget` or `type`. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "json_path": "/widgetKey", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + }, + { + "name": "filterKey", + "type": "string", + "required": true, + "values": [ + "buildStatus", + "buildName", + "users", + "teams", + "frameworks", + "folders", + "buildTags", + "hostName", + "os", + "browser", + "device", + "tags", + "branches", + "projects", + "testCaseRequirements", + "testCasePriorities", + "testCaseStatuses", + "testCaseTypes", + "testCaseTags", + "testCaseOwners", + "testCaseAutomationStatuses", + "testCaseFolders", + "buildTypes", + "buildOwners", + "workflowStatus", + "custom", + "customFields", + "issueType", + "testCaseAutomationStates" + ], + "description": "REQUIRED (@NotNull). Which filter's values to fetch. This is the field that selects the value set (the server switches on filterKey, not on widgetKey). filterKey=buildName additionally turns on selective project filtering.", + "json_path": "/filterKey" + }, + { + "name": "filters", + "type": "object", + "description": "The currently applied filters (WidgetFiltersValue) used to scope the value list. Optional \u2014 an omitted/null value is treated as empty. Its `projects` is always overwritten by the project resolved from the query params, so setting it in the body is pointless.", + "json_path": "/filters" + }, + { + "name": "filterId", + "type": "integer", + "required": false, + "description": "Optional custom-field id, used when filterKey targets a specific custom field.", + "json_path": "/filterId" + }, + { + "name": "applyDateRange", + "type": "boolean", + "required": false, + "default": false, + "description": "Opt in to bounding the value list by `filters.dateRange`. Defaults to false, i.e. the full value list is returned regardless of date range.", + "json_path": "/applyDateRange" + } + ], + "returns": [ + "data", + "fullList", + "info", + "suggestedAfterKey", + "othersAfterKey" + ], + "responses": { + "200": { + "$response": "WidgetFilterValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but READ-ONLY (@ReadOnly) \u2014 it fetches filter values and changes nothing.", + "The value set is chosen by `filterKey`, and `widgetKey` must also be present (both are @NotNull); the two are separate fields and the server dispatches on filterKey.", + "Pagination params are `page` and `per_page` (default 30), NOT `p`/`count`. `data` is a map keyed by bucket (`suggested`, `others`), each a list of value structs - the same shape as search_widget_filter_values. `fullList` and `info` can be null; continue with suggestedAfterKey / othersAfterKey.", + "`filters.projects` is always replaced by the project from the query params, so passing project ids in the body is ignored \u2014 set the project via project_id/project_name/project_identifier.", + "A public-link token may be supplied via the `Public-Link-Token` (or the observability public-link-token) header to run in shared read-only dashboard mode.", + "With source=TCM the request is forced to include both manual and automated runs." + ] + }, + { + "name": "check_dashboard_widget_ignored_filters", + "method": "POST", + "path": "/api/v1/dashboards/views/widgets/filters/ignored", + "mode": "read", + "entity": "dashboard_view", + "intent": "Validate a widget's saved test-case filter values against the current project and report which are no longer valid \u2014 so stale filter chips (a deleted tag, priority, status, type or linked requirement) can be flagged or dropped.", + "query": [ + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED; the saved filter values are validated against this project." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + } + ], + "body": [ + { + "name": "testCaseRequirements", + "type": "object", + "description": "Linked-requirement filter to validate. Only `issueIds` (array) and `issueType` are read.", + "fields": [ + { + "name": "issueIds", + "type": "array", + "description": "Requirement/issue ids to validate." + }, + { + "name": "issueType", + "type": "string", + "description": "Requirement issue type." + } + ] + }, + { + "name": "testCasePriorities", + "type": "array", + "description": "Saved test-case priority ids to validate." + }, + { + "name": "testCaseTags", + "type": "array", + "description": "Saved test-case tag values to validate." + }, + { + "name": "testCaseStatuses", + "type": "array", + "description": "Saved test-case status ids to validate." + }, + { + "name": "testCaseTypes", + "type": "array", + "description": "Saved test-case type ids to validate." + } + ], + "returns": [ + "filterMatches", + "ignoredFiltersCount", + "isMismatched" + ], + "responses": { + "200": { + "$response": "IgnoredFilterValuesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only \u2014 a pass-through to TestHub that computes validity and stores nothing.", + "The body is a full WidgetFiltersValue object, but ONLY five fields are actually read: testCaseRequirements (issueIds + issueType), testCasePriorities, testCaseTags, testCaseStatuses and testCaseTypes. Every other filter field you send is silently ignored here.", + "The response reports, per filter type, how many values still match vs are ignored (`filterMatches[]` with matchedValuesCount / ignoredValuesCount / isMismatched), a total `ignoredFiltersCount`, and an overall `isMismatched` flag \u2014 use these to flag or drop stale chips." + ] + }, + { + "name": "get_dashboard_view_widget_data", + "method": "POST", + "path": "/api/v1/dashboards/views/{dashboardId}/widgets/{widgetType}", + "mode": "read", + "entity": "dashboard_view", + "intent": "Compute the data for one widget on a project-scoped dashboard view \u2014 applying the caller's global filters and time window \u2014 to render that widget (charts, stat cards, trends, etc.).", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "The dashboard UUID. Present in the path for routing but NOT read by the handler \u2014 the widget is computed purely from the body and query params." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "A WidgetTypeEnum value identifying which widget to compute. FLAKINESS, ALWAYS_FAILING and NEW_FAILURES are plan-gated and return FEATURE_INACCESSIBLE if the group lacks the feature. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "required": true, + "values": [ + "TO", + "TCM" + ], + "description": "REQUIRED on this route (unlike the controller default): omitting it 400s with '`source` is required'. With source=TCM, TCM build widgets are forced to include both manual and automated runs." + }, + { + "name": "project_id", + "type": "integer", + "required": false, + "description": "One of project_id / project_name / project_identifier is REQUIRED on this route. The resolved project id is injected into the widget's filters automatically (the TestHub project id for ClickHouse-powered widgets, the observability project id otherwise) \u2014 do not pass project ids in the body." + }, + { + "name": "project_name", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "project_identifier", + "type": "string", + "required": false, + "description": "Alternative project selector. Supply exactly one of the three." + }, + { + "name": "isThProject", + "type": "boolean", + "required": false, + "default": false, + "description": "Treat the supplied project identifier as a TestHub project id." + }, + { + "name": "lowerBound", + "type": "integer", + "required": false, + "description": "Start of the time window, epoch milliseconds. Optional \u2014 when omitted the window is derived from the body's dateRange." + }, + { + "name": "upperBound", + "type": "integer", + "required": false, + "description": "End of the time window, epoch milliseconds. Optional \u2014 derived from the body's dateRange when omitted." + }, + { + "name": "pageNumber", + "type": "integer", + "required": false, + "description": "Optional page number for paginated widget data; copied into the widget's global filters." + } + ], + "body": [ + { + "name": "type", + "type": "string", + "description": "Widget type (WidgetTypeEnum). Describes the widget being computed." + }, + { + "name": "globalFilters", + "type": "object", + "description": "The applied dashboard filters (DashboardFilters): dateRange (drives the time window unless lowerBound/upperBound are given), tags, buildName, folders, tcmTestPlanIds, match, etc. Its `projects` is overwritten server-side with the resolved project.", + "json_path": "/globalFilters", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Time window used when lowerBound/upperBound query params are absent." + } + ] + }, + { + "name": "id", + "type": "string", + "description": "Widget id (up to 100 chars)." + }, + { + "name": "name", + "type": "string", + "description": "Widget name (1-100 chars)." + }, + { + "name": "desc", + "type": "string", + "description": "Widget description (up to 200 chars)." + }, + { + "name": "config", + "type": "object", + "description": "Widget configuration (display type, segments, per-widget filters, stats options, etc.) that shapes the computed data." + }, + { + "name": "layout", + "type": "object", + "description": "Grid layout of the widget (not needed to compute data)." + } + ], + "returns": [ + "(widget-type-specific)" + ], + "responses": { + "200": { + "$response": "WidgetDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but READ-ONLY (@ReadOnly, routed to a read replica) \u2014 it computes and returns widget data and persists nothing.", + "The response SHAPE depends entirely on `widgetType` (the handler returns an untyped object built per widget) \u2014 inspect it per widget type rather than assuming a fixed schema.", + "Unlike the other dashboard routes, BOTH `source` and a project identifier are mandatory here; omitting either 400s from the request binders.", + "The path `dashboardId` is not read \u2014 the widget is defined by the request body and `widgetType`. Do not rely on it selecting a saved widget.", + "Do not put project ids in `globalFilters.projects`; the resolved project (TestHub id for ClickHouse-powered widgets, observability id otherwise) is injected automatically and overrides the body.", + "FLAKINESS, ALWAYS_FAILING and NEW_FAILURES widgets are plan-gated \u2014 a group without the feature gets FEATURE_INACCESSIBLE, not empty data." + ] + }, + { + "name": "create_report", + "method": "POST", + "path": "/api/v1/reports", + "mode": "write", + "entity": "report", + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Report source. Defaults to TO (the end-user builder). TCM is the Test-Management-mirror source and is normally set by the sync path, not here." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Report name. Must be unique for the report's (type, level) combination or the create is rejected." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility: 1 = private, 2 = group. Omit and the report is created with no level set." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100, + "description": "Report type. Decides which widget list is persisted: a report-type value (in the backend's REPORT_TYPES set) persists reports[]; otherwise widgets[] is persisted. Defaults to 'Dashboard' when omitted, which would persist widgets[] instead." + }, + { + "name": "desc", + "type": "string", + "maxLength": 100, + "description": "Description. Max length is 100 characters (the validation message misstates it as 200)." + }, + { + "name": "filters", + "type": "object", + "description": "Global filters saved on the report.", + "fields": [ + { + "name": "dateRange", + "type": "object", + "description": "Date-range filter." + }, + { + "name": "projects", + "type": "array", + "description": "Observability project ids (List)." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build tag names." + }, + { + "name": "tags", + "type": "array", + "description": "Test-execution tag names; AND/OR controlled via match['tags']." + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "buildNames", + "type": "array", + "description": "Wire key is buildName (singular).", + "json_path": "/filters/buildName" + }, + { + "name": "branchList", + "type": "array", + "description": "Wire key is branches.", + "json_path": "/filters/branches" + }, + { + "name": "testPlanIds", + "type": "array", + "description": "Wire key is tcmTestPlanIds; the legacy alias testPlanIds is also accepted for old persisted blobs.", + "json_path": "/filters/tcmTestPlanIds" + }, + { + "name": "order", + "type": "array", + "description": "Filter display order. Wire key is filterOrder.", + "json_path": "/filters/filterOrder" + }, + { + "name": "match", + "type": "object", + "description": "Per-filter AND/OR match map." + } + ] + }, + { + "name": "reports", + "type": "array", + "description": "The report widgets, persisted when type is a report-type value. Each item is a widget (see 'widgets' for its shape).", + "fields": [ + { + "name": "id", + "type": "string", + "maxLength": 100 + }, + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + { + "name": "type", + "type": "string", + "description": "WidgetTypeEnum." + }, + { + "name": "reportType", + "type": "string", + "description": "e.g. TABULAR_TEST_RUNS." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget-level global filters. Wire key is globalFilters.", + "json_path": "/reports/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig \u2014 datasets, segments, display type, columns, etc." + }, + { + "name": "layout", + "type": "object" + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "Dashboard-style widgets, persisted only when type is NOT a report-type value. Send reports[] for a report-typed record; send the wrong list for the type and the record saves empty. Same item shape as reports[].", + "fields": [ + { + "name": "id", + "type": "string" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/widgets/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + } + ], + "intent": "Create a new saved report \u2014 its name, visibility level, global filters and the list of report widgets \u2014 and get back the persisted record.", + "returns": [ + "data" + ], + "guidance": [ + "Name must be unique for the given (type, level); a clash is rejected.", + "Whether the saved widgets come from reports[] or widgets[] depends on type: a report-type value persists reports[], anything else (including the default 'Dashboard') persists widgets[] \u2014 send the wrong list for the type and the report saves with no widgets.", + "Widget-level global filters ride under the key globalFilters (not dashboardFilters); the report-level filter list uses filterOrder / buildName / branches / tcmTestPlanIds on the wire (not their Java names).", + "source defaults to TO; TCM is the Test-Management-mirror source and normally set by the sync path, not here.", + "The new record is stamped with your user, group and team and returned under data with a fresh UUID id.", + "The response echoes the saved record, but `desc` is not echoed back, `widgets` is null for a report-typed record (not an empty array), and filters come back normalised to their wire keys (users, lastExecutedBy, teams, ... are added as empty/null)." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_saved_reports", + "method": "GET", + "path": "/api/v1/reports", + "mode": "read", + "entity": "report", + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Which report source to list. TO = end-user reports (default); TCM = reports mirrored from Test Management." + } + ], + "intent": "List the reports visible to you \u2014 your own plus group-level public ones \u2014 for the Reports landing page, with each report's name, level, type, filters and widget config.", + "returns": [ + "data" + ], + "guidance": [ + "Requires the custom-dashboard plan feature; without it the call is refused (feature inaccessible) rather than returning an empty list.", + "This is the report-typed view of the shared report/dashboard store, so dashboard-only filter rewriting is skipped.", + "Returns a flat list under data with NO pagination \u2014 there is no page / per_page on this listing.", + "The report listing is the source of the UUIDs the single-report read, update, delete, clone, dataset and tiles calls need.", + "Rows are summaries: `reports`, `widgets` and `filters` come back null even when totalWidget > 0, and source may be null. Read the report by id (get_report) for its widgets and filters." + ], + "responses": { + "200": { + "$response": "ReportRecordListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report", + "method": "GET", + "path": "/api/v1/reports/{id}", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The report UUID." + } + ], + "intent": "Load one saved report's full definition \u2014 name, level, filters and widget/report configuration \u2014 to render or edit it.", + "returns": [ + "data" + ], + "guidance": [ + "Scoped to your own group; a report id belonging to another group reads as not found (DASHBOARD_NOT_FOUND).", + "The dashboard-only filter rewrites and the automation-state widget transform are deliberately skipped for reports, so you read back the raw saved definition.", + "The report sits under data; its widgets are under data.reports for a report-typed record (data.widgets for a dashboard-typed one)." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "update_report", + "method": "PUT", + "path": "/api/v1/reports/{id}", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The report UUID." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "New name; re-checked for (type, level) uniqueness when it changes. Omit to keep the current name." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility 1 = private, 2 = group." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100 + }, + { + "name": "desc", + "type": "string", + "maxLength": 100 + }, + { + "name": "filters", + "type": "object", + "description": "Replacement global filters. Same shape/wire-key quirks as create (globalFilters, filterOrder, buildName, branches, tcmTestPlanIds). Omit to keep existing filters." + }, + { + "name": "reports", + "type": "array", + "description": "Replacement widget set for a report-typed record. Omit to keep the existing widgets. Item shape as in create.", + "fields": [ + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/reports/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + }, + { + "name": "widgets", + "type": "array", + "description": "Replacement widget set for a dashboard-typed record. Omit to keep the existing widgets.", + "fields": [ + { + "name": "dashboardFilters", + "type": "object", + "json_path": "/widgets/globalFilters" + }, + { + "name": "config", + "type": "object" + } + ] + } + ], + "intent": "Update an existing saved report in place \u2014 rename, change visibility level, change global filters, or replace its widget set.", + "returns": [ + "data" + ], + "guidance": [ + "No version history \u2014 the previous name, filters and widgets are overwritten with no way to restore them (there is no undo).", + "Name uniqueness is re-checked only when the name, type or level changes.", + "Widget replacement is whole-set: report-typed records replace from reports[], dashboard-typed from widgets[]; omit the list and the existing widgets are kept.", + "For a GCD-disabled user, persisted ClickHouse widgets omitted from the payload are merged back in rather than dropped.", + "In the demo-sandbox environment the built-in default report cannot be updated (400).", + "The lookup is scoped to your user and group; a report you cannot see is reported as not found." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "clone_report", + "method": "POST", + "path": "/api/v1/reports/{id}/clone", + "mode": "write", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "The SOURCE report UUID to duplicate." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Name for the copy. Must be unique for the (type, level)." + }, + { + "name": "level", + "type": "integer", + "minimum": 1, + "maximum": 2, + "values": [ + 1, + 2 + ], + "description": "Visibility for the copy: 1 = private, 2 = group." + }, + { + "name": "type", + "type": "string", + "minLength": 4, + "maxLength": 100 + }, + { + "name": "desc", + "type": "string", + "maxLength": 100 + } + ], + "intent": "Duplicate an existing report under a new name \u2014 'Make a copy' in the Reports UI \u2014 copying the source report's saved filters and widgets.", + "returns": [ + "data" + ], + "guidance": [ + "The copy takes its filters and widgets from the SOURCE report, not from your body \u2014 the body essentially only supplies the new name and level. A filters/widgets payload here is ignored.", + "Give a name that is unique for the report's (type, level), or the clone is rejected.", + "The source report's widget config is validated first, so a corrupt source is refused rather than copied through.", + "The new report comes back under data with its own new UUID id." + ], + "responses": { + "200": { + "$response": "ReportRecordResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_dataset_rows", + "method": "POST", + "path": "/api/v1/reports/{id}/datasets", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Rows per page." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Bound but not used for dataset computation." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body, not an error. Missing reportType is 400 'Report type is required'." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget global filters. Wire key is globalFilters.", + "json_path": "/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "type", + "type": "string", + "description": "WidgetTypeEnum." + }, + { + "name": "layout", + "type": "object" + }, + { + "name": "id", + "type": "string", + "maxLength": 100 + }, + { + "name": "name", + "type": "string", + "minLength": 1, + "maxLength": 100 + } + ], + "intent": "Run a report widget's query and get its paginated result rows \u2014 the actual table data behind a tabular test-runs report tile.", + "returns": [ + "data", + "summary", + "sourceFencingFlags" + ], + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", + "Only reportType TABULAR_TEST_RUNS is implemented; any other type returns a 200 with a null body rather than erroring.", + "The {id} in the path is ignored entirely \u2014 everything comes from the body, so the path id is just a placeholder.", + "Widget global filters are sent under the key globalFilters, NOT dashboardFilters.", + "Rows come back under data with a summary; summary.totalDatasets is -1 (not a real total), so page through using page / per_page and summary.hasNext rather than trusting a count.", + "A validation failure other than a known ObservabilityException is only logged, not surfaced \u2014 a 200 with empty data can mask a silently dropped dataset." + ], + "responses": { + "200": { + "$response": "ReportDatasetResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_stat_tiles", + "method": "POST", + "path": "/api/v1/reports/{id}/tiles", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "id", + "type": "string", + "format": "uuid", + "required": true, + "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "description": "Bound but IGNORED \u2014 tiles are not paginated." + }, + { + "name": "per_page", + "type": "integer", + "description": "Bound but IGNORED \u2014 tiles are not paginated." + }, + { + "name": "source", + "type": "string", + "values": [ + "TO", + "TCM" + ], + "description": "Bound but not used for tiles." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body. Missing reportType is 400 'Report type is required'." + }, + { + "name": "dashboardFilters", + "type": "object", + "description": "Widget global filters. Wire key is globalFilters.", + "json_path": "/globalFilters" + }, + { + "name": "config", + "type": "object", + "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + } + ], + "intent": "Get the summary stat tiles (aggregate KPI numbers) for a report widget \u2014 the headline figures shown above the report table.", + "returns": [ + "data", + "sourceFencingFlags" + ], + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", + "Same body as the dataset call but returns aggregates instead of rows; only TABULAR_TEST_RUNS is implemented, other types return a 200 with a null body.", + "page / per_page / source are accepted and ignored here \u2014 tiles are not paginated.", + "The {id} path variable is unused; the widget definition comes entirely from the body.", + "Tiles come back under data as {meta, value} pairs (totalUniqueTests, flakyTestsCount, newFailureTestsCount, executions counts, \u2026); totalIssuesCount is added only when configuredColumns includes 'openIssues', and activeRunCount / closedRunCount / totalTestCasesCount appear only when present." + ], + "responses": { + "200": { + "$response": "ReportStatTilesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_report_custom_fields", + "method": "GET", + "path": "/api/v1/reports/custom-fields", + "mode": "read", + "entity": "report", + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Page number." + }, + { + "name": "per_page", + "type": "integer", + "default": 300, + "description": "Page size, default 300 (the upstream max, i.e. the whole list in one call). The picker is not meant to paginate; values above 300 are clamped by the upstream, not rejected." + }, + { + "name": "search", + "type": "string", + "description": "Substring filter on field name." + }, + { + "name": "project_ids", + "type": "array", + "description": "Observability project ids (List, converted to TestHub ids for you). Omit for the whole group. Ids that resolve to nothing return an empty list, not an error." + } + ], + "intent": "List the custom-field / column definitions usable as report columns and filters, optionally narrowed to given projects \u2014 feeds the report builder's column picker and resolves stored field ids back to display names.", + "returns": [ + "customFields", + "info" + ], + "guidance": [ + "per_page defaults to 300 (the upstream max) because the picker is meant to load the whole list in one call; requests above 300 are clamped upstream, not rejected.", + "project_ids are observability project ids and are converted to TestHub project ids for you; omit them for the whole group.", + "A saved report stores columns by numeric fieldId \u2014 use this to map those ids back to fieldName. The same field name can appear twice, distinguished only by entityType (TestCase vs TestExecution)." + ], + "responses": { + "200": { + "$response": "ReportCustomFieldsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_report_static_builds", + "method": "GET", + "path": "/api/v1/reports/{projectNormalisedName}/builds", + "mode": "read", + "entity": "report", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "The project's normalised NAME (not its id). A report UUID and a project name share the /reports/{...} space, so this route matches only when the segment is a project name." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "minimum": 1, + "default": 1 + }, + { + "name": "per_page", + "type": "integer", + "default": 10 + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to]. Required (@NotNull @Size(2)); omitted -> 400 'dateRange must not be null'." + }, + { + "name": "buildStatuses", + "type": "array", + "description": "Explicit build display statuses. When present, wins outright (the inherited 'status' is not unioned in)." + }, + { + "name": "status", + "type": "array", + "description": "Interpreted here as build DISPLAY statuses: used only when buildStatuses is empty, and only values that case-insensitively map to a build display status are kept \u2014 the rest are silently ignored." + }, + { + "name": "frameworks", + "type": "array" + }, + { + "name": "buildOwners", + "type": "array", + "description": "User ids (List)." + }, + { + "name": "buildStates", + "type": "array" + }, + { + "name": "buildGroupIds", + "type": "array" + }, + { + "name": "testPlanIds", + "type": "array" + }, + { + "name": "requirements", + "type": "array" + }, + { + "name": "buildName", + "type": "array" + }, + { + "name": "buildIds", + "type": "array" + }, + { + "name": "projects", + "type": "array" + }, + { + "name": "creationType", + "type": "string" + }, + { + "name": "isArchived", + "type": "boolean" + }, + { + "name": "excludeLocked", + "type": "string" + }, + { + "name": "search", + "type": "string" + }, + { + "name": "ordering", + "type": "string" + } + ], + "intent": "List a project's builds that match a report's build-level filter set \u2014 used to populate the build picker / static build scope when building a report against a fixed set of builds.", + "returns": [ + "data", + "summary", + "sourceFencingFlags" + ], + "guidance": [ + "The path segment is the project's normalised NAME, not its id.", + "status is treated as a build DISPLAY status here (the filter object is shared with the stability/performance request): explicit buildStatuses wins; otherwise status values that map to a build display status are back-filled and non-matching values are ignored.", + "Builds come back under data as build rows (not test-run rows), with a summary whose totalDatasets is -1 \u2014 page with page / per_page and read summary.hasNext rather than a total.", + "An empty match returns a 200 with empty data (plus sourceFencingFlags), not a 404." + ], + "responses": { + "200": { + "$response": "ReportDatasetResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_report_test_stats", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/stats", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, taken from a row of the report table." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence. Defaults to TO (Test Observability); TCM narrows to test-management data." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params: project ids to scope the window." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter: build ids to scope the window." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq filter. On this build-level report, status values that map to a build-display-status are used and non-matching values are ignored; an explicit buildStatuses set takes precedence and is not widened by status." + } + ], + "intent": "Get the headline stats for one test case in the tabular test-runs report - its reliability (failure ratio), average duration, and failed/total occurrence counts over the filtered window - for the detail drawer.", + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "responses": { + "200": { + "description": "Headline stats for the test, or an empty object when the test has no matching runs in the window.", + "schema": { + "$schema": "ReportTestStats" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "When the test has zero matching runs in the window the body is an empty JSON object `{}` - not zeroed stats - so branch on emptiness before reading `reliability`.", + "`reliability` is the failure ratio as a percentage (failed*100/total), `duration` is the average duration, and `failureOccurrence` carries the raw `{failed, total}` counts.", + "Requires the REPORT_VIEW project permission. Filters are read from GetReportsFilterRq query params, not a body, even though sibling report calls post a body.", + "The `globalTestHash` is the test's cross-run hash from a report-table row, not a numeric test id." + ] + }, + { + "name": "list_report_test_runs", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/testsLists", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, from a report-table row." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number. Defaults to 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Page size. Defaults to 10. No server-enforced ceiling was found in the handler - the value is passed through to the backend unclamped." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq status filter (build-display-status resolution as on the stats call)." + } + ], + "intent": "List the individual test runs behind one row of the tabular test-runs report - every execution of that test case in the window, with status, duration, build and settings-version detail - for the drill-down view.", + "returns": [ + "data", + "summary" + ], + "responses": { + "200": { + "description": "The runs for this test, plus a pagination summary. An empty result returns an object with null data and summary.", + "schema": { + "$schema": "TabularTestRunsReportResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Rows are under `data` (each a report row: test/build ids, workflow status, avg duration in ms, build name/tags/number, project id, started-at, smart-tag settings). Paging state is under `summary` - `summary.hasNext` is true when the page came back full, and `summary.totalDatasets` is -1 (not a real total) on this route.", + "When there are no matching runs the body is an object with `data` and `summary` both null, rather than an empty array - check for null before iterating.", + "This call enriches each row with a build settings-version lookup, so it is heavier than a plain list; keep `per_page` modest.", + "`workflowStatus`/`status` are normalised to a known status or the literal 'unknown'.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "get_report_test_trend", + "method": "GET", + "path": "/api/v1/reports/view/{globalTestHash}/details/trend", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "globalTestHash", + "type": "string", + "required": true, + "description": "Global hash identifying one test case across runs, from a report-table row." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "projects", + "type": "array", + "description": "GetReportsFilterRq filter bound from query params." + }, + { + "name": "buildIds", + "type": "array", + "description": "GetReportsFilterRq filter." + }, + { + "name": "status", + "type": "array", + "description": "GetReportsFilterRq status filter." + } + ], + "intent": "Get the per-run trend series for one test case in the tabular test-runs report - duration per execution plus each point's workflow status and colour, keyed by build - to draw the test's history chart.", + "returns": [ + "keys", + "data" + ], + "responses": { + "200": { + "description": "Trend series for the test.", + "schema": { + "$schema": "ReportTestTrendResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`keys` is the x-axis: build started-at epochs, index-aligned to `data`. `data` is the point series.", + "Each point's `y` is the run duration in MILLISECONDS, `color` is derived from the workflow status, and `buildId`/`buildName`/`creationType` let the chart link a point back to its run.", + "Point fields that are null are omitted (the point serialises non-null only), so do not assume every optional flag is present.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "run_tabular_test_runs_report", + "method": "POST", + "path": "/api/v1/reports/view/{id}/search", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Ignored by the handler - the report is defined by the body, not this id. Any value satisfies the route; a placeholder is fine." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, default 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 10, + "description": "Page size, default 10. No server-enforced ceiling found in the handler." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "values": [ + "TABULAR_TEST_RUNS" + ], + "description": "Report type. Required (missing/null reportType is a 400 'Report type is required'). Only TABULAR_TEST_RUNS is implemented; any other value falls through the switch and returns a null body with 200." + }, + { + "name": "config", + "type": "object", + "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "reportGlobalFilters", + "type": "object", + "description": "Global filters (WidgetFiltersValue) merged into each dataset before the query runs." + }, + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Dashboard filters block (the DTO field `dashboardFilters` serialises as `globalFilters`); projects/date-range etc." + }, + { + "name": "configuredColumns", + "type": "array", + "description": "Column keys to include; e.g. adding 'openIssues' also surfaces an issues count in the tiles sibling call." + }, + { + "name": "id", + "type": "string", + "maxLength": 100, + "description": "Optional widget/report id echoed for telemetry." + } + ], + "intent": "Run a configured report and return its paginated table - post the full report definition (datasets, segments, global filters) and get back the tabular test-runs result set.", + "returns": [ + "data", + "summary" + ], + "responses": { + "200": { + "description": "The report table page. Null body when reportType is not TABULAR_TEST_RUNS; an object with null data when the definition yields nothing or all datasets conflict.", + "schema": { + "$schema": "TabularTestRunsReportResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", + "This is a read despite being a write-shaped call: the body is the query, not new state, and the route is marked read-only.", + "The `{id}` in the address is never read - the report is defined entirely by the body, so pass any placeholder.", + "`reportType` is required and only TABULAR_TEST_RUNS works; any other type returns a 200 with a NULL body (not an error), so a null response can mean 'unsupported report type', not 'no data'.", + "When every dataset mutually conflicts the body comes back empty rather than erroring; distinct from the null-body case above.", + "Rows are under `data`, paging under `summary`; `summary.totalDatasets` is -1 on this route, use `summary.hasNext` to page.", + "Requires the REPORT_VIEW project permission (public-link-token callers are also accepted for shared reports)." + ] + }, + { + "name": "get_tabular_test_runs_report_tiles", + "method": "POST", + "path": "/api/v1/reports/view/{id}/search/tiles", + "mode": "read", + "entity": "report_view", + "path_params": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "Ignored by the handler; any value satisfies the route." + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + } + ], + "body": [ + { + "name": "reportType", + "type": "string", + "required": true, + "values": [ + "TABULAR_TEST_RUNS" + ], + "description": "Required (missing is a 400). Only TABULAR_TEST_RUNS is implemented; other types return a null body with 200." + }, + { + "name": "config", + "type": "object", + "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", + "fields": [ + { + "name": "DATASETS", + "type": "object", + "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." + } + ] + }, + { + "name": "reportGlobalFilters", + "type": "object", + "description": "Global filters merged into each dataset." + }, + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Dashboard filters block (DTO field `dashboardFilters`)." + }, + { + "name": "configuredColumns", + "type": "array", + "description": "Including 'openIssues' adds a totalIssuesCount tile." + } + ], + "intent": "Get the summary stat tiles that sit above a report's table (totals and aggregates for the same report definition) so the header cards can load independently of the paginated rows.", + "returns": [ + "data", + "sourceFencingFlags" + ], + "responses": { + "200": { + "description": "Stat tiles for the report. When all datasets conflict, or no stats are returned, `data` is empty and only the source-fencing flags are populated.", + "schema": { + "$schema": "ReportStatTilesResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", + "Read-only despite being write-shaped; same body as the table call, but no pagination.", + "Each tile in `data` is `{meta, value}` where `meta` is the tile name (totalUniqueTests, flakyTestsCount, newFailureTestsCount, alwaysFailingTestsCount, totalPassedExecutions, totalFailedExecutions, totalSkippedExecutions, totalUnknownExecutions, grandTotalExecutions) and `value` is a Long count. Note the key is `meta`, not `name`.", + "Extra tiles appear only conditionally: totalIssuesCount only when `configuredColumns` contains 'openIssues'; activeRunCount, closedRunCount and totalTestCasesCount only when the backend returns them.", + "When all datasets conflict, or the backend has no statistics, `data` is empty/absent and `sourceFencingFlags` (all false) is still returned - an empty tiles body is not an error.", + "Non-TABULAR_TEST_RUNS report types return a null body with 200.", + "Requires the REPORT_VIEW project permission." + ] + }, + { + "name": "list_widget_filter_values", + "method": "GET", + "path": "/api/v1/dashboards/widgets/filters", + "mode": "read", + "entity": "widget", + "query": [ + { + "name": "key", + "type": "string", + "required": true, + "description": "Which filter's values to list.", + "values": [ + "buildStatus", + "buildName", + "users", + "teams", + "frameworks", + "folders", + "buildTags", + "hostName", + "os", + "browser", + "device", + "tags", + "branches", + "projects", + "testCaseRequirements", + "testCasePriorities", + "testCaseStatuses", + "testCaseTypes", + "testCaseTags", + "testCaseOwners", + "testCaseAutomationStatuses", + "testCaseFolders", + "buildTypes", + "buildOwners", + "workflowStatus", + "custom", + "customFields", + "issueType", + "testCaseAutomationStates" + ] + } + ], + "intent": "Look up the available values for a single widget filter key (build status, users, teams, frameworks, build tags, build name, branches, ...) as a flat list for a widget configuration dropdown.", + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "Flat list of filter values for the key.", + "schema": { + "$schema": "WidgetFilterValueList" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the simple/legacy flat-list variant. For search, paging, and suggested-vs-others buckets, use `search_widget_filter_values` (same path, richer request/response) instead.", + "`data` is a flat array of `{label, value, ...}` structs; there is no paging on this route.", + "Values come from Elasticsearch aggregations with deleted projects excluded.", + "The request binds `key` from query params only - there is no body.", + "Values are account-wide (every visible project), not scoped to one project. Each item is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null for most keys." + ] + }, + { + "name": "search_widget_filter_values", + "method": "POST", + "path": "/api/v1/dashboards/widgets/filters", + "mode": "read", + "entity": "widget", + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Page number, default 1." + }, + { + "name": "per_page", + "type": "integer", + "default": 30, + "description": "Page size, default 30. No server-enforced ceiling found in the handler." + }, + { + "name": "search", + "type": "string", + "description": "Free-text filter over the candidate values." + }, + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO. TCM forces manual+automated inclusion." + } + ], + "body": [ + { + "name": "widgetKey", + "type": "string", + "required": true, + "json_path": "/widgetKey", + "description": "The widget type the filter is being configured for (WidgetTypeEnum). REQUIRED. NOTE the JSON key is `widgetKey`, not `widget` - the DTO field `widget` is renamed on the wire; sending `widget` is dropped and the request fails @NotNull validation. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + }, + { + "name": "filterKey", + "type": "string", + "required": true, + "description": "Which filter's values to fetch (same value set as the GET `key`). REQUIRED (@NotNull)." + }, + { + "name": "filters", + "type": "object", + "description": "Already-applied filters (WidgetFiltersValue: projects, dateRange, ...) so the returned values are context-dependent. Defaults to empty when omitted.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids; for ClickHouse-powered widgets these are rewritten to TestHub project ids server-side." + }, + { + "name": "dateRange", + "type": "object", + "description": "Optional date-range scope; only applied when the widget/caller opts in." + } + ] + } + ], + "intent": "Fetch matching values for a widget filter, split into suggested and others with search and paging - the lookup that powers the dependent dropdowns in a widget's configuration panel.", + "returns": [ + "data", + "fullList", + "info", + "suggestedAfterKey", + "othersAfterKey" + ], + "responses": { + "200": { + "description": "Filter values bucketed by suggested/others, with paging cursors.", + "schema": { + "$schema": "WidgetEnhancedFilterResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Read-only despite being a POST; same path as the flat GET variant but a different request and response shape - do not treat them as interchangeable.", + "The widget field is sent as `widgetKey` on the wire, NOT `widget`; both `widgetKey` and `filterKey` are required or the request 400s on validation.", + "`data` is a map keyed by bucket (suggested / others), each a list of value structs; page onward with `suggestedAfterKey` / `othersAfterKey` cursors rather than by page number alone.", + "Behaviour is context-dependent: the TEST_EXECUTION_DISTRIBUTION widget forces only-manual-builds, source=TCM forces manual+automated, and ClickHouse-powered widgets have their project ids translated to TestHub ids first.", + "`customFields` values are restricted to a hardcoded allow-list of group ids per environment, so a custom-field filter may return nothing for a group not on that list.", + "`fullList` and `info` can come back null; rely on suggestedAfterKey / othersAfterKey. Valid widgetKey values are the WidgetTypeEnum constants; filterKey names follow the widget's filter keys (e.g. projects) - an unknown pair is an opaque 400." + ] + }, + { + "name": "get_org_dashboard_widget_data", + "method": "POST", + "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}", + "mode": "read", + "entity": "widget", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "Not read by the handler on this route; the projects come from the body. Any value satisfies the route." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "The widget to compute (WidgetTypeEnum). Determines the response shape. Bound by exact enum constant name; any other value is a 400.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Optional window start (epoch ms). If omitted, the window is resolved from the widget's own date-range filter." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Optional window end (epoch ms)." + } + ], + "body": [ + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Filters block (DTO field `dashboardFilters`, serialised as `globalFilters`). Carries `projects[]` - the org/multi-project scope for the widget. For ClickHouse-powered widgets these project ids are translated to TestHub ids.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids scoping this org-level widget." + }, + { + "name": "dateRange", + "type": "object", + "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." + } + ] + }, + { + "name": "config", + "type": "object", + "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", + "fields": [ + { + "name": "FILTERS", + "type": "object", + "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." + } + ] + }, + { + "name": "type", + "type": "string", + "description": "Widget type enum on the body; the path widgetType is what actually selects the computation." + } + ], + "intent": "Compute the data for one widget on a multi-project (org-level) dashboard, from the widget definition plus the global filters and time window supplied in the body.", + "returns": [ + "(widget-type-specific)" + ], + "responses": { + "200": { + "description": "Widget data. The shape is polymorphic per widget type (line chart, stat card, distribution, table, ...), so it is not a single fixed envelope.", + "schema": { + "type": "object", + "description": "Widget-type-specific payload; read the widgetType you requested to know the shape. Common CH-powered widgets embed a `sourceFencingFlags` object." + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", + "Projects come from the body's `globalFilters.projects` (this is the org-level route), NOT from the address - the `{dashboardId}` is ignored here.", + "The response envelope depends on `widgetType`; there is no single fixed shape, so switch on the type you asked for.", + "For ClickHouse-powered widget types the project ids you send are translated to TestHub ids before the query runs.", + "Special case: group 2 requesting the ACTIVE_BUILDS widget is forced to a 7-day window unless it explicitly asked for 1 day.", + "Unlike the project-view variant of this route, this org-level call does NOT apply the FLAKINESS / ALWAYS_FAILING / NEW_FAILURES feature gate.", + "Read-only despite being a POST." + ] + }, + { + "name": "get_widget_drilldown_records", + "method": "POST", + "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}/drilldown", + "mode": "read", + "entity": "widget", + "path_params": [ + { + "name": "dashboardId", + "type": "string", + "required": true, + "description": "Not read by the handler; any value satisfies the route." + }, + { + "name": "widgetType", + "type": "string", + "required": true, + "description": "The widget whose data point is being drilled into (WidgetTypeEnum). Bound by exact enum constant name; any other value is a 400.", + "values": [ + "SUMMARY_BANNER", + "BUILD_RUN_SUMMARY", + "FLAKINESS", + "STABILITY", + "MUTED_TESTS", + "FAILED_TESTS", + "PERFORMANCE", + "ALWAYS_FAILING", + "NEW_FAILURES", + "TEST_EXECUTIONS", + "TEST_STATUS_TREND", + "UNIQUE_TEST_CASES", + "DEFECTS_TREND", + "FAILURE_CATEGORIES", + "TOP_UNIQUE_ERRORS", + "UNIQUE_ERROR_TIMELINE", + "FAILURE_RATE_BY_FOLDERS", + "TOP_UNSTABLE_TEST_EXECUTIONS", + "TOP_TIME_TAKING_TEST_EXECUTIONS", + "DEFECTS_TABLE", + "PLATFORMS_COVERAGE", + "ALERTS", + "COMPONENTS_SUMMARY", + "GLOBAL_FILTER", + "TEST_CASE_STATISTICS_CARD", + "TEST_CASE_TYPE", + "TEST_CASE_TREND", + "ACTIVE_BUILDS", + "CLOSED_BUILDS", + "CLOSED_BUILDS_RESULTS", + "DEFECTS_LOGS", + "UNIQUE_BUILDS", + "BUILD_FREQUENCY", + "RELEASE_READINESS", + "AUTOMATION_COVERAGE", + "TABULAR_TEST_RUNS", + "BUILD_COMPARISON", + "TEST_EXECUTION_DISTRIBUTION", + "PIVOT_TABLE_WIDGET" + ] + } + ], + "query": [ + { + "name": "source", + "type": "string", + "default": "TO", + "values": [ + "TO", + "TCM" + ], + "description": "Data source fence, default TO." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Optional window start (epoch ms)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Optional window end (epoch ms)." + } + ], + "body": [ + { + "name": "globalFilters", + "type": "object", + "json_path": "/globalFilters", + "description": "Filters block (DTO field `dashboardFilters`) carrying the drill-down selection: projects, date range, page number, and drill-down date type.", + "fields": [ + { + "name": "projects", + "type": "array", + "description": "Project ids for the selection (translated to TestHub ids for CH-powered widgets)." + }, + { + "name": "pageNumber", + "type": "integer", + "description": "Page of drill-down rows." + }, + { + "name": "dateRange", + "type": "object", + "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." + } + ] + }, + { + "name": "config", + "type": "object", + "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", + "fields": [ + { + "name": "FILTERS", + "type": "object", + "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." + } + ] + } + ], + "intent": "Return the underlying row-level records behind a widget data point - the 'click a bar to see the builds/tests it represents' drill-down for a dashboard widget.", + "returns": [ + "data", + "insights", + "isConflicting", + "isEmptyNumerator", + "isEmptyDenominator" + ], + "responses": { + "200": { + "description": "Drill-down rows plus insight and conflict flags.", + "schema": { + "$schema": "WidgetDrillDownData" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", + "There is no separate project-view drill-down route: drilling down from a project view uses THIS route with the projects supplied in the body.", + "`data` rows carry project identity (projectId/thProjectId/projectName/normalisedName) plus widget-specific fields; `insights` is a `{value, meta}` summary and the boolean flags report a conflicting or empty numerator/denominator selection.", + "Same project-id translation for ClickHouse-powered widgets and same TCM manual+automated behaviour as the widget-data call.", + "Read-only despite being a POST; the dashboard id in the address is ignored." + ] + }, + { + "name": "get_parallel_queue_usage_summary", + "method": "GET", + "path": "/api/v1/parallels/queues/usageSummary", + "mode": "read", + "entity": "parallel_queue", + "query": [ + { + "name": "product", + "type": "string", + "description": "Product to report live queue usage for (e.g. automate / app-automate). OPTIONAL: omit it and only the test-execution usage block is returned, with no live parallel-queue fetch." + } + ], + "intent": "Get the top-level parallel-queue summary for the signed-in user's group: plan test-execution usage plus current parallels-in-use and queued sessions for the requested product.", + "returns": [ + "summary", + "testExecutionsUsage" + ], + "responses": { + "200": { + "description": "Group-level queue summary; `summary` present only when a product was supplied.", + "schema": { + "$schema": "ParallelQueueUsageSummary" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only works for JWT/session-token callers - it reads the authenticated user straight from the security context, not from a supplied credential.", + "Omitting `product` returns just the `testExecutionsUsage` block; the live `summary` (parallels/queued) is populated only when a product is given.", + "The internal userDetails block is stripped out before the response is returned.", + "`summary` is a list of per-product SummaryDetails (totalParallelsUsed, maximumParallelsAllowed, totalQueuedSessions, maximumQueueAllowed, with per-pool breakdowns)." + ] + }, + { + "name": "list_parallel_queue_team_usage", + "method": "GET", + "path": "/api/v1/parallels/queues/teams", + "mode": "read", + "entity": "parallel_queue", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product to break down (e.g. automate / app-automate). Required and non-empty, else 400; resolved to a per-product backend endpoint, so an unknown product fails downstream." + } + ], + "intent": "Break the group's parallel-session usage down by team and member - parallels used vs allowed, queued vs queue limit, with per-pool breakdowns - so an admin can see who is consuming the plan's concurrency.", + "returns": [ + "summary", + "teams" + ], + "responses": { + "200": { + "description": "Per-team and per-member concurrency usage, plus a group summary.", + "schema": { + "$schema": "ParallelQueueTeamUsage" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`product` is required and non-empty (empty/missing throws a 400); it is resolved to a per-product backend endpoint, so an unrecognised product fails there, not here.", + "Passes through to the billing backend with the caller's own BrowserStack username/access key, then reshapes and enriches members with user details.", + "`teams[].members[]` carry per-user parallelsUsed / sessionQueued and pool breakdowns; `showRunningBuildsButton` indicates whether a member has running builds to drill into (via `list_user_running_builds`)." + ] + }, + { + "name": "list_user_running_builds", + "method": "GET", + "path": "/api/v1/parallels/queues/running/builds/{userId}", + "mode": "read", + "entity": "parallel_queue", + "path_params": [ + { + "name": "userId", + "type": "integer", + "required": true, + "description": "The user whose currently-running builds to list. Taken from the path and NOT checked against the caller's own group." + } + ], + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "values": [ + "automate", + "app-automate" + ], + "description": "Product to query. Required and non-empty, else 400. Any value other than 'automate' is treated as App Automate." + } + ], + "intent": "Show which builds a given user currently has running for Automate or App Automate, with their projects and how long the oldest has been going - the drill-down from the parallel-queue view to who is holding the parallels.", + "returns": [ + "data" + ], + "responses": { + "200": { + "description": "The user's currently-running builds.", + "schema": { + "$schema": "RunningBuildsResponse" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`userId` is read from the path and is NOT checked against the caller's group, so this can surface another user's running builds.", + "`product` is required; anything other than the exact string 'automate' is treated as App Automate rather than rejected.", + "Backed by an Elasticsearch query (size 10000, locked builds excluded), so it returns up to that many rows without paging.", + "`data` is a list of build summaries (the standard build-summary shape)." + ] + }, + { + "name": "get_test_execution_usage_count", + "method": "GET", + "path": "/api/v1/usage", + "mode": "read", + "entity": "usage", + "query": [ + { + "name": "lowerBound", + "type": "integer", + "required": true, + "description": "Window start (epoch). Required." + }, + { + "name": "upperBound", + "type": "integer", + "required": true, + "description": "Window end (epoch). Required." + }, + { + "name": "userId", + "type": "integer", + "description": "User to count for. Optional. At least one of userId/groupId must be supplied; if both are given, userId wins." + }, + { + "name": "groupId", + "type": "integer", + "description": "Group to count for. Optional. Used only when userId is absent." + } + ], + "intent": "Count test executions reported to Observability in a time window for a user or group, split into total tests and tests that ran on BrowserStack infrastructure - the raw numbers behind usage/consumption reporting.", + "returns": [ + "totalTestCount", + "browserstackTestCount" + ], + "responses": { + "200": { + "description": "Execution counts for the window. When BOTH userId and groupId are missing, the body is instead the plain string 'Missing userId or groupId' with a 200.", + "schema": { + "$schema": "TestExecutionUsageCount" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`userId`/`groupId` are taken straight from the query with NO check that they belong to the caller - any authenticated caller can read another user's or group's counts.", + "Supply at least one of `userId`/`groupId`; if both are present `userId` takes precedence and `groupId` is ignored.", + "When BOTH are omitted the call returns HTTP 200 with the plain-text body `Missing userId or groupId` (not JSON, not a 400) - handle that string before parsing JSON.", + "`totalTestCount` is all tests reported; `browserstackTestCount` is the subset that carry a sessionId (ran on BrowserStack infra).", + "The action name behind this route mentions always-failing tests but that is a copy-paste artefact - this endpoint has nothing to do with always-failing tests." + ] + }, + { + "name": "get_group_plan_usage_details", + "method": "GET", + "path": "/api/v1/usageDetails", + "mode": "read", + "entity": "usage", + "intent": "Load the plan/usage panel: the group's plan info and plan details plus non-BrowserStack test-execution consumption - used vs total executions for the team and the whole group, usage percentages, BrowserStack-run count, and the billing reset date.", + "returns": [ + "planInfo", + "planDetails", + "nonBrowserstackTotalTestExecutions", + "nonBrowserstackUsedTestExecutions", + "nonBrowserstackPercentageOfTestExecutionUsage", + "billingResetDate", + "browserstackUsedTestExecutions", + "totalTestExecutions", + "nonBrowserstackPercentageOfGroupTestExecutionUsage", + "nonBrowserstackGroupTotalTestExecutions" + ], + "responses": { + "200": { + "description": "Plan info/details plus usage figures, all counts as compacted strings. Usage fields are null when the group has no execution limit.", + "schema": { + "$schema": "GroupPlanUsageDetails" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes no parameters; scoping comes from the authenticated session, so this needs a session/JWT caller (it reads the UserDetail from the security context).", + "All the execution counts come back as compacted DISPLAY STRINGS (e.g. '1.2K'), not numbers - do not do arithmetic on them.", + "This returns `planDetails` (not `userDetails`); it is the same underlying data as the usage block of the init call but requested with plan details included.", + "When the group has no execution limit configured, every usage figure is nulled out. Note the null-branch uses the key `nonBrowserstackTestExecutions` where the populated branch uses `nonBrowserstackUsedTestExecutions` - the key set differs between the two shapes.", + "`billingResetDate` is an epoch-millis timestamp." + ] + }, + { + "name": "get_device_tier_usage", + "method": "GET", + "path": "/api/v1/tier/info", + "mode": "read", + "entity": "usage", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product whose device tiers to report (e.g. automate / app-automate). Required and non-empty, else IllegalArgumentException (400)." + } + ], + "intent": "Get the group's mobile device-tier entitlement picture for a product - per tier, the devices in it, the device/parallel limits and how much of each is in use - for the device-tier/queue capacity view.", + "returns": [ + "tierInfo", + "devices" + ], + "responses": { + "200": { + "description": "Aggregated tier usage and device list for the product.", + "schema": { + "$schema": "DeviceTierUsageInfo" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Only MOBILE tiers are returned; non-mobile tiers are filtered out.", + "`product` is required and non-empty; missing/empty is a 400 (IllegalArgumentException).", + "Server-side the handler resolves the caller's own BrowserStack username + selenium access key and calls the public backend with them - no extra caller-supplied credential is needed.", + "This is a pass-through aggregation over two backend endpoints (tier usage + devices); `tierInfo` and `devices` are raw JSON trees, so read them defensively." + ] + }, + { + "name": "search_entities", + "method": "POST", + "path": "/api/v1/search", + "mode": "read", + "entity": "search", + "body": [ + { + "name": "query", + "type": "string", + "required": true, + "minLength": 3, + "description": "Free-text query. Must be non-blank and at least 3 characters or the request 400s. ClickHouse dollar-quote tokens ($q$) are stripped from it server-side." + }, + { + "name": "entity", + "type": "string", + "required": true, + "values": [ + "Build", + "BuildTestCaseExecutionResult", + "Project", + "All" + ], + "description": "Entity type to search. Must be exactly one of the supported values (CASE-SENSITIVE); any other value 400s." + }, + { + "name": "pageNumber", + "type": "integer", + "default": 1, + "minimum": 1, + "description": "1-based page. Values <= 0 or absent default to 1." + }, + { + "name": "days", + "type": "integer", + "default": 30, + "description": "Look-back window in days. Defaults to 30." + }, + { + "name": "filters", + "type": "object", + "description": "Per-entity filter block (build / buildTestCaseExecutionResult / project / all). Only recognised sub-fields are forwarded; e.g. build.onlyLocked, build.isArchived, build.userId, test.isFlaky/isMuted/failureCategories, project.userId.", + "fields": [ + { + "name": "build", + "type": "object", + "description": "Build filters: onlyLocked, isCIRun, isArchived, userId, status." + }, + { + "name": "buildTestCaseExecutionResult", + "type": "object", + "description": "Test-run filters: isMuted, isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly, failureCategories[], userId, status." + } + ] + }, + { + "name": "overlay", + "type": "boolean", + "description": "When true, returns the quick-overlay page (size 10) and OMITS pagingParams and totalCount. When false/absent, returns the full page (size 30) with paging and total count." + } + ], + "intent": "Global search across the product: given a free-text query and an entity type, return matching entities (builds, tests, projects) for the search bar or quick overlay, paginated and enriched with user names.", + "returns": [ + "response", + "pagingParams", + "totalCount" + ], + "responses": { + "200": { + "description": "Search results. `pagingParams` and `totalCount` are present only when overlay is off.", + "schema": { + "$schema": "GlobalSearchResult" + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`entity` is case-sensitive and must be exactly Build, BuildTestCaseExecutionResult, Project, or All; anything else 400s. `query` must be >= 3 non-blank characters.", + "Sort is fixed to updated_at-desc and cannot be changed by the caller.", + "Page size is NOT caller-controlled: overlay=true fixes it to 10 and omits `pagingParams`/`totalCount`; overlay off fixes it to 30 and includes them. Page through with `pageNumber` and `pagingParams.has_next`.", + "This is a thin pass-through to the backend global-search API; TRA only validates/sanitises the query, fixes the sort, and sets the page size.", + "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id.", + "Keep `days` at or below 90: on a large group, windows of 365 or more returned HTTP 500 'Internal System Exception' instead of results.", + "entity=\"Build\" is the only cross-project way to find a build by NAME or by CI run id: each hit carries `projectName`/`projectNormalizedName`, `uuid`, `serialId` (the #N) and `buildRunId` (the CI run id). Use it when a user names a build or a run number without a project.", + "entity=\"Project\" is unreliable for name lookup (an existing project can return zero hits); find projects by name with list_projects_lite_paginated `search` instead." + ] + }, + { + "name": "create_self_healing_pr", + "entity": "self_healing", + "method": "POST", + "path": "/api/v1/aiHealing/builds/{buildUuid}/createPR", + "mode": "write", + "intent": "Open a GitHub pull request that rewrites broken selectors to their AI-healed values \u2014 pick the healed locators you accept from a build's healing report and ask BrowserStack to raise the PR in the customer's repo.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$", + "description": "The build whose healing report the selectors were chosen from." + } + ], + "body": [ + { + "name": "selectors", + "type": "array", + "required": true, + "minItems": 1, + "maxItems": 200, + "description": "The healed selectors to apply. 1..200 items. Each carries `using`/`value` (NOT `type`/`value`) for both the original and healed locator \u2014 note this differs from the verdict endpoint, which uses `type`. There is no `elementIdentifier` here.", + "fields": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "The healed-selector id from the healing report; must be non-blank. Used to group files and to derive the head branch name." + }, + { + "name": "originalLocator", + "type": "object", + "required": true, + "description": "The broken locator to find and replace in source.", + "fields": [ + { + "name": "using", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Locator strategy, e.g. xpath / css. Non-blank, max 64 chars." + }, + { + "name": "value", + "type": "string", + "required": true, + "maxLength": 4000, + "description": "The original locator string. Non-blank, max 4000 chars." + } + ] + }, + { + "name": "healedLocator", + "type": "object", + "required": true, + "description": "The replacement locator to write in.", + "fields": [ + { + "name": "using", + "type": "string", + "required": true, + "maxLength": 64 + }, + { + "name": "value", + "type": "string", + "required": true, + "maxLength": 4000 + } + ] + }, + { + "name": "sessionId", + "type": "string", + "required": true, + "maxLength": 64, + "description": "Session the selector broke in. Non-blank, max 64. Distinct sessionIds across the batch must be <= 25 (each triggers one source-file lookup); over that the whole request is rejected." + } + ] + }, + { + "name": "baseBranch", + "type": "string", + "required": false, + "description": "Branch to open the PR against. Optional \u2014 defaults to the build's recorded VCS branch; if neither is present the call fails as missing VCS info." + }, + { + "name": "headBranch", + "type": "string", + "required": false, + "maxLength": 200, + "pattern": "^(?!/)(?!.*//)(?!.*\\.\\.)[A-Za-z0-9._/-]+(?- when blank. Must be a valid git ref: no '..', no leading/trailing '/', no '//'." + }, + { + "name": "repository", + "type": "string", + "required": false, + "maxLength": 200, + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", + "description": "Target repo in `owner/repo` form. Optional \u2014 auto-derived from the build's GitHub origin remote when blank. Auto-derivation is GitHub-only; a GitHub Enterprise / non-github.com host MUST pass this explicitly or the call fails as unparseable remote." + }, + { + "name": "title", + "type": "string", + "required": false, + "minLength": 10, + "maxLength": 200, + "description": "PR title. Optional \u2014 a default is generated. If supplied it must be 10..200 chars; it is prefixed with '[BrowserStack Heal] ' unless it already starts with that." + }, + { + "name": "description", + "type": "string", + "required": false, + "maxLength": 4000, + "description": "PR body. Optional \u2014 a default is generated. Control characters are stripped (newlines/tabs kept)." + } + ], + "returns": [ + "healingPrId", + "status" + ], + "guidance": [ + "Asynchronous: returns HTTP 202 with only `{healingPrId, status:\"IN_PROGRESS\"}`. The PR is NOT open yet \u2014 there is no prUrl in this response. Capture healingPrId and poll get_self_healing_pr_status for the outcome and the PR URL.", + "The job id lives only in Redis with a short TTL and is not listed anywhere, so if you lose it or stop polling in time it is gone \u2014 store it from this response immediately.", + "Preconditions that reject synchronously before any work: an operational kill switch may disable the feature (global or per-group); the AI feature must be within plan/entitlement and the build must not be plan-locked; the build must carry VCS info and resolve to a GitHub owner/repo; at least one selector; <= 25 distinct sessions; and after source-file resolution <= 100 files.", + "Rate limited per user (25/hr) and per group (100/hr). Over-limit calls are rejected; a transient downstream failure refunds the token rather than burning your quota.", + "This is GitHub-only. A non-GitHub origin remote is silently skipped during auto-derivation (you then get an unparseable-remote error) \u2014 pass `repository` explicitly for those.", + "Each selector's locator uses `using`/`value`. Do not send `type` here (that is the verdict endpoint's field); an object missing `using` or `value` fails validation.", + "No API undo: this opens a real PR in the customer's repo. Closing/reverting is done in GitHub, not here.", + "Live 2026-09-30: the healing worker's integration key 'github-scm' is the GitHub 'Source Code View' integration (integrations page \u2192 Source Code View \u2192 GitHub), connected per user. Without it the job FAILS at fetch_files with 401 'Invalid credentials for the integration'. The account-wide GitHub (issues) connection does not satisfy it." + ], + "responses": { + "202": { + "$response": "CreateHealingPrAccepted" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "429": { + "description": "Rate limited (per-user 25/hr or per-group 100/hr) via HEALING_PR_RATE_LIMITED.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_self_healing_pr_status", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/healingPrs/{healingPrId}/status", + "mode": "read", + "intent": "Poll a self-healing PR job started by create_self_healing_pr \u2014 get its in-progress/terminal state and, once ready, the created pull request URL.", + "path_params": [ + { + "name": "healingPrId", + "type": "string", + "required": true, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", + "description": "The job id returned by create_self_healing_pr (strict lowercase 8-4-4-4-12 hex UUID)." + } + ], + "returns": [ + "healingPrId", + "status", + "prUrl", + "prNumber", + "headSha", + "perFileReport", + "errorMessage" + ], + "guidance": [ + "`status` is one of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED. Treat PARTIAL and NO_CHANGES_NEEDED as successful terminal outcomes, not failures.", + "prUrl / prNumber / headSha are null until a terminal, PR-producing state; perFileReport (per-file applied/skipped detail) and errorMessage appear depending on the outcome. Do not expect a PR URL while IN_PROGRESS.", + "Status is stored only in Redis under a group-scoped key with a TTL. An unknown or EXPIRED id returns HEALING_PR_NOT_FOUND \u2014 that is not a terminal failure state, just a lapsed/foreign id.", + "Only visible to the group that initiated the PR." + ], + "responses": { + "200": { + "$response": "HealingPrStatusResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "description": "HEALING_PR_NOT_FOUND \u2014 the id is unknown or its Redis entry expired, or it belongs to another group.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "submit_self_healing_verdict", + "entity": "self_healing", + "method": "POST", + "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", + "mode": "write", + "intent": "Record a reviewer's verdict on one AI-healed locator ('accurate' or 'inaccurate', with optional feedback) to feed the human-in-the-loop healing loop.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$" + } + ], + "body": [ + { + "name": "healedLocator", + "type": "object", + "required": true, + "description": "The healed locator being judged. Note the field is `type` here (xpath|cssPath), NOT `using` as in create_self_healing_pr.", + "fields": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "xpath", + "cssPath" + ], + "description": "Locator strategy; must be exactly 'xpath' or 'cssPath'." + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The healed locator string. Non-blank." + } + ] + }, + { + "name": "verdict", + "type": "string", + "required": true, + "values": [ + "accurate", + "inaccurate" + ], + "description": "The review outcome; must be exactly 'accurate' or 'inaccurate'." + }, + { + "name": "elementIdentifier", + "type": "string", + "required": true, + "description": "Identifier of the element the locator targets; part of the hash that keys the verdict in TCG. Non-blank." + }, + { + "name": "baseUrl", + "type": "string", + "required": false, + "description": "Web automate: the page URL; required for the web hash (baseUrl + elementIdentifier + groupId). Leave null/empty for app automate." + }, + { + "name": "screenIdentifier", + "type": "string", + "required": false, + "description": "App automate only: native screen name used in the app hash (elementIdentifier + groupId + screenIdentifier). Null for web automate." + }, + { + "name": "client", + "type": "string", + "required": false, + "description": "Platform client type: 'app-automate' selects the app hash formula; empty/null means web automate." + }, + { + "name": "feedback", + "type": "object", + "required": false, + "description": "Optional free-form feedback (arbitrary JSON)." + }, + { + "name": "region", + "type": "string", + "required": false, + "maxLength": 64, + "description": "Optional session region code; only a lookup key into the TCG region-URL map (a miss is skipped, not an error). Max 64." + } + ], + "returns": [ + "elementHash", + "healedLocator", + "verdict", + "feedback", + "reviewedByUserId", + "reviewedAt", + "isCarryover", + "carryoverBuildSerialId" + ], + "guidance": [ + "Gated on the human-in-the-loop healing feature per group: when it is OFF this is refused outright (FORBIDDEN_OPERATION) \u2014 unlike list_self_healing_verdicts, which silently returns []. Check the feature before submitting.", + "Requires a full product role (Product Admin, Product User, Admin or User); Viewer / Read Only is rejected.", + "Dual-writes to observability storage AND the TCG service (keyed by a hash of the element identifier), so a TCG outage can fail the call even though it looks like a local write.", + "Unknown body keys are ignored. For web tests supply baseUrl; for app tests supply screenIdentifier and client='app-automate' \u2014 the wrong pairing computes a hash that won't match the element." + ], + "responses": { + "200": { + "$response": "SubmitHealingVerdictResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_self_healing_verdicts", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", + "mode": "read", + "intent": "List the human-in-the-loop verdicts (accurate/inaccurate) already recorded against a build's AI-healed locators, to show which healed selectors have been reviewed.", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$" + } + ], + "returns": [ + "elementHash", + "healedLocator", + "verdict", + "feedback", + "reviewedByUserId", + "reviewedAt", + "isCarryover", + "carryoverBuildSerialId" + ], + "guidance": [ + "Returns a bare JSON array of verdict objects (no wrapper envelope).", + "Feature-flagged: when the human-in-the-loop healing feature is OFF for the group this returns an empty array with HTTP 200 \u2014 NOT an error. Do not read [] as 'no verdicts recorded' without knowing the feature is on.", + "`isCarryover` is true when the verdict was first recorded against a different (earlier) build; `carryoverBuildSerialId` names that build. Join `elementHash` with the healing report to line each verdict up with its selector.", + "A build UUID outside the caller's group is rejected as build-not-found." + ], + "responses": { + "200": { + "$response": "HealingVerdictListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_self_heal_summary", + "entity": "self_healing", + "method": "GET", + "path": "/api/v1/aiHealing/builds/{uuid}/selfHealSummary", + "mode": "read", + "intent": "Get a build's self-healing headline numbers for the build-insights page \u2014 how many tests and locators were healed, with counts and percentages.", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "pattern": "^[a-zA-Z0-9-]{1,64}$", + "description": "Build UUID." + } + ], + "returns": [ + "testSummary", + "locatorSummary", + "buildSummary" + ], + "guidance": [ + "Numbers come from search aggregations over the build's runs: testSummary (totalTests, healedTests, percentHealedTests), locatorSummary (totalLocators, healedLocators, percentHealedLocators) and buildSummary (totalBuild, healedBuilds, percentHealedBuilds) - buildSummary can be null.", + "Refused with FORBIDDEN_OPERATION / plan-feature-not-allowed when the build is locked by plan quota \u2014 you get an error, not zeroes.", + "In the demo-sandbox environment this returns hardcoded dummy stats, not real data." + ], + "responses": { + "200": { + "$response": "SelfHealSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_comparison_aggregation", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/aggregation", + "mode": "read", + "intent": "Get the headline counts for a base-vs-target build comparison \u2014 additional, dropped, no-change, newly failed, started passing, and failed-with-a-different-reason \u2014 the summary strip at the top of the Build Comparison page.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID (top-level parent build). Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID (top-level parent build). Empty/missing \u2192 build-not-found." + }, + { + "name": "filterParams", + "type": "object", + "required": false, + "description": "Standard V2 test filters bound from the query string (same filter keys as the build test listing), applied to both builds before comparing." + } + ], + "returns": [ + "newlyFailed", + "failedWithDifferentReason", + "startedPassing", + "additionalTests", + "droppedTests", + "noChange", + "sourceFencingFlags" + ], + "guidance": [ + "Plan-gated on the build-comparison feature: FORBIDDEN_OPERATION / plan-feature-not-allowed otherwise. A successful call also bumps feature-usage counters.", + "Both builds must be top-level parents and supported: a child/archived/locked build is rejected as unsupported (BUILD_COMPARISON_BUILD_NOT_SUPPORTED). Normalise UUIDs with resolve_build_comparison_parent_uuids first.", + "A cross-source (incompatible) pair is NOT an error: you get 200 with all six counts at 0 and `sourceFencingFlags` populated. Check sourceFencingFlags before trusting the zeroes.", + "Expensive: resolves both builds' child builds and runs search + aggregation across them.", + "The controller method is internally named getBuildUuid but it returns counts, not a UUID \u2014 ignore the name." + ], + "responses": { + "200": { + "$response": "BuildComparisonAggregationResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "check_build_comparison_available", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/checkComparison", + "mode": "read", + "intent": "Pre-flight check of whether two builds can actually be compared (and why not), so the UI can show a 'comparison unavailable' state before firing the expensive aggregation.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "status", + "message", + "sourceFencingFlags" + ], + "guidance": [ + "Same build-comparison plan gate as the aggregation \u2014 refused when the plan lacks the feature.", + "For an incompatible-source pair it returns `sourceFencingFlags` and NO availability payload (status/message null); otherwise status/message describe whether the comparison is available. Check the flags first.", + "Same unsupported-build rejection as the other comparison endpoints for child/archived/locked builds.", + "Use this before get_build_comparison_aggregation / list_build_comparison_bucket_tests to avoid the heavy call when the pair can't be compared." + ], + "responses": { + "200": { + "$response": "BuildComparisonCheckResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "resolve_build_comparison_parent_uuids", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/getActual", + "mode": "read", + "intent": "Normalise two build UUIDs (which may be re-run/child builds) to their top-level parent build UUIDs \u2014 call this first so a rerun UUID pasted into the comparison picker is compared at the parent level.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID to resolve (may be a child/rerun UUID). Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID to resolve. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "base", + "target", + "sourceFencingFlags" + ], + "guidance": [ + "Returns `{base, target}` where each is the resolved top-level PARENT build UUID. Feed these into the other comparison calls.", + "NOT plan-gated (unlike the other four comparison endpoints) \u2014 works even without the build-comparison feature.", + "Any resolution failure is collapsed into a single BUILD_NOT_FOUND naming whichever of the two UUIDs appeared in the underlying error message; you cannot tell from the error which side genuinely failed.", + "sourceFencingFlags is part of the envelope but is not set on the normal success path here." + ], + "responses": { + "200": { + "$response": "BuildComparisonActualResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_comparison_bucket_tests", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/list", + "mode": "read", + "intent": "List the actual tests inside one bucket of a build comparison (e.g. newly-failed, started-passing, dropped, additional), grouped for display \u2014 the drill-down when a user clicks an aggregation tile.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "bucket", + "type": "string", + "required": true, + "description": "Comparison bucket, as the exact BucketEnum constant name (case-sensitive). The aggregation's camelCase keys (newlyFailed ...) are NOT accepted: any other value -> 500 'No enum constant'.", + "values": [ + "NEWLY_FAILED_TEST", + "STARTED_PASSING_TEST", + "DROPPED_TEST", + "ADDITIONAL_TEST", + "NO_CHANGE", + "FAILED_WITH_DIFFERENT_REASON" + ] + }, + { + "name": "fileOffset", + "type": "integer", + "required": true, + "description": "Pagination cursor (Long) over the grouped result; start at 0 and advance it to page through groups. Required." + }, + { + "name": "filterParams", + "type": "object", + "required": false, + "description": "Standard V2 test filters bound from the query string, applied before matching." + } + ], + "returns": [ + "bucketName", + "groups", + "sourceFencingFlags" + ], + "guidance": [ + "Plan-gated on the build-comparison feature.", + "Returns `{bucketName, groups}` where each group bundles a file/browser/device/os and its `scopes`, each scope holding base/target element pairs. A cross-source pair returns an empty `groups` with `sourceFencingFlags` set rather than an error.", + "Expensive: pulls test-run ids for both builds (and their child builds) from search with a large per-build ceiling (~10k) before matching.", + "`fileOffset` is a cursor, not a page number; carry the last value forward to continue. The page size / total is not returned as a simple count \u2014 walk until a page comes back empty." + ], + "responses": { + "200": { + "$response": "BuildComparisonListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_comparison_meta", + "entity": "build_comparison", + "method": "GET", + "path": "/api/v1/buildComparison/meta", + "mode": "read", + "intent": "Get the two build summary headers shown side by side on the Build Comparison page \u2014 name, status, timings, framework and status counts for both the base and the target build.", + "query": [ + { + "name": "base", + "type": "string", + "required": true, + "description": "Base build UUID. Empty/missing \u2192 build-not-found." + }, + { + "name": "target", + "type": "string", + "required": true, + "description": "Target build UUID. Empty/missing \u2192 build-not-found." + } + ], + "returns": [ + "base", + "target", + "sourceFencingFlags" + ], + "guidance": [ + "Returns `{base, target}`, each a full build summary object. A cross-source pair returns the envelope with `sourceFencingFlags` set and base/target null.", + "NOT explicitly plan-gated (unlike aggregation/list/checkComparison), but it DOES apply the same unsupported-build rejection and source-fencing behaviour.", + "The status counts are AMR-corrected: it re-runs the same dedup/coalesce aggregation the buckets use so the header agrees with them, and silently falls back to raw automation counts if that recompute fails. This makes it heavier than a plain build-summary fetch." + ], + "responses": { + "200": { + "$response": "BuildComparisonMetaResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_comments", + "entity": "comment", + "method": "GET", + "path": "/api/v1/projects/{normalizedName}/comments", + "mode": "read", + "intent": "Fetch the comments to render inline on a build-insights or test listing page \u2014 comments for a build (and a window of preceding build numbers), optionally narrowed to one test, to your own comments/mentions, and optionally excluding re-runs.", + "path_params": [ + { + "name": "normalizedName", + "type": "string", + "required": true, + "description": "Project normalised name (resolved within the caller's group)." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "description": "Comment page type (same enum as create_comment, by NAME). REQUIRED in practice \u2014 omitting it returns [] with 200.", + "values": [ + "TESTS", + "ALWAYS_FAILING", + "FLAKINESS", + "NEW_FAILURES", + "MUTED", + "BUILD_SUMMARY", + "BUILD_HISTORY", + "UNIQUE_ERRORS", + "BUILD_STABILITY", + "RUN_SUMMARY", + "ALERTS", + "FAILURE_CATEGORIES", + "DEFECT_STATS", + "BUILD_RERUN_STATS", + "TOP_ERROR_STATS", + "QUALITY_GATES", + "FAILURES_BY_FOLDERS", + "ATTACHMENTS", + "BUILD_OPTIMISATION", + "SELF_HEALING" + ] + }, + { + "name": "buildSerialId", + "type": "integer", + "description": "The build's numeric id (build_id, e.g. 160459727) - NOT its build_number. REQUIRED in practice \u2014 omitting it returns [] with 200 (not an error). The read looks back over a fixed window of preceding ids from this value." + }, + { + "name": "buildUuid", + "type": "string", + "description": "Build UUID to scope to when no runUuid is given." + }, + { + "name": "runUuid", + "type": "string", + "description": "When set (and not in re-run mode), scopes to this run instead of the whole build." + }, + { + "name": "testHash", + "type": "string", + "description": "Required when type=TESTS \u2014 type=TESTS without a testHash returns [] with 200. Also used to scope re-run/expand-all test queries." + }, + { + "name": "expandAll", + "type": "boolean", + "description": "Expand the full set for the context rather than a collapsed view; changes how buildUuid/testHash filtering is applied." + }, + { + "name": "showMyComments", + "type": "boolean", + "default": false, + "description": "Restrict to comments you authored or were mentioned in." + }, + { + "name": "showReRunComments", + "type": "boolean", + "default": false, + "description": "Include comments across re-run builds. REQUIRES parentBuildUuid \u2014 set without it throws INVALID_PARAMETERS_FOR_COMMENTS." + }, + { + "name": "parentBuildUuid", + "type": "string", + "description": "Parent build UUID; mandatory whenever showReRunComments=true." + } + ], + "returns": [ + "id", + "type", + "threadId", + "userId", + "groupId", + "commentNode", + "projectId", + "buildUuid", + "buildSerialId", + "runUuid", + "testHash", + "mentions", + "createdAt", + "modifiedAt" + ], + "guidance": [ + "Returns a bare JSON array of comment objects (no envelope). Note the array items carry `commentNode` but NOT the `content` HTML.", + "Silent empties: missing buildSerialId, missing type, or type=TESTS without testHash all return [] with HTTP 200 \u2014 easy to misread as 'no comments'. Supply buildSerialId and type (and testHash for TESTS).", + "showReRunComments=true without parentBuildUuid is a hard error (INVALID_PARAMETERS_FOR_COMMENTS), not an empty list.", + "Only looks back a FIXED number of preceding build serial ids from buildSerialId (so recurring discussion carries over), not all history, and is further bounded by the plan's data-retention window." + ], + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_comment_threads", + "entity": "comment", + "method": "GET", + "path": "/api/v1/projects/{normalizedName}/builds/{buildId}/comments", + "mode": "read", + "intent": "Power the build-level comments side panel \u2014 list the comment threads across a build and all its re-runs, grouped by thread and ordered by most recent activity, with each thread's page type and test-run uuid for deep-linking.", + "path_params": [ + { + "name": "normalizedName", + "type": "string", + "required": true, + "description": "Project normalised name (resolved within the caller's group)." + }, + { + "name": "buildId", + "type": "string", + "required": true, + "description": "PARENT build UUID. Threads are aggregated across all builds sharing this parent uuid \u2014 passing a re-run's own uuid returns nothing." + } + ], + "query": [ + { + "name": "showMyComments", + "type": "boolean", + "description": "Restrict to threads you authored or were mentioned in. REQUIRED in practice: omitted -> 500 (null Boolean unboxed). Send false for all threads.", + "required": true + } + ], + "returns": [ + "threadId", + "name", + "browser", + "os", + "device", + "type", + "testRunUuid", + "commentCount", + "testHash" + ], + "guidance": [ + "Returns a bare JSON array of thread summaries (threadId, commentCount, the thread's page type and testRunUuid for deep-linking), not the individual comments \u2014 fetch a thread's comments via list_comments.", + "`buildId` must be the PARENT build uuid; a re-run's uuid yields an empty result, not an error.", + "Thread aggregation is capped at 1000 threads, and results are bounded by the plan's data-retention window so older threads silently drop out.", + "Always send showMyComments (false for every thread): the server unboxes it without a null check, so omitting it returns 500 Internal System Exception." + ], + "responses": { + "200": { + "$response": "CommentThreadsSidePanelResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_comments", + "entity": "comment", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/comments", + "mode": "read", + "intent": "Public API: fetch the discussion thread on a test run \u2014 the comments testers left against that failure, with author info.", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Numeric test-run id (Long)." + } + ], + "returns": [ + "id", + "type", + "threadId", + "userId", + "groupId", + "commentNode", + "projectId", + "buildUuid", + "buildSerialId", + "runUuid", + "testHash", + "mentions", + "createdAt", + "modifiedAt" + ], + "guidance": [ + "Public /ext/v1 route: authenticate with the customer username + access key, not the internal session.", + "Read-only \u2014 there is NO external endpoint to post a comment; use the internal create_comment for that.", + "A run not in the caller's group returns TEST_RUN_NOT_FOUND.", + "Returns a bare JSON array of comment objects (same shape as list_comments items)." + ], + "responses": { + "200": { + "$response": "CommentListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_unique_errors", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors", + "mode": "read", + "entity": "sp_error", + "intent": "List the Suite Health unique errors for a project \u2014 test failures clustered by error signature over a date window, each cluster with its occurrence count, impacted-test count and a sample of the error text \u2014 so a team can attack the few root errors behind many failing tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], sent as a repeated query param (dateRange=&dateRange=). Required \u2014 missing, or not exactly two values, is a 400. lowerBound + upperBound singles are the only alternative." + }, + { + "name": "search", + "type": "string", + "description": "Free-text match against the error signature / sample text." + }, + { + "name": "status", + "type": "array", + "description": "Test-status filter values. Note the list is failed-only regardless." + }, + { + "name": "sources", + "type": "array", + "description": "Product-source filter.", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array", + "description": "Failure-category ids to include." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Restrict to these unique build names." + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array", + "description": "Owner user ids (integers)." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (integers)." + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "branches", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "description": "Muted-tests visibility. Unmuted is the default; an unrecognised value is a 400 (IllegalArgumentException).", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the cluster-id constraint is dropped, so results span all builds of a test rather than one error cluster." + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column. Ignored (forced to errorCount) for free / non-pro accounts.", + "values": [ + "errorCount", + "testCount" + ] + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Zero-based page index into the clustered result. Page size is fixed at 15. The response's pagingParams.pageNumber is the NEXT index to request (requested + 1), not the one you sent." + } + ], + "returns": [ + "pagingParams", + "errors", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "responses": { + "200": { + "$response": "UniqueErrorListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Take each row's `id` (a numeric string, the errorClusterId) \u2014 that is the cluster id every per-error detail view wants in its path.", + "Page with `pageNumber` (0-based) and a fixed size of 15. Walk pages while the response `pagingParams.hasNext` is true; note `pagingParams.pageNumber` is already the NEXT page to request, not the one you asked for.", + "An empty `errors` with a 200 is the honest answer for a demo tenant or a project with no builds \u2014 it does NOT mean the project is missing. A truly absent project is a 404.", + "Before concluding coverage is complete, check automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 source fencing may have silently dropped whole product families from the clustering.", + "Free / non-pro accounts get exactly one error back (the top one) and the sort is forced to errorCount desc; do not read that single row as the only error that exists.", + "Heavy filter combinations run under an Elasticsearch time budget and can be cut short, so a smaller-than-expected result on an expensive query may be a budget cut rather than the true count.", + "Any unrecognised extra query params are collected into customParams rather than rejected, so a mis-typed filter name is silently ignored, not errored." + ] + }, + { + "name": "get_unique_error_summary", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/info", + "mode": "read", + "entity": "sp_error", + "intent": "Get the header/summary block for one unique error \u2014 its test name, the sample error text (split into lines), and the OS/browser/device combinations it was seen on \u2014 to describe what the error actually is on the detail page.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the summary aggregates across all failed runs of the test (by stability hash) rather than this one cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "browser", + "type": "string", + "description": "Optional single-value browser narrowing (pro accounts only)." + }, + { + "name": "os", + "type": "string", + "description": "Optional single-value OS narrowing (pro accounts only)." + }, + { + "name": "device", + "type": "string", + "description": "Optional single-value device narrowing (pro accounts only)." + } + ], + "returns": [ + "name", + "error", + "combinations" + ], + "responses": { + "200": { + "$response": "UniqueErrorSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Pass the cluster id as a numeric `errorId`. A non-numeric id is NOT a 400 here \u2014 it is a raw 500 from Long.parseLong, because this handler does not validate or wrap exceptions the way its siblings do.", + "If no failing run matches the filters+window, the handler dereferences the first (absent) hit and 500s rather than returning an empty summary \u2014 treat a 500 here as possibly 'no sample found', and widen the window.", + "`error` is the sample text already split on newlines into an array of lines, not a single string.", + "`combinations` lists the platform combos the error appeared on ({os, browser} where browser also carries a `device`); combos that are NA on both os and browser are dropped from the list." + ] + }, + { + "name": "get_unique_error_occurrence_count", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/errorCount", + "mode": "read", + "entity": "sp_error", + "intent": "Get the total number of times one unique error occurred in the window, plus a per-day count series \u2014 the headline count and sparkline for the error detail panel.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the count spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "errorCount", + "chartData" + ], + "responses": { + "200": { + "$response": "UniqueErrorOccurrenceCountResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "`chartData` is a flat array of per-day occurrence counts with NO timestamps \u2014 reconstruct the x-axis yourself from the requested window (one bucket per calendar day, extended-bounds padded to the window).", + "`errorCount` is the total matching-run count for the window, independent of the bucket array length.", + "Buckets are fixed at one calendar day; the interval is not requestable." + ] + }, + { + "name": "get_unique_error_build_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/trend", + "mode": "read", + "entity": "sp_error", + "intent": "Get the occurrence trend of one unique error across builds over time \u2014 matching runs (timestamp, status, duration) grouped by status series and enriched with build info \u2014 to plot when and in which builds the error appeared.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped and the trend spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "data", + "keys" + ], + "responses": { + "200": { + "$response": "UniqueErrorBuildTrendResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Hard-capped at 5000 matching runs: for a very common error the trend is silently truncated to the first 5000 runs by start time, so totals here can understate the real count \u2014 use get_unique_error_occurrence_count for the true total.", + "Heavier than the other detail calls \u2014 it does a second relational lookup to enrich each run with build info \u2014 so expect higher latency.", + "`data` is a list of status series ({name, color, data[]}); `keys` is the parallel list of run start timestamps (epoch). A truly missing project is re-thrown as a 404; other failures surface as 500." + ] + }, + { + "name": "list_unique_error_builds", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds", + "mode": "read", + "entity": "sp_error", + "intent": "List the builds in which one unique error occurred, cursor-paginated, so a user can jump from an error cluster to the actual runs and builds where it appeared.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "searchAfter", + "type": "array", + "description": "Cursor: pass back the `pagingParams.searchAfter` array from the previous page verbatim. Follow this cursor rather than a page number; page size is fixed at 15." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped so all failed runs of the test are listed, not just this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "pagingParams", + "builds" + ], + "responses": { + "200": { + "$response": "UniqueErrorBuildsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Cursor pagination only \u2014 there is no page number. Take `pagingParams.searchAfter` from the response and send it back as the `searchAfter` query array for the next page; page size is fixed at 15.", + "Stop when the response has no further rows: `pagingParams.hasNext` is set to false only on an empty page, so an empty `builds` (or hasNext=false) is the end of the list.", + "Each build carries isFailedWithSameError, saying whether that build's run failed with THIS exact cluster or merely contains the test; use it to distinguish same-error recurrences from unrelated runs.", + "Some fields are exposed only through computed getters, not raw columns: versionControlInfo ({url, commitId}), issueType ({isAutoAnalyzed, value}, null unless the run is failed) and historyAggregate ({isAlwaysFailing, isFlaky, isNewlyFailed})." + ] + }, + { + "name": "get_unique_error_platform_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/platforms", + "mode": "read", + "entity": "sp_error", + "intent": "Break one unique error down by environment \u2014 which OS / browser / device combinations it occurred on and how often \u2014 so a team can tell whether the error is platform-specific.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + }, + { + "name": "errorId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "When true the errorClusterId constraint is dropped so the breakdown spans all failed runs of the test rather than this cluster." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "browser", + "os", + "chartData", + "totalFailure", + "errorCount" + ], + "responses": { + "200": { + "$response": "UniqueErrorPlatformBreakdownResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", + "Returns a FLAT array of platform buckets, not a nested os->browser->device tree; each entry is one {browser (with a `device`), os} combination.", + "Per entry, `totalFailure` is the occurrence doc count and `errorCount` is the distinct impacted-test count; `chartData` is per-day occurrence counts with no timestamps (reconstruct from the window).", + "Combos that are NA on both os and browser are dropped from the list.", + "The breakdown is a single composite aggregation page (up to 1000 combinations) and is not paginated, so a project with a very large platform matrix could be truncated." + ] + }, + { + "name": "get_unique_error_impacted_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/errors/{clusterId}/breakdown", + "mode": "read", + "entity": "sp_error", + "intent": "Expand one unique-error cluster into the individual tests that hit it \u2014 each with the browsers/devices/platforms it failed on and an occurrence series \u2014 the timeline/detail view behind a row in the unique-errors list.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Project name path variable, resolved within the caller's group." + }, + { + "name": "clusterId", + "type": "string", + "required": true, + "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). The error cluster id. It reaches the query only because Spring binds the URI variable onto the command object's `clusterId` setter \u2014 a query param of the same name would be overridden by this path value, so set it here.", + "pattern": "^[0-9]+$" + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "uniqueBuildNames", + "type": "array", + "description": "Restrict the breakdown to these unique build names (pro accounts only)." + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + }, + { + "name": "orderValue", + "type": "string", + "description": "Sorts the returned tests by occurrence count.", + "values": [ + "asc", + "desc" + ] + } + ], + "returns": [ + "id", + "title", + "filePath", + "scopeList", + "browsers", + "platforms", + "device", + "errorCount", + "testRunId", + "chartData" + ], + "responses": { + "200": { + "$response": "UniqueErrorImpactedTestsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response is a bare JSON array of impacted-test objects (no envelope). Each test's `id` is a stability hash (the test signature), not a numeric id; `testRunId` is one representative failing run.", + "Set the cluster id in the PATH \u2014 the {clusterId} path variable overrides any clusterId query param of the same name, because the handler never reads the path variable itself and relies on request binding.", + "The full Suite Health filter surface is accepted and narrows the matched runs, but pro-account filters (build names, flaky, etc.) are skipped for free / non-pro users.", + "It pages internally through all composite buckets under a loop deadline, so for a very large cluster the returned list can be truncated when the deadline is hit rather than erroring.", + "A genuinely absent project is re-thrown as a 404; other failures surface as a 500. `chartData` is per-day occurrence counts with no timestamps." + ] + }, + { + "name": "get_unique_error_timeline_charts", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/errors/metrics/chart", + "mode": "read", + "entity": "sp_error", + "intent": "Get the Suite Health unique-errors timeline in one call \u2014 three day-bucketed time series over the window (unique errors, unique impacted tests, total impacted executions), each with its headline total \u2014 for the project's failed tests.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group. No permission check beyond project visibility." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "search", + "type": "string" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array" + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "allBuilds", + "type": "boolean" + } + ], + "returns": [ + "uniqueErrorChart", + "uniqueTestChart", + "impactedTestChart" + ], + "responses": { + "200": { + "$response": "UniqueErrorTimelineChartsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Response is a map with exactly three keys: uniqueErrorChart, uniqueTestChart, impactedTestChart. Each is {data, insights}, where data is a single-entry map whose series key differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", + "Read the headline number from each chart's `insights.value` (a STRING, e.g. '42') with `insights.metric` as its label \u2014 not by summing the points.", + "Each series point carries x (bucket epoch), y (value, may be null), t and pointRange; the bucket interval is derived from the window, not requestable.", + "Only failed tests are counted; archived and usage-locked builds are excluded, and the failed-status field used depends on the group's new-build-listing flag, so totals can shift when that flag changes." + ] + }, + { + "name": "get_unique_error_filters", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters", + "mode": "read", + "entity": "sp_error", + "intent": "Build the whole filter sidebar for a project's Unique Errors page in one call \u2014 the currently applied selections echoed back as display fields, the static dropdown option sets (failure categories, statuses, platforms, users, teams, sources, folders...), and the project's default hooks visibility.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. Scopes which option values exist. lowerBound + upperBound singles are the only fallback." + }, + { + "name": "search", + "type": "string" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "values": [ + "TO", + "AUT", + "AA", + "ATS", + "TM", + "LCNC", + "ALLY", + "LTS" + ] + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array" + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "branches", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "muted", + "unmuted", + "true", + "false" + ] + } + ], + "returns": [ + "applied", + "staticFilters", + "hooksVisibility" + ], + "responses": { + "200": { + "$response": "UniqueErrorFiltersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`applied` echoes the caller's current selections resolved into display objects (each option is {id, value, label, count, pId}); `staticFilters` is the full set of selectable option lists for the page.", + "Options are fetched from Elasticsearch and TestHub concurrently and degrade to EMPTY dropdowns on timeout or rejection rather than failing the request \u2014 an empty option list does NOT mean there is no data.", + "This is the errors-page filter surface; do not confuse it with the general build/test sidebar filters. Use the sibling typeahead capabilities (branches / build names / build tags / host names / test tags) for search-as-you-type option lookups." + ] + }, + { + "name": "search_unique_error_filter_branches", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/branches", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the branch filter on the Unique Errors page \u2014 return git branch names in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; applied as a leading+trailing Elasticsearch wildcard." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (this is the QueryRq shape, NOT the full error filter). lowerBound/upperBound singles are NOT accepted here." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Whether manual (Test Management) runs are considered. Defaults to true on this endpoint (unlike build-names, which forces it false)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Uses the compact QueryRq body (query + dateRange + isManuallyOverridden + includeManualRuns) \u2014 NOT the full Unique Errors filter set. dateRange must be exactly two bounds; there is no lowerBound/upperBound fallback.", + "Returns a BOUNDED set under `data` (each {id, value, label, count, pId}), matched by wildcard on `query`; it is a typeahead, not the full branch list.", + "Source fencing is applied, so branches from fenced-off product families may not appear." + ] + }, + { + "name": "search_unique_error_filter_build_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildNames", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the build-name filter on the Unique Errors page \u2014 return unique build names in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "IGNORED / silently forced to false on this endpoint \u2014 manual (Test Management) runs never appear in build-name suggestions whatever you send." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Unlike its sibling typeahead endpoints this one FORCES includeManualRuns=false server-side, so manual (Test Management) build names are always excluded regardless of the value you pass.", + "It delegates to the shared build-filter service rather than the suite-health filter service, so its result set can differ subtly from the other error-filter typeaheads.", + "Returns a bounded set under `data` (each {id, value, label, count, pId}); it is a typeahead, not the full list. Uses the compact QueryRq shape (no lowerBound/upperBound fallback)." + ] + }, + { + "name": "search_unique_error_filter_build_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildTags", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the build-tag filter on the Unique Errors page \u2014 return build tags used in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; applied as an Elasticsearch wildcard terms lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true; this endpoint does NOT force it false (only build-names does)." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard terms lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape (query + dateRange + isManuallyOverridden + includeManualRuns); dateRange must be exactly two bounds with no lowerBound/upperBound fallback." + ] + }, + { + "name": "search_unique_error_filter_host_names", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/hostNames", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the host-name (CI machine / runner) filter on the Unique Errors page \u2014 return matching host names within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; Elasticsearch wildcard lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false).", + "Runs without a recorded host name aggregate into one option with empty id/value/label (and a count) - skip it when building a host filter." + ] + }, + { + "name": "search_unique_error_filter_test_tags", + "method": "GET", + "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/testTags", + "mode": "read", + "entity": "sp_error", + "intent": "Typeahead for the test-tag filter on the Unique Errors page \u2014 return tags attached to tests in the project matching a substring within the date window.", + "path_params": [ + { + "name": "projectNormalisedName", + "type": "string", + "required": true, + "description": "Project normalised name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "query", + "type": "string", + "description": "Substring to match; Elasticsearch wildcard lookup." + }, + { + "name": "dateRange", + "type": "array", + "required": true, + "minItems": 2, + "maxItems": 2, + "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." + }, + { + "name": "isManuallyOverridden", + "type": "boolean" + }, + { + "name": "includeManualRuns", + "type": "boolean", + "default": true, + "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "FieldSearchResponse__w3_sp_error" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", + "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false)." + ] + }, + { + "name": "trigger_bulk_test_rca", + "method": "POST", + "path": "/api/v1/ai/builds/{buildUuid}/bulkTestRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "The build's UUID. All failed tests of this build (optionally narrowed by the filter body) are queued for AI RCA." + } + ], + "body": [ + { + "name": "filters", + "type": "object", + "description": "Optional. Same test-listing filter shape (FilterParamsV2) the build's test list uses \u2014 narrows which failed tests are analysed. Omit to target the build's failures as a whole.", + "fields": [ + { + "name": "status", + "type": "array", + "description": "Test statuses to include (e.g. failed). Part of the shared build test-listing filter." + } + ] + }, + { + "name": "limit", + "type": "integer", + "description": "Optional cap on how many failed runs are queued in this request." + } + ], + "intent": "Kick off AI root-cause analysis for all failed tests of a build in one go (the 'Analyse all failures' action) \u2014 optionally narrowed by the test-listing filters and capped by a limit.", + "returns": [ + "state", + "buildUuid", + "bulkRcaUuid", + "trialQuota" + ], + "responses": { + "200": { + "$response": "BulkTestRcaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: this only queues work. The reply's bulkRcaUuid is the handle for the batch and each run's finished RCA arrives later \u2014 poll the individual test runs' RCA (get_test_rca_data) rather than expecting analyses in this response.", + "Plan-fenced: a free group that is not test-level-trial eligible is refused with 403 (PLAN_FEATURE_NOT_ALLOWED). It also requires the group's AI opt-in consent and the test-runs-RCA feature on the AI token; a source-fenced build is refused with AI_FEATURE_FENCED.", + "Needs the caller's own BrowserStack username + Selenium access key, resolved server-side from the caller's user record; a caller whose record lacks them gets 400 'User details are not available'.", + "Disabled entirely in the demo-sandbox environment.", + "The body is optional \u2014 POST with no body queues the build's failures with no extra filtering." + ] + }, + { + "name": "get_test_rca_data", + "method": "GET", + "path": "/api/v1/ai/testRuns/{testRunId}/testRca/data", + "mode": "read", + "entity": "test_rca", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Read an already-generated AI RCA for a test run with NO side effects \u2014 the safe counterpart to get_test_rca that never creates a record or queues a generation.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "bulkRcaId", + "isAgentic", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pure read: no AI consent/feature checks and no generation queued. Use this to poll for a result after triggering one elsewhere.", + "When nothing has been generated yet this is a 400 (BAD_REQUEST, 'Test Run RCA not found for the given test run ID'), NOT a 404 or an empty 200 \u2014 treat that 400 as 'not generated yet', not as a bad request.", + "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found.", + "404 'RCA not found' means no RCA has been generated for this test run yet (not a bad id): generating one is a write (get_test_rca / trigger_test_rca) and needs user confirmation." + ] + }, + { + "name": "get_test_rca", + "method": "GET", + "path": "/ext/v1/testRun/{test_run_id}/testRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "peek", + "type": "boolean", + "default": false, + "description": "peek=true makes the call strictly read-only: it returns the current RCA state and never enqueues a generation. With the default peek=false the GET is get-or-create and CAN queue work \u2014 treat that form as a trigger, not a read." + } + ], + "intent": "Public-API fetch of a failed test run's AI root-cause analysis; by default get-or-create (queues a generation and returns the pending state if none exists), or a strict read with peek=true.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "isLive", + "pendingMessageId", + "alreadyTracked", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the public-API twin of get_test_rca and accepts username + access-key auth. Behaviour splits on peek: peek=true is safe (never enqueues); peek=false (the default) is get-or-create and has a side effect.", + "To poll without triggering, always pass peek=true. The peek-only fields isLive / pendingMessageId / alreadyTracked describe an in-flight generation; gate a re-trigger on isLive (alreadyTracked only reflects whether a chat row holds a handle and is null for web/MCP-started runs).", + "Requires AI opt-in consent and AI enablement; source-fenced groups get AI_FEATURE_FENCED. Resolves the caller's BrowserStack username/access key server-side and 400s if missing.", + "To register an https callback instead of polling, use the POST twin trigger_test_rca.", + "peek=true on a run with NO RCA returns 200 with every field null (id, state, isAgentic, ...), not a 404 - check id/state for null. isAgentic can read false on the first (Pending) response and true once generation starts." + ] + }, + { + "name": "trigger_test_rca", + "method": "POST", + "path": "/ext/v1/testRun/{test_run_id}/testRca", + "mode": "write", + "entity": "test_rca", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "body": [ + { + "name": "webhook_url", + "type": "string", + "maxLength": 2048, + "description": "Optional https callback URL the pipeline POSTs the finished RCA to when a generation is actually queued. https only; localhost/.internal/.local/loopback/private/link-local/multicast hosts are rejected (SSRF-guarded at accept time and re-checked at dispatch). Absent/blank means no callback." + } + ], + "intent": "Public-API trigger for a test run's AI RCA with a fire-and-forget callback: same get-or-queue behaviour as the GET, plus an https webhook the pipeline notifies when the analysis is ready.", + "returns": [ + "id", + "testRunId", + "rcaData", + "thinkingData", + "state", + "isLive", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "$response": "TestRunsRcaDataResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "With no body this behaves exactly like get_test_rca with peek=false (get-or-queue). The only added capability is registering webhook_url.", + "The callback fires ONLY when a generation is actually queued \u2014 if the RCA already exists (or is already in flight) no callback is sent, so still read the 200 body's state.", + "webhook_url must be https and pass the SSRF host checks both at accept time and again at dispatch; a URL that resolves to a blocked host is rejected with a 400 naming the reason.", + "The field is named webhook_url on the wire (snake_case), not webhookUrl." + ] + }, + { + "name": "list_build_groups", + "method": "GET", + "path": "/api/v1/projects/{projectId}/build-groups", + "mode": "read", + "entity": "build_group", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "TestHub project id (NOT the observability project id) used by the build-group backend." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "description": "1-based page number." + }, + { + "name": "pageSize", + "type": "integer", + "default": 30, + "description": "Rows per page (passed through to TestHub; server default 30)." + }, + { + "name": "name", + "type": "string", + "description": "Substring name search over run groups." + }, + { + "name": "ids", + "type": "string", + "description": "Comma-separated build-group ids to fetch a specific set instead of the full listing." + } + ], + "intent": "List a project's run groups (build groups) with paging and name search \u2014 the source of run-group ids for linking, closing, editing or deleting; can also fetch a specific set by ids.", + "returns": [ + "data", + "info" + ], + "responses": { + "200": { + "$response": "BuildGroupListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The groups are at the top-level `data` array (there is no `success` wrapper on this route); paging is under `info` (page, pageSize, count, prev, next).", + "By default closed groups are excluded from this listing (the controller requests excludeClosed=true).", + "projectId in the path is the TestHub project id, not the observability project id \u2014 using the wrong one returns an empty/foreign result.", + "Each row's `state` is derived: ACTIVE until closed, then CLOSED. Take the integer `id` for the link/close/edit/delete calls." + ] + }, + { + "name": "create_build_group", + "method": "POST", + "path": "/api/v1/projects/{projectId}/build-groups", + "mode": "write", + "entity": "build_group", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "TestHub project id." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "maxLength": 255, + "description": "Run-group name. Required and must not be blank; the reserved name 'Ungrouped' (any case) is rejected with 400 'Please enter a valid group name'." + }, + { + "name": "description", + "type": "string", + "maxLength": 1000, + "description": "Optional description." + } + ], + "intent": "Create a new run group (build group) in a project so builds can be grouped for a release or test cycle.", + "returns": [ + "success", + "data", + "message" + ], + "responses": { + "200": { + "$response": "BuildGroupOperationResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Requires the project's BUILD_ADD_TO_RUN_GROUP permission.", + "The created group is at `data` (a build-group object with its integer `id`); `success` is true and `message` is 'Build group created successfully'.", + "Creation actually happens in TestHub and is mirrored back \u2014 the returned id is the TestHub group id used by the other build-group calls.", + "'Ungrouped' is reserved and rejected even though it is otherwise a valid name." + ] + }, + { + "name": "list_build_accessibility_test_runs", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/a11y-testRuns", + "mode": "read", + "entity": "accessibility", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Build UUID." + } + ], + "query": [ + { + "name": "status", + "type": "string", + "description": "Filter by test status." + }, + { + "name": "searchQuery", + "type": "string", + "description": "Substring search over test name." + }, + { + "name": "run", + "type": "array", + "description": "Restrict to specific run ids.", + "fields": [ + { + "name": "", + "type": "integer", + "description": "A run id." + } + ] + }, + { + "name": "page", + "type": "integer", + "description": "Page number (see guidance on 0/1 indexing)." + }, + { + "name": "limit", + "type": "integer", + "description": "Rows per page." + } + ], + "intent": "List the accessibility-scanned tests of one build for the build's Accessibility tab \u2014 per test: name, file, status, OS/browser, a11y score, page/component counts and a critical/serious/moderate/minor issue breakdown, with paging.", + "returns": [ + "testCases", + "pagingParams" + ], + "responses": { + "200": { + "$response": "AccessibilityTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The build must have FINISHED (or TIMEOUT) and carry accessibility data, else a typed 4xx: A11Y_BUILD_NOT_FOUND (no build/no a11y), A11Y_BUILD_STATUS_INVALID (no status), or A11Y_BUILD_NOT_COMPLETED (still running).", + "Feature-flag fork: when the App-Accessibility flag is on for the group AND the build's source is the App-Accessibility automation source, the whole call is proxied to a separate Accessibility service and the RESPONSE SHAPE IS THAT SERVICE'S, not the one described here. Do not assume this shape for App-Accessibility builds.", + "Tests with no a11y summary/severity breakdown are SILENTLY DROPPED from the list, so the row count can be lower than the build's total tests.", + "Paging is re-based to 0-index for the frontend: pagingParams.nextRoot.page is (upstream next page - 1), and pagingParams.hasNext says whether to continue.", + "Accepts a public-link token in addition to the normal auth types (public dashboards)." + ] + }, + { + "name": "get_test_run_accessibility_meta", + "method": "GET", + "path": "/api/v1/testRuns/{btcerId}/a11y-testMeta", + "mode": "read", + "entity": "accessibility", + "path_params": [ + { + "name": "btcerId", + "type": "integer", + "required": true, + "description": "TestHub build-test-case-execution-result (btcer) id \u2014 a plain integer, NOT a test-run TR-NNN identifier." + } + ], + "query": [ + { + "name": "page", + "type": "integer", + "default": 1, + "description": "Page number. Page size is fixed server-side at 10 and cannot be changed by the caller." + } + ], + "intent": "Accessibility detail for a single test on the Test Details page \u2014 that test's a11y score, total issues and severity breakdown, and its report URL.", + "returns": [ + "totalIssues", + "severityBreakdown", + "reportURL", + "score", + "testCaseUuid" + ], + "responses": { + "200": { + "$response": "AccessibilityTestMetaResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Error mapping is LOSSY: every failure except A11Y_SERVICE_USER_UNAUTHORIZED and A11Y_SERVICE_NOT_CONFIGURED is rewritten to A11Y_TESTCASE_RESULTS_NOT_FOUND \u2014 so a genuine upstream error surfaces as 'no results'. Do not read 'not found' as definitely empty.", + "App-Accessibility feature-flag fork again: when the flag is on and the run's source is the App-Accessibility automation source, the call is proxied to a separate Accessibility service and returns that service's shape, not the one described here.", + "Page size is fixed at 10 server-side; only `page` is caller-controllable.", + "reportURL points to the a11y report to fetch elsewhere; the response itself does not embed the full report." + ] + }, + { + "name": "update_code_fix_preference", + "method": "PUT", + "path": "/api/v1/ai/projects/{projectId}/codeFixPreferences/{id}", + "mode": "write", + "entity": "ai_insight", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Observability project id. Must belong to the caller's group (IDOR-checked) and must match the group+project the preference row was created under." + }, + { + "name": "id", + "type": "integer", + "required": true, + "description": "Integer id of the code-fix preference row to edit. The row is located by this id alone; buildName in the body does not select it." + } + ], + "body": [ + { + "name": "buildName", + "type": "string", + "required": true, + "description": "Required and must be non-blank (validation), but its value is IGNORED on update \u2014 the row is located by the path id and buildName is never re-written. Send any non-blank string (echo the preference's existing build name)." + }, + { + "name": "instruction", + "type": "string", + "maxLength": 5000, + "description": "Free-text code-fix guidance. Omit (null) to leave the stored instruction unchanged. There is no way to send null to clear it; to end up with no instruction you must keep at least one file attached, else the call is 400." + }, + { + "name": "fileAttachments", + "type": "array", + "maxLength": 1, + "description": "TRI-STATE. Omit (null) = leave existing attachments untouched. Send [] = remove ALL existing attachments. Send a non-empty array = REPLACE them. On replace or remove the previously attached S3 objects are permanently deleted (no undo, no version history). At most ONE attachment (MAX_PREFERENCE_FILES=1).", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "maxLength": 512, + "description": "S3 key of the already-uploaded file from a prior code-fix-preference presign+upload. Confirmed against this group/project; an s3Key that is not confirmable is rejected." + }, + { + "name": "fileName", + "type": "string", + "required": true, + "maxLength": 200 + }, + { + "name": "contentType", + "type": "string", + "required": true, + "maxLength": 100 + } + ] + } + ], + "intent": "Edit an existing AI code-fix preference for a project/build \u2014 change the instruction text and/or replace (or remove) its attached context file.", + "guidance": [ + "fileAttachments is tri-state: null (omit) leaves attachments untouched, [] removes all of them, a non-empty array replaces them. Replacing or removing permanently deletes the old S3 objects \u2014 there is no undo and preferences keep no version history, so an overwritten instruction or removed file is gone for good.", + "The final state must still carry either an instruction or at least one file: a request that would leave the row empty is rejected 400 'At least one of instruction or fileAttachments is required' before any S3 work runs.", + "buildName is required by validation on every update but its value is silently ignored \u2014 the update targets the row named by the path id and never rewrites buildName. Do not use this call to rename the build a preference is bound to.", + "instruction omitted (null) means 'unchanged'; only a non-null instruction overwrites it. Max 5000 characters.", + "Only one attachment is allowed (max 1). Each must reference an s3Key produced by the code-fix-preference presign+upload flow; a key not confirmable for this group/project fails the request.", + "The path id must belong to the SAME group and project as the path projectId, else 400 'Preference does not belong to this project' (not 404). A missing id is 404 code-fix-preferences-not-found.", + "Plan-gated: 403 plan-feature-not-allowed when the AI-TFA fence trips, and per-user AI must be enabled for the caller." + ], + "returns": [ + "id", + "projectId", + "buildName", + "instruction", + "fileAttachments", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt" + ], + "responses": { + "200": { + "description": "Updated code-fix preference. fileAttachments[].downloadUrl is a freshly presigned GET URL, omitted when its generation failed.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "projectId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "instruction": { + "type": "string" + }, + "fileAttachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "s3Key": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "downloadUrl": { + "type": "string", + "description": "Presigned GET URL for the attachment. Present only when it could be generated (NON_NULL)." + } + } + } + }, + "createdBy": { + "type": "integer" + }, + "updatedBy": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "improve_test_rca", + "method": "POST", + "path": "/ext/v1/ai/testRuns/{test_run_id}/rcaContext/retrigger", + "mode": "write", + "entity": "ai_insight", + "path_params": [ + { + "name": "test_run_id", + "type": "integer", + "required": true, + "description": "Integer test-run id. IDOR-scoped to the caller's group via its build." + } + ], + "body": [ + { + "name": "instruction", + "type": "string", + "maxLength": 5000, + "description": "Extra user instruction to steer the improved RCA. Optional." + }, + { + "name": "attachments", + "type": "array", + "maxLength": 3, + "description": "Up to 3 context files, each already uploaded via the RCA-context presign flow.", + "fields": [ + { + "name": "s3Key", + "type": "string", + "required": true, + "maxLength": 512 + }, + { + "name": "fileName", + "type": "string", + "required": true, + "maxLength": 200 + }, + { + "name": "contentType", + "type": "string", + "required": true, + "maxLength": 100 + }, + { + "name": "description", + "type": "string", + "maxLength": 500 + } + ] + }, + { + "name": "usePreferences", + "type": "boolean", + "description": "Whether to fold the project's stored code-fix preferences into the retrigger." + }, + { + "name": "webhook_url", + "type": "string", + "maxLength": 2048, + "description": "Optional https callback the pipeline POSTs the finished RCA to. SSRF-validated at accept time and again at dispatch: https only, and NOT localhost/private/link-local/.internal/.local hosts \u2014 a violation is 400. Absent/blank means no callback." + } + ], + "intent": "Re-run ('improve') a test's RCA with an extra instruction and optional attachments, on behalf of an agent acting for the customer \u2014 returns the new conversation message id to poll.", + "guidance": [ + "Precondition-heavy: requires an existing RCA row for the test run, AI opt-in consent, and RCA-v2 eligibility, and the test run must not exceed the age limit \u2014 otherwise it is refused before any generation.", + "Refused with FORBIDDEN_OPERATION when the run is already owned by the MCP-driven agent flow.", + "webhook_url must be https and must not target localhost/private/link-local/.internal/.local hosts; it is validated when accepted and re-validated at dispatch, so a bad URL is a 400 up front.", + "instruction is capped at 5000 chars and at most 3 attachments are accepted; each attachment needs an s3Key from a prior RCA-context presign+upload.", + "Asynchronous: the body carries only messageId (a UUID) \u2014 poll the RCA conversation/turn-status read for the outcome; the RCA itself is delivered elsewhere (the polled read, or the webhook when supplied). trialQuota.used appears only for trial tenants.", + "This is the external mirror of the session-auth internal retrigger; authenticate with the customer's BrowserStack username + access key." + ], + "returns": [ + "messageId", + "trialQuota" + ], + "responses": { + "200": { + "description": "Retrigger accepted; poll the returned messageId.", + "schema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "UUID of the new conversation turn to poll." + }, + "trialQuota": { + "type": "object", + "description": "Present (NON_NULL) only for trial tenants.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_split_build_legs", + "method": "GET", + "path": "/api/v1/builds/{uuid}/getSplitBuildDetails", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The PARENT build's 40-char uuid. The child legs are resolved as builds whose parentId is this build's id." + } + ], + "query": [ + { + "name": "pageLimit", + "type": "integer", + "default": 10, + "description": "Page size (child legs per page). Defaults to 10 when omitted. Bound from the query string despite the DTO marking it @JsonIgnore (that only affects JSON body binding, not query binding)." + }, + { + "name": "searchAfter", + "type": "string", + "pattern": "^[0-9a-zA-Z]{40}$", + "description": "Cursor for the NEXT page: the build UUID of the last leg you received, NOT an offset or page number. Must be a 40-char alphanumeric build uuid or the request is rejected. Echoed back to you in pagingParams.searchAfter." + } + ], + "intent": "List the individual legs (child builds) of a split / parallel build with their ids and uuids, so you can offer a leg switcher or aggregate stats across the whole split run.", + "returns": [ + "id", + "name", + "builds", + "pagingParams" + ], + "responses": { + "200": { + "$response": "SplitBuildLegsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Pagination is CURSOR-based, keyed by build uuid: read `pagingParams.searchAfter` (the last leg's uuid) from the response and pass it as the next call's `searchAfter`. There is no offset/page-number paging here.", + "Walk pages while `pagingParams.hasNext` is true; hasNext is set from whether the page came back full (>= pageLimit), so a final short page ends the walk.", + "An unknown parent uuid is ObservabilityException(BUILD_NOT_FOUND), not a 200 with an empty list.", + "Each leg in `builds` is a full build-summary object (same shape as get_build_details), so you get every leg's status and counts without a per-leg call.", + "`builds` are the CHILD legs only; the parent build itself is addressed by get_build_details." + ] + }, + { + "name": "get_build_notification_summary", + "method": "GET", + "path": "/api/v1/builds/{id}/summaryLite", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "id", + "type": "integer", + "required": true, + "description": "The NUMERIC build id (not the 40-char uuid). This route takes the integer id; passing a uuid will not resolve." + } + ], + "intent": "Get the compact build summary used to render the build-summary email / notification card \u2014 name, project, run-by, status/issue-type/history aggregates, muted and total counts, build alerts and quality-gate status.", + "returns": [ + "uuid", + "name", + "projectName", + "status", + "totalCount", + "mutedCount", + "qualityGateStatus", + "statusStats", + "issueTypeAggregate", + "historyAggregate", + "alerts", + "buildNumber", + "colorMapping" + ], + "responses": { + "200": { + "$response": "BuildNotificationSummaryResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Takes the INTEGER build id, unlike almost every other build endpoint (which take the uuid). Get the integer id from a build read or listing first.", + "An unknown id returns HTTP 200 with a NULL body, not a 404 \u2014 treat an empty/null response as 'build not found', not as an empty summary.", + "This is NOT group-scoped in the lookup: it resolves the build by id alone. Do not rely on it to enforce workspace ownership.", + "This is a different payload from get_build_details / get_build_summary_stats \u2014 it is the trimmed 'email card' shape (BuildSummaryEmail) with formatted duration/date helpers, not the full detail object.", + "`accessibility` is present only for APP_ALLY_AUT builds (populated fail-open) and omitted entirely otherwise; do not treat its absence as a11y failure.", + "`startedAt` is not serialised as a raw timestamp \u2014 the payload exposes formatted date/time/duration helper fields instead." + ] + }, + { + "name": "get_build_stability_tile", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildStabilityStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Any extra query params not bound to the FilterParamsV2 fields are collected into a custom-params map and forwarded to the query. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the stability tile for a build \u2014 the share of tests that ran cleanly versus flaky / always-failing / new-failure noise, with a percentile insight versus recent builds \u2014 for the build insights page.", + "returns": [ + "data", + "keys", + "overview", + "median", + "commentCount" + ], + "responses": { + "200": { + "$response": "BuildStabilityTileResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403 (FORBIDDEN_OPERATION / plan feature not allowed) \u2014 the stability tile is a gated feature.", + "An unknown uuid is BUILD_NOT_FOUND, not an empty tile.", + "This is the stability TILE (single build, based on the last ~30 runs, `overview.meta` says how many) \u2014 not the historical stability chart (that is get_build_stability_chart).", + "This route accepts the full FilterParamsV2 filter set as query params plus arbitrary customParams; filters narrow which of the build's tests feed the stability number.", + "`overview.count` is a percentage string (e.g. \"92.3\") and `overview.insights` is a positive/negative/default verdict versus recent builds \u2014 it is only populated once there are >= 10 comparison runs." + ] + }, + { + "name": "get_build_summary_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildSummary", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the headline test-count strip for a build under the currently applied filters \u2014 total / passed / failed / skipped / pending roll-ups plus AI-RCA and session counts \u2014 the summary strip at the top of the build insights page.", + "returns": [ + "data", + "aiRcaStats", + "isArchived", + "commentCount", + "hooksVisibility", + "manuallyOverriddenCount", + "sessionStats" + ], + "responses": { + "200": { + "$response": "BuildSummaryStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Name-collision trap: this is `/{uuid}/buildSummary` (headline counters, action getBuildStats). It is NOT `/{uuid}` build metadata (get_build_details) and NOT `/{uuid}/getBuildStats` (get_build_non_sdk_test_counts). The similar names back completely different payloads.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "`data` is an array of colored metric tiles (each `{id, name, value, color, testsCount, hooksCount, ...}`), not a flat counts object \u2014 read each tile's `value`.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the counters reflect the applied filters, so an unfiltered total needs an unfiltered call." + ] + }, + { + "name": "get_build_timeline_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/buildTimelineStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "intent": "Get the build's execution timeline \u2014 per-status series (passed / failed / pending=running) over the build's lifetime \u2014 for the timeline visualisation on the build page.", + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "BuildTimelineStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No filters apply on this route \u2014 it takes only the uuid; any query params are ignored.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "The payload is a single top-level `data` array of three series (ids passed, failed, pending - pending is the running count), each carrying `[x, value]` pairs where x is milliseconds since the first test started, not an epoch timestamp. Only passed/failed runs are counted." + ] + }, + { + "name": "get_build_details", + "method": "GET", + "path": "/api/v1/builds/{uuid}", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "run", + "type": "integer", + "required": false, + "description": "Optional attempt / run number to scope the summary to a specific re-run attempt. Omit for the latest." + } + ], + "intent": "Get the main build-detail payload for one build \u2014 status, run counts, framework/CI metadata, cross-product and test-plan info \u2014 plus the per-build UI gates (whether to show the Self-Healing tab, the Authoring Agent tab, and whether App Accessibility is enabled).", + "returns": [ + "uuid", + "id", + "name", + "status", + "displayStatus", + "duration", + "statusStats", + "issueTypeAggregate", + "historyAggregate", + "projectId", + "qualityGateStatus", + "showHealingReport", + "showAuthoringReport", + "appAccessibilityEnabled", + "tags", + "tcmTestPlans", + "workflowStatusStats" + ], + "responses": { + "200": { + "$response": "BuildDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "An unknown uuid throws ObservabilityException(BUILD_NOT_FOUND) (a 404-equivalent), not a 200 with an empty body.", + "`showAuthoringReport` and `showHealingReport` are UI gates derived from an S3 key-existence check on the build metadata \u2014 they say whether the report EXISTS to show, not whether you may access it. On demo-sandbox environments `showAuthoringReport` is forced true regardless.", + "`appAccessibilityEnabled` is a per-group feature flag and fails CLOSED (false) if the flag lookup errors \u2014 do not read false as 'definitely no a11y data'.", + "`duration` is the consolidated duration when present, otherwise the raw duration \u2014 the field already resolves this, so use it directly.", + "`displayStatus` is the settled pipeline verdict and is preferred over `status` for the final outcome; `status` can still reflect a live aggregation mid-run.", + "Use the `run` query param to inspect a specific re-run attempt; omitting it returns the latest attempt's summary." + ] + }, + { + "name": "get_build_failure_category_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/defectStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the failure-category (defect) breakdown for a build \u2014 how many failures fall into each issue type/category, how many have AI RCA available, and the comment count \u2014 for the 'Failure Categories' panel.", + "returns": [ + "data", + "aiRcaStats", + "commentCount", + "message" + ], + "responses": { + "200": { + "$response": "BuildFailureCategoryStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "While the build is still PENDING (or has no status yet) this returns HTTP 200 with an EMPTY `data` array, zeroed `aiRcaStats`/`commentCount`, and `message` = 'Failure Categories will be available after your build finishes'. That is a valid 200, not an error \u2014 check `message`/`data` before concluding there are no defects.", + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Each `data` entry is a category with a `value` count and optional nested `categories` sub-buckets; `aiRcaStats.completedCount` is how many of these failures already have AI RCA.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the breakdown reflects the applied filters." + ] + }, + { + "name": "get_build_failures_by_module", + "method": "GET", + "path": "/api/v1/builds/{uuid}/failureByModules", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get a build's failures grouped by test module / file path, so you can see which modules or suites contribute most of the failures.", + "returns": [ + "data", + "commentCount", + "isConflicting", + "sourceFencingFlags" + ], + "responses": { + "200": { + "$response": "BuildFailuresByModuleResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Each `data` entry is a module node with a `value` (total) and `failedTests` count, plus `parent`/`customPathSplit` so you can rebuild the folder/module tree \u2014 the path separator is inferred per node (`/` or `\\`).", + "`sourceFencingFlags` is present only when the group's source-fencing feature excludes the build's product; when present it names which products' tests were skipped from the aggregation, so counts may be partial.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_filter_values", + "method": "GET", + "path": "/api/v1/builds/{buildId}/getFilters", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (despite the `buildId` name, this is the 40-char uuid, resolved via getBuildbyUuId)." + } + ], + "intent": "Get the distinct filter values available for one build's test listing (browsers, OSes, tags, issue types, statuses, plus static history/flaky options) so the UI can render the filter dropdowns.", + "returns": [ + "filters" + ], + "responses": { + "200": { + "$response": "BuildFilterValuesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "This is the OLDER filter shape. The richer replacement is get_build_test_filters (route `/{buildUuid}/filters`), which additionally returns the currently-applied filters, hooks visibility, split-build children and freemium pro-gating. Prefer that one unless you specifically need this compact shape.", + "Not group-scoped in the lookup (resolves the build by uuid alone).", + "The payload is a single `filters` map keyed by facet name (browser, os, issueType, tags, status, folder, history, flaky), each a list of `{label, value}` options; `history` and `flaky` are static option lists always present.", + "Values are computed from the build's LATEST test runs, so a facet a filter narrowed away will not appear as an option here." + ] + }, + { + "name": "get_build_flaky_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/flakyStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the flaky-tests tile for a build \u2014 how many of the build's tests were detected as flaky (with a 'out of total' meta) plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary: count/meta/commentCount/autoUnmuteCount) as get_build_muted_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the flaky count; `meta` carries the 'out of N' context string.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "list_build_test_folders", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/folder", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (despite the `buildId` name it is the 40-char uuid). WARNING: there is no not-found guard \u2014 an unknown uuid NPEs into a 500, not a 404." + } + ], + "query": [ + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Absolute folder path within the build's test listing. OMIT it (or send blank) to get the paginated ROOT folders with pass/fail roll-ups; provide it to get that folder's details and its immediate sub-folders." + }, + { + "name": "p", + "type": "integer", + "default": 1, + "description": "Page number, used ONLY on the root-folder (no folder_path) branch. Ignored on the sub-folder branch." + }, + { + "name": "size", + "type": "integer", + "default": 30, + "description": "Page size, used ONLY on the root-folder branch. The sub-folder branch ignores it and uses default paging." + }, + { + "name": "after_key", + "type": "string", + "required": false, + "description": "Composite-aggregation cursor for the root-folder branch. Ignored on the sub-folder branch. NOTE: the response's `after_key` is returned null even on the root branch (see guidance)." + }, + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded." + } + ], + "intent": "Browse the folder-tree view of a build's test listing: with no folder_path, list the paginated root folders (with pass/fail roll-ups); with a folder_path, get that folder's details and its sub-folders. All the build's test-listing filters apply.", + "returns": [ + "project_id", + "folders", + "name", + "folder_path", + "cases_count", + "total_cases_count", + "sub_folders_count", + "sub_folders", + "pagingParams", + "after_key" + ], + "responses": { + "200": { + "$response": "BuildTestFoldersResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The response SHAPE depends on the branch. No `folder_path` -> root shape `{project_id, folders[], pagingParams, after_key}`. With `folder_path` -> folder-detail shape `{project_id, name, folder_path, cases_count, total_cases_count, sub_folders_count, sub_folders[], pagingParams}`. Do not expect `folders` on the detail branch or `sub_folders` on the root branch.", + "There is NO build-not-found guard on this route: an unknown uuid throws an NPE that surfaces as a 500, not a clean 404. Validate the uuid (e.g. via get_build_details) first.", + "Root-folder pagination is composite-aggregation based; walk it with `pagingParams` (and `after_key`/`p`). Note the top-level `after_key` in the response is set to null even on the root branch \u2014 rely on `pagingParams` to page.", + "Passing a `folder_path` on this route ignores `p`/`size`/`after_key` entirely and returns that folder with default paging.", + "The full FilterParamsV2 test-listing filter set (status, tags, users, dateRange, isFlaky, ...) is bound from query params and applied to the folder roll-ups; delegates to TestHub's folder-hierarchy API.", + "A blank/whitespace `folder_path` is treated as 'no path' (root branch); a non-blank but unresolvable path on the detail branch is a 400 'Invalid folder_path'." + ] + }, + { + "name": "get_build_muted_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/mutedStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the muted-tests tile for a build \u2014 how many of the build's tests are muted (with a 'out of total' meta) plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the muted count; `autoUnmuteCount` is how many are scheduled to auto-unmute.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_new_failure_stats", + "method": "GET", + "path": "/api/v1/builds/{uuid}/newFailureStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid." + } + ], + "query": [ + { + "name": "customParams", + "type": "object", + "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." + } + ], + "intent": "Get the new-failures tile for a build \u2014 how many tests failed in this build that were not failing before \u2014 plus the tile's comment count.", + "returns": [ + "count", + "meta", + "commentCount", + "autoUnmuteCount" + ], + "responses": { + "200": { + "$response": "SingleMetricSummaryResponse__w4_build2" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", + "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_muted_stats \u2014 only the metric differs. `count` is the new-failure count; `meta` carries the 'out of N' context.", + "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." + ] + }, + { + "name": "get_build_non_sdk_test_counts", + "method": "GET", + "path": "/api/v1/builds/{uuid}/getBuildStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "The 40-char build uuid. The counts include the build's split child builds." + } + ], + "intent": "Get data-hygiene counters for a build (including its split child builds) \u2014 how many test runs are untitled, how many are unmarked, and how many have no failure reason \u2014 used to nudge non-SDK / untitled instrumentation setup.", + "returns": [ + "untitled_test_count", + "unmarked_test_count", + "missing_failure_reason_count" + ], + "responses": { + "200": { + "$response": "BuildNonSdkTestCountsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Name-collision trap: this is `/{uuid}/getBuildStats` (data-hygiene counters, action getNonSdkUntitledTestCount). It is NOT the build headline counters \u2014 those are `/{uuid}/buildSummary` (get_build_summary_stats).", + "The body is a JSON *string* (a Gson JsonObject serialised via toString), not a JSON object \u2014 the caller must parse the string to read the three keys `untitled_test_count`, `unmarked_test_count`, `missing_failure_reason_count`.", + "Counts span the parent build AND its split child builds combined; there is no per-leg breakdown here." + ] + }, + { + "name": "get_build_insights", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/insights", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build UUID. Looked up group-scoped; an unknown uuid is BUILD_NOT_FOUND (404)." + } + ], + "intent": "One-call failure summary for a build, built for agents and CI bots \u2014 status metrics (test vs hook counts), failure categorisation (new failures / always-failing / flaky), defect stats and a top-errors analysis for the run.", + "returns": [ + "buildId", + "buildName", + "status", + "startedAt", + "finishedAt", + "duration", + "summary", + "failureCategorization", + "failureContext", + "topErrorsAnalysis", + "defectStats" + ], + "guidance": [ + "This is the build-keyed sibling of the test-run buildInsights call. Because no test run is supplied, `failureContext` (the file / parent-module drill-down) is ALWAYS null on this route \u2014 use the test-run variant if you need it.", + "`topErrorsAnalysis.status` is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED. When it is not COMPLETE, `uniqueErrors` may be empty even though the build has failures \u2014 the clustering has not finished, so re-read rather than concluding there are no errors.", + "`summary.statusMetrics` splits every status into `testsCount` vs `hooksCount`; `summary.totalTests` and `summary.totalHooks` are the run-wide totals.", + "`topErrorsAnalysis.uniqueErrors[].truncatedError` is only the first ~200 chars of the error \u2014 an overview string, not the full message. Carry `clusterId` to the unique-errors endpoints for the full text.", + "`defectStats` is keyed by issueType, each with per-category failure test/hook counts." + ], + "responses": { + "200": { + "$schema": "BuildInsights__w4_build4" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_latest_build", + "method": "GET", + "path": "/ext/v1/builds/latest", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "project_name", + "type": "string", + "required": true, + "description": "Project name (normalised internally). Missing \u2192 400 'Parameter project_name is required'. Resolved within the caller's group." + }, + { + "name": "build_name", + "type": "string", + "description": "Exact original build name to match." + }, + { + "name": "user_name", + "type": "string", + "description": "Triggering user's username. If it resolves to no user id, the whole call is BUILD_NOT_FOUND (404) \u2014 not a validation error." + }, + { + "name": "build_tags", + "type": "string", + "description": "Comma-separated build tags; split and trimmed server-side into a tag set." + }, + { + "name": "framework", + "type": "string", + "description": "Test framework name; enum-validated. An unrecognised framework is reported as BUILD_NOT_FOUND (404), not a 400." + } + ], + "intent": "Find the most recent build in a project matching optional filters (build name, triggering user, build tags, framework) and get its full build-details payload \u2014 the entry point for 'the latest run of X' when you don't know the build uuid.", + "returns": [ + "build_id", + "id", + "name", + "build_number", + "status", + "started_at", + "finished_at", + "duration", + "user", + "tags", + "status_stats", + "observability_url", + "tcmTestRunIdentifier" + ], + "guidance": [ + "Always returns a SINGLE build (the page limit is fixed at 1), not a list \u2014 the newest match. No match for the given filters is BUILD_NOT_FOUND (404).", + "The requester must hold automated-test-run view permission on the project (service-account project permission is enforced); otherwise the call is refused.", + "The response shape is feature-flagged per group: the newer build-listing flag returns the V2 build-details envelope, otherwise the v1 one. Both are FURTHER field-gated by the account's plan through the external JsonView wrapper \u2014 a field absent from the body may be plan-gated rather than empty, so do not treat a missing field as 'no value'.", + "Carry the returned `build_id` (the hashed uuid; keys are snake_case and there is no `uuid` key) to the per-build endpoints (insights, reports, history); the display `tcmTestRunIdentifier` is not a build id." + ], + "responses": { + "200": { + "$schema": "ExternalBuildDetails" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_project_builds_by_project_id", + "method": "GET", + "path": "/ext/v1/projects/{project_id}/builds", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "project_id", + "type": "integer", + "required": true, + "description": "Numeric TRA project id." + } + ], + "query": [ + { + "name": "unique_build_names", + "type": "array", + "description": "Filter to these build names." + }, + { + "name": "build_tags", + "type": "array", + "description": "Filter to builds carrying any of these tags." + }, + { + "name": "users", + "type": "array", + "description": "Triggering user ids (numeric)." + }, + { + "name": "frameworks", + "type": "array", + "description": "Test framework names; enum-validated. An invalid framework value is a 400." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (numeric)." + }, + { + "name": "build_status", + "type": "array", + "description": "Build display statuses; enum-validated. An invalid status value is a 400." + }, + { + "name": "branches", + "type": "array", + "description": "VCS branch names." + }, + { + "name": "is_archived", + "type": "boolean", + "description": "Restrict to archived (true) or non-archived builds." + }, + { + "name": "is_ci_run_only", + "type": "boolean", + "description": "Restrict to CI-triggered builds." + }, + { + "name": "exclude_lock", + "type": "boolean", + "description": "Exclude locked builds." + }, + { + "name": "date_range", + "type": "array", + "description": "Exactly two epoch-millisecond values [start, end]. OPTIONAL despite the DTO's @NotNull \u2014 this route binds it without validation, so when omitted it DEFAULTS to the last 7 days. Provide both to widen the window.", + "fields": [ + { + "name": "(item)", + "type": "integer" + } + ] + }, + { + "name": "next_page", + "type": "string", + "description": "Opaque base64 pagination cursor from the previous page's `pagination.next_page`. Omit for the first page." + } + ], + "intent": "List a project's builds over the public REST API with status, counts and a deep link back to the TRA UI \u2014 filterable like the UI build listing (names, users, teams, tags, branches, frameworks, statuses, date range) and cursor-paginated.", + "returns": [ + "id", + "name", + "builds", + "pagination" + ], + "guidance": [ + "Cursor pagination only: read `pagination.has_next` and pass `pagination.next_page` back as `next_page` (snake_case keys). There is NO caller-settable page size; walk the cursor until `has_next` is false.", + "The item shape is feature-flagged per group: with the 'new build listing' flag on, `status_stats` holds workflow-status counts and items add `is_manually_overridden`; otherwise v1 status counts. Same keys otherwise.", + "When `date_range` is omitted the window is the LAST 7 DAYS \u2014 older builds simply will not appear unless you pass an explicit [start,end] in epoch millis.", + "Within a page builds come back OLDEST-first: the handler reverses the fetched page (Lists.reverse) before returning.", + "Requires automated-test-run view permission on the project.", + "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls.", + "For 'the latest / last N builds' prefer list_project_builds_by_name (newest-first, `pageLimit`, project-scoped `search`): this route is oldest-first within a page, 10 per page and defaults to the last 7 days, so 'last 10 builds' can cost several calls.", + "Each item carries `build_number` (the serial #N) and, when the CI set one, the CI run id; a user's 'run ' may be the CI run id rather than `build_number`.", + "The filters are NOT the UI set: this route takes users, build_tags, unique_build_names, is_ci_run_only and exclude_lock, while list_project_builds_by_name takes search, sortOrder, statuses, sources and types. Pick the route by the filter you need." + ], + "responses": { + "200": { + "$schema": "ExternalProjectBuildListing" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_project_builds_by_name", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/builds/v2", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "Project's normalised name." + } + ], + "query": [ + { + "name": "statuses", + "type": "array", + "description": "Build display statuses to include (enum)." + }, + { + "name": "sources", + "type": "array", + "description": "Product sources (e.g. Automate / App Automate) (enum)." + }, + { + "name": "types", + "type": "array", + "description": "Build types (enum)." + }, + { + "name": "teams", + "type": "array", + "description": "Team ids (numeric)." + }, + { + "name": "frameworks", + "type": "array", + "description": "Test framework names (enum)." + }, + { + "name": "buildTags", + "type": "array", + "description": "Build tags to filter on." + }, + { + "name": "branches", + "type": "array", + "description": "VCS branch names." + }, + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond values [start, end] (@Size min=2,max=2).", + "fields": [ + { + "name": "(item)", + "type": "integer" + } + ] + }, + { + "name": "search", + "type": "string", + "description": "Free-text search over builds." + }, + { + "name": "searchAfter", + "type": "string", + "description": "Cursor for the next page (from the previous response's paging params)." + }, + { + "name": "pageLimit", + "type": "integer", + "description": "Page size. Defaults to 10 when omitted. No hard maximum is enforced in this handler \u2014 see could_not_determine." + }, + { + "name": "sortOrder", + "type": "string", + "values": [ + "Asc", + "Desc" + ], + "description": "Sort direction on the build ordering." + }, + { + "name": "state", + "type": "string", + "description": "Build state filter (defaults to `active`)." + }, + { + "name": "isArchived", + "type": "boolean", + "description": "Restrict to archived / non-archived builds." + } + ], + "intent": "The main build-listing endpoint: a filtered, sorted, cursor-paginated page of a project's builds with their status and run counts, for the builds table.", + "returns": [ + "id", + "name", + "builds", + "pagingParams", + "activeCount", + "closedCount" + ], + "guidance": [ + "PREFERRED build listing for agents: newest-first, `pageLimit` sets the page size (keep it at or below 50 \u2014 each row is large and a big page overflows the tool output), `search` filters by build name, and rows carry buildNumber, `buildRunId` (CI run id), statusStats, historyAggregate (flaky / newFailure / alwaysFailing counts) and qualityGateStatus, so one call answers 'last N builds with status'.", + "Takes the project's NORMALISED NAME in the path, not a numeric id. Uses the same large filter object as the build-groups listing.", + "SIDE EFFECT: this call marks the project as subscribed for the calling user \u2014 a read that quietly changes the user's subscription state. It also applies the group's custom data-retention window for the duration of the request, so builds older than the retention window will not appear regardless of `dateRange`.", + "Cursor pagination via `pagingParams` / `searchAfter`; page through until the paging params report no next cursor. `pageLimit` defaults to 10.", + "Each build row is a rich BuildSummary (status, statusStats, run/session counts, quality-gate status, tags, etc.), not the trimmed external shape.", + "This route does NOT accept username+access-key auth (it is not on the /ext/v1 public surface)." + ], + "responses": { + "200": { + "$schema": "ProjectBuildListing" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_running_builds_on_device", + "method": "GET", + "path": "/api/v1/tier/running/builds", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "product", + "type": "string", + "required": true, + "description": "Product; mapped to Automate ('automate') vs App Automate (anything else). Missing/empty \u2192 400." + }, + { + "name": "os", + "type": "string", + "required": true, + "description": "Operating system. Missing/empty \u2192 400." + }, + { + "name": "osVersion", + "type": "string", + "required": true, + "description": "OS version. A trailing '.0' is also matched without it. Missing/empty \u2192 400." + }, + { + "name": "device", + "type": "string", + "required": true, + "description": "Device name. Missing/empty \u2192 400." + } + ], + "intent": "Show which of the group's builds are currently queued/running on a specific device+OS combination, with the number of parallel sessions each consumes \u2014 used to explain why a device tier is saturated.", + "returns": [ + "data" + ], + "guidance": [ + "All four query params (product, os, osVersion, device) are REQUIRED \u2014 any missing/empty one is a 400.", + "Only counts test runs in `pending` status for the caller's group; a build with no pending runs on that device+OS will not appear.", + "Returns `{data: []}` (empty list) when nothing matches \u2014 that is a normal 'tier not saturated' result, not an error.", + "Each build in `data` carries `parallelsUsed` \u2014 the count of unique parallel sessions that build is consuming on the combination.", + "This route does NOT accept username+access-key auth.", + "Live 2026-09-30: a running non-SDK App Automate (plain Appium) session on the queried device/OS did NOT appear (data:[]) \u2014 the listing counts TRA test runs in pending status, which non-SDK sessions do not create." + ], + "responses": { + "200": { + "$schema": "RunningBuildsOnDevice" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_sidebar_user_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/users", + "mode": "read", + "entity": "build_filter", + "intent": "List the users to offer in the build-list sidebar's Users facet - the people who actually triggered builds under the current filter, returned as {id, name} and searchable by name.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the projects of the caller's sub-group.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Substring searched against each user's FULL NAME (wrapped as a *query* wildcard inside Elasticsearch); special characters are escaped. Blank/absent returns the top users by build count." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time. Only projects, query and dateRange from the body affect this route.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is the start, element 1 the end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarUserFilterResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Only `projects`, `query` and `dateRange` from the body affect the result on this route. Every other build-filter field you send (teams, statuses, buildTags, branches, sources, buildIds, ...) is accepted and SILENTLY IGNORED here - it does not narrow the users returned.", + "Each element is an object {id, name} where `name` is the user's full name - NOT a bare string. `id` is the user id you would pass back as a `users` filter value.", + "`query` matches the full name as a substring; ids are aggregated from the builds index, then names are resolved from the database.", + "Results are an unordered, de-duplicated set capped at 100 options. There is no paging on this route, so a filter matching more than 100 users returns only the first 100 the aggregation surfaces.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_tag_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/tags", + "mode": "read", + "entity": "build_filter", + "intent": "List build-tag options for the build-list sidebar's Tags facet - the top tags on builds under the current filter, or the tags matching a search string.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Tag search substring (wrapped as *query* wildcard, special chars escaped). Blank/absent switches to the top-tags-by-frequency path instead of a search." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is start, element 1 end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarSearchResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Two different code paths depending on `query`: a blank/absent query returns the top build tags by frequency (terms aggregation), a non-blank query runs a wildcard substring tag search with highlighting. Elements are bare tag strings either way.", + "Only `projects`, `query` and `dateRange` from the body affect the result. Every other build-filter field in the body is accepted and SILENTLY IGNORED on this route.", + "Results are an unordered, de-duplicated set capped at 100 options; there is no paging, so more than 100 matches returns only the first 100.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_team_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/teams", + "mode": "read", + "entity": "build_filter", + "intent": "List the team options for the build-list sidebar's Teams facet - the BrowserStack teams whose sub-groups own builds under the current filter, returned as {id, name} and optionally filtered by name.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + }, + { + "name": "query", + "type": "string", + "description": "Team-name search substring. NOTE it is applied by the BrowserStack teams service AFTER the sub-group aggregation, not inside Elasticsearch - see guidance." + }, + { + "name": "dateRange", + "type": "array", + "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Epoch milliseconds; element 0 is start, element 1 end." + } + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "SidebarTeamFilterResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "A request body is REQUIRED even when every field is optional: calling with no body is a 400 'Invalid request payload'. Send at least {} (or {projects:[...]}).", + "POST but read-only - the body is a build filter but nothing is created or changed.", + "Two-step and `query` does NOT filter at the Elasticsearch stage: the sub-group ids that own matching builds are aggregated first (capped at 100), then the BrowserStack teams service resolves those ids to team names and applies `query` to the names. So a very large group can have owning sub-groups beyond the 100 aggregated that never appear.", + "Each element is an object {id, name} (team id + team name) - NOT a bare string. Names come from central/rails team data, not the builds index.", + "Only `projects`, `query` and `dateRange` from the body affect the result; every other build-filter field is accepted and SILENTLY IGNORED here.", + "When `projects` is omitted the scope defaults to the caller's sub-group projects." + ] + }, + { + "name": "list_sidebar_test_case_status_filter_options", + "method": "POST", + "path": "/api/v2/builds/filters/testCaseStatuses", + "mode": "read", + "entity": "build_filter", + "intent": "List the manual test-case status options (Test Management's TestResult custom-field values) configured for the selected projects, to offer manual test-case statuses as a build-list sidebar filter.", + "body": [ + { + "name": "projects", + "type": "array", + "description": "Observability project ids. The ONLY body field this route reads; they are translated to Test Management project ids before the lookup. Omit/empty to default to the sub-group projects, then to the group's first 100 projects.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "Observability project id." + } + ] + } + ], + "returns": [], + "responses": { + "200": { + "$response": "SidebarTestCaseStatusesResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "POST but read-only. Only `projects` is read - `query`, `dateRange` and every other body field are ignored, and the result is NOT scoped by the current build filter at all; it returns the configured status options for the projects regardless of which builds exist.", + "Pure pass-through to Test Management's custom-fields API for entity `TestResult`: the response body is the RAW TestHub JSON, not the {data:[...]} envelope the other sidebar facets return. Do not assume a `data` array here.", + "Observability project ids are converted to Test Management project ids first; a project with no Test Management mapping contributes nothing.", + "Unlike the other sidebar facets this route is not exposed to public-link tokens." + ] + }, + { + "name": "list_home_filter_options", + "method": "GET", + "path": "/api/v1/home/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List the selectable values for one dropdown of the Observability home/overview page's personal filters (build status, product source, framework, build tag, unique build name, or folder).", + "query": [ + { + "name": "key", + "type": "string", + "description": "Which filter dropdown's values to return. Required - omitting it fails the call. One of the UserFilterKey values.", + "values": [ + "buildTags", + "uniqueBuildNames", + "folders", + "sources", + "buildStatus", + "frameworks" + ] + } + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "HomeUserFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "`key` is required and must be one of the six UserFilterKey values; a value outside the enum is a 400, and omitting `key` entirely fails the request.", + "`buildStatus` and `sources` are computed purely from enums with no backend call, so they always return the full option set.", + "`frameworks` FAILS OPEN: on an internal error (ES or the fence setup) it returns the full hardcoded framework list rather than erroring. On success it lists only frameworks seen in the caller's builds (plus the saved selection), which can be EMPTY.", + "The build-backed keys (`buildTags`, `uniqueBuildNames`, `folders`) aggregate Elasticsearch scoped to the caller's sub-group, and to visible projects only when UDAC is enabled, returning up to 10000 values. There is no paging - the whole set comes back in one call.", + "Each element is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null here. `folders` labels embed the count ('Language(45)') and can repeat a value across projects, and folders span every project in scope, not one." + ] + }, + { + "name": "list_project_webhook_filter_options", + "method": "GET", + "path": "/api/v1/projects/{projectName}/settings/webhooks/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List type-ahead/facet values for a project's webhook rule builder (build names, tags, test names, folders, and other trigger-condition fields), with search and cursor paging - the values behind 'fire only when is ...'.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "The project whose values to aggregate. Scope is this project only on this route." + } + ], + "query": [ + { + "name": "type", + "type": "string", + "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", + "values": [ + "build_name", + "build_tag", + "qg_status", + "build_status", + "test_name", + "folder", + "alert_name", + "failure_category", + "smart_tags", + "browser", + "os", + "device", + "alert_severity", + "test_tag", + "test_status" + ] + }, + { + "name": "projectIds", + "type": "string", + "description": "IGNORED on this route - the scope is the path `projectName` only. Use the group-level webhook filter route to scope by projectIds." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response to get the next page." + } + ], + "returns": [ + "data", + "afterKey" + ], + "responses": { + "200": { + "$response": "WebhookFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "The value set returned depends entirely on `type`. Only build_name, build_tag, test_name, test_tag, folder, failure_category, alert_name, os, browser, device and test_status are wired; the remaining enum values (qg_status, build_status, smart_tags, alert_severity) return empty `data`.", + "`projectIds` in the query is IGNORED here - scope is the path `projectName`. This is the opposite of the group-level route, which reads projectIds and ignores any project path.", + "Cursor-paged via a composite aggregation: pass the response's `afterKey` back as the `afterKey` query param for the next page. afterKey is set even on the final page, so stop when a page returns fewer rows than the page size or none.", + "Per-page size is 10 for most types and up to 1000 for `folder`. `query` and `afterKey` are each capped at 100 characters.", + "Each element is {label, value, pId, count}. For `folder`, `label` is 'name(count)', `value` is the full path, and `pId` is the parent path; for other types `pId` echoes the value." + ] + }, + { + "name": "list_group_webhook_filter_options", + "method": "GET", + "path": "/api/v1/settings/webhooks/filters", + "mode": "read", + "entity": "build_filter", + "intent": "List type-ahead/facet values for the group-level webhook rule builder across selected projects (build names, tags, and the other supported condition fields), with search and cursor paging.", + "query": [ + { + "name": "type", + "type": "string", + "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", + "values": [ + "build_name", + "build_tag", + "qg_status", + "build_status", + "test_name", + "folder", + "alert_name", + "failure_category", + "smart_tags", + "browser", + "os", + "device", + "alert_severity", + "test_tag", + "test_status" + ] + }, + { + "name": "projectIds", + "type": "string", + "description": "Comma-separated TestHub/Test Management project ids (thProjectId, NOT the TRA project id - a TRA id gives an empty 200). Each is translated to a TO project id before the query. A non-numeric entry fails the request; blank resolves to no projects and empty `data`." + }, + { + "name": "query", + "type": "string", + "maxLength": 100, + "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." + }, + { + "name": "afterKey", + "type": "string", + "maxLength": 100, + "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response for the next page." + } + ], + "returns": [ + "data", + "afterKey" + ], + "responses": { + "200": { + "$response": "WebhookFilterOptionsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Gated by a controller-wide entitlement: WEBHOOKS (non-TM groups) or GROUP_WEBHOOKS (TM groups) must be accessible, otherwise the whole controller is refused as feature-inaccessible.", + "`projectIds` is a comma-separated string of TestHub/Test Management project ids (a project's thProjectId, not the TRA id - a TRA id silently yields empty `data`), mapped to TO project ids before the aggregation. A non-numeric entry throws (parse failure) and fails the call; an empty/blank value yields no projects and empty `data`.", + "The value set returned depends entirely on `type`; qg_status, build_status, smart_tags and alert_severity return empty `data`.", + "Cursor-paged like the project route: pass the response's `afterKey` back for the next page. afterKey is set even on the last page, so stop when a page has fewer than the page size (10 for most types, 1000 for `folder`) or is empty. `query` and `afterKey` are capped at 100 characters.", + "Each element is {label, value, pId, count}; for `folder`, `pId` is the parent path and `label` is 'name(count)'." + ] + }, + { + "name": "list_project_build_names", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/buildNames", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug). It is URL-decoded and every '+' is turned back into a space before lookup, so a project named 'My App' is addressed as 'My+App'. A name that matches no project returns 404." + } + ], + "intent": "List the distinct build names seen in a project \u2014 the values that populate the build-name filter dropdown on the build listing.", + "returns": [], + "guidance": [ + "The 200 body is a bare JSON array of strings (the build names), with no wrapper object \u2014 not {success, ...}.", + "Names are drawn only from builds inside the account's retention window, so a very old build's name may not appear even though the build once existed.", + "No product-source filter is applied on this route, so names from every source (Automate, App Automate, SDK, etc.) are merged into one list.", + "The path takes the normalised name, not the numeric project id; '+' in the slug is decoded to a space." + ], + "responses": { + "200": { + "description": "Distinct build names for the project.", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_non_sdk_summary", + "method": "GET", + "path": "/api/v1/projects/{normalisedName}/nonSdkSummary", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "normalisedName", + "type": "string", + "required": true, + "description": "The project's normalised name (URL slug); '+' is decoded to a space. Resolved to the project first, so an unknown name returns 404." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "description": "Exactly two epoch-millisecond longs, [lowerBound, upperBound]. OPTIONAL despite the DTO marking it required: when the range (or either bound) is absent the handler substitutes the last 7 days. If you do send it, send both bounds \u2014 a one-element range is rejected.", + "fields": [ + { + "name": "lowerBound", + "type": "integer", + "description": "dateRange[0], epoch millis, inclusive start." + }, + { + "name": "upperBound", + "type": "integer", + "description": "dateRange[1], epoch millis, inclusive end." + } + ] + } + ], + "intent": "Get, over a date range, how many of a project's builds were ingested WITHOUT the BrowserStack SDK plus the resulting data-quality gaps (untitled tests, unmarked tests, tests with no failure reason) \u2014 the numbers behind the 'switch to the SDK' nudge.", + "returns": [ + "nonSDKBuildCount", + "unnamedTestCount", + "unmarkedTestCount", + "missingFailureReasonCount" + ], + "guidance": [ + "Elasticsearch-backed and scoped to a date range: with no dateRange it reports the last 7 days, so a zero here can just mean 'nothing in the last week', not 'nothing ever' \u2014 use get_project_non_sdk_build_count for the unbounded count.", + "Deleted and archived builds (and their tests) are excluded from every count, so the numbers track the live project view rather than raw ingest.", + "The three test-quality counts (unnamedTestCount, unmarkedTestCount, missingFailureReasonCount) come from a separate aggregation over the test index; each is the count of non-SDK tests hitting that gap in the window.", + "The other QueryRq fields (query, isManuallyOverridden, includeManualRuns) are not consulted on this route \u2014 only the date range matters." + ], + "responses": { + "200": { + "description": "Non-SDK build count and data-quality gap counts for the window.", + "schema": { + "type": "object", + "properties": { + "nonSDKBuildCount": { + "type": "integer", + "description": "Builds in the window ingested without the SDK." + }, + "unnamedTestCount": { + "type": "integer", + "description": "Non-SDK tests with no title in the window." + }, + "unmarkedTestCount": { + "type": "integer", + "description": "Non-SDK tests left unmarked in the window." + }, + "missingFailureReasonCount": { + "type": "integer", + "description": "Non-SDK tests with no failure reason in the window." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_non_sdk_build_count", + "method": "GET", + "path": "/api/v1/projects/{projectId}/nonSDKBuildCount", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The observability project id (numeric). Note this route takes the numeric id, not the normalised name that the summary/build-names routes take." + } + ], + "intent": "Get just the count of non-SDK builds in a project \u2014 a cheap, all-time yes/no signal for whether to show SDK-onboarding prompts.", + "returns": [ + "project_id", + "non_sdk_build_count" + ], + "guidance": [ + "The 200 body is a JSON STRING, not a JSON object: the payload {\"project_id\":..,\"non_sdk_build_count\":..} is serialised to a string and returned as-is, so you must JSON-parse the response body a second time before reading the fields.", + "Unbounded in time \u2014 unlike get_project_non_sdk_summary there is no date range, so this counts every non-SDK build ever (excluding deleted/archived builds).", + "Keys are snake_case here (project_id, non_sdk_build_count), unlike the camelCase summary route.", + "The count falls back to 0 rather than erroring when the search returns no total." + ], + "responses": { + "200": { + "description": "A JSON-encoded STRING of {project_id, non_sdk_build_count}. Parse the string to get the object.", + "schema": { + "type": "string" + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_project_by_id_or_name", + "method": "GET", + "path": "/api/v1/projects/{idOrName}", + "mode": "read", + "entity": "project", + "path_params": [ + { + "name": "idOrName", + "type": "string", + "required": true, + "description": "Either the numeric project id OR the (normalised) project name \u2014 the handler resolves both. Because this is a catch-all mapping, the literal segments 'lite', 'lite/v2', 'pusherAuth' and any other declared path win first, so you cannot address a project whose name collides with a reserved segment through this route." + } + ], + "intent": "Fetch one project's full resource representation by either numeric id or normalised name \u2014 the project header/settings context (id, name, entry type, configuration, permissions).", + "returns": [ + "projectId", + "projectName", + "entryType", + "configuration", + "organization", + "permissions" + ], + "guidance": [ + "Accepts either the numeric id or the normalised name in the one path segment \u2014 carry whichever you have, no separate lookup needed.", + "configuration.attributes is always present; configuration.subTypes and configuration.alertConfiguration are omitted when null (the DTO is NON_NULL), so treat their absence as 'none configured', not an error.", + "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer.", + "The numeric id must be the TRA project id (e.g. from list_projects_lite_paginated `id`), NOT the TestHub/Test Management id (`thProjectId`): a TestHub id gives 404 and an unknown id can give a bare 500 'Internal System Exception'.", + "`permissions` reflects the API role, which can be broader than what the UI shows for the same user; do not tell the user they can edit settings based on it alone." + ], + "responses": { + "200": { + "description": "Single project resource.", + "schema": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "entryType": { + "type": "string" + }, + "configuration": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "description": "Map of project attribute key -> value." + }, + "subTypes": { + "type": "object", + "description": "Map of issue type -> list of issue sub-type resources. Omitted when null." + }, + "alertConfiguration": { + "type": "object", + "description": "Project alert config. Omitted when null." + } + } + }, + "organization": { + "type": "string" + }, + "permissions": { + "type": "array", + "description": "Caller's permission entries for this project." + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_full", + "method": "GET", + "path": "/api/v1/projects", + "mode": "read", + "entity": "project", + "intent": "List the caller's group's projects as full records with complete metadata (attributes, issue types, alerts) \u2014 the heavyweight variant to use when you need full project detail rather than a picker payload.", + "returns": [ + "id", + "name", + "userId", + "groupId", + "thProjectId", + "normalisedName" + ], + "guidance": [ + "This is the heaviest of the project-list variants (full entity records, no paging). For pickers, sidebars and dashboards prefer list_projects_lite, list_projects_lite_paginated or list_projects_for_sidebar instead.", + "The group is taken from the session \u2014 there are no parameters; you get exactly the projects the caller's group owns.", + "The 200 body is a bare JSON array of project objects, with no wrapper and no pagination envelope, so the whole group is returned in one response." + ], + "responses": { + "200": { + "description": "All of the group's projects as full records.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "normalisedName": { + "type": "string", + "description": "Derived slug: spaces become '+', '#' becomes '%23', but case and characters such as '&' are kept, so it is not URL-safe - encode it before putting it in a path." + }, + "projectAttributes": { + "type": "array", + "description": "Project attribute rows." + }, + "projectIssueTypes": { + "type": "array", + "description": "Issue-type rows configured on the project." + }, + "alerts": { + "type": "array", + "description": "Alert rows attached to the project." + }, + "appLogFilter": { + "type": "object", + "description": "App-log filter settings: {apps, envs}, each null when unset." + }, + "latestAlerts": { + "type": "array", + "description": "Most recent alert rows for the project." + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_default_project", + "method": "GET", + "path": "/api/v1/projects/lite/default", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "normalisedName", + "type": "string", + "description": "Optional. If given and it resolves, that project is returned; if it does not resolve the call silently falls back to the group's most recently updated project rather than 404-ing." + } + ], + "intent": "Resolve which project the UI should land on: the named project if normalisedName is given and exists, otherwise the group's most recently updated project \u2014 for app load and deep links where the named project may no longer exist.", + "returns": [ + "id", + "name", + "thProjectId", + "normalisedName", + "userId", + "groupId", + "createdAt", + "finishedAt" + ], + "guidance": [ + "Fault-tolerant by design: a normalisedName that does not resolve does NOT 404 \u2014 it falls back to the last-updated project. So a 200 does not confirm the name you asked for exists; compare the returned normalisedName if you need to know.", + "If the group has no projects at all, this returns HTTP 200 with a null body (not 404). Guard for a null/empty response.", + "Returns a single lightweight project object, not a list." + ], + "responses": { + "200": { + "description": "The resolved landing project (lightweight), or null when the group has no projects.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "normalisedName": { + "type": "string" + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "rename_project", + "method": "PUT", + "path": "/api/v1/projects/{projectId}", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "The observability project id to rename." + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "The new project name. Trimmed server-side; a blank or whitespace-only name is rejected with 400 ('Project name cannot be empty.'). Sent flat at the top level of the body." + } + ], + "intent": "Rename a project \u2014 use this to change a project's display name (and, as a side effect, its URL slug).", + "returns": [ + "message", + "normalisedName" + ], + "guidance": [ + "Renaming CHANGES THE URL SLUG: the new normalised name becomes the project's slug, so existing links and any saved normalisedName keyed on the old name stop resolving. There is no automatic redirect \u2014 update stored slugs from the returned normalisedName.", + "No true undo: to reverse it you must rename back to the old name, and links broken in the meantime are not restored \u2014 treat it as a write with no history.", + "Blocked in demo-sandbox environments: returns 400 'Cannot rename default project' there.", + "The rename happens in TestHub and is mirrored locally; TestHub's own 400/401/404 are translated into observability errors, so a 404 here can mean TestHub could not find the project.", + "The 200 body carries a human-readable message and the new normalisedName \u2014 read normalisedName, not the message, to rebuild links." + ], + "responses": { + "200": { + "description": "Rename succeeded.", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "normalisedName": { + "type": "string", + "description": "The project's new URL slug." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "assign_issue_types_to_test_runs", + "method": "PUT", + "path": "/api/v1/projects/{projectId}/updateIssueTypes", + "mode": "write", + "entity": "project", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "Project id scoping the test runs. Despite the project-scoped path this call updates TEST RUNS, not the project. In TM the id is interpreted as a TestHub project id." + } + ], + "body": [ + { + "name": "issues", + "type": "array", + "required": true, + "description": "The list of (test run -> issue type) assignments to apply. Sent flat at the top level as 'issues'.", + "fields": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "The test run's integer id to categorise. The wire key is 'testRunId' \u2014 a plain 'id' will not bind." + }, + { + "name": "issueTypeId", + "type": "integer", + "required": true, + "description": "The issue-type / failure-category id to assign. The wire key is 'issueTypeId' \u2014 not 'issueType'." + } + ] + } + ], + "intent": "Bulk-assign a failure category / issue type to a list of test runs (the 'mark failure reason' action on the failure-analysis screens) and get back how many rows were updated.", + "returns": [ + "updatedCount" + ], + "guidance": [ + "Despite the action's project-scoped path and 'updateProject'-style naming, this updates TEST RUNS \u2014 it does not modify the project itself.", + "Each item's wire keys are testRunId and issueTypeId; a body using id/issueType (the shape some callers assume) silently fails to bind those fields. Send exactly testRunId and issueTypeId.", + "Reversible: re-run with a different issueTypeId to re-categorise, so this is a write, not destructive. There is no per-call undo, though.", + "Rejected with 400 while a project archive is in progress \u2014 retry after the archive completes rather than treating it as bad input.", + "The 200 body is just {updatedCount}: the number of test-run rows actually changed. A count lower than the number you sent means some ids did not match \u2014 it is not an error." + ], + "responses": { + "200": { + "description": "Count of test runs updated.", + "schema": { + "type": "object", + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of test-run rows whose issue type was updated." + } + } + } + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_for_sidebar", + "method": "POST", + "path": "/api/v2/projects/lite", + "mode": "read", + "entity": "project", + "body": [ + { + "name": "queryStr", + "type": "string", + "description": "Optional case-insensitive project-name substring (ILIKE)." + }, + { + "name": "projects", + "type": "array", + "description": "Optional list of project ids (Long) to EXCLUDE from the page - the already-selected projects the picker shows separately (SQL `p.id NOT IN (:selectedProjectIds)`). It does not pin them in. Nulls are dropped.", + "fields": [ + { + "name": "(item)", + "type": "integer", + "description": "A selected project id to leave out." + } + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 1, + "description": "1-based page number. Values <= 0 (and absent) default to 1." + } + ], + "intent": "Populate the build-list sidebar project picker: a page of lightweight project records for the group ordered by most recent build activity, with optional name search and the ability to exclude the already-selected project ids from the page.", + "returns": [ + "data", + "pagingParams" + ], + "guidance": [ + "POST but READ-ONLY \u2014 the body is only search/paging/selection state; nothing is mutated.", + "hasNext is inferred from 'the page came back non-empty', so the last full page still reports has_next=true and next_page = pageNumber+1; the empty page after it is how you learn you are past the end. Stop when data is empty, not when has_next flips.", + "Page size is fixed server-side at 10 records and cannot be changed; a short page (under 10 rows) is the last one, even though has_next is still true.", + "Pagination keys are snake_case here (has_next, next_page under pagingParams) \u2014 different from the GET lite/aggregate routes which use hasNext/searchAfter.", + "Each row is the sidebar-lite shape (thProjectId, id, displayName, normalisedName) \u2014 note displayName, not name. This is distinct from list_projects_lite_paginated (a GET with offset paging and no id-pinning).", + "`projects[]` EXCLUDES the listed ids from the result (the picker renders selected projects itself); pass it only to leave those out. Combining a search with a selected id that matches it returns an empty page." + ], + "responses": { + "200": { + "description": "A page of sidebar-lite projects plus pagination.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "thProjectId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "displayName": { + "type": "string" + }, + "normalisedName": { + "type": "string" + } + } + } + }, + "pagingParams": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean", + "description": "True whenever the page was non-empty; not a true 'more rows exist' signal." + }, + "next_page": { + "type": "string", + "description": "pageNumber+1 as a string, or null when has_next is false." + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_lite_paginated", + "method": "GET", + "path": "/api/v2/projects/lite/v2", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "search", + "type": "string", + "description": "Optional project-name search." + }, + { + "name": "searchAfter", + "type": "integer", + "default": 1, + "description": "Despite the cursor-style name this is a PLAIN OFFSET (integer). Defaults to 1 when absent. In demo-sandbox environments it is forced to 0. Advance it using the value returned in pagingParams.searchAfter." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route \u2014 paging is driven by searchAfter." + } + ], + "intent": "Get a paginated, optionally name-searched list of the group's projects in lightweight form (id, name, normalised name, TestHub id) for pickers and listing pages that do not need per-project metrics.", + "returns": [ + "projectLiteList", + "pagingParams" + ], + "guidance": [ + "PREFERRED route to resolve a project by name: pass `search` with the user's words; the exact match is usually on the first page. The row carries the TRA `id` (for integer-id routes), `normalisedName` (for name routes) and `thProjectId` (TestHub/TM id, NOT accepted by TRA routes).", + "searchAfter is an OFFSET, not a cursor \u2014 the name is misleading. Page by reading pagingParams.searchAfter back into the next request; hasNext is computed by over-fetching one row.", + "Page size is hardcoded at 100 and cannot be changed.", + "In demo-sandbox environments the offset is forced to 0, so you always get the first page there regardless of searchAfter.", + "Ordered by most recent build activity (build_updated_at desc). The 200 wraps the rows under projectLiteList with pagingParams alongside \u2014 not a bare array.", + "Overlaps heavily with list_projects_for_sidebar (POST /api/v2/projects/lite): that one pins selected ids and pages at size 10; this one does neither and pages at 100." + ], + "responses": { + "200": { + "description": "A page of lite projects plus pagination.", + "schema": { + "type": "object", + "properties": { + "projectLiteList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "normalisedName": { + "type": "string" + } + } + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "The offset to send as searchAfter on the next request." + }, + "nextRoot": { + "type": "object" + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_projects_with_build_aggregates_paginated", + "method": "GET", + "path": "/api/v2/projects/v2", + "mode": "read", + "entity": "project", + "query": [ + { + "name": "search", + "type": "string", + "description": "Optional project-name search." + }, + { + "name": "searchAfter", + "type": "integer", + "default": 1, + "description": "A PLAIN OFFSET (integer) despite the cursor-style name. Defaults to 1 when absent; forced to 0 in demo-sandbox. Advance using pagingParams.searchAfter from the response." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route." + } + ], + "intent": "Back the Projects landing page: the same paginated project list as the lite paginated variant but enriched with per-project build aggregates (build counts/status roll-ups, stability) from Elasticsearch, so each project card can show its health at a glance.", + "returns": [ + "projectWithAggregates", + "pagingParams" + ], + "guidance": [ + "Heavier than list_projects_lite_paginated because it runs an aggregate pass over builds for the page \u2014 use it only when you need the per-project numbers.", + "Same paging quirks as the lite paginated route: searchAfter is an offset (not a cursor), page size is hardcoded at 100, hasNext is computed by over-fetching one row, and the offset is forced to 0 in demo-sandbox.", + "When the project page is empty the response still returns 200 with an empty projectWithAggregates list (no aggregate pass is run).", + "The 200 wraps rows under projectWithAggregates with pagingParams alongside \u2014 not a bare array." + ], + "responses": { + "200": { + "description": "A page of projects with build aggregates plus pagination.", + "schema": { + "type": "object", + "properties": { + "projectWithAggregates": { + "type": "array", + "items": { + "$schema": "ProjectWithAggregates" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "The offset to send as searchAfter on the next request." + }, + "nextRoot": { + "type": "object" + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_health_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests", + "mode": "read", + "entity": "sp_test", + "intent": "List a project's tests on the legacy Test Health surface \u2014 one row per unique test (stability hash) over a date range, with its reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find the least reliable tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "description": "Window as EXACTLY two epoch-millis values [start, end]. NOT validated on this legacy route (no @Valid), so omitting it does not 400 \u2014 the window then falls back to lowerBound/upperBound, and with neither the query is unbounded and slow. Prefer dateRange.", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis). Used only when dateRange is absent." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis). Used only when dateRange is absent." + }, + { + "name": "search", + "type": "string", + "description": "Substring match on test name." + }, + { + "name": "status", + "type": "array", + "description": "Test statuses to include." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "hostNames", + "type": "array" + }, + { + "name": "users", + "type": "array", + "description": "Numeric user ids." + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Numeric user ids of last executor. Capped at 500 entries (@Size max 500) \u2014 more is a 400.", + "maxItems": 500 + }, + { + "name": "teams", + "type": "array", + "description": "Numeric team ids." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "When true (pro accounts only), keeps only tests with a non-zero flaky count." + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "isMuted", + "type": "string", + "description": "Muted-tests visibility. One of the three tokens below; anything else is treated as ALL.", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column. Honoured only for pro accounts; free accounts are forced to reliability. Recognised: reliability, average (duration), totalFailures." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. 10 tests per page." + }, + { + "name": "offset", + "type": "integer", + "description": "Accepted but internally capped at 100 (getRestrictedOffset)." + } + ], + "intent_notes": "", + "returns": [ + "pagingParams", + "tests", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "guidance": [ + "This is the OLDER Test Health generation. For current behaviour (validated window, public-link sharing, workflow-status-aware failure counts) use list_suite_health_tests instead.", + "reliability is a FAILURE percentage (failed*100/total) \u2014 HIGHER means LESS stable. duration is the average run duration. Neither is a 0-100 stability score.", + "Free (non-pro) accounts get a truncated result: only the single least-reliable test is returned, and orderKey/orderValue are ignored (forced to reliability desc).", + "Paged by pageNumber at 10 per page; walk pages until pagingParams.hasNext is false. There is no per-page size control (offset is capped at 100 and does not set the page size).", + "isMuted is a plain query token, not a boolean: send \"true\"/\"false\"/\"all\".", + "Auth: central-auth, obs-token, service-key, rails-basic or speedboat-token. Unlike the Suite Health list this route is NOT reachable with a public-link token.", + "ES-aggregation backed and internally paginated (loops pages until the slice is filled), so wide date ranges are slow.", + "browsers and platforms in each row are empty on this listing path \u2014 read browserList and platformList for the platform combinations." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_suite_health_tests", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests", + "mode": "read", + "entity": "sp_test", + "intent": "List a project's tests on the current Suite Health surface \u2014 one row per unique test (stability hash) over a required date range, with reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find flaky, always-failing or slow tests.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name, resolved within the caller's group." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as EXACTLY two epoch-millis values [start, end]. REQUIRED and validated here (@NotNull @Size(min=2,max=2)) \u2014 missing it, or a length other than 2, is a 400.", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "search", + "type": "string", + "description": "Substring match on test name." + }, + { + "name": "buildTags", + "type": "array" + }, + { + "name": "uniqueBuildNames", + "type": "array" + }, + { + "name": "testTags", + "type": "array" + }, + { + "name": "folders", + "type": "array" + }, + { + "name": "status", + "type": "array" + }, + { + "name": "sources", + "type": "array", + "description": "Product sources to include." + }, + { + "name": "users", + "type": "array" + }, + { + "name": "lastExecutedBy", + "type": "array", + "description": "Numeric user ids; capped at 500 (@Size max 500).", + "maxItems": 500 + }, + { + "name": "teams", + "type": "array" + }, + { + "name": "isFlaky", + "type": "boolean" + }, + { + "name": "isNewFailure", + "type": "boolean" + }, + { + "name": "isAlwaysFailing", + "type": "boolean" + }, + { + "name": "failureCategories", + "type": "array" + }, + { + "name": "osList", + "type": "array" + }, + { + "name": "browserList", + "type": "array" + }, + { + "name": "deviceList", + "type": "array" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "hooksVisibility", + "type": "string", + "description": "Whether test hooks are included." + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, failure counts are sourced from the recorded automation status rather than workflow status." + }, + { + "name": "orderKey", + "type": "string", + "description": "Sort column (pro only). Recognised: reliability, average (duration), totalFailures." + }, + { + "name": "orderValue", + "type": "string", + "values": [ + "asc", + "desc" + ] + }, + { + "name": "pageNumber", + "type": "integer", + "default": 0, + "description": "Zero-based page index. 10 tests per page." + } + ], + "returns": [ + "pagingParams", + "tests", + "automateTestsSkipped", + "appAutomateTestsSkipped", + "lcncTestsSkipped" + ], + "guidance": [ + "Any query key this route does not recognise is SWALLOWED into customParams, never rejected \u2014 so a misspelled filter name is silently ignored and widens the result instead of erroring. Check spelling against the parameter list.", + "reliability is a FAILURE percentage (failed*100/total), HIGHER = less stable; duration is the average run duration.", + "A build-less-but-existing project still resolves (it falls back to its Postgres id and returns an honest empty result). Only a genuinely absent project is a 404 PROJECT_NOT_FOUND.", + "When source feature-fencing hides tests, the response sets automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 an empty-looking list may just be fenced, not truly empty.", + "Free (non-pro) accounts get only the single least-reliable test, with sorting forced to reliability desc.", + "Paged by pageNumber at 10 per page; stop when pagingParams.hasNext is false. No per-page size control.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token, and (unlike the Test Health list) public-link-token \u2014 so this listing can back a shared public link.", + "browsers and platforms per row are empty on this path; use browserList and platformList." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestListResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_health_stats", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/stats", + "mode": "read", + "entity": "sp_test", + "intent": "Get the aggregate stat tiles for one test on the legacy Test Health detail view \u2014 reliability (failure %), average duration and failed/total run counts \u2014 optionally narrowed to one browser/OS/device.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (the id field from a test-listing row)." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ] + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis)." + }, + { + "name": "buildName", + "type": "string", + "description": "Restrict to a single build name." + } + ], + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "guidance": [ + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "reliability is a failure percentage (higher = worse); duration is the average run duration; failureOccurrence carries {failed, total} raw counts.", + "Failure count on this legacy route is always the raw status count. The Suite Health equivalent (get_suite_health_stats) instead uses the workflow-status count when new build listing is enabled, so the two can disagree for the same test.", + "Any backend error is rethrown as a bare RuntimeException and surfaces as HTTP 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." + ], + "responses": { + "200": { + "$response": "SuiteHealthStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_stats", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/stats", + "mode": "read", + "entity": "sp_test", + "intent": "Get the aggregate stat tiles for one test on the Suite Health detail page \u2014 reliability (failure %), average duration and failed/total run counts over the selected window.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash sent in the query." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "reliability", + "duration", + "failureOccurrence" + ], + "guidance": [ + "stabilityHash comes from the PATH and overrides any value in the query string.", + "Failure count uses the workflow-status aggregation when new build listing is enabled for the group, otherwise the raw status count \u2014 so it can differ from the legacy get_test_health_stats for the same test and window.", + "reliability is a failure percentage (higher = worse); duration is average run duration; failureOccurrence = {failed, total}.", + "Unknown query keys are swallowed into customParams rather than rejected.", + "Any backend error surfaces as a bare 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthStatsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_health_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/trend", + "mode": "read", + "entity": "sp_test", + "intent": "Get the per-execution trend line for one test on the legacy Test Health detail view \u2014 each run's status colour, duration and build context over the window \u2014 to plot stability and duration over time.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (read from the path on this route)." + } + ], + "query": [ + { + "name": "isMuted", + "type": "string", + "required": true, + "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", + "values": [ + "ALL", + "MUTED", + "UNMUTED" + ] + }, + { + "name": "lowerBound", + "type": "integer", + "description": "Window start (epoch millis)." + }, + { + "name": "upperBound", + "type": "integer", + "description": "Window end (epoch millis)." + }, + { + "name": "buildName", + "type": "string", + "description": "Restrict to one build name." + }, + { + "name": "browser", + "type": "string" + }, + { + "name": "os", + "type": "string" + }, + { + "name": "device", + "type": "string" + }, + { + "name": "allBuilds", + "type": "boolean", + "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." + }, + { + "name": "isFlaky", + "type": "boolean", + "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." + } + ], + "returns": [ + "keys", + "data" + ], + "guidance": [ + "Response is {keys, data}: keys is the parallel array of startedAt epochs, data is the points. Each point is MINIMAL \u2014 {color, y (duration), id (test-run id), buildId (serial id), buildName}. There are NO smart-tag flags and no workflow status here; use get_suite_health_trend for those.", + "allBuilds and isFlaky are accepted by the endpoint but silently dropped (never passed to the service) \u2014 do not rely on them to filter.", + "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", + "Points are ordered oldest-first (startedAt asc) and capped at 5000 executions with no paging.", + "Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." + ], + "responses": { + "200": { + "$response": "TestHealthTrendResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_trend", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/trend", + "mode": "read", + "entity": "sp_test", + "intent": "Get the per-execution time series for one test on the Suite Health detail page \u2014 each run's status, workflow status, duration, build context and smart-tag flags (flaky / newly failed / always failing / performance anomaly) \u2014 for the trend chart.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. This handler declares no @PathVariable and never calls setStabilityHash \u2014 the {stabilityHash} path segment is bound into the filter object by ExtendedServletRequestDataBinder (it binds URI path vars onto the command object). Supply it in the path as the route requires." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "browser", + "type": "string" + }, + { + "name": "os", + "type": "string" + }, + { + "name": "device", + "type": "string" + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, only the most recent run per build is kept in the series." + } + ], + "returns": [ + "keys", + "data" + ], + "guidance": [ + "Response is {keys, data}: keys is the parallel array of startedAt epochs; data points are RICH \u2014 {color, workflowStatus, y (duration), id (test-run id), buildId (serial id), buildName, isFlaky, isAlwaysFailing, isNewlyFailed, isPerformanceAnomaly, flakyReason, creationType}. Null-valued fields are omitted (JsonInclude NON_NULL).", + "Unlike get_test_health_trend, this carries the smart-tag flags and workflow status \u2014 prefer it when you need them.", + "This handler relies on the data binder for stabilityHash rather than an explicit @PathVariable; the source inventory's claim that the path segment is ignored does not hold under ExtendedServletRequestDataBinder. Pass stabilityHash in the path.", + "Points are ordered oldest-first and capped at 5000 executions with no paging. With isAutomationLatest=true the series is deduped to the latest run per build.", + "Unknown query keys are swallowed into customParams, not rejected. Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthTrendResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_test_info", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/info", + "mode": "read", + "entity": "sp_test", + "intent": "Get the header metadata for one test's Suite Health detail page \u2014 the test's display name plus the list of OS / browser / device combinations it has executed on \u2014 used to populate the platform filter.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "name", + "combinations" + ], + "guidance": [ + "Returns {name, combinations}. name is the test's display name from a sample run \u2014 an empty string \"\" when no run matches the window (not null, not an error).", + "combinations lists the platform combos the test ran on, each {os, browser} where browser also nests its device. Combos whose browser and device are both non-displayable are filtered out, so the list can be shorter than the raw platform set.", + "stabilityHash from the path overrides anything sent in the query. Unknown query keys are swallowed into customParams.", + "Any backend error surfaces as a bare 500.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token.", + "This is the Suite Health platform/identity header \u2014 distinct from the get_test_base_info capability." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestInfoResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_suite_health_test_platform_breakdown", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/breakdown", + "mode": "read", + "entity": "sp_test", + "intent": "Break one test's stability and performance down per OS / browser / device combination, so you can see which platform is driving its flakiness or slowness.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + } + ], + "returns": [ + "[]" + ], + "guidance": [ + "The 200 body is a BARE JSON ARRAY (no wrapping object), one row per OS/browser/device combination, sorted by reliability descending.", + "Each row's id is the composite \"-\" string, and carries reliability (failure %), duration (average), totalFailures {failed,total}, plus os / browser / browserDevice objects for display.", + "Rows whose browser and device are both non-displayable are dropped, so the array can be shorter than the raw combination set.", + "stabilityHash from the path overrides the query string. Unknown query keys are swallowed into customParams.", + "Any service-level error is rethrown as a bare RuntimeException \u2192 HTTP 500 with no typed body.", + "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthPlatformBreakdownResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_suite_health_test_runs", + "method": "GET", + "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds", + "mode": "read", + "entity": "sp_test", + "intent": "List the individual executions of one test across builds \u2014 per-run status, duration, smart tags (flaky / newly failed / always failing / performance anomaly) and build context \u2014 for the run-history table on the Suite Health detail page.", + "path_params": [ + { + "name": "projectName", + "type": "string", + "required": true, + "description": "Normalised project name." + }, + { + "name": "stabilityHash", + "type": "string", + "required": true, + "description": "The test's stability hash (set from the path onto the filter object)." + } + ], + "query": [ + { + "name": "dateRange", + "type": "array", + "required": true, + "description": "Window as exactly two epoch-millis values [start, end].", + "fields": [ + { + "name": "[]", + "type": "integer" + } + ] + }, + { + "name": "start", + "type": "integer", + "description": "Pagination cursor (epoch millis). Pass the pagingParams.start returned by the previous page to fetch the next, older page; runs are returned newest-first and start acts as the exclusive upper time bound. Omit for the first page." + }, + { + "name": "browser", + "type": "string", + "description": "Narrow to one browser." + }, + { + "name": "os", + "type": "string", + "description": "Narrow to one OS." + }, + { + "name": "device", + "type": "string", + "description": "Narrow to one device." + }, + { + "name": "isMuted", + "type": "string", + "values": [ + "all", + "true", + "false" + ] + }, + { + "name": "isAutomationLatest", + "type": "boolean", + "description": "When true, keeps only the latest run per build within the page." + } + ], + "returns": [ + "pagingParams", + "builds" + ], + "guidance": [ + "Cursor-paged, NOT page-numbered: 20 runs per page, newest-first. To get the next (older) page, pass the pagingParams.start from the previous response as the `start` query param.", + "The offset field inside pagingParams is a fixed placeholder (always 20) kept only for the FE contract \u2014 the backend does not read it; drive paging with start.", + "pagingParams.hasNext is true whenever the page returned any rows; a subsequent empty page is how you know you have reached the end.", + "Smart-tag flags for each run are NOT top-level \u2014 they are under the run's historyAggregate object {isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly}, resolved per run from that build's setting version.", + "Two-stage internally: Elasticsearch resolves the matching test-run ids, then per-run detail (status, duration, CI/VCS info, PM-tool links) is read from the test-run store.", + "Unknown query keys are swallowed into customParams. Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." + ], + "responses": { + "200": { + "$response": "SuiteHealthTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_overview", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testOverview", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id (the numeric BTCer id, not a TR-NNN string)." + } + ], + "intent": "Get the Overview-tab payload for a single test run \u2014 framework/SDK and platform context (browser, OS, device), the BrowserStack session id, user and BrowserStack tags, and the run's start time.", + "guidance": [ + "runInfo is a NESTED object ({duration, browser, os, device}); duration, browser, os and device do NOT appear at the top level even though they read like headline attributes \u2014 read them under runInfo.", + "sessionId is not always a plain BrowserStack Automate session id: for App Automate non-SDK frameworks it is resolved differently, so do not assume it addresses the Automate session API directly.", + "bsTags is the BrowserStack-side tag list (array of objects); userTags is the plain string array the user set \u2014 they are two different fields.", + "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data.", + "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." + ], + "returns": [ + "runInfo", + "sessionId", + "userTags", + "bsTags", + "isRealDevice", + "startedAt", + "unmuteConditionConfig" + ], + "responses": { + "200": { + "description": "Overview payload for the test run.", + "schema": { + "type": "object", + "properties": { + "runInfo": { + "type": "object", + "description": "Nested platform block; duration/browser/os/device live here, not at top level.", + "properties": { + "duration": { + "type": "integer", + "description": "Run duration in ms." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor." + }, + "os": { + "type": "object", + "description": "Formatted platform/OS descriptor." + }, + "device": { + "description": "Device name, or null for non-real-device runs." + } + } + }, + "sessionId": { + "type": "string", + "description": "BrowserStack session id; resolved differently (not a plain Automate id) for App Automate non-SDK frameworks." + }, + "userTags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "User-set tags." + }, + "bsTags": { + "type": "array", + "items": { + "type": "object" + }, + "description": "BrowserStack-side tag objects." + }, + "isRealDevice": { + "type": "boolean" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "unmuteConditionConfig": { + "type": "object", + "description": "Auto-unmute condition config for the run, when set." + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_network_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "default": false, + "description": "When true, keep only HAR entries whose startedDateTime falls within the test's start/finish window. Default false returns all session entries." + } + ], + "intent": "Get a test run's network (HAR) entries parsed server-side \u2014 obs-api downloads the HAR, filters it to the test window and returns the request entries so the Network tab renders without the browser downloading the raw file.", + "guidance": [ + "Returns {log:{entries:[\u2026HAR entries\u2026]}, responseOrigin} \u2014 NOT the raw HAR envelope. When the HAR exceeds the network-log size limit the download is skipped: entries comes back EMPTY and a meta:{isTruncated:true} flag is added \u2014 fall back to get_test_run_network_log_url for oversized logs.", + "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration \u2014 treat that as 'no logs', not an error.", + "This is the server-parsed counterpart of get_test_run_network_log_url; prefer the URL read when you only need the pointer or the file may be large.", + "In non-prod a Redis key can make it return mocked network logs." + ], + "returns": [ + "log", + "responseOrigin", + "meta" + ], + "responses": { + "200": { + "description": "Parsed network logs, or null when the run has no BrowserStack integration.", + "schema": { + "type": "object", + "properties": { + "log": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object" + }, + "description": "HAR entries; empty when the file was oversized (see meta.isTruncated) or absent." + } + } + }, + "responseOrigin": { + "type": "string", + "description": "Where the logs were sourced from, e.g. 'observability' or 'bstack'." + }, + "meta": { + "type": "object", + "description": "Present ONLY when the HAR was too big to parse.", + "properties": { + "isTruncated": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_network_log_url", + "method": "GET", + "path": "/api/v1/testRuns/v2/{testRunId}/networkLogs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "trim", + "type": "boolean", + "default": false, + "description": "When true, ALSO return testStartedAt/testFinishedAt so the client can window the HAR itself. It does NOT filter the file server-side (that is get_test_run_network_logs)." + } + ], + "intent": "Get the pointer to a test run's network (HAR) log rather than its content \u2014 the S3/BrowserStack URL plus status code and message, so the client fetches (or skips) the file itself.", + "guidance": [ + "Returns {networkLogs:, status_code, message}. The URL key is `networkLogs`, NOT `url`.", + "When the file is oversized a meta:{isTruncated:true, fileSize:} block is added so the UI can show the long-session notice instead of downloading \u2014 the URL is still returned alongside it.", + "trim=true adds testStartedAt/testFinishedAt to the body but does not trim the file; use get_test_run_network_logs when you want the entries filtered server-side.", + "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration. In non-prod a Redis key can make it return mocked network logs." + ], + "returns": [ + "networkLogs", + "status_code", + "message", + "testStartedAt", + "testFinishedAt", + "meta" + ], + "responses": { + "200": { + "description": "Network-log descriptor, or null when the run has no BrowserStack integration.", + "schema": { + "type": "object", + "properties": { + "networkLogs": { + "type": "string", + "description": "S3/BrowserStack URL of the raw HAR (the log content is fetched from here)." + }, + "status_code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "testStartedAt": { + "type": "string", + "nullable": true, + "description": "Present only when trim=true. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." + }, + "testFinishedAt": { + "type": "string", + "nullable": true, + "description": "Present only when trim=true; null when the run has not finished. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." + }, + "meta": { + "type": "object", + "description": "Present ONLY when the HAR is oversized.", + "properties": { + "isTruncated": { + "type": "boolean" + }, + "fileSize": { + "type": "integer", + "description": "HAR size in bytes." + } + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_detail_tabs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/tabs", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "List which tabs the test-detail page should render for a run \u2014 Overview, Behaviour, Logs, Network, Test code, Test cases and so on \u2014 computed from the run's data, the build's framework and whether it is a BDD build.", + "guidance": [ + "The tab list is DERIVED per run, not stored; each element is {label, value} and the set varies from run to run.", + "A missing/unknown test run surfaces here as a 500 (an internal null-pointer), NOT a typed TEST_RUN_NOT_FOUND \u2014 do not read the 500 as an outage.", + "Use this as a UI hint: a tab's presence implies the corresponding data read is worth calling for this run, its absence that the run has nothing for it." + ], + "returns": [ + "label", + "value" + ], + "responses": { + "200": { + "description": "Ordered list of tabs to render.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display label for the tab." + }, + "value": { + "type": "string", + "description": "Tab key (Overview, Behaviour, Logs, Network, TestCode, TestCases, \u2026)." + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_linked_test_cases", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testCases", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "searchAfter", + "type": "integer", + "description": "Offset cursor. Pass the searchAfter value returned in the previous response's pagingParams. Defaults to 1 server-side when omitted; forced to 0 in the demo sandbox." + }, + { + "name": "nextRoot", + "type": "string", + "description": "Opaque JSON paging token; echo back the previous response's pagingParams.nextRoot unchanged. Optional." + } + ], + "intent": "List the Test Management test cases linked to a test run (its BTCer test cases), paginated, for the Test cases tab of the test-detail page.", + "guidance": [ + "Page size is FIXED at 100 server-side \u2014 there is no page-size parameter. Walk pages while pagingParams.hasNext is true.", + "The cursor is an OFFSET that advances by 1 per page (the response's pagingParams.searchAfter = current offset + 1), NOT by the page size of 100 \u2014 echo the returned pagingParams rather than computing your own offset.", + "In the demo sandbox the offset is forced to 0, so paging is effectively disabled there.", + "The linked cases are under `testCases`; `pagingParams` carries hasNext, searchAfter and nextRoot for the next call." + ], + "returns": [ + "pagingParams", + "testCases" + ], + "responses": { + "200": { + "description": "A page of linked test cases plus the cursor for the next page.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "type": "integer", + "description": "Offset for the next page (current offset + 1)." + }, + "nextRoot": { + "type": "object", + "description": "Opaque paging token map.", + "nullable": true + } + } + }, + "testCases": { + "type": "array", + "items": { + "type": "object", + "description": "BtcerTestCases entry \u2014 a TM test case linked to the run (identifier, name, status and TM links; keys vary)." + } + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_test_code", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testCode", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Get the source location of the test that produced a run \u2014 the file name and a deep link into the connected VCS (GitHub/GitLab/etc.) built from the build's VCS info \u2014 for the Test code tab.", + "guidance": [ + "The `url` deep link depends on VCS-integration data captured on the build; without it only `filename` is meaningful and `url` may be empty.", + "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build is refused with FORBIDDEN_OPERATION.", + "`code`, `language` and `type` are populated only when the source was captured; often only `filename` is set. `testRunId` comes back null - keep the id you asked with." + ], + "returns": [ + "language", + "code", + "filename", + "url", + "testRunId", + "type" + ], + "responses": { + "200": { + "description": "Test-code descriptor for the run.", + "schema": { + "type": "object", + "properties": { + "language": { + "type": "string", + "nullable": true + }, + "code": { + "type": "string", + "description": "Source of the test, when available.", + "nullable": true + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string", + "description": "VCS deep link; empty when the build has no VCS integration." + }, + "testRunId": { + "type": "integer", + "nullable": true, + "description": "Null on this route (not an echo of the requested id)." + }, + "type": { + "type": "string", + "nullable": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_media_logs", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testDetails", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "Get the media/session block for a test run's detail page \u2014 the session video (with playback offsets), audio log, the BrowserStack session and Percy build URLs, framework, whether the run is SDK-based, plus custom metadata.", + "guidance": [ + "Despite the /testDetails route this returns the MEDIA + session-link block (videoLogs, audioLogs, browserstackSessionUrl, percyBuildUrl, framework, isSdk, customMetadata), NOT the run's metadata \u2014 use get_test_run_meta for identity/status/history.", + "videoLogs carries startOffset/finishOffset/startTimeStamp for aligning playback with the log timeline; audioLogs and percyBuildUrl are present only when the run actually has them.", + "testStartedAt drives the FE's 30-day-expired empty state and buildId feeds the long-session cURL fallback \u2014 they are context flags, not log content. buildId can be null even for a real SDK run.", + "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build returns FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED rather than partial data." + ], + "returns": [ + "videoLogs", + "audioLogs", + "browserstackSessionUrl", + "percyBuildUrl", + "isSdk", + "device", + "framework", + "customMetadata", + "testStartedAt", + "buildId" + ], + "responses": { + "200": { + "description": "Media/session block for the test run.", + "schema": { + "type": "object", + "properties": { + "videoLogs": { + "$schema": "ObsSessionVideo" + }, + "audioLogs": { + "description": "Audio-log descriptor; null when absent.", + "nullable": true + }, + "browserstackSessionUrl": { + "type": "string" + }, + "percyBuildUrl": { + "type": "string", + "description": "Percy build URL; null when the run has no Percy build.", + "nullable": true + }, + "isSdk": { + "type": "boolean" + }, + "device": { + "type": "string" + }, + "framework": { + "type": "string" + }, + "customMetadata": { + "description": "Opaque custom metadata attached to the run.", + "nullable": true + }, + "testStartedAt": { + "type": "string", + "description": "Drives the FE 30-day-expired empty state for network logs.", + "nullable": true + }, + "buildId": { + "type": "string", + "description": "Hashed BrowserStack build id for the long-session cURL fallback; can be null even for an SDK Automate run with a session.", + "nullable": true + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_meta", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testMeta", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "query": [ + { + "name": "excludeHistory", + "type": "boolean", + "default": false, + "description": "When true, skip execution-history assembly (much cheaper). Use when only the header is needed; fetch history separately via list_test_run_execution_history." + } + ], + "intent": "Get the primary metadata blob for a single test run \u2014 identity (name, file/test-case identifier), status, flakiness/mute flags, issue type and RCA data, retries, build context and, unless excluded, its recent execution history \u2014 feeding the header of the test-detail drawer.", + "guidance": [ + "excludeHistory=true skips history assembly and is much cheaper \u2014 use it when you only need the header; the history is separately available from list_test_run_execution_history.", + "The test-case identifier is OVERWRITTEN with the third-party (TCM/Jira) identifier when the case has one, so testCase.identifier may not be the native TC-NNN id.", + "`status` comes back NULL when the run is retention-locked (the run itself is still returned) \u2014 do not read a null status as 'no status recorded'.", + "`history` is [] when excludeHistory=true and `retries` is null when the test has no retries \u2014 both are absent-as-empty, not errors." + ], + "returns": [ + "testName", + "status", + "testHash", + "buildId", + "buildName", + "history", + "retries", + "issueType", + "testCase", + "buildDetails", + "observabilityVersion", + "testRunRcaData", + "isMuted", + "isFlaky" + ], + "responses": { + "200": { + "description": "Test-run metadata blob.", + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "'HOOK' or 'TEST'." + }, + "isHookAsTest": { + "type": "boolean" + }, + "errorClusterId": { + "type": "string", + "description": "Unique-error cluster id; only resolved for FAILED runs." + }, + "history": { + "type": "array", + "items": { + "$schema": "ObsHistoryTab" + }, + "description": "Recent executions across builds; [] when excludeHistory=true." + }, + "retries": { + "description": "List of retry-attempt metadata, or null when there are none." + }, + "testName": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Null when the run is retention-locked." + }, + "isFlaky": { + "type": "boolean" + }, + "testHash": { + "type": "string" + }, + "buildId": { + "type": "string", + "description": "Build UUID." + }, + "buildName": { + "type": "string" + }, + "commentCount": { + "type": "integer" + }, + "isMuted": { + "type": "boolean" + }, + "isLatest": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "issueType": { + "type": "object", + "description": "Failure issue type with resolved displayName." + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "isAutoAnalyzerRunning": { + "type": "boolean" + }, + "isUniqueErrorsRunning": { + "type": "boolean" + }, + "buildDetails": { + "type": "object", + "description": "Build context: status, state, reRun eligibility, issueTypes, ciBuildData, isArchived, finishedAt, id." + }, + "observabilityVersion": { + "type": "object", + "description": "Framework/SDK version info for the build." + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "workflowStatusEditedBy": { + "type": "integer" + }, + "workflowStatusEditedAt": { + "type": "string", + "format": "date-time" + }, + "testCaseVersionId": { + "type": "integer" + }, + "testCaseId": { + "type": "integer" + }, + "testCase": { + "type": "object", + "description": "Linked TM test case; identifier is the third-party (TCM/Jira) id when one exists." + }, + "thProjectId": { + "type": "integer" + }, + "testRunRcaData": { + "type": "object", + "description": "RCA data with resolved failureTypeDisplayName." + }, + "projectId": { + "type": "integer" + }, + "device": { + "type": "string" + }, + "cbtBrowser": { + "type": "string" + }, + "cbtPlatform": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "description": "Opaque JSON meta blob for the run." + }, + "source": { + "type": "string" + }, + "testCaseCount": { + "type": "integer" + }, + "note": { + "description": "Session note (banner parity); null when absent." + }, + "sdkNudge": { + "description": "SDK nudge; null when absent." + }, + "nudges": { + "description": "Mobile-CTM upsell nudges for the current viewer; {} when ineligible." + } + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_test_run_execution_history", + "method": "GET", + "path": "/api/v1/testRuns/{testRunId}/testMetaHistory", + "mode": "read", + "entity": "test_detail", + "path_params": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Integer test-run id." + } + ], + "intent": "List the recent execution history of the same test across previous builds (the history strip on the test-detail page) so you can tell whether a failure is new, flaky or long-standing.", + "guidance": [ + "The same data is embedded in get_test_run_meta unless excludeHistory=true \u2014 this endpoint exists so the header can load fast and the history be fetched separately.", + "Each row carries testRunId, status, serialId, testRunUuid, buildUuid, workflowStatus, creationType, usageQuotaStatus, isHittingRetentionQuota and thProjectId; startedAt is used for ordering but is not returned.", + "An unknown id returns TEST_RUN_NOT_FOUND." + ], + "returns": [ + "testRunId", + "status", + "serialId", + "testRunUuid", + "buildUuid", + "workflowStatus", + "creationType", + "isHittingRetentionQuota" + ], + "responses": { + "200": { + "description": "List of past executions of the same test.", + "schema": { + "type": "array", + "items": { + "$schema": "ObsHistoryTab" + } + } + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_runs_history_details", + "method": "POST", + "path": "/api/v1/builds/testRuns/historyDetails", + "mode": "read", + "entity": "test_run", + "intent": "For a batch of test runs (or hook runs), get each one's execution history across previous builds together with its logs and failure/issue-type info \u2014 the data behind the test details 'history' panel, used to judge flakiness and whether a failure is new.", + "body": [ + { + "name": "testRunIds", + "type": "array", + "required": true, + "description": "Integer ids of the test runs to fetch history for. When `isHook` is true these are HOOK run ids, resolved against a different backing store \u2014 mixing test-run and hook-run ids in one call returns history for whichever set matches the `isHook` flag.", + "fields": [ + { + "name": "item", + "type": "integer", + "description": "A test run id (or hook run id when isHook=true)." + } + ] + }, + { + "name": "isHook", + "type": "boolean", + "json_path": "/isHook", + "description": "Routes the whole request. false (default) treats `testRunIds` as test run ids via the test-run service; true reinterprets them as hook run ids and resolves them through the hook-run service instead. Bound from the JSON key `isHook` exactly (a differently cased key is ignored and defaults to false)." + } + ], + "guidance": [ + "This is a POST that only carries a filter body \u2014 it reads and changes nothing (routed to a read replica), so it is safe to call freely.", + "`isHook: true` switches the ENTIRE lookup to hook runs: the ids you send are then hook run ids, not test run ids. Sending test run ids with isHook=true returns history for the hook runs (if any) with those ids, not your tests.", + "The response is a JSON OBJECT keyed by the id you asked for (`{ \"\": {\u2026history\u2026} }`), not an array. An id with no history is simply absent from the map \u2014 iterate the ids you sent rather than assuming every one comes back.", + "Each entry's `logs` is an object with only the keys that exist for that run: `TEST_FAILURE` (an ARRAY of strings - message then stack), `TEST_LOG` (an array of screenshot/attachment URLs the caller fetches directly from storage), and `BROWSERSTACK_ERROR`. `logs` is {} for passed runs. Several flags (isNewlyFailed, isPerformanceAnomaly, flakySetting) can be null.", + "Scoped to the caller's group in the repository query \u2014 ids outside the caller's group are dropped from the result, not errored." + ], + "returns": [ + "buildId", + "serialId", + "status", + "issueType", + "duration", + "isFlaky", + "isAlwaysFailing", + "isNewlyFailed", + "isPerformanceAnomaly", + "flakyReason", + "flakySetting", + "creationType", + "tags", + "logs", + "workflowStatus", + "workflowStatusEditedBy", + "workflowStatusEditedAt" + ], + "responses": { + "200": { + "$response": "TestRunHistoryDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_test_run_retry_logs", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRuns/{testRunUuid}/retryLogs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "Declared in the route but NOT read by the handler \u2014 any value satisfies routing; the run is located by testRunUuid alone." + }, + { + "name": "testRunUuid", + "type": "string", + "format": "uuid", + "required": true, + "description": "UUID of the retry attempt / test run whose evidence to fetch. This route keys off the UUID, unlike the sibling /testRuns/{testRunId} routes which take the numeric id. A malformed UUID throws before any lookup." + } + ], + "intent": "Get the screenshot/log attachment structure captured for a single retry attempt of a test, so the retries tab can render that attempt's evidence.", + "guidance": [ + "Identify the run by UUID here \u2014 the numeric id that the other /testRuns/{\u2026} endpoints take will not resolve on this route.", + "The response is a log-structure object with only the keys that exist: `TEST_FAILURE` (failure backtrace), `TEST_LOG` (an array of screenshot URLs \u2014 fetch each from storage yourself; they are not proxied through this API), and `BROWSERSTACK_ERROR`. An attempt with no captured evidence throws `IllegalArgumentException(\"retry doesn't exist\")` rather than returning an empty object, so treat that error as 'no screenshot data', not a missing run.", + "Reads through the group-scoped repository factory, so a run in another group behaves as absent." + ], + "returns": [ + "TEST_FAILURE", + "TEST_LOG", + "BROWSERSTACK_ERROR" + ], + "responses": { + "200": { + "$response": "TestRunRetryLogsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_run_sdk_logs", + "method": "GET", + "path": "/api/v1/builds/{buildId}/testRunLogs", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID (NOT a numeric build id), resolved by (uuid, groupId). A UUID outside the caller's group 404s with BUILD_NOT_FOUND." + } + ], + "query": [ + { + "name": "cursor", + "type": "integer", + "required": false, + "description": "Opaque forward cursor \u2014 pass back `cursorInfo.nextCursor` from the previous page. Omit it for the first page; the per-build `totalAttachments` count is computed ONLY on that first (cursor-absent) page and is absent on later pages." + } + ], + "intent": "Page through the test runs in a build that have SDK-captured logs/attachments, with the per-build attachment total \u2014 backs the build's log/attachment viewer.", + "guidance": [ + "Page size is fixed at 15 and cannot be changed; walk pages by feeding `cursorInfo.nextCursor` back as `cursor` until `cursorInfo.hasMore` is false.", + "`totalAttachments` is returned only on the first page (when `cursor` is omitted). Read it there and carry it; do not expect it on subsequent pages.", + "Each row's `url` is a direct attachment/log URL fetched from storage, not through this API. `attachmentsCount` and `screenshotsCount` are per-test-run totals.", + "Path variable is named buildId but is bound to the build UUID string \u2014 sending a numeric id yields BUILD_NOT_FOUND." + ], + "returns": [ + "data", + "cursorInfo", + "totalAttachments" + ], + "responses": { + "200": { + "$response": "TestRunSdkLogsListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "assign_test_run_failure_categories", + "method": "PUT", + "path": "/api/v1/builds/{projectId}/update", + "mode": "write", + "entity": "test_run", + "path_params": [ + { + "name": "projectId", + "type": "integer", + "required": true, + "description": "A PROJECT id, despite the /builds/ route prefix. Interpreted per product: when the caller's CAD product is TM it is a TestHub project id and mapped to the observability project; otherwise it is the observability project id directly." + } + ], + "body": [ + { + "name": "issues", + "type": "array", + "required": true, + "description": "The per-run category assignments to apply. Required and validated (@NotNull); each item is validated too.", + "fields": [ + { + "name": "testRunId", + "type": "integer", + "required": true, + "description": "Id of the test run to categorise. Bound from the JSON key `testRunId` (maps to the internal `id`); required per item." + }, + { + "name": "issueTypeId", + "type": "integer", + "required": true, + "description": "Id of the issue type / sub-type to assign to that run. Required per item." + } + ] + } + ], + "intent": "Bulk-assign failure categories (issue types / sub-types) to a set of test runs \u2014 the 'categorise these failures' action on the test listing; returns the resulting issue types.", + "guidance": [ + "Requires the BUILD_UPDATE_FAILURE_CATEGORIES permission on the (resolved) project; otherwise AccessDenied. The path id is a project id, not a build id \u2014 do not pass a build id.", + "This overwrites each run's current category; there is no version history, so the previous category is not recoverable through this API. Read the runs first if you need to record what they were.", + "A 409 (AUTO_ANALYSIS_IN_PROGRESS) means auto-analysis is running \u2014 it is SAFE to retry, because the whole batch is validated up front and nothing is written until it passes, so a 409 wrote nothing.", + "The whole batch is pre-checked before any write, so an invalid item fails the entire request rather than partially applying.", + "Response is a flat array of the resulting IssueType objects, not a per-run map." + ], + "returns": [ + "id", + "name", + "locator", + "abbreviation", + "color", + "groupId", + "userId", + "displayName", + "issueGroup" + ], + "responses": { + "200": { + "$response": "AssignTestRunFailureCategoriesResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "409": { + "$response": "Conflict" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_bstack_error_sessions", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/bstackErrorSessions", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID. Looked up with findOneByUuid WITHOUT the group scoping the other endpoints on this controller apply \u2014 do not rely on cross-group isolation here." + } + ], + "intent": "List the sessions in a build that failed because of BrowserStack-side infrastructure errors, so the test listing can separate platform problems from genuine test failures.", + "guidance": [ + "SDK builds only. A non-SDK build \u2014 and the demo-sandbox environment \u2014 returns an EMPTY array, so `[]` does not mean 'no infra errors occurred'; confirm the build is an SDK build before concluding there were none.", + "The response is a raw JSON array of session objects straight from the session repository (no envelope, no fixed schema) \u2014 treat the element fields as opaque and read defensively.", + "Unlike the sibling listing endpoints, the build here is resolved without group scoping (findOneByUuid)." + ], + "returns": [], + "responses": { + "200": { + "$response": "BstackErrorSessionsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_flat_filtered", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/flatTestRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "headers": [ + { + "name": "Public-Link-Token", + "type": "string", + "required": false, + "description": "Public-link token; when present (or via X-Public-Link-Token) the response is restricted \u2014 pending/in-progress tests are excluded from shared reports." + }, + { + "name": "X-Public-Link-Token", + "type": "string", + "required": false, + "description": "Alternate public-link token header, same effect as Public-Link-Token." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "lastExecutedBy", + "type": "array", + "required": false, + "description": "User ids; capped at 500 (@Size max 500)." + }, + { + "name": "isFlaky", + "type": "boolean", + "required": false, + "description": "Pro filter. On a freemium account, applying any pro filter (isFlaky / hasPerformanceAnomaly / isNewFailure / isAlwaysFailing) returns an EMPTY list rather than erroring." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token (bound via a custom setter)." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level entries; echo it back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Elasticsearch search_after cursor; echo it back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Requested run-count flag, but FORCED to false outside the demo-sandbox environment regardless of what you send \u2014 do not rely on it in production." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as a flat, filterable, sortable, cursor-paged list (no suite/file nesting) \u2014 the build's test-listing table, with the full V2 filter surface and public-link sharing support.", + "guidance": [ + "This is the flat listing on the newer surface: it takes the full FilterParamsV2 filter set, sorting, folder scoping, cursor paging (nextRoot + searchAfter) and public-link tokens. The older flat listing (list_build_test_runs_flat) has none of that; pick this one when you need filtering or sorting.", + "Any unrecognised query params are collected and forwarded as custom filter params \u2014 a misspelled filter name is NOT rejected, it is silently passed through and ignored, so verify the filter took effect against the result.", + "`showRunCount` is forced to false outside the demo sandbox, so run counts will be absent in production even if you ask for them.", + "On a freemium account, applying a pro filter (isFlaky, hasPerformanceAnomaly, isNewFailure, isAlwaysFailing) returns an empty `testRuns` with `pagingParams.hasNext=false` \u2014 an empty result there is a plan gate, not 'no matches'.", + "Page by feeding `pagingParams` (nextRoot + searchAfter) back; the per-page size is a server default that is not caller-configurable here.", + "Send the public-link token via Public-Link-Token or X-Public-Link-Token to get the shared-report view (pending/in-progress hidden)." + ], + "returns": [ + "testRuns", + "pagingParams", + "isArchived", + "smartTagSettings", + "status", + "workflowStatus", + "folder_path" + ], + "responses": { + "200": { + "$response": "FlatTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_hierarchy", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "headers": [ + { + "name": "Public-Link-Token", + "type": "string", + "required": false, + "description": "Public-link token; when present (or via X-Public-Link-Token) pending/in-progress tests are hidden from the shared report." + }, + { + "name": "X-Public-Link-Token", + "type": "string", + "required": false, + "description": "Alternate public-link token header, same effect." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "lastExecutedBy", + "type": "array", + "required": false, + "description": "User ids; capped at 500." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level suite/file roots; echo back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "search_after cursor; echo back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Forced to false outside the demo-sandbox environment regardless of what you send." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as the hierarchical suite/file/test tree the build detail page renders, with full filtering, sorting and cursor paging; this is the variant that honours public-link sharing and forwards custom filter params.", + "guidance": [ + "This is the canonical hierarchical listing: use it for shared/public reports (it reads the Public-Link-Token / X-Public-Link-Token headers and hides pending/in-progress tests for public callers) and when you want arbitrary extra query params forwarded as custom filters. Its twin list_build_test_runs_hierarchy_without_public_link does neither.", + "Unrecognised query params are silently collected and passed through as custom filters \u2014 a misspelled filter name is not rejected; verify it took effect against the tree.", + "Paging is by top-level ROOT nodes, not individual tests: the page holds 5 roots (just 1 for builds over 2000 runs). Walk pages by feeding `pagingParams` (nextRoot + searchAfter) back until it reports no next.", + "`showRunCount` is forced false outside the demo sandbox.", + "The tree sits under the top-level `hierarchy` array (each element is a suite/file/test node); aggregates come back under `status` and `workflowStatus`." + ], + "returns": [ + "hierarchy", + "pagingParams", + "status", + "workflowStatus", + "isArchived", + "smartTagSettings", + "source", + "folder_path" + ], + "responses": { + "200": { + "$response": "HierarchyTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_runs_hierarchy_without_public_link", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns/hierarchy", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "query": [ + { + "name": "search", + "type": "string", + "required": false, + "description": "Free-text search over test name/error." + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Test status filter." + }, + { + "name": "issueType", + "type": "array", + "required": false, + "description": "Failure category / issue-type locator filter." + }, + { + "name": "testTags", + "type": "array", + "required": false, + "description": "Test tag filter." + }, + { + "name": "isMuted", + "type": "string", + "required": false, + "description": "Muted-tests visibility token." + }, + { + "name": "nextRoot", + "type": "string", + "required": false, + "description": "Cursor for the next page of top-level roots; echo back from the previous response's pagingParams." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "search_after cursor; echo back from the previous response's pagingParams." + }, + { + "name": "sortBy", + "type": "object", + "required": false, + "description": "Sort spec ({sortParam, sortOrder}); validated." + }, + { + "name": "showRunCount", + "type": "boolean", + "required": false, + "description": "Forced to false outside the demo-sandbox environment." + }, + { + "name": "has_folder", + "type": "boolean", + "required": false, + "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." + }, + { + "name": "folder_path", + "type": "string", + "required": false, + "description": "Folder path to scope results to; only applied when non-blank." + } + ], + "intent": "List a build's tests as the same hierarchical suite/file/test tree, for session-authenticated callers that do not need public-link sharing or custom-param pass-through.", + "guidance": [ + "Identical tree to list_build_test_runs_hierarchy (same underlying query) with two deliberate differences: this route does NOT read the Public-Link-Token / X-Public-Link-Token headers, and it does NOT collect arbitrary query params as custom filters. Prefer this only for internal/session views; use the public-link-aware twin for shared reports.", + "Because it ignores the public-link headers, a public-link caller hitting this route is NOT given the pending/in-progress suppression \u2014 do not use it to serve shared/public reports.", + "Only the declared query params are honoured here; unknown params are dropped rather than forwarded.", + "Paging is by top-level ROOT nodes (5 per page, or 1 for builds over 2000 runs); walk `pagingParams` (nextRoot + searchAfter). `showRunCount` is forced false outside the demo sandbox." + ], + "returns": [ + "hierarchy", + "pagingParams", + "status", + "workflowStatus", + "isArchived", + "smartTagSettings", + "source", + "folder_path" + ], + "responses": { + "200": { + "$response": "HierarchyTestRunListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "list_build_test_run_counts", + "method": "GET", + "path": "/api/v1/builds/v2/{buildId}/testRuns/count", + "mode": "read", + "entity": "test_run", + "path_params": [ + { + "name": "buildId", + "type": "string", + "required": true, + "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." + } + ], + "intent": "For every unique test in a build (keyed by test hash), get how many times it ran across the build and its re-runs, and whether those runs spanned different platforms \u2014 what the UI uses for rerun/retry badges.", + "guidance": [ + "Returns per-test rows under a top-level `data` array (`{testHash, count, isDifferentPlatformsUsed}`), NOT a single total. Sum `count` yourself if you need a build total.", + "Counts are aggregated over the PARENT build id, so a re-run chain (a build and its re-runs) is counted as one build, not per re-run.", + "`isDifferentPlatformsUsed` is true when the run count for that test hash spanned more than one platform." + ], + "returns": [ + "data" + ], + "responses": { + "200": { + "$response": "TestRunCountListResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } + }, + { + "name": "get_build_top_error_stats", + "method": "GET", + "path": "/api/v1/builds/{buildUuid}/topErrorStats", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "buildUuid", + "type": "string", + "required": true, + "description": "Build uuid." + } + ], + "query": [ + { + "name": "filter", + "type": "object", + "required": false, + "description": "FilterParamsV2 listing-filter set (statuses, frameworks, tags, date range, search, etc.) bound from flat query params; the panel is computed over the tests these match." + }, + { + "name": "customParams", + "type": "object", + "required": false, + "description": "Any extra query params are collected verbatim into a custom-params map and folded into the filter." + } + ], + "intent": "Get the 'Top unique errors' insight panel for a build \u2014 the most frequent error clusters with their test-run counts, respecting the active listing filters.", + "returns": [ + "overview", + "data", + "ready", + "commentCount", + "message" + ], + "responses": { + "200": { + "$response": "BuildTopErrorStatsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Handle the not-ready state: while the auto-analyser is still running you get ready:false with only a `message` ('Unique Errors are being detected') and the default 'Analysis yet to run' overview \u2014 not an error and not the final data.", + "While clustering is incomplete the panel comes back with an empty `data` set; poll again rather than concluding there are no errors.", + "`data` is sorted by count descending; each row carries the cluster id, title (the error, often a full stack trace), count, testRuns and aiRcaTestRunIds. impactedTests can be null and testRuns can be empty even for a ready panel - use aiRcaTestRunIds (populated) to reach the runs.", + "Build lookup is group-scoped, so a build uuid from another group reads as not found." + ] + }, + { + "name": "recalculate_build_quality_gate", + "method": "POST", + "path": "/api/v1/builds/{uuid}/recalculate-quality-gate", + "mode": "write", + "entity": "build", + "path_params": [ + { + "name": "uuid", + "type": "string", + "required": true, + "description": "Build uuid." + } + ], + "body": [ + { + "name": "close", + "type": "boolean", + "required": false, + "description": "Part of the RecalculateQGBody shape; effectively ignored on this route \u2014 the handler forces the recalculation branch regardless." + }, + { + "name": "reCalculateQG", + "type": "boolean", + "required": false, + "description": "Ignored \u2014 the handler forces reCalculateQG=true. The body must be PRESENT (a valid RecalculateQGBody), but its contents do not change behaviour; unknown keys are ignored." + } + ], + "intent": "Re-run the quality-gate evaluation for a build (e.g. after profile rules or a status override changed) \u2014 use after quality-gate inputs change to refresh the build's gate result.", + "returns": [ + "message" + ], + "responses": { + "200": { + "$response": "BuildQualityGateRecalcAckResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Asynchronous: responds immediately with 200 {message:'Quality Gate result will be available after recalculation finishes'} \u2014 the result is NOT ready yet. Poll the build's quality-gate summary read for the outcome.", + "This OVERWRITES the build's previous quality-gate result; there is no undo for the prior result.", + "A request body must be sent (an object; {} is fine) even though its fields are ignored \u2014 the handler forces the recalculation.", + "403 PLAN_FEATURE_NOT_ALLOWED when the build is plan-locked.", + "Live 2026-09-30: results can take a few minutes to reappear; if they stay empty, one enabled profile in the project may be un-evaluable (see create_quality_profile) \u2014 it blanks every profile's result, not just its own." + ] + }, + { + "name": "resolve_build_uuid_from_identifiers", + "method": "GET", + "path": "/api/v2/builds/buildUuid", + "mode": "read", + "entity": "build", + "query": [ + { + "name": "creationType", + "type": "string", + "required": true, + "description": "Build.CreationTypeEnum. AUTOMATION uses the name/serial identifiers below; any other (manual/TCM) type uses ONLY tcmTestRunId and ignores the name/serial params." + }, + { + "name": "buildNormalisedName", + "type": "string", + "required": false, + "default": "", + "description": "Normalised build name (AUTOMATION path)." + }, + { + "name": "projectNormalisedName", + "type": "string", + "required": false, + "default": "", + "description": "Project normalised name (AUTOMATION path); pair with buildNormalisedName + buildSerialId." + }, + { + "name": "buildSerialId", + "type": "integer", + "required": false, + "default": 0, + "description": "Per-project sequential build number (AUTOMATION path). On a serial-id collision the first match wins (logged)." + }, + { + "name": "tcmTestRunId", + "type": "integer", + "required": false, + "default": 0, + "description": "TCM test-run id \u2014 the only identifier used for non-AUTOMATION creation types." + }, + { + "name": "thProjectId", + "type": "integer", + "required": false, + "default": 0, + "description": "TestHub project id, an alternative to projectNormalisedName for scoping the AUTOMATION lookup." + } + ], + "intent": "Resolve a build UUID from human/CI-facing identifiers \u2014 project name (or TestHub project id) + build name + serial id for automation builds, or a TCM test-run id for manual/TCM runs \u2014 so a caller can deep-link to a build without already knowing its uuid.", + "returns": [ + "uuid" + ], + "responses": { + "200": { + "$response": "ResolvedBuildUuidResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "No match returns HTTP 200 {uuid:null} \u2014 NOT a 404. Check the uuid value, not the status code.", + "For non-AUTOMATION creation types only tcmTestRunId is consulted; buildNormalisedName / projectNormalisedName / buildSerialId are ignored on that path.", + "Serial-id collisions are possible \u2014 the first matching build wins (a pager marker is logged)." + ] + }, + { + "name": "list_sidebar_build_runs", + "method": "POST", + "path": "/api/v2/builds", + "mode": "read", + "entity": "build", + "body": [ + { + "name": "projects", + "type": "array", + "required": false, + "description": "Project ids to scope to. When null and UDAC is on, the accessible project list is resolved for the caller's sub-group." + }, + { + "name": "query", + "type": "string", + "required": false, + "description": "Free-text search over build names/identifiers." + }, + { + "name": "afterKey", + "type": "string", + "required": false, + "description": "Forward cursor \u2014 pass the response's pagingParams.afterKey to page down." + }, + { + "name": "beforeKey", + "type": "string", + "required": false, + "description": "Backward cursor \u2014 pass the response's pagingParams.beforeKey to page up." + }, + { + "name": "selectedBuildUuid", + "type": "string", + "required": false, + "description": "Centre the returned window on this build (roughly half a page above it, the rest below) so a deep-linked build appears in context." + }, + { + "name": "includeManualRuns", + "type": "boolean", + "required": false, + "description": "Include manual/TCM runs in the listing (default true)." + }, + { + "name": "isManuallyOverridden", + "type": "boolean", + "required": false, + "description": "Optional filter on the manual-override flag." + }, + { + "name": "buildNames", + "type": "array", + "required": false, + "description": "Part of the inherited GetBuildFilterRqV2 filter surface: build-name filter. The body also accepts users, teams, frameworks, statuses, sources, tags, branches, dateRange, search, etc." + }, + { + "name": "isArchived", + "type": "boolean", + "required": false, + "description": "Filter to archived vs live builds (inherited filter field)." + } + ], + "intent": "List a page of unique build runs for the build sidebar/list rail, matching a large filter body, with bidirectional cursor paging and optional centring on a deep-linked build.", + "returns": [ + "data", + "pagingParams" + ], + "responses": { + "200": { + "$response": "SidebarBuildRunsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "OBSERVED LIVE: `buildNames` and `query` are IGNORED by the product (200 with the unfiltered page, even when `projects` is set). Do not use this route to find a build by name; use list_project_builds_by_name (project-scoped, `search`) or search_entities (cross-project).", + "Only the body fields declared here are sent; the inherited filter surface mentioned in the field description (users, teams, statuses, tags, dateRange, search...) is not bindable through this capability.", + "POST but a pure READ \u2014 the body is a filter; nothing is written.", + "Page size is fixed server-side (BUILDS_PAGESIZE); it is NOT caller-controllable, so there is no page-size body field. Page with the afterKey/beforeKey cursors from pagingParams (hasNext/hasPrev tell you when to stop).", + "Honours UDAC project scoping: with projects null and UDAC on, only the caller's accessible projects are returned.", + "Each row's usageQuotaStatus splits retention-accessible builds from 'pro quota' ones \u2014 a build can appear but be gated by quota.", + "Use selectedBuildUuid to keep a deep-linked build visible in the window rather than paging to find it." + ] + }, + { + "name": "generate_build_report", + "method": "POST", + "path": "/ext/v1/builds/buildReport", + "mode": "read", + "entity": "build", + "body": [ + { + "name": "requestingCi", + "type": "string", + "required": true, + "description": "The CI requesting the report (e.g. the Jenkins plugin). Missing \u2192 error." + }, + { + "name": "requestType", + "type": "string", + "required": true, + "values": [ + "FIRST", + "POLL" + ], + "description": "FIRST for the initial request, POLL for follow-ups. An unknown value raises a 400 'Invalid request_type'. Missing \u2192 error." + }, + { + "name": "reportFormat", + "type": "array", + "required": true, + "description": "Requested formats: richHtml, basicHtml and/or plainText (case-sensitive). Other values render nothing -> 400 'Build Report request format is invalid'.", + "values": [ + "richHtml", + "basicHtml", + "plainText" + ] + }, + { + "name": "buildUuid", + "type": "string", + "required": false, + "description": "Optional hint passed with originalBuildName (+buildStartedAt); on its own it does not resolve the build." + }, + { + "name": "originalBuildName", + "type": "string", + "required": true, + "description": "Build's original name. REQUIRED: resolution only runs when it is present; buildUuid alone -> 400 'Original build name not found' (40021)." + }, + { + "name": "buildStartedAt", + "type": "integer", + "required": false, + "description": "Build start time in epoch milliseconds; pairs with originalBuildName for resolution." + }, + { + "name": "userTimeout", + "type": "integer", + "required": false, + "description": "Caller's overall timeout budget, used to bound the polling protocol." + } + ], + "intent": "Ask for a build's summary report in the requested formats \u2014 the CI-plugin endpoint. It is a polling protocol: the first request usually returns IN_PROGRESS with the retry count and interval to use, and later poll requests return the finished report once the build's data has settled.", + "returns": [ + "reportStatus", + "retryCount", + "pollingInterval", + "report", + "errorReason" + ], + "responses": { + "200": { + "$response": "BuildReportPollingResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Polling contract, not a single call: on reportStatus IN_PROGRESS you also get retryCount and pollingInterval \u2014 wait that interval and POST again with requestType=POLL until reportStatus is COMPLETED/TEST_AVAILABLE (or NOT_AVAILABLE/DATA_NOT_AVAILABLE).", + "Body is an untyped map validated by hand: requestingCi, requestType and reportFormat are each required and their absence is a distinct error; an unknown requestType is a 400.", + "Resolve the build by originalBuildName (+ buildStartedAt; buildUuid optional). Without originalBuildName the handler skips resolution and returns 400 'Original build name not found'. If the resolved build row is missing you get a 200 with a NULL body: handle that as 'not found', not a report.", + "The report itself (report.richHtml/richCss/basicHtml/plainText) is only populated on the terminal statuses; the envelope is @JsonInclude(NON_NULL) so absent fields are omitted.", + "In the demo-sandbox environment this returns HTTP 404 with body 'NOT FOUND'." + ] + }, + { + "name": "get_build_summary", + "method": "GET", + "path": "/ext/v1/builds/{build_id}", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build uuid. A re-run uuid resolves through its parent, so you get the parent build's aggregate." + } + ], + "intent": "Get one build's public record by uuid \u2014 identity and naming, status and per-status test counts, plus the pro-plan smart tags and unique errors. Narrower than get_build_details, which is the same build seen from inside the product.", + "returns": [ + "build_id", + "id", + "project_id", + "project_name", + "name", + "build_number", + "status", + "status_stats" + ], + "responses": { + "200": { + "$response": "PublicBuildDetailsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Enforces the TEST_RUN_AUTOMATED_VIEW service-account project permission on the build's project.", + "Passing a re-run uuid returns the PARENT build's aggregate, not the re-run's own numbers.", + "A build in 'deleted' state raises BUILD_ID_DELETED, not a plain 404 \u2014 distinguish 'deleted' from 'never existed'.", + "The response SHAPE is feature-flagged: when new build listing is enabled it is the V2 build-details shape, otherwise the legacy one \u2014 do not hard-code one envelope.", + "Pro-only fields (smart_tags, unique_errors, failure_categories, alerts) are stripped by JsonView for non-pro plans, so their absence reflects the plan, not the build.", + "`status` here is the build's LIFECYCLE ('finished'), not its outcome ('failed') as get_build_details reports it \u2014 the two routes answer different questions with the same field name.", + "project_id, project_name and created_at can come back null on this route even when get_build_details has them; read those from the internal route when they matter.", + "Each route carries what the other lacks: this one has smart_tags and unique_errors, while get_build_details has the quality-gate status and AI RCA counts." + ] + }, + { + "name": "list_build_history_with_defect_counts", + "method": "GET", + "path": "/ext/v1/builds/{build_id}/history", + "mode": "read", + "entity": "build", + "path_params": [ + { + "name": "build_id", + "type": "string", + "required": true, + "description": "Build uuid the history is anchored on." + } + ], + "query": [ + { + "name": "limit", + "type": "integer", + "required": false, + "default": 10, + "minimum": 1, + "maximum": 10, + "description": "How many builds to return (this build plus prior runs of the same name). Silently CLAMPED to 1..10 \u2014 asking for more than 10 is ignored, not an error." + } + ], + "intent": "Get the trend context for a build for an automated failure agent \u2014 this build plus up to N earlier runs of the same build name, newest first, each with number, status, timings and a pass/fail/skip plus defect-type count summary.", + "returns": [ + "builds" + ], + "responses": { + "200": { + "$response": "BuildHistoryWithDefectCountsResponse" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + }, + "guidance": [ + "Not a general history read \u2014 distinct from the internal build-history capability: this is the agent-facing trend view with per-build defect and status counts.", + "limit is clamped server-side to 1..10; there is no paging beyond 10 runs.", + "Only the anchor build and EARLIER runs of the same name are returned; later runs of the same name are filtered out. The anchor build is flagged current:true.", + "Build lookup is group-scoped (the IDOR gate), so a foreign uuid is not found.", + "Per-build counts (statusStats + defectStats: automation bug / product bug / to-be-investigated / not-determined / environment issue) come from a single Elasticsearch aggregation; per-entity recurrence (flaky/theme names) is intentionally not included." + ] + }, + { + "name": "list_build_ai_failure_cluster_test_runs", + "method": "GET", + "path": "/api/v1/ai/failures/{buildUUID}/flat", + "mode": "read", + "entity": "ai_failure", + "path_params": [ + { + "name": "buildUUID", + "type": "string", + "required": true, + "description": "Build UUID (opaque string)." + } + ], + "query": [ + { + "name": "testRunIds", + "type": "array", + "required": false, + "description": "Comma-separated integer test-run ids to fetch directly, skipping the theme/workflow lookup. Max 200 (more => 400). Sending this selects the by-ids behaviour; omit it to scope by cluster or whole build.", + "fields": [ + { + "name": "item", + "type": "integer" + } + ] + }, + { + "name": "buildFailureThemeId", + "type": "integer", + "required": false, + "description": "Integer id of a failure THEME to drill into (from get_build_ai_failures). Give this OR buildFailureWorkflowId to scope to one cluster. Ignored if testRunIds is sent." + }, + { + "name": "buildFailureWorkflowId", + "type": "integer", + "required": false, + "description": "Integer id of a failure WORKFLOW to drill into (from get_build_ai_failures). Ignored if testRunIds is sent." + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend's Elasticsearch default; do not rely on it. Use searchAfter to page." + }, + { + "name": "searchAfter", + "type": "string", + "required": false, + "description": "Opaque search-after cursor. Pass the previous response's pagingParams.searchAfter (JSON-encoded) to fetch the next page." + } + ], + "intent": "List a build's AI-failure test runs as a flat, cursor-paginated list with full details \u2014 fetch an explicit set of test-run ids, drill into one failure theme or workflow cluster, or omit both to span the whole build.", + "returns": [ + "testRuns", + "pagingParams" + ], + "guidance": [ + "ONE route, three modes chosen by which query params you send: testRunIds -> fetch exactly those runs (skips the theme/workflow lookup); buildFailureThemeId OR buildFailureWorkflowId -> drill into one cluster (ids from get_build_ai_failures); neither -> the whole build.", + "testRunIds is comma-separated integer ids, capped at 200; more returns 400 naming testRunIds.", + "Group-scoped / IDOR-safe: results filter on buildId, so ids or clusters outside the caller's build simply do not match \u2014 a short or empty testRuns (with hasNext=false) is not an error.", + "limit is ACCEPTED BUT IGNORED (fixed backend page size). Paginate with searchAfter and stop when pagingParams.hasNext is false.", + "Not plan-fenced; a plan-downgraded account can still read prior content." + ], + "responses": { + "200": { + "$response": "FlatAIFailureTestRunsResponse" + }, + "400": { + "$response": "BadRequest" + }, + "401": { + "$response": "Unauthorized" + }, + "403": { + "$response": "Forbidden" + }, + "404": { + "$response": "NotFound" + }, + "500": { + "$response": "InternalServerError" + } + } } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A project query param is mandatory here purely to satisfy the controller-wide project resolver; the returned list is not scoped by it, so passing any project the caller can access is enough.", - "The response is a bare `{data: [...]}` envelope with no `success` flag and no pagination \u2014 the whole visible set comes back in one call.", - "Each row carries the dashboard UUID under `data[].id`; carry that forward to get/update/delete/clone. The built-in views may or may not appear here, so address those by their `default` / `automation-trend` aliases on the read call rather than expecting them in this list." - ] - }, - { - "name": "create_dashboard_view", - "method": "POST", - "path": "/api/v1/dashboards/views", - "mode": "write", - "entity": "dashboard_view", - "intent": "Create a new custom dashboard view \u2014 its name, visibility level, global filters and widget layout \u2014 for the Test Observability (TO) or Test Management (TCM) dashboard page.", - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which surface the dashboard belongs to. TO = Test Observability, TCM = Test Management." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it both resolves the project and is the resource the DASHBOARD_CREATE permission check runs against." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of project_id / project_name / project_identifier." - } ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "Dashboard name, 1-100 characters. Must be unique within the project \u2014 a duplicate name is rejected. Not annotated @NotNull, but a dashboard is expected to carry a name." - }, - { - "name": "level", - "type": "integer", - "default": 2, - "description": "Visibility level. Defaults to 2 (group / shared). Controls who else can see the dashboard." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Optional description. Capped at 100 characters (the validation message misstates the cap as 200)." - }, - { - "name": "filters", - "type": "object", - "description": "Global dashboard filters (DashboardFilters): dateRange, buildTags, tags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields. Applied across all widgets on the dashboard.", - "fields": [ - { - "name": "dateRange", + "schemas": { + "ErrorResponse": { + "type": "object", + "description": "Standard TRA error body built by GlobalExceptionHandler.", + "properties": { + "errorTraceId": { + "type": "string", + "description": "UUID correlating the error to server logs." + }, + "message": { + "type": "string", + "description": "Human-readable error message; for relayed TestHub errors this is the upstream message." + }, + "code": { + "type": "string", + "description": "Application/TestHub error code. Present on many 4xx; absent on generic 500s." + }, + "classification": { + "type": "string", + "description": "Blame classification when stamped, e.g. USER_ISSUE. Never present on /ext responses.", + "example": "USER_ISSUE" + }, + "hasAccess": { + "type": "boolean", + "description": "Present (false) on access-denied 403s.", + "example": false + } + } + }, + "BuildGroupAggResult": { + "type": "object", + "description": "One build-group row in the grouped build listing (models/BuildGroupAggResult).", + "properties": { + "buildGroupId": { + "type": "integer", + "description": "Build group id." + }, + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "buildCount": { + "type": "integer" + }, + "closedBuildCount": { + "type": "integer" + }, + "closingBuildCount": { + "type": "integer", + "nullable": true + }, + "isGroupClosing": { + "type": "boolean", + "nullable": true + }, + "manualBuildCount": { + "type": "integer" + }, + "automationBuildCount": { + "type": "integer" + }, + "latestCreatedAt": { + "type": "number", + "description": "Epoch millis of the group's newest build (a double)." + }, + "buildGroup": { + "type": "object", + "nullable": true, + "description": "The TestHub build-group record (id, name, closedBy, closedByName, ...), when the group exists in TestHub." + } + } + }, + "SourceFencingFlags": { + "type": "object", + "description": "Cross-source feature-fencing flags; embedded in many widget/build responses. A true flag means results for that product were skipped for this caller.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + }, + "PageInfo": { + "type": "object", + "description": "Standard pagination info envelope.", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer", + "description": "Previous page number, null on the first page." + }, + "next": { + "type": "integer", + "description": "Next page number, null on the last page." + } + } + }, + "BuildAggregate": { + "type": "object", + "description": "Run counts by status for one build name.", + "properties": { + "count": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "BuildSummary": { + "type": "object", + "description": "Build summary (models/response/BuildSummary). Every field except uuid/id/name/status can be null - nulls are serialised, not omitted. About 30 further keys are also returned (alertCount, buildError, creationType, workflowStatusStats, aiHealingMeta, origins, testCaseCount, parallelsUsed, ...); only the ones agents normally need are declared.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "derivedName": { + "type": "string", + "nullable": true + }, + "originalName": { + "type": "string", + "nullable": true + }, + "isAutoDetectedName": { + "type": "boolean", + "nullable": true + }, + "normalisedName": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "description": "Lifecycle status (e.g. finished); the pass/fail verdict is displayStatus." + }, + "duration": { + "type": "integer", + "nullable": true + }, + "consolidatedDuration": { + "type": "integer", + "nullable": true + }, + "finishedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "startedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sessionCount": { + "type": "integer", + "nullable": true + }, + "hostInfo": { + "type": "object", + "nullable": true + }, + "observabilityVersion": { + "type": "object", + "nullable": true + }, + "ciBuildData": { + "type": "object", + "nullable": true + }, + "user": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "integer", + "nullable": true + }, + "closedBy": { + "type": "string", + "nullable": true + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "metaData": { + "type": "string", + "nullable": true, + "description": "JSON-encoded string." + }, + "percyBuildUrl": { + "type": "string", + "nullable": true + }, + "requestlyRunUrl": { + "type": "string", + "nullable": true, + "description": "Not serialised on every route (absent from the daily-summary by-ids listing)." + }, + "source": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "issueTypes": { + "type": "array", + "nullable": true + }, + "statusStats": { + "type": "object", + "description": "StatusAggregate: pending/passed/failed/skipped/unknown counts, plus an integer `aggregate` total.", + "nullable": true + }, + "aiRcaCompletedCount": { + "type": "integer", + "nullable": true + }, + "selfHealedCount": { + "type": "integer", + "nullable": true + }, + "showHealingReport": { + "type": "boolean", + "nullable": true + }, + "showAuthoringReport": { + "type": "boolean", + "nullable": true + }, + "appAccessibilityEnabled": { + "type": "boolean", + "nullable": true + }, + "issueTypeAggregate": { + "type": "object", + "nullable": true + }, + "historyAggregate": { + "type": "object", + "nullable": true + }, + "buildNumber": { + "type": "integer", + "nullable": true + }, + "sdKVersion": { + "type": "string", + "nullable": true + }, + "displayStatus": { + "type": "string", + "nullable": true, + "description": "Build verdict shown in the UI (passed/failed/...); prefer this over status." + }, + "state": { + "type": "string", + "nullable": true, + "description": "Build lifecycle state, e.g. NEW or CLOSED." + }, + "projectId": { + "type": "integer", + "nullable": true, + "description": "TRA project id." + }, + "thProjectId": { + "type": "integer", + "nullable": true, + "description": "TestHub project id." + }, + "projectName": { + "type": "string", + "nullable": true + }, + "qualityGateStatus": { + "type": "string", + "nullable": true + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "reRun": { + "type": "boolean", + "nullable": true + }, + "tcmTestRunIdentifier": { + "type": "string", + "nullable": true, + "description": "Linked Test Management run identifier (TR-...), when any." + }, + "runInfos": { + "type": "array", + "nullable": true, + "items": { + "type": "object" + } + }, + "versionControlInfo": { + "type": "object", + "description": "{url, name, commitId, branch}; always an object, fields null when no VCS data.", + "properties": { + "url": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "commitId": { + "type": "string", + "nullable": true + }, + "branch": { + "type": "string", + "nullable": true + } + } + } + } + }, + "BuildRunsSummary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "BuildAggregateV2": { + "type": "object", + "description": "Top failed build entry, optionally enriched with best-effort AI summaries (omitted when null).", + "properties": { + "name": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "serialId": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "aiRcaSummary": { + "type": "string", + "description": "Best-effort; may be absent if enrichment failed." + }, + "aiRcaStatus": { + "type": "string" + }, + "themeCount": { + "type": "integer" + }, + "workflowCount": { + "type": "integer" + }, + "selfHealingSummary": { + "type": "string", + "description": "Best-effort; omitted when null." + }, + "authoringSummary": { + "type": "string", + "description": "Best-effort; omitted when null." + } + } + }, + "FailedTestSummary": { + "type": "object", + "properties": { + "uniqueErrors": { + "type": "integer" + }, + "flaky": { + "type": "integer" + }, + "totalFailed": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "newlyFailing": { + "type": "integer" + } + } + }, + "IssueTypeSummary": { + "type": "object", + "description": "Failure-category counts (pb=Product Bug, tbi=To be Investigated, ei=Environment Issue, ab=Automation Bug, nd=No Defect).", + "properties": { + "pb": { + "type": "integer" + }, + "tbi": { + "type": "integer" + }, + "ei": { + "type": "integer" + }, + "ab": { + "type": "integer" + }, + "nd": { + "type": "integer" + } + } + }, + "ProjectSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string", + "nullable": true, + "description": "Can be null in the daily-summary payload; key projects by id." + }, + "normalisedName": { + "type": "string", + "nullable": true + }, + "buildRunsSummary": { + "$schema": "BuildRunsSummary" + }, + "topFailedBuilds": { + "type": "array", + "items": { + "$schema": "BuildAggregateV2" + } + }, + "failedTestSummary": { + "$schema": "FailedTestSummary" + }, + "issueTypeSummary": { + "$schema": "IssueTypeSummary" + } + } + }, + "OverallDailySummary": { + "type": "object", + "description": "Aggregated daily-summary report across projects.", + "properties": { + "filterQueries": { + "type": "object", + "description": "Fixed canned query strings (dateFilter, buildRuns, topFailedBuilds, failedTests, issueTypes) used to deep-link filtered views." + }, + "projects": { + "type": "array", + "items": { + "$schema": "ProjectSummary" + } + } + } + }, + "QualityProfile": { + "type": "object", + "description": "A quality-gate profile: a named rule set plus the build filters it applies to. Serialized with null fields omitted (JSON include NON_NULL). Note the @JsonProperty renames: the wire fields are `isGlobalProfile` (internal isRootProfile) and `applicableBuilds` (internal buildFilters).", + "properties": { + "id": { + "type": "integer", + "description": "Internal DB id; changes on every edit because an edit writes a new version row." + }, + "uuid": { + "type": "string", + "format": "uuid", + "description": "Public profile identifier. NOTE: a new UUID is minted on every edit, so it is not stable across updates." + }, + "name": { + "type": "string" + }, + "ruleStatus": { + "type": "string", + "enum": [ + "pass", + "fail" + ], + "description": "'pass' = profile passes only when all rules hold; 'fail' = profile fails when any rule holds." + }, + "rules": { + "type": "array", + "items": { + "$schema": "QualityProfileRule" + } + }, + "applicableBuilds": { + "$schema": "BuildFilter", + "description": "Wire name for the internal buildFilters." + }, + "isGlobalProfile": { + "type": "boolean", + "description": "Wire name for the internal isRootProfile. True = applies group-wide." + }, + "hooksVisibility": { + "type": "string", + "enum": [ + "all", + "failed", + "beforeFailed", + "none" + ] + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "isLatest": { + "type": "boolean", + "description": "Whether this is the current version of the profile; a retired/deleted version has this false." + } + } + }, + "QualityProfileRule": { + "type": "object", + "description": "One rule inside a quality profile. Serialized with null fields omitted.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Client-generated rule UUID; required on write." + }, + "type": { + "type": "string", + "enum": [ + "TEST_STATUS", + "TEST_STATUS_THRESHOLD", + "STABILITY", + "TEST_EXECUTIONS_THRESHOLD", + "BUILD_DURATION", + "SMART_TAGS", + "SMART_TAGS_THRESHOLD", + "FAILURE_CATEGORY", + "FAILURE_CATEGORY_THRESHOLD", + "ALERTS", + "UNIQUE_ERROR_THRESHOLD", + "NEW_UNIQUE_ERROR", + "UNIQUE_ERROR_STACK_TRACE" + ] + }, + "operator": { + "type": "string", + "enum": [ + "has", + "triggered", + "not_triggered", + "is", + "is_in", + "is_not", + "less_than", + "less_than_equals", + "equals", + "greater_than", + "greater_than_equals" + ] + }, + "lhs": { + "$schema": "RuleLHS" + }, + "rhs": { + "$schema": "RuleRHS" + } + } + }, + "RuleLHS": { + "type": "object", + "description": "Left-hand side of a rule. Null fields omitted.", + "properties": { + "value": { + "type": "string", + "enum": [ + "ALL_TESTS", + "SELECTED_TESTS", + "OVERALL_BUILD", + "ALL_ALERTS", + "SELECTED_ALERTS", + "SHOULD_BE", + "SHOULD_NOT_BE" + ] + }, + "helperConfig": { + "type": "object", + "description": "Scoping for SELECTED_* rules: {regex, testTags[], folders[], alert}.", + "properties": { + "regex": { + "type": "string" + }, + "testTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "alert": { + "type": "object" + } + } + }, + "with": { + "type": "string", + "maxLength": 500, + "description": "Operand (test status, smart tag, failure-category key, ...)." + }, + "displayWith": { + "type": "string", + "description": "Human display name for a failure-category operand; populated only on the single-profile detail reads for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules." + }, + "denominator": { + "type": "string" + } + } + }, + "RuleRHS": { + "type": "object", + "description": "Right-hand side / threshold of a rule. Null fields omitted.", + "properties": { + "type": { + "type": "string", + "enum": [ + "number", + "percentage", + "string" + ] + }, + "value": { + "type": "string", + "maxLength": 500 + } + } + }, + "BuildFilter": { + "type": "object", + "description": "Which builds a profile is evaluated against.", + "properties": { + "allBuilds": { + "type": "boolean", + "description": "Required. True = every build; otherwise scope with buildNames/buildTags." + }, + "buildTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "buildNames": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "QualityProfileLiteDto": { + "type": "object", + "description": "Compact profile row in the settings/list response.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rulesCount": { + "type": "integer" + }, + "applicableBuilds": { + "$schema": "BuildFilter", + "description": "Wire name for buildFilters." + }, + "isGlobalProfile": { + "type": "boolean", + "description": "Wire name for isRootProfile." + }, + "enabled": { + "type": "boolean" + } + } + }, + "QualityGateResponse": { + "type": "object", + "description": "A project's quality-gate settings: the project-level switches plus the profiles mapped to it.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether Quality Gates is on for the project (from a project attribute)." + }, + "shouldOverrideBuildStatus": { + "type": "boolean", + "description": "Whether a failing gate also marks the build failed (from a project attribute)." + }, + "qualityProfiles": { + "type": "array", + "items": { + "$schema": "QualityProfileLiteDto" + }, + "description": "Default profile sorted to the front." + } + } + }, + "QualityProfileResultStats": { + "type": "object", + "description": "Per-status rule counts for a profile on a build.", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + } + } + }, + "QualityProfileResultLiteDto": { + "type": "object", + "description": "Per-profile result summary for a build.", + "properties": { + "uuid": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Computed profile outcome: 'passed', 'failed' or 'skipped'." + }, + "statusStats": { + "$schema": "QualityProfileResultStats" + } + } + }, + "QualityGateSummary": { + "type": "object", + "description": "Readiness-aware build gate summary. Exactly one of qgResult / message / state is populated per response.", + "properties": { + "qgResult": { + "type": "array", + "items": { + "$schema": "QualityProfileResultLiteDto" + }, + "description": "Per-profile results when ready." + }, + "message": { + "type": "string", + "description": "Present instead of results when the build is pending or the auto-analyser is still running." + }, + "state": { + "type": "string", + "description": "A cached evaluation state string, present instead of results/message when one is cached." + } + } + }, + "QualityProfileRuleResult": { + "type": "object", + "description": "One rule's outcome for a build in the profile detail drawer.", + "properties": { + "rule": { + "$schema": "QualityProfileRule" + }, + "ruleStatus": { + "type": "string", + "description": "Per-build status of this rule (e.g. running/passed/failed)." + }, + "ruleResult": { + "type": "string", + "description": "Rendered result/value for this rule on the build." + } + } + }, + "QualityRuleResult": { + "type": "object", + "description": "One rule's evaluated result inside the CI quality-gate result. Null fields omitted.", + "properties": { + "condition": { + "type": "string", + "description": "Human-rendered rule condition text." + }, + "value": { + "type": "string", + "description": "The rule's per-build status." + }, + "result": { + "type": "string" + }, + "regex": { + "type": "string" + }, + "testTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "folders": { + "type": "array", + "items": { + "type": "string" + } + }, + "alert": { + "type": "string" + } + } + }, + "QualityProfiles": { + "type": "object", + "description": "A profile block inside the CI quality-gate result.", + "properties": { + "id": { + "type": "string", + "description": "Profile UUID." + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Rendered as ' if', e.g. 'pass if'." + }, + "result": { + "type": "string", + "description": "'passed', 'failed' or 'skipped'." + }, + "rules": { + "type": "array", + "items": { + "$schema": "QualityRuleResult" + } + } + } + }, + "ExtQualityGateResult": { + "type": "object", + "description": "CI-facing quality gate result for a build. Serialized with null fields omitted (JSON include NON_NULL), so while running only build_uuid/status/quality_gate_result appear.", + "properties": { + "build_uuid": { + "type": "string", + "format": "uuid" + }, + "build_url": { + "type": "string", + "description": "Deep link to the build in the product; absent while the build is still running." + }, + "status": { + "type": "string", + "description": "'running' while unfinished/analysing, otherwise 'passed'/'failed'/'skipped'." + }, + "quality_gate_result": { + "type": "string", + "description": "While running, a human 'try again' message; once done, the same value as status." + }, + "quality_profiles": { + "type": "array", + "items": { + "$schema": "QualityProfiles" + }, + "description": "Per-profile breakdown; absent while running." + } + } + }, + "FilterStruct": { + "type": "object", + "description": "One filter value option for the quality-profile filter builder.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true + }, + "pId": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "displayName": { + "type": "string", + "description": "Present only when set (JSON include NON_NULL)." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + } + } + }, + "QGFilterResponseData": { + "type": "object", + "description": "Filter values for the quality-profile filter builder, with a cursor.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterStruct" + } + }, + "afterKey": { + "type": "string", + "description": "The last key of this page. Still set on a final short page, so stop when fewer than the page size come back.", + "nullable": true + }, + "query": { + "type": "string", + "description": "Echo of the search prefix; null when not sent.", + "nullable": true + } + } + }, + "TrendTimelineDataPoint": { + "type": "object", + "description": "One point of a Testing Trends timeline series. `y` is the metric value (null - or 0 on count cards - when the bucket had no runs). `t` is the total unique tests in the bucket, null on most cards. `pointRange` is [bucketStart, bucketStart+interval] epoch ms, or null on series that do not set it (e.g. performance-chart lineData) - the key is always present.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket start, epoch milliseconds." + }, + "y": { + "type": "number", + "description": "Metric value for the bucket (count, percentage or duration depending on the card) as a JSON number. Null (percent/duration cards) or 0 (count cards such as test executions) for an empty bucket.", + "nullable": true + }, + "t": { + "type": "integer", + "description": "Total unique tests in the bucket; null on most cards (observed null on the test-executions and performance cards).", + "nullable": true + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "[bucketStart, bucketEnd] epoch ms; null (not omitted) on series that do not set it, e.g. performance-chart lineData.", + "nullable": true + } + } + }, + "TrendInsight": { + "type": "object", + "description": "Headline card summary. Fields are computed on serialisation: `count` is the headline number as a STRING (a percentage for flakiness/new-failures/always-failing, a raw total for test-executions); `meta` is a human sentence ('N of M unique tests are ') or null; `metric` is the metric label or null.", + "properties": { + "metric": { + "type": "string", + "description": "Metric label (e.g. 'flaky', 'new failures'); null when not applicable.", + "nullable": true + }, + "meta": { + "type": "string", + "description": "Human summary sentence; null when there is no metric.", + "nullable": true + }, + "count": { + "type": "string", + "description": "Headline value as a string - a percentage (one decimal) for the gated cards, or a raw count for test-executions." + } + } + }, + "PercentTimelineCard": { + "type": "object", + "description": "Standard Testing Trends card: a timeline series plus an optional headline insight. `insights` is null on the test-growth card.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + }, + "insights": { + "$schema": "TrendInsight" + } + } + }, + "FailureCategorySeries": { + "type": "object", + "description": "One defect/failure category with its own timeline.", + "properties": { + "name": { + "type": "string", + "description": "Defect/issue category name." + }, + "color": { + "type": "string", + "description": "Display colour from the group's defect-type mapping." + }, + "data": { + "type": "array", + "description": "Per-bucket counts. Each point's y is null when no tests ran in that bucket (distinct from 0 failures).", + "items": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "Bucket start, epoch ms." + }, + "y": { + "type": "integer", + "description": "Failure count in the bucket; null when the bucket had no runs." + } + } + } + } + } + }, + "CbtDataPoint": { + "type": "object", + "description": "A cross-browser coverage slice. Every point carries name/value/percentage/drilldown/color/os/browser/device; os/browser/device are null on primary points, drilldown is null on secondary points.", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "integer", + "description": "Raw execution count for the slice." + }, + "percentage": { + "type": "string", + "description": "Share as a formatted string (two decimals)." + }, + "drilldown": { + "type": "string", + "description": "Key linking a primary slice to its drill-down entry; null on secondary points.", + "nullable": true + }, + "color": { + "type": "string" + }, + "os": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + }, + "browser": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + }, + "device": { + "type": "string", + "description": "Set only on secondary points; the key is present as null on primary points.", + "nullable": true + } + } + }, + "StabilityLeaderboardRow": { + "type": "object", + "description": "One build's row in the stability leaderboard.", + "properties": { + "id": { + "type": "string", + "description": "Row id: \"all\" for the project-wide row, otherwise \"buildName-\"." + }, + "percentage": { + "type": "string", + "description": "Pass-rate / stability percentage as a formatted string (one decimal)." + }, + "buildName": { + "type": "string", + "description": "Build name, or \"All\" for the project-wide row." + }, + "chartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-bucket pass-rate series; entries may be null for empty buckets." + } + } + }, + "PerformanceLeaderboardRow": { + "type": "object", + "description": "One build's row in the performance leaderboard.", + "properties": { + "id": { + "type": "string", + "description": "Row id, \"buildName-\"." + }, + "buildName": { + "type": "string" + }, + "avgDuration": { + "type": "number", + "description": "Average execution duration over the window." + }, + "testCount": { + "type": "integer", + "description": "Average test-run count." + }, + "lineChartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-bucket test COUNTS (null for empty buckets)." + }, + "barChartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-bucket average DURATIONS (null for empty buckets)." + } + } + }, + "TrendPagingParams": { + "type": "object", + "description": "Fixed-page pagination echo. Note the leaderboards nest this under the key `pagingParam` (singular) while recent-runs uses `pagingParams` (plural).", + "properties": { + "hasNext": { + "type": "boolean", + "description": "True when another page exists." + }, + "pageNumber": { + "type": "integer", + "description": "One-based page number just returned (the requested zero-based pageNumber + 1)." + } + } + }, + "BuildFrequencyStats": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Positional index into the list, NOT a build id." + }, + "buildName": { + "type": "string" + }, + "runs": { + "type": "integer", + "description": "Number of runs of this build name in the window." + } + } + }, + "TrendBuildSummary": { + "type": "object", + "description": "Standard build summary as used by the recent-runs picker. Large object; the salient fields are listed. Serialised with default (nulls included) inclusion; vcsInfo is write-only and not returned.", + "properties": { + "uuid": { + "type": "string", + "description": "Build's stable uuid identifier." + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "startedAt": { + "type": "string", + "description": "Timestamp." + }, + "finishedAt": { + "type": "string", + "description": "Timestamp." + }, + "sessionCount": { + "type": "integer" + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "statusStats": { + "type": "object", + "description": "Aggregated pass/fail counts for the build." + }, + "source": { + "type": "string" + }, + "qualityGateStatus": { + "type": "string" + }, + "currentQGOverrideStatus": { + "type": "boolean" + } + } + }, + "WebhookProjectMapping": { + "type": "object", + "description": "Projects a rule is linked to, with pagination info. Hydrated from TestHub.", + "properties": { + "info": { + "$schema": "WebhookPaginationInfo" + }, + "projects": { + "type": "array", + "items": { + "$schema": "WebhookProjectInfoLite" + } + } + } + }, + "WebhookPaginationInfo": { + "type": "object", + "description": "Project-picker / project-mapping pagination info (distinct from WebhookListInfo).", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "totalProjectsCount": { + "type": "integer" + }, + "linkedProjectsCount": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "WebhookProjectInfoLite": { + "type": "object", + "description": "A project row in the webhook project picker / mapping list.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "isSelected": { + "type": "boolean", + "description": "True when this project is already linked to the queried webhook rule." + } + } + }, + "RcaRetrigger": { + "type": "object", + "description": "Result of retrigger_rca_analysis. Asynchronous \u2014 carries only the new turn's id and, for trial users, the quota used.", + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Id of the new RCA conversation turn; poll this for the refreshed analysis." + }, + "trialQuota": { + "type": "object", + "description": "Present (non-null) only for test-level trial users; omitted otherwise.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + }, + "BuildAccessibilityMeta": { + "type": "object", + "description": "Web/Automate build-level accessibility summary. Note: App-Automate builds with App Accessibility enabled are proxied and return a different shape.", + "properties": { + "totalIssue": { + "type": "integer", + "description": "Sum of the severity counts that were present." + }, + "severityBreakdown": { + "type": "object", + "description": "Per-severity issue counts as the build summary reports them (zero counts included when reported).", + "properties": { + "critical": { + "type": "integer" + }, + "serious": { + "type": "integer" + }, + "moderate": { + "type": "integer" + }, + "minor": { + "type": "integer" + } + } + }, + "autoEnabledBuild": { + "type": "boolean", + "description": "Whether accessibility auto-scan was enabled for the build." + }, + "autoEnabled": { + "type": "boolean", + "description": "Duplicate of autoEnabledBuild." + }, + "reportUrl": { + "type": "string", + "description": "Deep link into the Accessibility dashboard (not an API endpoint)." + } + } + }, + "SimilarTestRun": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "stackTrace": { + "type": "array", + "items": { + "type": "string" + } + }, + "projectNormalisedName": { + "type": "string" + }, + "buildNormalisedName": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + } + } + }, + "SimilarTestRunRs": { + "type": "object", + "properties": { + "similar": { + "type": "array", + "items": { + "$schema": "SimilarTestRun" + } + }, + "current": { + "$schema": "SimilarTestRun" + }, + "message": { + "type": "string" + } + } + }, + "SingleMetricSummary": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "The metric value (e.g. always-failing test count)." + }, + "meta": { + "type": "string", + "description": "Metric metadata, typically the total the count is out of." + }, + "commentCount": { + "type": "integer" + }, + "autoUnmuteCount": { + "type": "integer" + } + } + }, + "BuildAISummary": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Narrative failure summary; null when status is NOT_AVAILABLE." + }, + "topFailureThemes": { + "type": "array", + "description": "Up to 5 failure themes.", + "items": { + "type": "object", + "properties": { + "themeId": { + "type": "string" + }, + "themeName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failurePercentage": { + "type": "string" + } + } + } + }, + "topAffectedWorkflows": { + "type": "array", + "description": "Up to 5 affected workflows.", + "items": { + "type": "object", + "properties": { + "workflowId": { + "type": "string" + }, + "workflowName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failurePercentage": { + "type": "string" + } + } + } + }, + "status": { + "type": "string", + "description": "Availability status; \"NOT_AVAILABLE\" on soft failure." + } + } + }, + "WorkflowStatusAggregate": { + "type": "object", + "description": "Per-workflow-status counts. Null-valued keys are omitted. Statuses use display-cased JSON keys.", + "properties": { + "Passed": { + "type": "integer" + }, + "Failed": { + "type": "integer" + }, + "In Progress": { + "type": "integer" + }, + "Skipped": { + "type": "integer" + }, + "Retest": { + "type": "integer" + }, + "Blocked": { + "type": "integer" + }, + "Untested": { + "type": "integer" + }, + "Pending": { + "type": "integer" + }, + "Unknown": { + "type": "integer" + }, + "otherCount": { + "type": "object", + "description": "Counts for any non-standard statuses, keyed by status name." + } + } + }, + "BuildAttemptRunStats": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "attemptNumber": { + "type": "integer" + }, + "totalAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "BuildAttemptRunStatsResponse": { + "type": "object", + "properties": { + "attempts": { + "type": "array", + "items": { + "$schema": "BuildAttemptRunStats" + } + }, + "summary": { + "type": "object", + "description": "Aggregate across attempts (same fields as an attempt entry, without a label).", + "properties": { + "attemptNumber": { + "type": "integer" + }, + "totalAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + } + } + }, + "BuildHistoryEntry": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Series name (a test/workflow status)." + }, + "data": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-build value of this status, aligned with the response `keys`." + }, + "color": { + "type": "string" + } + } + }, + "BuildHistory": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildHistoryEntry" + }, + "description": "One series per status." + }, + "keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Build labels along the x-axis (order of the ~10 recent builds)." + }, + "commentCount": { + "type": "integer" + } + } + }, + "BuildIdFromCoordinates": { + "type": "object", + "properties": { + "buildId": { + "type": "string", + "description": "The resolved build UUID." + } + } + }, + "BuildCoordinates": { + "type": "object", + "properties": { + "buildNormalisedName": { + "type": "string", + "description": "URI-encoded, spaces as '+'." + }, + "buildSerialId": { + "type": "integer" + }, + "projectNormalisedName": { + "type": "string", + "description": "URI-encoded, spaces as '+'." + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "projectFinishedAt": { + "type": "string" + }, + "projectCreatedAt": { + "type": "string" + }, + "toProjectId": { + "type": "integer", + "description": "Observability project id." + }, + "thProjectId": { + "type": "integer", + "description": "TestHub project id." + } + } + }, + "BuildReRunEntry": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "e.g. \"First Run\", \"Re-run1\", \"Manual Run\"." + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "id": { + "type": "string", + "description": "The leg's integer build id, as a string." + }, + "buildSdkLogs": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "meta": { + "type": "object", + "nullable": true + } + } + }, + "BuildReRun": { + "type": "object", + "properties": { + "retryData": { + "type": "array", + "items": { + "$schema": "BuildReRunEntry" + }, + "description": "Flat list of re-run legs." + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + } + } + }, + "RerunSummary": { + "type": "object", + "properties": { + "totalTestAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + } + } + }, + "SplitResult": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "totalTestAttempts": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "workflowStatusStats": { + "$schema": "WorkflowStatusAggregate" + }, + "metadata": { + "type": "object", + "description": "Build summary metadata for the split." + } + } + }, + "RerunGroup": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "splits": { + "type": "array", + "items": { + "$schema": "SplitResult" + } + }, + "summary": { + "$schema": "RerunSummary" + }, + "metadata": { + "type": "object", + "description": "Build summary metadata for the group." + } + } + }, + "BuildReRunV2": { + "type": "object", + "properties": { + "retryData": { + "type": "array", + "items": { + "$schema": "RerunGroup" + }, + "description": "Re-run groups, each with per-split breakdowns." + }, + "count": { + "type": "integer" + }, + "commentCount": { + "type": "integer" + }, + "summary": { + "$schema": "RerunSummary" + } + } + }, + "FilterField": { + "type": "object", + "description": "A single selectable filter option. `id`/`value` are what you send back as the filter selection; `label` is the display text. For branch/tag options all three are the raw value; for user/team options `id` is the numeric id (stringified) and value/label are the name.", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "count": { + "type": "integer", + "description": "Match count for this option; usually null on these facet lists.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Optional parent-id grouping token; empty string on these lists.", + "nullable": true + } + } + }, + "BuildFilterStatic": { + "type": "object", + "description": "Static catalogue of selectable build-listing filter options. Each list is the full menu of choices, not a presence list.", + "properties": { + "statusList": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "frameworkList": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "types": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "sources": { + "type": "array", + "items": { + "$schema": "FilterField" + } + }, + "creationType": { + "type": "array", + "items": { + "$schema": "FilterField" + } + } + } + }, + "BuildFilterApplied": { + "type": "object", + "description": "The caller's applied build-listing filter, echoed back in resolved form (ids turned into name-bearing objects). Anything not sent comes back null (not an empty list).", + "properties": { + "uniqueBuildNames": { + "type": "array", + "items": { + "$schema": "FilterField" + }, + "nullable": true + }, + "statuses": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "dateRange": { + "type": "object", + "nullable": true, + "description": "Resolved range {lowerBound, upperBound} (epoch ms); null when not sent.", + "properties": { + "lowerBound": { + "type": "integer" + }, + "upperBound": { + "type": "integer" + } + } + }, + "frameworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Raw framework values echoed back.", + "nullable": true + }, + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "nullable": true + }, + "assignees": { + "type": "array", + "nullable": true + }, + "teams": { + "type": "array", + "nullable": true + }, + "buildGroupIds": { + "type": "array", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Echo of the request's buildTags (build-level tags)." + }, + "branches": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "search": { + "type": "string", + "nullable": true, + "description": "Echo of the request's `search` field (not `query`)." + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "state": { + "type": "string" + }, + "types": { + "type": "array", + "nullable": true + }, + "sources": { + "type": "array", + "nullable": true + }, + "excludeLocked": { + "type": "boolean", + "nullable": true + }, + "isCiRun": { + "type": "boolean", + "nullable": true + }, + "selfHealed": { + "type": "boolean", + "nullable": true + }, + "aiAuthored": { + "type": "boolean", + "nullable": true + }, + "appCrash": { + "type": "boolean", + "nullable": true + }, + "selfHosted": { + "type": "boolean", + "nullable": true + }, + "creationType": { + "type": "array", + "nullable": true + }, + "testCasesStatuses": { + "type": "array", + "nullable": true + } + } + }, + "FilterStruct__w2_build_filter1": { + "type": "object", + "description": "A static test-filter option: a display label and the value to send back. `pId` is present only for hierarchical options (e.g. folder trees).", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "pId": { + "type": "string" + } + } + }, + "Dashboard": { + "type": "object", + "description": "A custom dashboard's full definition, as returned under `data` by the dashboard read/create/update/clone endpoints.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "Visibility level (1 or 2)." + }, + "filters": { + "type": "object", + "description": "Saved global filters (DashboardFilters shape).", + "nullable": true + }, + "widgets": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Widget definitions with id/name/type/config/layout.", + "nullable": true + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id." + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer", + "nullable": true + }, + "modifiedByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdByName": { + "type": "string" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM.", + "nullable": true + }, + "type": { + "type": "string", + "description": "Dashboard | View | TABULAR_TEST_RUNS | BUILD_COMPARISON." + }, + "isDefault": { + "type": "boolean", + "nullable": true + }, + "isAutomation": { + "type": "boolean", + "nullable": true + }, + "reports": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Report-widget definitions (present for report-type dashboards).", + "nullable": true + } + } + }, + "MetaItem": { + "type": "object", + "description": "An id resolved to its display name.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "ErrorSearchResult": { + "type": "object", + "description": "A unique-error search hit.", + "properties": { + "errorClusterId": { + "type": "string", + "description": "Stable id of the unique-error cluster." + }, + "error": { + "type": "string", + "description": "The error signature text." + } + } + }, + "FilterDetails": { + "type": "object", + "description": "A custom field offered as a dashboard widget filter.", + "properties": { + "id": { + "type": "integer", + "description": "Custom-field id." + }, + "label": { + "type": "string", + "description": "Custom-field name/key." + }, + "type": { + "type": "string", + "description": "Filter type: Basic or Custom (always Custom on this route)." + }, + "fieldType": { + "type": "string", + "description": "The custom field's data type." + } + } + }, + "DashboardPageInfo": { + "type": "object", + "description": "Pagination cursor for the paginated widget-custom-field-filters response.", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "ReportSummary": { + "type": "object", + "description": "One row of the reports/dashboards listing. Many fields are conditionally present (external-integration and project fields only appear for scheduled/linked reports).", + "properties": { + "id": { + "description": "Dashboard/report id (UUID or numeric depending on row)." + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM." + }, + "type": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "isAutomation": { + "type": "boolean" + }, + "external_report_id": { + "type": "integer" + }, + "report_type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "frequency": { + "type": "string", + "description": "Schedule frequency (scheduled reports only)." + }, + "next_run_at": { + "type": "string", + "format": "date-time" + }, + "identifier": { + "type": "string" + }, + "is_scheduled": { + "type": "boolean" + }, + "owner": { + "type": "object", + "description": "{id, full_name, email, browserstack_user_id}." + }, + "project_id": { + "type": "integer", + "description": "The TestHub project id (thProjectId), not the TRA project id." + }, + "project_name": { + "type": "string" + }, + "projects": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Linked projects as {project_id, project_name}." + } + } + }, + "ReportListPageInfo": { + "type": "object", + "description": "Pagination cursor for the reports listing.", + "properties": { + "page": { + "type": "integer" + }, + "next": { + "type": "integer", + "nullable": true + }, + "prev": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer" + }, + "page_size": { + "type": "integer" + } + } + }, + "TakeAwayMetric": { + "type": "object", + "description": "A single Home takeaway metric: current value and the previous-window value.", + "properties": { + "value": { + "type": "number" + }, + "prev": { + "type": "number", + "nullable": true, + "description": "Previous-window value; null (time-savings cards) or 0 when the prior 7 days have no data." + } + } + }, + "TakeAwayMetricV2": { + "type": "object", + "description": "A v2 failure-noise category: absolute count and its percentage of total failures.", + "properties": { + "count": { + "type": "integer" + }, + "reduction": { + "type": "number", + "description": "This category as a percentage of totalFailures (for uniqueErrors it is the complementary reduction)." + } + } + }, + "TakeAwayData": { + "type": "object", + "description": "Home key-takeaway cards, each metric comparing the current 7 days to the prior 7 days.", + "properties": { + "stability": { + "$schema": "TakeAwayMetric" + }, + "uniqueTestExecutions": { + "$schema": "TakeAwayMetric" + }, + "newFailures": { + "$schema": "TakeAwayMetric" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetric" + }, + "flakiness": { + "$schema": "TakeAwayMetric" + }, + "failures": { + "$schema": "TakeAwayMetric" + } + } + }, + "TimeSavingMetric": { + "type": "object", + "description": "Aggregate time-saved breakdown returned by the older Home takeaways variant.", + "properties": { + "timeSavings": { + "$schema": "TakeAwayMetric" + }, + "testToVerify": { + "$schema": "TakeAwayMetric" + }, + "uniqueErrors": { + "$schema": "TakeAwayMetric" + }, + "flakiness": { + "$schema": "TakeAwayMetric" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetric" + }, + "autoTagged": { + "$schema": "TakeAwayMetric" + } + } + }, + "TimeSavingV2Metric": { + "type": "object", + "description": "v2 failure-noise breakdown returned by the newer Home takeaways variant. When totalFailures is 0 the per-category objects are omitted.", + "properties": { + "totalFailures": { + "type": "integer" + }, + "flakyFailing": { + "$schema": "TakeAwayMetricV2" + }, + "alwaysFailing": { + "$schema": "TakeAwayMetricV2" + }, + "autoAnalyzedFailure": { + "$schema": "TakeAwayMetricV2" + }, + "uniqueErrors": { + "$schema": "TakeAwayMetricV2" + } + } + }, + "ProjectExternal": { + "type": "object", + "description": "A project as exposed by the public/external API.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "created_by": { + "type": "integer", + "description": "User id of the project owner." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Derived from the project's finishedAt; can be null.", + "nullable": true + }, + "observability_url": { + "type": "string", + "description": "Ready-made deep link to the project's builds page." + } + } + }, + "ExternalPagination": { + "type": "object", + "description": "Cursor pagination envelope for the external API.", + "properties": { + "has_next": { + "type": "boolean", + "description": "True whenever the current page was non-empty; the final full page is followed by one empty page." + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor to pass as next_page for the following page." + } + } + }, + "RecentView": { + "type": "object", + "description": "One recently-viewed item on the Home page.", + "properties": { + "type": { + "type": "string", + "description": "Item kind (e.g. dashboard, build, view)." + }, + "link": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Field": { + "type": "object", + "description": "A filter option / type-ahead suggestion item as returned across the Suite Health filter routes.", + "properties": { + "id": { + "type": "string", + "description": "Stable identifier for the option (often equal to value)." + }, + "value": { + "type": "string", + "description": "The raw value to send back when applying this option as a filter." + }, + "label": { + "type": "string", + "description": "Human-readable display label." + }, + "count": { + "type": "integer", + "description": "Occurrence count; null on suggestion routes that do not compute it.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Parent id for hierarchical options (e.g. folders); null or empty when not applicable.", + "nullable": true + } + } + }, + "TimelineDataPoint": { + "type": "object", + "description": "One time-bucketed chart point.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket timestamp (epoch ms)." + }, + "t": { + "type": "integer", + "description": "Secondary timestamp/marker for the bucket; may be null." + }, + "y": { + "type": "number", + "description": "Value at the bucket (count, average, or percentage depending on the series); null when the series has no value for this bucket." + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "The [start, end] epoch-ms span the bucket covers (present on the failure-rate series)." + } + } + }, + "SPChart": { + "type": "object", + "description": "A Suite Health chart: named data series plus a summary insight.", + "properties": { + "data": { + "type": "object", + "description": "Map of series name -> array of TimelineDataPoint. Series keys depend on the chart (e.g. totalExecutions/totalFailures, averageDuration, averageFailureRates).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "TimelineDataPoint" + } + } + }, + "insights": { + "type": "object", + "description": "Headline summary for the chart.", + "properties": { + "metric": { + "type": "string", + "description": "Metric label, e.g. 'Total Failures'." + }, + "value": { + "type": "string", + "description": "Formatted headline value, e.g. '12/340' or '3.5%' or '02m 10s'." + }, + "meta": { + "type": "string", + "description": "Optional secondary text; null when not set." + } + } + } + } + }, + "SPTestBreakdownRow": { + "type": "object", + "description": "One environment (OS/browser/device) row in a test's stability breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Composite browser-platform key, e.g. 'chrome-windows'." + }, + "reliability": { + "type": "number", + "description": "Failure percentage (failed*100/total), 2 decimals; 0 when there were no executions." + }, + "duration": { + "type": "number", + "description": "Average duration for this environment." + }, + "os": { + "type": "object", + "description": "Formatted platform descriptor." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor, with a nested `device` field." + }, + "browserDevice": { + "type": "object", + "description": "Alternate browser descriptor carrying the full device object." + }, + "totalFailures": { + "type": "object", + "description": "Failure tally for this environment.", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + }, + "AppLogResponse": { + "type": "object", + "description": "Parsed application-log response from /api/v1/appLogs/query.", + "properties": { + "status": { + "type": "string", + "description": "'success', or 'failure' (with empty logs) when Loki reported a non-success status.", + "values": [ + "success", + "failure" + ] + }, + "appLogQuery": { + "type": "object", + "description": "The echoed Loki query parameters the service built from the run.", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Group (tenant) ids the query was scoped to." + }, + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "envs": { + "type": "array", + "items": { + "type": "string" + } + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "logs": { + "type": "array", + "description": "Flat, timestamp-sorted app-log lines (abnormal/error level only). Empty on a clean run or a Loki non-success.", + "items": { + "$schema": "AppLog" + } + } + } + }, + "AppLog": { + "type": "object", + "description": "One parsed application-log line.", + "properties": { + "app": { + "type": "string", + "description": "App name from the Loki stream." + }, + "env": { + "type": "string", + "description": "Environment from the Loki stream." + }, + "level": { + "type": "string", + "description": "Log level; 'unknown' when Loki did not tag a level." + }, + "epochNano": { + "type": "integer", + "description": "Timestamp in epoch nanoseconds; the list is sorted ascending by this." + }, + "line": { + "type": "string", + "description": "Log text after the ' cSep=S ' separator; the raw line when the separator is absent." + } + } + }, + "LogUrls": { + "type": "object", + "description": "Test-run log bundle as URLs plus metadata (from /ext/v1/testRun/{id}/logs and the v2 consolidated-logs route).", + "properties": { + "testStartedAt": { + "type": "string", + "format": "date-time" + }, + "testFinishedAt": { + "type": "string", + "format": "date-time" + }, + "browserstackLogs": { + "$schema": "SessionLogs", + "description": "Per-type BrowserStack session log URLs." + }, + "sdkLogs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "SDK log URLs." + }, + "hookRunLogs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hook-run log URLs." + }, + "failureLogs": { + "type": "array", + "items": { + "$schema": "GenericLog" + }, + "description": "Inline failure log entries." + }, + "stepLogs": { + "type": "array", + "items": { + "$schema": "GenericLog" + }, + "description": "Inline step log entries." + }, + "framework": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + } + }, + "SessionLogs": { + "type": "object", + "description": "Per-type session log artefacts (URLs / nodes). Null-valued types are omitted (JsonInclude NON_NULL).", + "properties": { + "text": { + "type": "object", + "description": "Raw/text log artefact." + }, + "console": { + "type": "object" + }, + "selenium": { + "type": "object" + }, + "appium": { + "type": "object" + }, + "playwright": { + "type": "object" + }, + "performance": { + "type": "object", + "description": "{urls: [], count}." + }, + "network": { + "$schema": "SessionNetwork" + }, + "device": { + "type": "object" + }, + "video": { + "type": "object", + "description": "{url, offsetUrl, status_code, startTime, startTimeStamp, startOffset, finishOffset, launchingAppTime, isIos, isV2}." + }, + "audio": { + "type": "object" + }, + "screenshots": { + "type": "array", + "items": { + "type": "string" + } + }, + "terminal": { + "type": "object", + "description": "{session: {url}, build: {url}}." + }, + "telemetry": { + "type": "object", + "description": "{isSupported, url?}." + }, + "crash": { + "type": "object" + }, + "appProfiling": { + "type": "object" + }, + "instrumentation": { + "type": "object" + }, + "browserprofiling": { + "type": "object" + }, + "issues": { + "type": "object" + }, + "maestro": { + "type": "object" + }, + "commandLogs": { + "type": "object" + }, + "maestroScreenshot": { + "type": "object" + }, + "config": { + "type": "object" + }, + "cypress_screenshots": { + "type": "object", + "description": "Serialised as cypress_screenshots." + } + } + }, + "SessionNetwork": { + "type": "object", + "description": "A session's network (HAR) log artefact pointer.", + "properties": { + "status_code": { + "type": "integer", + "description": "Upstream fetch status code." + }, + "url": { + "type": "string", + "description": "URL of the HAR file." + }, + "message": { + "type": "string", + "description": "Upstream message, if any." + } + } + }, + "SessionNetworkLogUrlResponse": { + "type": "object", + "description": "Envelope returned by /api/v1/sessions/{sessionId}/networkLogs (unparsed form): the HAR URL plus fetch status.", + "properties": { + "networkLogs": { + "type": "string", + "description": "URL of the session's HAR network log file (NOT the parsed entries)." + }, + "status_code": { + "type": "integer", + "description": "Upstream fetch status code." + }, + "message": { + "type": "string", + "description": "Upstream message, if any." + } + } + }, + "GenericLog": { + "type": "object", + "description": "One log entry in a merged/consolidated log stream. Null fields are omitted (JsonInclude NON_NULL).", + "properties": { + "logLevel": { + "type": "string", + "description": "Log level enum (e.g. ERROR, INFO)." + }, + "logType": { + "type": "string", + "description": "Log source/type enum (e.g. FAILURE, APPLICATION_LOGS)." + }, + "timeStamp": { + "type": "string", + "format": "date-time" + }, + "finishedTimeStamp": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string", + "description": "Inline log text (present because content is merged server-side)." + }, + "url": { + "type": "string", + "description": "URL for entries backed by an external artefact rather than inline content." + }, + "duration": { + "type": "number", + "description": "Duration in seconds, formatted to 2 decimals." + }, + "startOffset": { + "type": "integer" + }, + "finishOffset": { + "type": "integer" + }, + "noOfSimilarErrors": { + "type": "integer" + }, + "customHookRunUuid": { + "type": "string" + }, + "customHookType": { + "type": "string" + }, + "failure": { + "type": "string" + }, + "message": { + "type": "string" + }, + "selfHealData": { + "type": "string" + }, + "aiAuthoringData": { + "type": "string" + }, + "logSubType": { + "type": "string" + } + } + }, + "TestDetailResponse": { + "type": "object", + "description": "Per-hook (or per-test) detail bundle: its test code, opaque metadata and any hook code.", + "properties": { + "testCode": { + "$schema": "TestCodeResponse", + "description": "The hook's source/file info; null for SDK-ingested hooks without captured code.", + "nullable": true + }, + "testMetadata": { + "type": "object", + "description": "Test-run metadata: type (BEFORE_ALL/AFTER_ALL...), testName, status, isFlaky, buildId, origin, issueType, observabilityVersion, scopes, isNewFailure, isAlwaysFailing, plus many null keys." + }, + "hooksCode": { + "type": "array", + "items": { + "$schema": "TestCodeResponse" + }, + "description": "Source for each associated hook.", + "nullable": true + } + } + }, + "TestCodeResponse": { + "type": "object", + "description": "A test's source code and location.", + "properties": { + "code": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string", + "description": "VCS URL to the file, built from the run's VCS info." + }, + "language": { + "type": "string" + } + } + }, + "TestRunCode": { + "type": "object", + "description": "Captured source for a test or hook run. code/language are null when nothing was captured for that run; the row is still returned.", + "properties": { + "language": { + "type": "string" + }, + "code": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "testRunId": { + "type": "integer" + }, + "type": { + "type": "string", + "description": "Run type (test vs hook kind)." + } + } + }, + "TestRunDetail": { + "type": "object", + "description": "Full detail of a single test run: its captured code, its metadata, and the code of the hooks that ran around it.", + "properties": { + "testCode": { + "$schema": "TestRunCode", + "nullable": true, + "description": "Captured code; null when nothing was captured (e.g. JUnit-report builds)." + }, + "testMetadata": { + "type": "object", + "description": "Metadata for the run - the broad internal test-meta object (testName, status, buildId, history, retries, testCase, projectId, device/cbtBrowser/cbtPlatform, observabilityVersion, origin, workflowStatus*, errorClusterId, isAutoAnalyzed*, ... many null). Read keys off the actual response." + }, + "hooksCode": { + "type": "array", + "description": "Code of before/after hooks. Populated on get_test_run_details; the key is present but null on the baseline map read.", + "items": { + "$schema": "TestRunCode" + }, + "nullable": true + } + } + }, + "BuildInsights": { + "type": "object", + "description": "Build-level failure/flakiness insights surrounding a test run.", + "properties": { + "buildId": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer" + }, + "summary": { + "type": "object", + "properties": { + "statusMetrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" + } + } + } + }, + "totalTests": { + "type": "integer" + }, + "totalHooks": { + "type": "integer" + } + } + }, + "failureCategorization": { + "type": "object", + "properties": { + "newFailures": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "flaky": { + "type": "integer" + } + } + }, + "failureContext": { + "type": "object", + "description": "Per-run failure context: file and parent-module failure rates for the run under inspection.", + "properties": { + "file": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failedCount": { + "type": "integer" + }, + "failureRate": { + "type": "number", + "description": "failedCount/testCount * 100" + } + } + }, + "parentModule": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "testCount": { + "type": "integer" + }, + "failedCount": { + "type": "integer" + }, + "failureRate": { + "type": "number" + } + } + } + } + }, + "topErrorsAnalysis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty/partial unless COMPLETE." + }, + "uniqueErrorCount": { + "type": "integer" + }, + "uniqueErrors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "occurrences": { + "type": "integer" + }, + "truncatedError": { + "type": "string", + "description": "First ~200 chars of the error for a quick overview." + } + } + } + } + } + }, + "defectStats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueType": { + "type": "string" + }, + "totalFailureTestsCount": { + "type": "integer" + }, + "totalFailureHooksCount": { + "type": "integer" + }, + "categories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "failureTestCount": { + "type": "integer" + }, + "failureHooksCount": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "BuildTestRunWithAttachments": { + "type": "object", + "description": "Trimmed test-run row for the 'runs having attachments' discovery listing.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "build_id": { + "type": "integer" + }, + "attachments_count": { + "type": "integer" + }, + "screenshots_count": { + "type": "integer" + } + } + }, + "FlatTestRun": { + "type": "object", + "description": "One row of the flat build test-run listing. Top-level `id` is the test HASH (string); the numeric run id is `details.id`.", + "properties": { + "id": { + "type": "string", + "description": "Test hash, NOT the numeric run id." + }, + "title": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "details": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Numeric test run id." + }, + "status": { + "type": "string" + }, + "os": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "browser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "device": { + "type": "string" + }, + "isRealDevice": { + "type": "boolean" + }, + "filePath": { + "type": "string" + }, + "vcFileUrl": { + "type": "string" + }, + "issueType": { + "type": "object" + }, + "aiRcaState": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isMuted": { + "type": "boolean" + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "VcsCheckMapping": { + "type": "object", + "description": "A vcs_ingestion row: a PR check registered against a check configuration for evaluation. Serialised directly from the JPA entity (no wrapper).", + "properties": { + "id": { + "type": "integer", + "description": "Ingestion row id \u2014 the id used by get_checks_mapping." + }, + "prNumber": { + "type": "integer", + "description": "Pull request number." + }, + "commitSha": { + "type": "string" + }, + "callbackUrl": { + "type": "string", + "description": "URL the evaluation pipeline calls back with results." + }, + "checkStates": { + "type": "string", + "description": "Evaluation state; newly created rows are 'Not Started'." + }, + "checkType": { + "type": "string", + "description": "QG_STATUS or BUILD_STATUS, copied from the matched check configuration.", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "retryCount": { + "type": "integer", + "description": "Evaluation retry count; 0 on creation." + }, + "splitRepos": { + "type": "boolean" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id, copied from the matched check configuration." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "commitTimestamp": { + "type": "string", + "format": "date-time", + "description": "Parsed from pr_data.date; may be unset when the date was omitted." + }, + "prMetadataJson": { + "type": "object", + "description": "The pr_data object stored verbatim, with githubCheckId merged in." + }, + "thProjectId": { + "type": "integer", + "description": "Target project id, copied from the matched check configuration." + }, + "buidlParentId": { + "type": "integer", + "description": "Build parent id (field is spelled 'buidlParentId' in the payload); typically null at creation." + }, + "checkConfiguration": { + "$schema": "CheckConfigurationEntity" + } + } + }, + "CheckConfigurationEntity": { + "type": "object", + "description": "A checks_configuration row as embedded inside a vcs_ingestion mapping (the raw JPA entity, camelCase fields).", + "properties": { + "id": { + "type": "integer", + "description": "Check-configuration id." + }, + "checkName": { + "type": "string" + }, + "repoName": { + "type": "string" + }, + "vcsName": { + "type": "string" + }, + "vcsOrgName": { + "type": "string" + }, + "splitRepos": { + "type": "boolean" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "branchName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "checkType": { + "type": "string", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "commitCheck": { + "type": "boolean" + }, + "isEnabled": { + "type": "boolean" + }, + "buildName": { + "type": "string" + }, + "buildTags": { + "type": "string" + }, + "thProjectId": { + "type": "integer" + } + } + }, + "CheckConfigurationItem": { + "type": "object", + "description": "A single check-run configuration as returned by the check-runs endpoints (CheckConfigurationItemResponse).", + "properties": { + "checkId": { + "type": "integer", + "description": "Check-configuration id \u2014 the id used in the update/toggle/delete path." + }, + "checkName": { + "type": "string" + }, + "repoName": { + "type": "string" + }, + "vcsOrgName": { + "type": "string" + }, + "vcsName": { + "type": "string" + }, + "checkType": { + "type": "string", + "enum": [ + "QG_STATUS", + "BUILD_STATUS" + ] + }, + "splitRepos": { + "type": "boolean" + }, + "projectId": { + "type": "integer", + "description": "Resolved BrowserStack project id for projectName." + }, + "projectName": { + "type": "string" + }, + "branchName": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "buildTags": { + "type": "string" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "commitCheck": { + "type": "boolean" + }, + "isEnabled": { + "type": "boolean" + } + } + }, + "View": { + "type": "object", + "description": "A saved view (filters row) as returned by the view endpoints.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "View id (UUID)." + }, + "type": { + "type": "integer", + "description": "Screen type code: 1 builds, 2 testRuns, 3 testHealth, 4 uniqueErrors, 5 buildInsights." + }, + "query": { + "type": "string", + "description": "The stored serialised filter." + }, + "level": { + "type": "integer", + "description": "1 = private, 2 = public/shared.", + "enum": [ + 1, + 2 + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "description": "Optional free-text description; typically null (not settable through create/update)." + }, + "createdBy": { + "type": "integer", + "description": "Creator user id (from the session, not the body)." + }, + "groupId": { + "type": "integer", + "description": "Owning group id (from the session, not the body)." + } + } + }, + "DashboardView": { + "type": "object", + "description": "A dashboard view as returned by the dashboards/views read, create, update and clone endpoints (the object under the response `data` key).", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Dashboard UUID." + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "Visibility level (2 = group/shared by default)." + }, + "filters": { + "type": "object", + "description": "Global dashboard filters (DashboardFilters): dateRange, tags, buildTags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields." + }, + "widgets": { + "type": "array", + "description": "Widget layout (Widgets objects: id, name, type, globalFilters, desc, config, layout).", + "items": { + "type": "object" + } + }, + "totalWidget": { + "type": "integer", + "description": "Count of widgets on the dashboard." + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer", + "description": "Owner user id." + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer" + }, + "modifiedByName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "createdByName": { + "type": "string" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM." + }, + "type": { + "type": "string", + "description": "DashboardType." + }, + "isDefault": { + "type": "boolean", + "description": "True for the built-in default dashboard (date range then forced to last 30 days)." + }, + "isAutomation": { + "type": "boolean", + "description": "True for the built-in automation-trend dashboard." + }, + "reports": { + "type": "array", + "description": "Report widgets, when present.", + "items": { + "type": "object" + } + } + } + }, + "WidgetCatalogueEntry": { + "type": "object", + "description": "One entry in the addable-widget catalogue (WidgetDetails).", + "properties": { + "name": { + "type": "string", + "description": "Display name, e.g. 'Test Case Statistics'." + }, + "key": { + "type": "string", + "description": "WidgetTypeEnum value to use as a widget's `type`." + } + } + }, + "CustomFieldFilterDetail": { + "type": "object", + "description": "A custom (user-defined) test-case field usable as a dashboard-widget filter (FilterDetails).", + "properties": { + "id": { + "type": "integer", + "description": "Custom field id." + }, + "label": { + "type": "string", + "description": "Filter key / display label." + }, + "type": { + "type": "string", + "description": "FilterType \u2014 'Custom' for custom-field filters." + }, + "fieldType": { + "type": "string", + "description": "The underlying field data type." + } + } + }, + "ReportRecord": { + "type": "object", + "description": "A saved report (the DashboardsData shape; reports and dashboards share it). Nulls are included. Widgets for a report-typed record are under `reports`; for a dashboard-typed record under `widgets`.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "level": { + "type": "integer", + "description": "1 = private, 2 = group." + }, + "filters": { + "type": "object", + "description": "Saved global filters (DashboardFilters shape); null in the listing.", + "nullable": true + }, + "widgets": { + "type": "array", + "description": "Widget list for a dashboard-typed record; null for a report-typed one.", + "nullable": true + }, + "reports": { + "type": "array", + "description": "Widget list for a report-typed record ({id, name, type, config, layout, reportType}); null in the listing.", + "nullable": true + }, + "totalWidget": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "modifiedBy": { + "type": "integer", + "nullable": true + }, + "modifiedByName": { + "type": "string", + "nullable": true + }, + "createdByName": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "description": "TO or TCM.", + "nullable": true + }, + "type": { + "type": "string" + }, + "isDefault": { + "type": "boolean", + "nullable": true + }, + "isAutomation": { + "type": "boolean", + "nullable": true + } + } + }, + "TestRunReportRow": { + "type": "object", + "description": "One row in a tabular test-runs report dataset OR one build row in the static-builds listing (the same TestRunReport class, populated differently per endpoint). A large flat row; only representative fields are listed. For datasets these are test-run rows (testName, status, flakiness, customFields, \u2026); for static-builds these are build rows (buildId, buildName, buildNumber, displayStatus, buildStartedAt, workflowStatusStats, \u2026).", + "properties": { + "uuid": { + "type": "string" + }, + "testName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "buildId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "displayStatus": { + "type": "string", + "description": "Build display status (static-builds rows)." + }, + "projectId": { + "type": "integer" + }, + "customFields": { + "type": "object" + }, + "isFlaky": { + "type": "boolean" + }, + "flakiness": { + "type": "number" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + } + } + }, + "PaginationSummary": { + "type": "object", + "description": "Paging summary for report datasets/builds. totalDatasets is -1 on these routes (not a real total); page using hasNext.", + "properties": { + "totalDatasets": { + "type": "integer" + }, + "paginatedResults": { + "type": "integer" + }, + "hasNext": { + "type": "boolean" + } + } + }, + "StatTile": { + "type": "object", + "description": "One aggregate stat tile: a metric key and its value.", + "properties": { + "meta": { + "type": "string", + "description": "Metric key, e.g. totalUniqueTests, flakyTestsCount, totalPassedExecutions." + }, + "value": { + "type": "integer" + } + } + }, + "ReportCustomFieldItem": { + "type": "object", + "description": "A custom-field / column definition usable in a report.", + "properties": { + "fieldId": { + "type": "integer" + }, + "fieldName": { + "type": "string" + }, + "fieldType": { + "type": "string" + }, + "entityType": { + "type": "string", + "description": "TestCase or TestExecution \u2014 the only thing distinguishing two same-named fields." + } + } + }, + "ReportPageInfo": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + } + }, + "ReportTestStats": { + "type": "object", + "description": "Headline stats for one test in the tabular report. An empty object `{}` is returned when the test has no matching runs.", + "properties": { + "reliability": { + "type": "number", + "description": "Failure ratio as a percentage: failed*100/total." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "failureOccurrence": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + }, + "ReportTrendDataPoint": { + "type": "object", + "description": "One point in a test's trend series. Null fields are omitted.", + "properties": { + "color": { + "type": "string", + "description": "Colour derived from the workflow status." + }, + "workflowStatus": { + "type": "string" + }, + "y": { + "type": "integer", + "description": "Run duration in MILLISECONDS." + }, + "id": { + "type": "integer", + "description": "Test run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id." + }, + "buildName": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "flakyReason": { + "type": "integer" + } + } + }, + "ReportTestTrendResponse": { + "type": "object", + "description": "Per-run trend series for one test.", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Build started-at epochs, index-aligned to `data`." + }, + "data": { + "type": "array", + "items": { + "$schema": "ReportTrendDataPoint" + } + } + } + }, + "TabularReportRow": { + "type": "object", + "description": "One row of the tabular test-runs report as built by the report handler (only the fields this path populates).", + "properties": { + "testId": { + "type": "integer" + }, + "globalTestHash": { + "type": "string" + }, + "testCaseId": { + "type": "integer" + }, + "folderId": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "avgDuration": { + "type": "number", + "description": "Average duration in ms." + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isAutoAnalyzerRunning": { + "type": "boolean" + }, + "buildId": { + "type": "integer" + }, + "buildName": { + "type": "string" + }, + "originalBuildName": { + "type": "string" + }, + "buildTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "buildNumber": { + "type": "integer" + }, + "projectId": { + "type": "integer" + }, + "buildStartedAt": { + "type": "string", + "format": "date-time" + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "normalisedBuildName": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Normalised workflow status, or 'unknown'." + }, + "workflowStatus": { + "type": "string" + }, + "smartTagSettings": { + "type": "object", + "description": "Present only for builds newer than the applied schema date." + } + } + }, + "TabularTestRunsReportResponse": { + "type": "object", + "description": "Paginated tabular test-runs report page. Empty result returns null data and null summary.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TabularReportRow" + } + }, + "summary": { + "type": "object", + "properties": { + "totalDatasets": { + "type": "integer", + "description": "-1 on these routes (not a real total)." + }, + "paginatedResults": { + "type": "integer", + "description": "Count of rows on this page." + }, + "hasNext": { + "type": "boolean", + "description": "True when the page came back full." + } + } + }, + "sourceFencingFlags": { + "type": "object", + "description": "Present only when non-null.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "ReportStatTilesResponse": { + "type": "object", + "description": "Summary tiles for the tabular report.", + "properties": { + "data": { + "type": "array", + "description": "Tiles. Empty/absent when all datasets conflict or no stats were returned.", + "items": { + "type": "object", + "properties": { + "meta": { + "type": "string", + "description": "Tile name (e.g. totalUniqueTests). Note the key is `meta`, not `name`." + }, + "value": { + "type": "integer" + } + } + } + }, + "sourceFencingFlags": { + "type": "object", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "WidgetFilterValue": { + "type": "object", + "description": "A single selectable filter value.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true + }, + "pId": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + } + } + }, + "WidgetFilterValueList": { + "type": "object", + "description": "Flat list of values for one widget filter key.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + } + }, + "WidgetEnhancedFilterResponse": { + "type": "object", + "description": "Widget filter values bucketed with paging cursors.", + "properties": { + "data": { + "type": "object", + "description": "Map keyed by bucket (e.g. suggested / others); each value is a list of filter-value structs.", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + }, + "fullList": { + "type": "boolean", + "nullable": true + }, + "info": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer" + }, + "next": { + "type": "integer" + } + }, + "nullable": true + }, + "suggestedAfterKey": { + "type": "string", + "description": "Cursor to continue the suggested bucket.", + "nullable": true + }, + "othersAfterKey": { + "type": "string", + "description": "Cursor to continue the others bucket.", + "nullable": true + } + } + }, + "WidgetDrillDownData": { + "type": "object", + "description": "Row-level records behind a widget data point.", + "properties": { + "data": { + "type": "array", + "description": "Drill-down rows. Each carries project identity (projectId, thProjectId, projectName, normalisedName) plus widget-specific fields.", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "integer" + }, + "thProjectId": { + "type": "integer" + }, + "projectName": { + "type": "string" + }, + "normalisedName": { + "type": "string" + } + } + } + }, + "insights": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "meta": { + "type": "string" + } + } + }, + "isConflicting": { + "type": "boolean" + }, + "isEmptyNumerator": { + "type": "boolean" + }, + "isEmptyDenominator": { + "type": "boolean" + } + } + }, + "ParallelQueueSummaryDetails": { + "type": "object", + "description": "Per-product concurrency figures.", + "properties": { + "product": { + "type": "string" + }, + "totalParallelsUsed": { + "type": "integer" + }, + "maximumParallelsAllowed": { + "type": "integer" + }, + "totalQueuedSessions": { + "type": "integer" + }, + "maximumQueueAllowed": { + "type": "integer" + }, + "userLevelQueuedSessions": { + "type": "integer" + }, + "totalParallelsBreakdown": { + "type": "object", + "description": "Per-pool breakdown." + }, + "maximumParallelsBreakdown": { + "type": "object" + }, + "totalQueuedBreakdown": { + "type": "object" + }, + "maximumQueueBreakdown": { + "type": "object" + } + } + }, + "ParallelQueueUsageSummary": { + "type": "object", + "description": "Group-level parallel-queue summary.", + "properties": { + "summary": { + "type": "array", + "description": "Per-product SummaryDetails; present only when a product was supplied.", + "items": { + "$schema": "ParallelQueueSummaryDetails" + } + }, + "testExecutionsUsage": { + "type": "object", + "description": "Plan test-execution usage block." + } + } + }, + "ParallelQueueTeamUsage": { + "type": "object", + "description": "Per-team and per-member parallel-session usage.", + "properties": { + "summary": { + "$schema": "ParallelQueueSummaryDetails" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "teamName": { + "type": "string" + }, + "totalCount": { + "type": "integer" + }, + "teamId": { + "type": "integer" + }, + "maximumTeamParallelsAllowed": { + "type": "integer" + }, + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "integer" + }, + "userName": { + "type": "string" + }, + "parallelsUsed": { + "type": "integer" + }, + "sessionQueued": { + "type": "integer" + }, + "lastBuildRanAt": { + "type": "integer" + }, + "showRunningBuildsButton": { + "type": "boolean" + }, + "parallelsBreakdown": { + "type": "object" + }, + "sessionQueuedBreakdown": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "RunningBuildsResponse": { + "type": "object", + "description": "A user's currently-running builds.", + "properties": { + "data": { + "type": "array", + "description": "Running builds in the standard build-summary shape (uuid, id, name, status, duration, startedAt, projectId, tags, statusStats, ...).", + "items": { + "$schema": "BuildSummary" + } + } + } + }, + "DeviceTierUsageInfo": { + "type": "object", + "description": "Aggregated mobile device-tier usage and device list for a product. Both fields are raw JSON trees from the backend.", + "properties": { + "tierInfo": { + "type": "array", + "description": "One row per mobile tier: {id, name, maxUsed, maxAvailable}.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Tier id, e.g. Tier1." + }, + "name": { + "type": "string" + }, + "maxUsed": { + "type": "integer" + }, + "maxAvailable": { + "type": "integer" + } + } + } + }, + "devices": { + "type": "object", + "description": "Map of tier id (Tier1..Tier4) to the array of devices in that tier (each carrying maxParallels and parallelsUsed).", + "additionalProperties": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + }, + "TestExecutionUsageCount": { + "type": "object", + "description": "Execution counts for a window. NOTE: when both userId and groupId are missing the endpoint instead returns the plain string 'Missing userId or groupId'.", + "properties": { + "totalTestCount": { + "type": "integer", + "description": "All tests reported in the window." + }, + "browserstackTestCount": { + "type": "integer", + "description": "Subset that ran on BrowserStack infra (carry a sessionId)." + } + } + }, + "GroupPlanUsageDetails": { + "type": "object", + "description": "Plan info/details plus non-BrowserStack usage figures. All counts are compacted display strings (e.g. '1.2K'). Usage fields are null when the group has no execution limit; the null-branch uses `nonBrowserstackTestExecutions` in place of `nonBrowserstackUsedTestExecutions`.", + "properties": { + "planInfo": { + "type": "object" + }, + "planDetails": { + "type": "object" + }, + "nonBrowserstackTotalTestExecutions": { + "type": "string" + }, + "nonBrowserstackUsedTestExecutions": { + "type": "string", + "description": "Populated branch only." + }, + "nonBrowserstackTestExecutions": { + "type": "string", + "description": "Null-branch key only (when no execution limit)." + }, + "nonBrowserstackPercentageOfTestExecutionUsage": { + "type": "string" + }, + "billingResetDate": { + "type": "integer", + "description": "Epoch millis; null when no limit." + }, + "browserstackUsedTestExecutions": { + "type": "string" + }, + "totalTestExecutions": { + "type": "string" + }, + "nonBrowserstackPercentageOfGroupTestExecutionUsage": { + "type": "string" + }, + "nonBrowserstackGroupTotalTestExecutions": { + "type": "string" + } + } + }, + "GlobalSearchEntity": { + "type": "object", + "description": "One search result. Concrete shape is per-type (Build / BuildTestCaseExecutionResult / Project), discriminated by `entity`.", + "properties": { + "id": { + "type": "integer" + }, + "entity": { + "type": "string", + "description": "Type discriminator: Build / BuildTestCaseExecutionResult / Project." + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "userName": { + "type": "string", + "description": "Resolved from userId server-side." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GlobalSearchResult": { + "type": "object", + "description": "Search results. pagingParams and totalCount are omitted in overlay mode.", + "properties": { + "response": { + "type": "array", + "items": { + "$schema": "GlobalSearchEntity" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string" + } + } + }, + "totalCount": { + "type": "integer" + } + } + }, + "CreateHealingPrResponse": { + "type": "object", + "description": "Self-healing PR job state. At create time only healingPrId + status are set (status=IN_PROGRESS); the remaining fields are populated by the background worker and read back via the status endpoint.", + "properties": { + "healingPrId": { + "type": "string", + "description": "The job id (UUID)." + }, + "status": { + "type": "string", + "description": "One of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED." + }, + "prUrl": { + "type": "string", + "description": "URL of the opened PR; null until a terminal PR-producing state." + }, + "prNumber": { + "type": "integer", + "description": "PR number; null until opened." + }, + "headSha": { + "type": "string", + "description": "Head commit SHA of the PR branch; null until committed." + }, + "perFileReport": { + "type": "object", + "description": "Per-file applied/skipped detail (arbitrary JSON); present on terminal outcomes." + }, + "errorMessage": { + "type": "string", + "description": "Failure detail; present on FAILED/PARTIAL outcomes." + } + } + }, + "HealingVerdictResponse": { + "type": "object", + "description": "A recorded verdict on one AI-healed locator.", + "properties": { + "elementHash": { + "type": "string", + "description": "Hash keying the healed element (used to join with the healing report)." + }, + "healedLocator": { + "type": "object", + "description": "The healed locator that was judged (arbitrary JSON node, e.g. {type, value})." + }, + "verdict": { + "type": "string", + "description": "'accurate' or 'inaccurate'." + }, + "feedback": { + "type": "object", + "description": "Optional free-form feedback (arbitrary JSON); null when none." + }, + "reviewedByUserId": { + "type": "integer" + }, + "reviewedAt": { + "type": "string" + }, + "isCarryover": { + "type": "boolean", + "description": "True when this verdict was first recorded against an earlier build." + }, + "carryoverBuildSerialId": { + "type": "integer", + "description": "The earlier build's serial id when isCarryover is true." + } + } + }, + "SelfHealSummaryStats": { + "type": "object", + "description": "Build-level self-healing headline numbers.", + "properties": { + "testSummary": { + "type": "object", + "properties": { + "totalTests": { + "type": "integer" + }, + "healedTests": { + "type": "integer" + }, + "percentHealedTests": { + "type": "number" + } + } + }, + "locatorSummary": { + "type": "object", + "properties": { + "totalLocators": { + "type": "integer" + }, + "healedLocators": { + "type": "integer" + }, + "percentHealedLocators": { + "type": "number" + } + } + }, + "buildSummary": { + "type": "object", + "properties": { + "totalBuild": { + "type": "integer" + }, + "healedBuilds": { + "type": "integer" + }, + "percentHealedBuilds": { + "type": "number" + } + }, + "nullable": true, + "description": "Build-level roll-up; null on a single-build read without healing data." + } + } + }, + "BuildComparisonAggregation": { + "type": "object", + "description": "Headline counts for a base-vs-target build comparison. On a fenced (cross-source) pair all counts are 0 and sourceFencingFlags is set.", + "properties": { + "newlyFailed": { + "type": "integer" + }, + "failedWithDifferentReason": { + "type": "integer" + }, + "startedPassing": { + "type": "integer" + }, + "additionalTests": { + "type": "integer" + }, + "droppedTests": { + "type": "integer" + }, + "noChange": { + "type": "integer" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags", + "description": "Present only on a fenced (cross-source) pair; OMITTED (not null/false) on a normal pair." + } + } + }, + "BuildComparisonAvailable": { + "type": "object", + "description": "Comparison availability pre-check. On a fenced pair only sourceFencingFlags is set (status/message null).", + "properties": { + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonStringEnvelope": { + "type": "object", + "description": "base/target build UUIDs resolved to their parent builds.", + "properties": { + "base": { + "type": "string", + "description": "Resolved parent build UUID for the base side." + }, + "target": { + "type": "string", + "description": "Resolved parent build UUID for the target side." + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonMetaEnvelope": { + "type": "object", + "description": "The two build summary headers, base and target. On a fenced pair base/target are null and sourceFencingFlags is set. Status counts are AMR-corrected.", + "properties": { + "base": { + "$schema": "BuildSummary" + }, + "target": { + "$schema": "BuildSummary" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags", + "description": "Present only on a fenced pair; omitted otherwise." + } + } + }, + "BuildComparisonList": { + "type": "object", + "description": "One comparison bucket's tests, grouped for display. Fenced pairs return an empty groups list with sourceFencingFlags set.", + "properties": { + "bucketName": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "$schema": "BuildComparisonListGroup" + } + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + }, + "BuildComparisonListGroup": { + "type": "object", + "description": "A file/environment grouping of compared tests.", + "properties": { + "file": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "vcFilepath": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "$schema": "BuildComparisonListGroupScope" + } + } + } + }, + "BuildComparisonListGroupScope": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "elements": { + "type": "array", + "description": "base/target pairs of test elements.", + "items": { + "type": "object", + "properties": { + "base": { + "$schema": "BuildComparisonListGroupScopeElement" + }, + "target": { + "$schema": "BuildComparisonListGroupScopeElement" + }, + "sourceFencingFlags": { + "$schema": "SourceFencingFlags" + } + } + } + } + } + }, + "BuildComparisonListGroupScopeElement": { + "type": "object", + "description": "One compared test's detail on one side.", + "properties": { + "id": { + "type": "integer" + }, + "testHash": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "isManuallyOverridden": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "issueType": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isPerformanceAnamoly": { + "type": "boolean" + }, + "fileName": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "vcFilepath": { + "type": "string" + } + } + }, + "CommentRes": { + "type": "object", + "description": "A comment as returned by the comment list reads. Note it carries `commentNode` but NOT the `content` HTML.", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "nullable": true, + "description": "Comment page type; null in list reads even when filtered by type." + }, + "threadId": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "commentNode": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "projectId": { + "type": "integer" + }, + "buildUuid": { + "type": "string" + }, + "buildSerialId": { + "type": "integer" + }, + "runUuid": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "testHash": { + "type": "string", + "description": "May be the literal STRING \"null\" (not JSON null) when unset." + }, + "mentions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "BuildRunComment": { + "type": "object", + "description": "A comment-thread summary for the build-level side panel.", + "properties": { + "threadId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "browser": { + "type": "object", + "description": "Browser descriptor (name/version)." + }, + "os": { + "type": "object", + "description": "OS descriptor (name/version)." + }, + "device": { + "type": "string" + }, + "type": { + "type": "string", + "description": "The thread's comment page type." + }, + "testRunUuid": { + "type": "string", + "format": "uuid" + }, + "commentCount": { + "type": "integer" + }, + "testHash": { + "type": "string" + } + } + }, + "SPFilterField": { + "type": "object", + "description": "A single filter option / labelled value used across the Unique Errors filter surface.", + "properties": { + "id": { + "type": "string", + "description": "Option key (for OS/browser the comma-encoded value is turned into an underscore id)." + }, + "value": { + "type": "string", + "description": "Backing value; usually equal to id for tags/branches." + }, + "label": { + "type": "string", + "description": "Display text." + }, + "count": { + "type": "integer", + "description": "Occurrence count for the option; usually populated, may be null.", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Parent grouping key (JSON key is `pId`); null or empty for non-hierarchical options.", + "nullable": true + } + } + }, + "UniqueErrorClusterRow": { + "type": "object", + "description": "One error cluster row in the unique-errors list (serialised ErrorResult).", + "properties": { + "id": { + "type": "string", + "description": "The errorClusterId as a numeric string \u2014 the cluster id to pass as errorId / clusterId to the detail views." + }, + "impactedTests": { + "type": "integer", + "description": "Distinct tests affected by this error (cardinality of stabilityHash)." + }, + "errorCount": { + "type": "integer", + "description": "Total occurrences of the error in the window." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps; one bucket per calendar day over the window)." + }, + "error": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample error text split on newlines into lines." + } + } + }, + "UniqueErrorImpactedTest": { + "type": "object", + "description": "One impacted test in an error-cluster breakdown (serialised ErrorTests).", + "properties": { + "id": { + "type": "string", + "description": "Stability hash (test signature), not a numeric id." + }, + "title": { + "type": "string", + "description": "Test name (empty string when absent)." + }, + "filePath": { + "type": "string", + "description": "Source file path; may be null." + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scope/describe hierarchy; may be null." + }, + "browsers": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Browser objects (each with a formatted browser label and a `device`)." + }, + "platforms": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Formatted OS/platform objects." + }, + "device": { + "type": "string", + "description": "Device label; may be null." + }, + "errorCount": { + "type": "integer", + "description": "Occurrences of the error for this test." + }, + "testRunId": { + "type": "integer", + "description": "A representative failing run id; may be null." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps)." + } + } + }, + "UniqueErrorBuild": { + "type": "object", + "description": "One build in which a unique error occurred (serialised ErrorBuild). Several derived fields are exposed only via getters.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "originalName": { + "type": "string" + }, + "normalisedName": { + "type": "string", + "description": "URL-encoded form of name (getter-derived)." + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "startedAt": { + "type": "string", + "description": "Timestamp." + }, + "finishedAt": { + "type": "string", + "description": "Timestamp." + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "browser": { + "type": "object", + "description": "Formatted browser object." + }, + "os": { + "type": "object", + "description": "Formatted platform object." + }, + "device": { + "type": "string" + }, + "buildNumber": { + "type": "integer" + }, + "ciBuildData": { + "type": "object", + "description": "CI info; null when the build has none." + }, + "isFailedWithSameError": { + "type": "boolean", + "description": "True when this build's run failed with THIS exact cluster." + }, + "thProjectId": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "testCreationType": { + "type": "string" + }, + "state": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string" + }, + "tcmTestRunId": { + "type": "integer" + }, + "pmToolDetails": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Linked PM-tool (Jira etc.) entries; empty array when none." + }, + "flakyReason": { + "type": "integer" + }, + "smartTagSettings": { + "type": "object", + "description": "Smart-tag settings applied to the build; may be null." + }, + "workflowStatus": { + "type": "string" + }, + "versionControlInfo": { + "type": "object", + "description": "Getter-derived {url, commitId}.", + "properties": { + "url": { + "type": "string" + }, + "commitId": { + "type": "string" + } + } + }, + "issueType": { + "type": "object", + "description": "Getter-derived {isAutoAnalyzed, value}; null unless the run status is failed.", + "properties": { + "isAutoAnalyzed": { + "type": "boolean" + }, + "value": { + "type": "string" + } + } + }, + "historyAggregate": { + "type": "object", + "description": "Getter-derived flags.", + "properties": { + "isAlwaysFailing": { + "type": "boolean" + }, + "isFlaky": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + } + } + } + } + }, + "SPTimelineDataPoint": { + "type": "object", + "description": "One point in a Suite Health chart series.", + "properties": { + "x": { + "type": "integer", + "description": "Bucket epoch." + }, + "y": { + "type": "number", + "description": "Value; may be null." + }, + "t": { + "type": "integer", + "description": "Secondary timestamp; may be null." + }, + "percentage": { + "type": "number", + "description": "Percentage value; may be null." + }, + "pointRange": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SPChart__w3_sp_error": { + "type": "object", + "description": "A Suite Health chart: one or more named series plus a headline insight.", + "properties": { + "data": { + "type": "object", + "description": "Single-entry map from series name to a list of points. The series name differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "SPTimelineDataPoint" + } + } + }, + "insights": { + "type": "object", + "properties": { + "metric": { + "type": "string", + "description": "Human label, e.g. 'Total Unique Errors'." + }, + "value": { + "type": "string", + "description": "Headline total, formatted as a STRING." + }, + "meta": { + "type": "string" + } + } + } + } + }, + "TestRunsRcaData": { + "type": "object", + "description": "The stored AI RCA for a test run. Peek-only fields (pendingMessageId, alreadyTracked, isLive) and NON_NULL fields (failureTypeDisplayName, conversation, trialQuota) appear only when set. rcaData/thinkingData are model output populated once state is Completed. A peek on a run with no RCA returns 200 with every field null.", + "properties": { + "id": { + "type": "integer", + "description": "The stored RCA row id.", + "nullable": true + }, + "testRunId": { + "type": "integer", + "nullable": true + }, + "rcaData": { + "type": "object", + "description": "Generated analysis {root_cause, description, failure_type, failure_sub_type, possible_fix, related_prs, alternatives_considered, unique_id}; {} while Pending.", + "nullable": true + }, + "thinkingData": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "data": {} + } + }, + "description": "Reasoning/thinking trace: an ARRAY of {type, data}." + }, + "state": { + "type": "string", + "description": "Generation state.", + "enum": [ + "Pending", + "FETCHING_LOGS", + "GENERATING_RCA", + "GENERATED_RCA", + "THINKING", + "RETRYING", + "LLM_SERVICE_ERROR", + "LOG_FETCH_ERROR", + "AI_SYSTEM_ERROR", + "UNKNOWN_ERROR", + "TIMEOUT", + "Completed" + ], + "nullable": true + }, + "bulkRcaId": { + "type": "string", + "description": "UUID linking runs analysed together via a bulk RCA request; null for a singly-generated RCA.", + "nullable": true + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "isAgentic": { + "type": "boolean", + "description": "Whether this RCA used the agentic flow. Forced false for LTS-product callers.", + "nullable": true + }, + "isMcpDriven": { + "type": "boolean", + "description": "True when the RCA is owned by the MCP-powered chat agent (the web composer is then disabled).", + "nullable": true + }, + "failureTypeDisplayName": { + "type": "string", + "description": "Present only when set." + }, + "pendingMessageId": { + "type": "string", + "description": "Peek-only: messageId of the in-flight PENDING conversation row, if any.", + "nullable": true + }, + "alreadyTracked": { + "type": "boolean", + "description": "Peek-only: whether a build-chat row already tracks pendingMessageId. Null for web/MCP-started generations.", + "nullable": true + }, + "isLive": { + "type": "boolean", + "description": "Peek-only: true when a generation is genuinely running right now \u2014 the field to gate a re-trigger on.", + "nullable": true + }, + "conversation": { + "type": "object", + "description": "Present only for a chat-driven RCA turn; its `state` uses the UPPER-CASE conversation enum (COMPLETED) while the top-level state uses the RCA enum (Completed)." + }, + "trialQuota": { + "type": "object", + "description": "Present only for trial users; carries used-quota info." + } + } + }, + "BuildGroup": { + "type": "object", + "description": "A run group (build group). Serialized with non-null fields only; `state` is computed (ACTIVE until closed, then CLOSED).", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "project_id": { + "type": "integer", + "description": "TestHub project id the group belongs to." + }, + "group_id": { + "type": "integer" + }, + "created_by": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "isClosed": { + "type": "boolean" + }, + "closedAt": { + "type": "string", + "format": "date-time" + }, + "closedBy": { + "type": "integer" + }, + "closedByName": { + "type": "string" + }, + "state": { + "type": "string", + "description": "Computed from isClosed.", + "enum": [ + "ACTIVE", + "CLOSED" + ] + } + } + }, + "SplitBuildLegs": { + "type": "object", + "description": "Split-build legs payload built by BuildService.getSplitBuildDetails (a ProjectDto subset). Only the fields this render actually populates are described.", + "properties": { + "id": { + "type": "integer", + "description": "Observability project id the split build belongs to." + }, + "name": { + "type": "string", + "description": "Project name." + }, + "builds": { + "type": "array", + "description": "The child legs, each a full build-summary object (same shape as BuildDetails).", + "items": { + "$schema": "BuildDetails" + } + }, + "pagingParams": { + "$schema": "PagingParams" + } + } + }, + "PagingParams": { + "type": "object", + "description": "Cursor pagination envelope. `searchAfter` is a build uuid (or list) to pass back as the next cursor; `hasNext` says whether another page exists.", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "description": "Opaque next-root cursor map (composite-agg paging); may be null." + }, + "searchAfter": { + "description": "Either a list of cursor tokens (build uuids) or a single integer, depending on the endpoint." + } + } + }, + "BuildDetails": { + "type": "object", + "description": "Full single-build detail object (BuildSummary DTO). Large flat object (~80 keys); only the non-obvious / gating fields are declared. Nulls ARE serialised (derivedName, originalName, ciBuildData, versionControlInfo, ... often null) - the NON_NULL notes on the nested aggregates do not apply to this object. `vcsInfo` is write-only and never serialised.", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "Live/computed status; may reflect an in-flight aggregation mid-run." + }, + "displayStatus": { + "type": "string", + "description": "Settled pipeline verdict; prefer this over `status` for the final outcome." + }, + "duration": { + "type": "integer", + "description": "Consolidated duration when present, else raw duration (the getter resolves this)." + }, + "statusStats": { + "$schema": "StatusAggregate" + }, + "issueTypeAggregate": { + "$schema": "IssueTypeAggregate", + "nullable": true, + "description": "Null when no failures have been categorised yet." + }, + "historyAggregate": { + "$schema": "HistoryAggregate", + "description": "Smart-tag roll-up; all zeros (not null) when nothing is tagged." + }, + "showHealingReport": { + "type": "boolean", + "description": "UI gate: whether a self-healing report exists to show (S3 key-existence check)." + }, + "showAuthoringReport": { + "type": "boolean", + "description": "UI gate: whether an authoring-agent report exists (S3 check); forced true on demo-sandbox envs." + }, + "appAccessibilityEnabled": { + "type": "boolean", + "description": "Per-group feature flag; fails CLOSED (false) if the flag lookup errors." + }, + "qualityGateStatus": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "tcmTestPlans": { + "type": "array", + "description": "Cross-product test-management plan links, if any.", + "items": { + "type": "object" + } + }, + "workflowStatusStats": { + "type": "object", + "description": "Workflow-status roll-up." + }, + "runInfos": { + "type": "array", + "items": { + "type": "object" + } + }, + "isArchived": { + "type": "boolean", + "nullable": true + }, + "isLocked": { + "type": "boolean", + "nullable": true + }, + "buildNumber": { + "type": "integer", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true, + "description": "TestHub project id." + }, + "tcmTestRunIdentifier": { + "type": "string", + "nullable": true, + "description": "Linked Test Management run (TR-...), when any." + }, + "issueTypes": { + "type": "array", + "nullable": true, + "items": { + "type": "object" + } + }, + "aiRcaCompletedCount": { + "type": "integer", + "nullable": true + }, + "selfHealedCount": { + "type": "integer", + "nullable": true + }, + "testCaseCount": { + "type": "integer", + "nullable": true + }, + "state": { + "type": "string", + "nullable": true, + "description": "Lifecycle state, e.g. NEW or CLOSED." + } + } + }, + "BuildNotificationSummary": { + "type": "object", + "description": "Compact build-summary email/notification card (BuildSummaryEmail DTO). `startedAt` is @JsonIgnore; the payload exposes formatted date/time/duration helpers instead. `accessibility` is present only for APP_ALLY_AUT builds.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "projectNormalisedName": { + "type": "string" + }, + "normalisedName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "displayStatus": { + "type": "string", + "description": "Computed display status derived from status + statusStats." + }, + "duration": { + "type": "integer" + }, + "formattedDuration": { + "type": "string", + "description": "Human-readable duration helper (e.g. '2m 5s')." + }, + "startTime": { + "type": "string" + }, + "startDate": { + "type": "string" + }, + "qualityGateStatus": { + "type": "string" + }, + "totalCount": { + "type": "integer" + }, + "mutedCount": { + "type": "integer" + }, + "statusStats": { + "$schema": "StatusAggregate" + }, + "issueTypeAggregate": { + "$schema": "IssueTypeAggregate" + }, + "historyAggregate": { + "$schema": "HistoryAggregate" + }, + "alerts": { + "$schema": "BuildAlerts__w4_build2" + }, + "buildNumber": { + "type": "integer" + }, + "colorMapping": { + "type": "object", + "description": "Map of status/category -> hex color for rendering the card." + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "accessibility": { + "type": "object", + "description": "APP_ALLY_AUT builds only; omitted (NON_NULL) otherwise. Populated fail-open.", + "properties": { + "totalIssues": { + "type": "integer" + }, + "totalScans": { + "type": "integer" + }, + "a11yScore": { + "type": "number" + }, + "severityBreakdown": { + "type": "object", + "properties": { + "critical": { + "type": "integer" + }, + "serious": { + "type": "integer" + }, + "moderate": { + "type": "integer" + }, + "minor": { + "type": "integer" + } + } + } + } + } + } + }, + "BuildStabilityTile": { + "type": "object", + "description": "Stability tile for a build (BuildStability DTO). `count` on the DTO is @JsonIgnore; the surfaced number lives in `overview.count`.", + "properties": { + "data": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Per-run stability display series." + }, + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Bucket keys aligned to `data`." + }, + "median": { + "type": "number" + }, + "commentCount": { + "type": "integer" + }, + "overview": { + "type": "object", + "description": "Headline verdict for the tile.", + "properties": { + "count": { + "type": "string", + "description": "Current stability as a formatted percentage string (e.g. '92.3')." + }, + "inPercentage": { + "type": "boolean" + }, + "meta": { + "type": "string", + "description": "e.g. 'based on last 30 runs'." + }, + "insights": { + "type": "object", + "description": "Positive/negative/default verdict vs recent builds; only populated with >= 10 comparison runs.", + "properties": { + "type": { + "type": "string", + "values": [ + "positive", + "negative", + "default" + ] + }, + "text": { + "type": "string" + } + } + } + } + } + } + }, + "BuildSummaryStats": { + "type": "object", + "description": "Headline test-count strip for a build (BuildSummaryStats DTO).", + "properties": { + "data": { + "type": "array", + "description": "Colored metric tiles (total/passed/failed/skipped/pending roll-ups).", + "items": { + "$schema": "ColoredMetric" + } + }, + "aiRcaStats": { + "$schema": "AiRcaStats" + }, + "isArchived": { + "type": "boolean" + }, + "commentCount": { + "type": "integer" + }, + "hooksVisibility": { + "type": "string", + "description": "Whether hook rows are shown/hidden for this project." + }, + "manuallyOverriddenCount": { + "type": "integer" + }, + "sessionStats": { + "$schema": "SessionStats" + } + } + }, + "BuildTimelineStats": { + "type": "object", + "description": "Build execution timeline (BuildTimelineAggregate DTO).", + "properties": { + "data": { + "type": "array", + "description": "Three chart series with ids `passed`, `failed` and `pending` (pending = tests still running at that instant). Each point is [x, count] where x is MILLISECONDS since the build's first test started (relative, not epoch time).", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Series key: passed, failed or pending." + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "type": { + "type": "string", + "description": "Chart type, default 'column'." + }, + "data": { + "type": "array", + "description": "Array of [msSinceFirstTestStart, count] pairs.", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + } + } + } + }, + "BuildFailureCategoryStats": { + "type": "object", + "description": "Failure-category (defect) breakdown (DefectStats DTO). On a PENDING build, `data` is empty and `message` explains why (still HTTP 200).", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "DefectStatsData" + } + }, + "aiRcaStats": { + "$schema": "AiRcaStats" + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string", + "description": "Set to a 'available after your build finishes' notice while pending; otherwise typically absent." + } + } + }, + "DefectStatsData": { "type": "object", - "description": "Default time window for the dashboard (a DateRangeFilter with a `key` such as days30, or a custom range)." - }, - { - "name": "projects", - "type": "array", - "description": "Project ids the dashboard spans." - }, - { - "name": "buildName", - "type": "array", - "description": "Build-name filter values (wire key is `buildName`, not `buildNames`)." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names." - }, - { - "name": "tcmTestPlanIds", - "type": "array", - "description": "Test Plan ids (wire key `tcmTestPlanIds`; legacy `testPlanIds` is accepted as an alias)." - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "The widget layout to save. Each item is a Widgets object; see the widget-data capability for the per-widget field shape (id, name, type, globalFilters, desc, config, layout).", - "fields": [ - { - "name": "id", - "type": "string", - "description": "Client widget id, up to 100 chars." - }, - { - "name": "name", - "type": "string", - "description": "Widget title, 1-100 chars." - }, - { - "name": "type", + "description": "One failure-category bucket.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "value": { + "type": "integer", + "description": "Count of failures in this category." + }, + "color": { + "type": "string" + }, + "categories": { + "type": "array", + "description": "Optional nested sub-buckets.", + "items": { + "$schema": "ColoredMetric" + } + } + } + }, + "ColoredMetric": { + "type": "object", + "description": "A single colored metric tile / bucket.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." + }, + "displayName": { + "type": "string", + "description": "Present only when non-null." + }, + "value": { + "type": "integer" + }, + "color": { + "type": "string" + }, + "filterCount": { + "type": "integer" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" + } + } + }, + "BuildFailuresByModule": { + "type": "object", + "description": "Failures grouped by test module / file path (FailureByModuleStats DTO).", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "A module/folder node.", + "properties": { + "id": { + "type": "string", + "description": "Full module/file path." + }, + "name": { + "type": "string" + }, + "value": { + "type": "integer", + "description": "Total tests in the module." + }, + "colorValue": { + "type": "integer" + }, + "failedTests": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "parent": { + "type": "string", + "description": "Parent node id for tree reconstruction." + }, + "customPathSplit": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Path split by the inferred separator." + }, + "separator": { + "type": "string" + }, + "joinSeparator": { + "type": "string" + } + } + } + }, + "commentCount": { + "type": "integer" + }, + "isConflicting": { + "type": "boolean" + }, + "sourceFencingFlags": { + "type": "object", + "description": "Present (NON_NULL) only when source-fencing excludes the build's product; names which products' tests were skipped.", + "properties": { + "automateTestsSkipped": { + "type": "boolean" + }, + "appAutomateTestsSkipped": { + "type": "boolean" + }, + "lcncTestsSkipped": { + "type": "boolean" + } + } + } + } + }, + "BuildFilterValues": { + "type": "object", + "description": "Older compact build-filter shape (FilterValues DTO): a single `filters` map keyed by facet name.", + "properties": { + "filters": { + "type": "object", + "description": "Map keyed by facet (browser, os, issueType, tags, status, folder, history, flaky). Each value is a list of options. `history` and `flaky` are static option lists always present.", + "properties": { + "browser": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "os": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "issueType": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "tags": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "status": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "folder": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "history": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + }, + "flaky": { + "type": "array", + "items": { + "$schema": "FilterStruct__w4_build2" + } + } + } + } + } + }, + "BuildTestFolders": { + "type": "object", + "description": "Build test-listing folder tree. Two shapes depending on folder_path: root-folders (RootFoldersResponse) or folder-detail (FolderDetailsResponse). Both are @JsonInclude(NON_NULL). Fields from both are shown; only one branch's fields are populated per call.", + "properties": { + "project_id": { + "type": "integer" + }, + "folders": { + "type": "array", + "description": "ROOT branch only: the root folders.", + "items": { + "$schema": "SubFolderDetails" + } + }, + "name": { + "type": "string", + "description": "DETAIL branch only: the folder's name (last path segment)." + }, + "folder_path": { + "type": "string", + "description": "DETAIL branch only: full folder path." + }, + "cases_count": { + "type": "integer", + "description": "DETAIL branch: unique cases directly in this folder." + }, + "total_cases_count": { + "type": "integer", + "description": "DETAIL branch: unique cases including nested subfolders." + }, + "sub_folders_count": { + "type": "integer", + "description": "DETAIL branch: immediate subfolder count." + }, + "sub_folders": { + "type": "array", + "description": "DETAIL branch only: immediate subfolders.", + "items": { + "$schema": "SubFolderDetails" + } + }, + "pagingParams": { + "$schema": "PagingParams" + }, + "after_key": { + "type": "string", + "description": "ROOT branch composite-agg cursor field; observed returned as null \u2014 page via pagingParams instead." + } + } + }, + "SubFolderDetails": { + "type": "object", + "description": "One folder node in the build's test-listing tree (NON_NULL).", + "properties": { + "name": { + "type": "string" + }, + "folder_path": { + "type": "string" + }, + "cases_count": { + "type": "integer" + }, + "total_cases_count": { + "type": "integer" + }, + "sub_folders_count": { + "type": "integer" + } + } + }, + "BuildNonSdkTestCounts": { "type": "string", - "description": "Widget type (WidgetTypeEnum)." - }, - { - "name": "globalFilters", + "description": "IMPORTANT: the body is a JSON *string* (Gson JsonObject.toString()), not a JSON object. After parsing the string it is `{untitled_test_count:int, unmarked_test_count:int, missing_failure_reason_count:int}`, counts spanning the parent build and its split child builds." + }, + "StatusAggregate": { + "type": "object", + "description": "Test-status roll-up (NON_NULL). Keys are lower-case status names.", + "properties": { + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer", + "description": "Running/pending count (DTO field runningCount)." + }, + "skipped": { + "type": "integer" + }, + "unknown": { + "type": "integer" + }, + "aggregate": { + "type": "integer", + "nullable": true, + "description": "Aggregate (total) count across statuses." + } + } + }, + "IssueTypeAggregate": { + "type": "object", + "description": "Issue-type roll-up (NON_NULL). Keys are the human-readable issue-type labels, not camelCase.", + "properties": { + "To be Investigated": { + "type": "integer" + }, + "Automation Bug": { + "type": "integer" + }, + "Product Bug": { + "type": "integer" + }, + "No Defect": { + "type": "integer" + }, + "Environment Issue": { + "type": "integer" + } + } + }, + "HistoryAggregate": { + "type": "object", + "description": "Smart-tag history roll-up (NON_NULL).", + "properties": { + "isFlaky": { + "type": "integer" + }, + "isAlwaysFailing": { + "type": "integer" + }, + "isNewFailure": { + "type": "integer" + }, + "isPerformanceAnomaly": { + "type": "integer" + } + } + }, + "SessionStats": { + "type": "object", + "description": "Session-level counters (SessionStats DTO).", + "properties": { + "totalSessions": { + "type": "integer" + }, + "passed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "error": { + "type": "integer" + }, + "unmarked": { + "type": "integer" + }, + "idletimeout": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "done": { + "type": "integer" + }, + "timeout": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + } + }, + "AiRcaStats": { + "type": "object", + "description": "AI RCA availability counter.", + "properties": { + "completedCount": { + "type": "integer", + "description": "How many failures already have AI RCA completed." + } + } + }, + "FilterStruct__w4_build2": { + "type": "object", + "description": "A single filter dropdown option. Folder labels embed the count, e.g. 'auth.test.js(3)'.", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true + }, + "pId": { + "type": "string", + "description": "Serialised from the DTO's `pid` field via @JsonProperty(\"pId\").", + "nullable": true + }, + "id": { + "type": "string", + "description": "Option id (same as value)." + } + } + }, + "SingleMetricSummary__w4_build2": { + "type": "object", + "description": "Shared single-metric tile (SingleMetricSummary DTO) used by the flaky / muted / new-failure tiles.", + "properties": { + "count": { + "type": "integer", + "description": "The metric count (flaky / muted / new-failure depending on endpoint)." + }, + "meta": { + "type": "string", + "description": "Context string, typically the 'out of N total' text." + }, + "commentCount": { + "type": "integer" + }, + "autoUnmuteCount": { + "type": "integer", + "description": "Most meaningful for the muted tile; how many are scheduled to auto-unmute." + } + } + }, + "BuildAlerts__w4_build2": { + "type": "object", + "description": "Build alerts panel (BuildAlerts DTO).", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Individual alert entries." + }, + "alertsConfigured": { + "type": "boolean" + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "ExternalBuildDetails": { "type": "object", - "description": "Per-widget filter overrides (DashboardFilters shape)." - }, - { - "name": "config", + "description": "Single external build-details object returned by get_latest_build (BuildDetails / BuildDetailsV2 by the new-build-listing flag). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are plan-gated by the external JsonView wrapper.", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "id": { + "type": "integer", + "description": "Integer build id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "status_stats": { + "type": "object", + "description": "Per-status counts (V2: workflow-status counts), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 shape only." + }, + "failure_categories": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "smart_tags": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "unique_errors": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "alerts": { + "type": "array", + "description": "Pro-only (JsonView)." + }, + "re_run": { + "type": "boolean" + }, + "build_error": { + "type": "object" + }, + "is_archived": { + "type": "boolean" + }, + "observability_version": { + "type": "object" + }, + "vcs_info": { + "type": "object" + }, + "ci_info": { + "type": "object" + }, + "host_info": { + "type": "object" + }, + "run_information": { + "type": "array" + }, + "observability_url": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "app_details": { + "type": "object" + } + } + }, + "ExternalProjectBuildListing": { "type": "object", - "description": "Widget configuration (display type, segments, filters, etc.)." - }, - { - "name": "layout", + "description": "Public-API build listing (BuildListingResponse / BuildListingResponseV2, selected by the new-build-listing group flag). Keys are snake_case except tcmTestRunIdentifier; the two versions differ only in what status_stats holds and V2's is_manually_overridden.", + "properties": { + "id": { + "type": "integer", + "description": "Project id." + }, + "name": { + "type": "string", + "description": "Project name." + }, + "builds": { + "type": "array", + "description": "The fetched page, reversed before returning (Lists.reverse), so oldest-first within a page.", + "items": { + "type": "object", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid (40 chars); use it for per-build calls. There is no `uuid` or integer `id` key (id is @JsonIgnore)." + }, + "name": { + "type": "string" + }, + "original_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "build_number": { + "type": "integer" + }, + "is_archived": { + "type": "boolean" + }, + "observability_url": { + "type": "string", + "description": "Deep link to the build in the TRA UI." + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "status_stats": { + "type": "object", + "description": "Per-status counts. Same key on both shapes: v1 StatusAggregate, V2 WorkflowStatusAggregate." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 items only." + } + } + } + }, + "pagination": { + "type": "object", + "description": "snake_case keys (models/external/Pagination).", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." + } + } + } + } + }, + "ProjectBuildListing": { "type": "object", - "description": "Grid position/size of the widget." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The created dashboard is returned under `data`, with the assigned UUID at `data.id` \u2014 take that for subsequent get/update/delete/clone.", - "Name must be unique in the project; a clash is rejected rather than silently de-duplicated.", - "`level` defaults to 2 (group/shared); set it explicitly if you need a private dashboard.", - "`source` (TO vs TCM) decides which dashboard surface the view belongs to and is not part of the body \u2014 pass it as a query param (defaults to TO)." - ] - }, - { - "name": "get_dashboard_view", - "method": "GET", - "path": "/api/v1/dashboards/views/{id}", - "mode": "read", - "entity": "dashboard_view", - "intent": "Load one dashboard view for rendering \u2014 its name, global filters and full widget configuration, with filter values injected for the built-in dashboards.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "A dashboard UUID, OR one of the read-only aliases `default` / `automation-trend`. `default` resolves to the TCM view when source=TCM and to the TO 'testing trends' view when source=TO; `automation-trend` resolves to the automation-trend view. Custom (non-built-in) UUIDs are scoped to the caller's user/group and read as not-found if they belong to someone else." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "TO or TCM. Also disambiguates which view the `default` alias resolves to." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide project resolver)." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "SIDE EFFECT: reading a dashboard pushes it onto the caller's Redis 'recent views' list (capped at the most recent few), which is what the home recent-views listing returns. This write happens for ordinary users and is skipped only for super-users (except super-users in group 2). There is no read that avoids it.", - "For the built-in default dashboard the response is doctored: its date range is forced to the last 30 days regardless of what was stored, and widget filter values are injected \u2014 so what you read back is not necessarily what was persisted.", - "The aliases `default` and `automation-trend` work ONLY here (and other reads). Update, delete and clone-source require the raw UUID and reject the aliases.", - "The dashboard is under `data`; `data.isDefault` / `data.isAutomation` flag the built-in views, and `data.totalWidget` is the widget count." - ] - }, - { - "name": "update_dashboard_view", - "method": "PUT", - "path": "/api/v1/dashboards/views/{id}", - "mode": "write", - "entity": "dashboard_view", - "intent": "Save changes to an existing dashboard view \u2014 rename it, change its visibility level, change its global filters, or persist an edited widget layout.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "The dashboard UUID (raw UUID only \u2014 the `default` / `automation-trend` aliases are NOT accepted on this route). The update is additionally scoped to the caller's user/group." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_UPDATE permission check." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "New name, 1-100 characters. Must stay unique within the project." - }, - { - "name": "level", - "type": "integer", - "description": "New visibility level." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "New description (capped at 100 characters)." - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters (DashboardFilters shape \u2014 same as create)." - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget layout (array of Widgets objects \u2014 same shape as create)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "NO UNDO: dashboards keep no version history, so an overwritten name, filter set or widget layout is gone for good \u2014 read the current dashboard first if you need to preserve anything.", - "The three built-in dashboards (default TCM, default TO/testing-trends, automation-trend) cannot be updated \u2014 the call returns 400 'Cannot update default dashboard'.", - "The body replaces the fields it carries; send the full intended state (e.g. the complete widgets array) rather than a partial patch.", - "The path takes a raw UUID only. Unlike the read, `default` / `automation-trend` are not accepted here." - ] - }, - { - "name": "clone_dashboard_view", - "method": "POST", - "path": "/api/v1/dashboards/views/{id}/clone", - "mode": "write", - "entity": "dashboard_view", - "intent": "Duplicate an existing dashboard \u2014 copy its filters, widgets, source and type into a new dashboard owned by the caller, under a new name.", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "UUID of the SOURCE dashboard to copy (raw UUID only \u2014 aliases not accepted). The source is read with the caller's permission, so you can only clone a dashboard you can see." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED: it resolves the project and drives the DASHBOARD_CREATE permission check." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": false, - "minLength": 1, - "maxLength": 100, - "description": "Name for the NEW cloned dashboard, 1-100 chars, unique within the project. This is the only field of the body that is used \u2014 level, filters and widgets are copied verbatim from the source, not taken from the body." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "DashboardViewResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only `name` from the body matters. Any level/filters/widgets you send are ignored \u2014 the clone copies the source dashboard's filters, widgets, source and type verbatim, so edit the copy afterwards to change them.", - "The new name must be unique in the project; a duplicate is rejected.", - "The clone is owned by the calling user; the new dashboard (with its own UUID at `data.id`) is returned synchronously in `data`.", - "The source id must be a raw UUID \u2014 the `default` / `automation-trend` aliases are not accepted here even though the read accepts them." - ] - }, - { - "name": "list_dashboard_view_widget_types", - "method": "GET", - "path": "/api/v1/dashboards/views/widgets", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the catalogue of widget types that can be added to a dashboard view (the 'add widget' palette) with their display names.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED by the controller-wide resolver even though this static catalogue does not use it." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "name", - "key" - ], - "responses": { - "200": { - "description": "Static catalogue of addable widget types, returned as a BARE JSON ARRAY (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "WidgetCatalogueEntry" - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a bare array `[{name, key}]`, not wrapped in `data` or `success`.", - "This is static, hard-coded data (Test Case Statistics, Test Case Type, Test Case Trend) \u2014 it makes no DB or downstream call and does not vary by project, despite the mandatory project query param.", - "`key` is the WidgetTypeEnum value to send as a widget's `type` when building a dashboard layout or requesting widget data." - ] - }, - { - "name": "list_dashboard_view_custom_field_filters", - "method": "GET", - "path": "/api/v1/dashboards/views/filters/{widgetType}", - "mode": "read", - "entity": "dashboard_view", - "intent": "List the project's custom (user-defined) test-case fields that can be used as filters when configuring a test-case dashboard widget.", - "path_params": [ - { - "name": "widgetType", - "type": "string", - "required": true, - "values": [ - "TEST_CASE_TREND", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE" - ], - "description": "A WidgetTypeEnum value. Only TEST_CASE_TREND, TEST_CASE_STATISTICS_CARD and TEST_CASE_TYPE are valid here; any other widget type returns 400 'Custom Filters are not available for widget type: X'." - } - ], - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED (controller-wide resolver); the custom fields returned are this project's." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "returns": [ - "id", - "label", - "type", - "fieldType" - ], - "responses": { - "200": { - "description": "Custom-field filters for the project, as a BARE JSON ARRAY (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "CustomFieldFilterDetail" - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only the three test-case widget types are accepted; anything else is a 400 naming the widget type, not an empty list.", - "The response is a bare array `[{id, label, type, fieldType}]` \u2014 each entry is a custom field usable as a widget filter (type is 'Custom').", - "Distinct from the group-level `/api/v1/dashboards/filters/{widgetType}` route; this one is scoped to a single dashboard-view project." - ] - }, - { - "name": "list_dashboard_widget_filter_values", - "method": "POST", - "path": "/api/v1/dashboards/views/widgets/filters", - "mode": "read", - "entity": "dashboard_view", - "intent": "Fetch the selectable values for one filter inside a dashboard widget's filter editor (the type-ahead / value picker) \u2014 e.g. the build names, tags, users, teams or test-case field values available for a project, given the currently applied filters.", - "query": [ - { - "name": "page", - "type": "integer", - "required": false, - "default": 1, - "description": "Page number for the value list. Note the param is `page` (not `p`)." - }, - { - "name": "per_page", - "type": "integer", - "required": false, - "default": 30, - "description": "Values per page (default 30). No server-side upper bound was found in the resolved code \u2014 see guidance." - }, - { - "name": "search", - "type": "string", - "required": false, - "description": "Type-ahead substring to narrow the returned values." - }, - { - "name": "source", - "type": "string", - "required": false, - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "TO or TCM. With source=TCM the request is forced to includeManualAndAutomated=true." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED. The resolved project OVERWRITES whatever `filters.projects` is in the body." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "widgetKey", - "type": "string", - "required": true, - "description": "REQUIRED (@NotNull). The widget type (WidgetTypeEnum) the filter belongs to. Note the wire key is `widgetKey`, not `widget` or `type`. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "json_path": "/widgetKey", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - }, - { - "name": "filterKey", - "type": "string", - "required": true, - "values": [ - "buildStatus", - "buildName", - "users", - "teams", - "frameworks", - "folders", - "buildTags", - "hostName", - "os", - "browser", - "device", - "tags", - "branches", - "projects", - "testCaseRequirements", - "testCasePriorities", - "testCaseStatuses", - "testCaseTypes", - "testCaseTags", - "testCaseOwners", - "testCaseAutomationStatuses", - "testCaseFolders", - "buildTypes", - "buildOwners", - "workflowStatus", - "custom", - "customFields", - "issueType", - "testCaseAutomationStates" - ], - "description": "REQUIRED (@NotNull). Which filter's values to fetch. This is the field that selects the value set (the server switches on filterKey, not on widgetKey). filterKey=buildName additionally turns on selective project filtering.", - "json_path": "/filterKey" - }, - { - "name": "filters", - "type": "object", - "description": "The currently applied filters (WidgetFiltersValue) used to scope the value list. Optional \u2014 an omitted/null value is treated as empty. Its `projects` is always overwritten by the project resolved from the query params, so setting it in the body is pointless.", - "json_path": "/filters" - }, - { - "name": "filterId", - "type": "integer", - "required": false, - "description": "Optional custom-field id, used when filterKey targets a specific custom field.", - "json_path": "/filterId" - }, - { - "name": "applyDateRange", - "type": "boolean", - "required": false, - "default": false, - "description": "Opt in to bounding the value list by `filters.dateRange`. Defaults to false, i.e. the full value list is returned regardless of date range.", - "json_path": "/applyDateRange" - } - ], - "returns": [ - "data", - "fullList", - "info", - "suggestedAfterKey", - "othersAfterKey" - ], - "responses": { - "200": { - "$response": "WidgetFilterValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but READ-ONLY (@ReadOnly) \u2014 it fetches filter values and changes nothing.", - "The value set is chosen by `filterKey`, and `widgetKey` must also be present (both are @NotNull); the two are separate fields and the server dispatches on filterKey.", - "Pagination params are `page` and `per_page` (default 30), NOT `p`/`count`. `data` is a map keyed by bucket (`suggested`, `others`), each a list of value structs - the same shape as search_widget_filter_values. `fullList` and `info` can be null; continue with suggestedAfterKey / othersAfterKey.", - "`filters.projects` is always replaced by the project from the query params, so passing project ids in the body is ignored \u2014 set the project via project_id/project_name/project_identifier.", - "A public-link token may be supplied via the `Public-Link-Token` (or the observability public-link-token) header to run in shared read-only dashboard mode.", - "With source=TCM the request is forced to include both manual and automated runs." - ] - }, - { - "name": "check_dashboard_widget_ignored_filters", - "method": "POST", - "path": "/api/v1/dashboards/views/widgets/filters/ignored", - "mode": "read", - "entity": "dashboard_view", - "intent": "Validate a widget's saved test-case filter values against the current project and report which are no longer valid \u2014 so stale filter chips (a deleted tag, priority, status, type or linked requirement) can be flagged or dropped.", - "query": [ - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED; the saved filter values are validated against this project." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - } - ], - "body": [ - { - "name": "testCaseRequirements", - "type": "object", - "description": "Linked-requirement filter to validate. Only `issueIds` (array) and `issueType` are read.", - "fields": [ - { - "name": "issueIds", - "type": "array", - "description": "Requirement/issue ids to validate." - }, - { - "name": "issueType", - "type": "string", - "description": "Requirement issue type." - } - ] - }, - { - "name": "testCasePriorities", - "type": "array", - "description": "Saved test-case priority ids to validate." - }, - { - "name": "testCaseTags", - "type": "array", - "description": "Saved test-case tag values to validate." - }, - { - "name": "testCaseStatuses", - "type": "array", - "description": "Saved test-case status ids to validate." - }, - { - "name": "testCaseTypes", - "type": "array", - "description": "Saved test-case type ids to validate." - } - ], - "returns": [ - "filterMatches", - "ignoredFiltersCount", - "isMismatched" - ], - "responses": { - "200": { - "$response": "IgnoredFilterValuesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only \u2014 a pass-through to TestHub that computes validity and stores nothing.", - "The body is a full WidgetFiltersValue object, but ONLY five fields are actually read: testCaseRequirements (issueIds + issueType), testCasePriorities, testCaseTags, testCaseStatuses and testCaseTypes. Every other filter field you send is silently ignored here.", - "The response reports, per filter type, how many values still match vs are ignored (`filterMatches[]` with matchedValuesCount / ignoredValuesCount / isMismatched), a total `ignoredFiltersCount`, and an overall `isMismatched` flag \u2014 use these to flag or drop stale chips." - ] - }, - { - "name": "get_dashboard_view_widget_data", - "method": "POST", - "path": "/api/v1/dashboards/views/{dashboardId}/widgets/{widgetType}", - "mode": "read", - "entity": "dashboard_view", - "intent": "Compute the data for one widget on a project-scoped dashboard view \u2014 applying the caller's global filters and time window \u2014 to render that widget (charts, stat cards, trends, etc.).", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "The dashboard UUID. Present in the path for routing but NOT read by the handler \u2014 the widget is computed purely from the body and query params." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "A WidgetTypeEnum value identifying which widget to compute. FLAKINESS, ALWAYS_FAILING and NEW_FAILURES are plan-gated and return FEATURE_INACCESSIBLE if the group lacks the feature. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "required": true, - "values": [ - "TO", - "TCM" - ], - "description": "REQUIRED on this route (unlike the controller default): omitting it 400s with '`source` is required'. With source=TCM, TCM build widgets are forced to include both manual and automated runs." - }, - { - "name": "project_id", - "type": "integer", - "required": false, - "description": "One of project_id / project_name / project_identifier is REQUIRED on this route. The resolved project id is injected into the widget's filters automatically (the TestHub project id for ClickHouse-powered widgets, the observability project id otherwise) \u2014 do not pass project ids in the body." - }, - { - "name": "project_name", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "project_identifier", - "type": "string", - "required": false, - "description": "Alternative project selector. Supply exactly one of the three." - }, - { - "name": "isThProject", - "type": "boolean", - "required": false, - "default": false, - "description": "Treat the supplied project identifier as a TestHub project id." - }, - { - "name": "lowerBound", - "type": "integer", - "required": false, - "description": "Start of the time window, epoch milliseconds. Optional \u2014 when omitted the window is derived from the body's dateRange." - }, - { - "name": "upperBound", - "type": "integer", - "required": false, - "description": "End of the time window, epoch milliseconds. Optional \u2014 derived from the body's dateRange when omitted." - }, - { - "name": "pageNumber", - "type": "integer", - "required": false, - "description": "Optional page number for paginated widget data; copied into the widget's global filters." - } - ], - "body": [ - { - "name": "type", - "type": "string", - "description": "Widget type (WidgetTypeEnum). Describes the widget being computed." - }, - { - "name": "globalFilters", - "type": "object", - "description": "The applied dashboard filters (DashboardFilters): dateRange (drives the time window unless lowerBound/upperBound are given), tags, buildName, folders, tcmTestPlanIds, match, etc. Its `projects` is overwritten server-side with the resolved project.", - "json_path": "/globalFilters", - "fields": [ - { - "name": "dateRange", + "description": "Internal build-listing payload (ProjectDto) returned by list_project_builds. builds[] are full BuildSummary objects.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "builds": { + "type": "array", + "description": "Rich BuildSummary rows (uuid, id, name, status, statusStats, duration, session/run counts, tags, qualityGateStatus, isArchived, displayStatus, parallelsUsed, tcmTestRunIdentifier, ...).", + "items": { + "type": "object" + } + }, + "pagingParams": { + "type": "object", + "description": "Cursor paging state (hasNext + searchAfter) for the next page." + }, + "activeCount": { + "type": "integer" + }, + "closedCount": { + "type": "integer" + }, + "manualBuildCount": { + "type": "integer" + }, + "automationBuildCount": { + "type": "integer" + }, + "isArchiving": { + "type": "boolean" + }, + "isClosing": { + "type": "boolean" + } + } + }, + "RunningBuildsOnDevice": { "type": "object", - "description": "Time window used when lowerBound/upperBound query params are absent." - } - ] - }, - { - "name": "id", - "type": "string", - "description": "Widget id (up to 100 chars)." - }, - { - "name": "name", - "type": "string", - "description": "Widget name (1-100 chars)." - }, - { - "name": "desc", - "type": "string", - "description": "Widget description (up to 200 chars)." - }, - { - "name": "config", - "type": "object", - "description": "Widget configuration (display type, segments, per-widget filters, stats options, etc.) that shapes the computed data." - }, - { - "name": "layout", - "type": "object", - "description": "Grid layout of the widget (not needed to compute data)." - } - ], - "returns": [ - "(widget-type-specific)" - ], - "responses": { - "200": { - "$response": "WidgetDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but READ-ONLY (@ReadOnly, routed to a read replica) \u2014 it computes and returns widget data and persists nothing.", - "The response SHAPE depends entirely on `widgetType` (the handler returns an untyped object built per widget) \u2014 inspect it per widget type rather than assuming a fixed schema.", - "Unlike the other dashboard routes, BOTH `source` and a project identifier are mandatory here; omitting either 400s from the request binders.", - "The path `dashboardId` is not read \u2014 the widget is defined by the request body and `widgetType`. Do not rely on it selecting a saved widget.", - "Do not put project ids in `globalFilters.projects`; the resolved project (TestHub id for ClickHouse-powered widgets, observability id otherwise) is injected automatically and overrides the body.", - "FLAKINESS, ALWAYS_FAILING and NEW_FAILURES widgets are plan-gated \u2014 a group without the feature gets FEATURE_INACCESSIBLE, not empty data." - ] - }, - { - "name": "create_report", - "method": "POST", - "path": "/api/v1/reports", - "mode": "write", - "entity": "report", - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Report source. Defaults to TO (the end-user builder). TCM is the Test-Management-mirror source and is normally set by the sync path, not here." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Report name. Must be unique for the report's (type, level) combination or the create is rejected." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility: 1 = private, 2 = group. Omit and the report is created with no level set." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100, - "description": "Report type. Decides which widget list is persisted: a report-type value (in the backend's REPORT_TYPES set) persists reports[]; otherwise widgets[] is persisted. Defaults to 'Dashboard' when omitted, which would persist widgets[] instead." - }, - { - "name": "desc", - "type": "string", - "maxLength": 100, - "description": "Description. Max length is 100 characters (the validation message misstates it as 200)." - }, - { - "name": "filters", - "type": "object", - "description": "Global filters saved on the report.", - "fields": [ - { - "name": "dateRange", + "description": "Builds currently queued/running on a device+OS combination (PQRunningBuildsResponse). Empty data list when nothing matches.", + "properties": { + "data": { + "type": "array", + "description": "BuildSummary rows for pending builds on the combination; each carries parallelsUsed (unique parallel sessions consumed).", + "items": { + "type": "object", + "properties": { + "uuid": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "projectId": { + "type": "integer" + }, + "parallelsUsed": { + "type": "integer", + "description": "Number of unique parallel sessions this build is consuming on the device+OS combination." + } + } + } + } + } + }, + "BuildInsights__w4_build4": { "type": "object", - "description": "Date-range filter." - }, - { - "name": "projects", - "type": "array", - "description": "Observability project ids (List)." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build tag names." - }, - { - "name": "tags", - "type": "array", - "description": "Test-execution tag names; AND/OR controlled via match['tags']." - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "buildNames", - "type": "array", - "description": "Wire key is buildName (singular).", - "json_path": "/filters/buildName" - }, - { - "name": "branchList", - "type": "array", - "description": "Wire key is branches.", - "json_path": "/filters/branches" - }, - { - "name": "testPlanIds", - "type": "array", - "description": "Wire key is tcmTestPlanIds; the legacy alias testPlanIds is also accepted for old persisted blobs.", - "json_path": "/filters/tcmTestPlanIds" - }, - { - "name": "order", - "type": "array", - "description": "Filter display order. Wire key is filterOrder.", - "json_path": "/filters/filterOrder" - }, - { - "name": "match", + "description": "Agent-oriented build failure summary returned by get_build_insights (BuildInsightsResponse).", + "properties": { + "buildId": { + "type": "string" + }, + "buildName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer" + }, + "summary": { + "type": "object", + "properties": { + "statusMetrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "testsCount": { + "type": "integer" + }, + "hooksCount": { + "type": "integer" + } + } + } + }, + "totalTests": { + "type": "integer" + }, + "totalHooks": { + "type": "integer" + } + } + }, + "failureCategorization": { + "type": "object", + "properties": { + "newFailures": { + "type": "integer" + }, + "alwaysFailing": { + "type": "integer" + }, + "flaky": { + "type": "integer" + } + } + }, + "failureContext": { + "type": "object", + "description": "ALWAYS null on this build-keyed route (no test run supplied). Present only on the test-run variant: { file:{path,testCount,failedCount,failureRate}, parentModule:{...} }.", + "nullable": true + }, + "topErrorsAnalysis": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty until COMPLETE." + }, + "uniqueErrorCount": { + "type": "integer" + }, + "uniqueErrors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clusterId": { + "type": "string" + }, + "occurrences": { + "type": "integer" + }, + "truncatedError": { + "type": "string", + "description": "First ~200 chars of the error only." + } + } + } + } + } + }, + "defectStats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "issueType": { + "type": "string" + }, + "totalFailureTestsCount": { + "type": "integer" + }, + "totalFailureHooksCount": { + "type": "integer" + }, + "categories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "failureTestCount": { + "type": "integer" + }, + "failureHooksCount": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "ProjectWithAggregates": { "type": "object", - "description": "Per-filter AND/OR match map." - } - ] - }, - { - "name": "reports", - "type": "array", - "description": "The report widgets, persisted when type is a report-type value. Each item is a widget (see 'widgets' for its shape).", - "fields": [ - { - "name": "id", - "type": "string", - "maxLength": 100 - }, - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - { - "name": "type", - "type": "string", - "description": "WidgetTypeEnum." - }, - { - "name": "reportType", - "type": "string", - "description": "e.g. TABULAR_TEST_RUNS." - }, - { - "name": "dashboardFilters", + "description": "A project enriched with Elasticsearch-derived build activity aggregates, as returned by the aggregate project-list routes.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "integer" + }, + "groupId": { + "type": "integer" + }, + "normalisedName": { + "type": "string" + }, + "buildCount": { + "type": "integer", + "description": "Distinct build count for the project in the caller's product context." + }, + "projectStability": { + "type": "number", + "description": "Aggregate stability score for the project." + }, + "stabilityChartData": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Series of stability values for a mini chart." + }, + "lastRanAt": { + "type": "string", + "format": "date-time", + "description": "When the project's most recent build finished." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastRanBy": { + "type": "integer", + "description": "User id who triggered the most recent build." + }, + "thProjectId": { + "type": "integer" + } + } + }, + "SuiteHealthTestListItem": { "type": "object", - "description": "Widget-level global filters. Wire key is globalFilters.", - "json_path": "/reports/globalFilters" - }, - { - "name": "config", + "description": "One test row in the Stability & Performance / Suite/Test Health listing (com.browserstack.observability.models.response.SuiteHealth.TestStabilityAndPerformance).", + "properties": { + "id": { + "type": "string", + "description": "The test's stabilityHash \u2014 the identifier to carry into the detail endpoints (serialized from testHash via @JsonProperty(\"id\"))." + }, + "title": { + "type": "string", + "description": "Test name." + }, + "filePath": { + "type": "string" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "isPlatformError": { + "type": "boolean", + "description": "True when the sampled run's error was a platform (infra) error." + }, + "reliability": { + "type": "number", + "description": "Failure percentage (failed*100/total). HIGHER = less stable." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "totalFailures": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "browserList": { + "type": "array", + "description": "Browser/device combinations the test ran on.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "string" + } + } + } + }, + "platformList": { + "type": "array", + "description": "OS/platform combinations the test ran on.", + "items": { + "type": "object" + } + }, + "browsers": { + "type": "array", + "description": "Always EMPTY on this listing path (populated only when the internal browserString is set, which this path never sets). Read browserList instead.", + "items": { + "type": "object" + } + }, + "platforms": { + "type": "array", + "description": "Always EMPTY on this listing path. Read platformList instead.", + "items": { + "type": "object" + } + } + } + }, + "SuiteHealthPlatformBreakdownItem": { "type": "object", - "description": "WidgetsConfig \u2014 datasets, segments, display type, columns, etc." - }, - { - "name": "layout", - "type": "object" - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "Dashboard-style widgets, persisted only when type is NOT a report-type value. Send reports[] for a report-typed record; send the wrong list for the type and the record saves empty. Same item shape as reports[].", - "fields": [ - { - "name": "id", - "type": "string" - }, - { - "name": "name", - "type": "string" - }, - { - "name": "type", - "type": "string" - }, - { - "name": "dashboardFilters", + "description": "One OS/browser/device row of a test's platform breakdown (TestStabilityAndPerformanceBreakdown).", + "properties": { + "id": { + "type": "string", + "description": "Composite \"-\" key (lower-cased)." + }, + "reliability": { + "type": "number", + "description": "Failure percentage for this combination (higher = worse)." + }, + "duration": { + "type": "number", + "description": "Average run duration for this combination." + }, + "totalFailures": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "os": { + "type": "object", + "description": "Platform display object {name, version}." + }, + "browser": { + "type": "object", + "description": "Browser display object with device as a plain string.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "string" + } + } + }, + "browserDevice": { + "type": "object", + "description": "Browser display object with device as a nested object.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "object" + } + } + } + } + }, + "SuiteHealthTrendPoint": { "type": "object", - "json_path": "/widgets/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - } - ], - "intent": "Create a new saved report \u2014 its name, visibility level, global filters and the list of report widgets \u2014 and get back the persisted record.", - "returns": [ - "data" - ], - "guidance": [ - "Name must be unique for the given (type, level); a clash is rejected.", - "Whether the saved widgets come from reports[] or widgets[] depends on type: a report-type value persists reports[], anything else (including the default 'Dashboard') persists widgets[] \u2014 send the wrong list for the type and the report saves with no widgets.", - "Widget-level global filters ride under the key globalFilters (not dashboardFilters); the report-level filter list uses filterOrder / buildName / branches / tcmTestPlanIds on the wire (not their Java names).", - "source defaults to TO; TCM is the Test-Management-mirror source and normally set by the sync path, not here.", - "The new record is stamped with your user, group and team and returned under data with a fresh UUID id.", - "The response echoes the saved record, but `desc` is not echoed back, `widgets` is null for a report-typed record (not an empty array), and filters come back normalised to their wire keys (users, lastExecutedBy, teams, ... are added as empty/null)." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_saved_reports", - "method": "GET", - "path": "/api/v1/reports", - "mode": "read", - "entity": "report", - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Which report source to list. TO = end-user reports (default); TCM = reports mirrored from Test Management." - } - ], - "intent": "List the reports visible to you \u2014 your own plus group-level public ones \u2014 for the Reports landing page, with each report's name, level, type, filters and widget config.", - "returns": [ - "data" - ], - "guidance": [ - "Requires the custom-dashboard plan feature; without it the call is refused (feature inaccessible) rather than returning an empty list.", - "This is the report-typed view of the shared report/dashboard store, so dashboard-only filter rewriting is skipped.", - "Returns a flat list under data with NO pagination \u2014 there is no page / per_page on this listing.", - "The report listing is the source of the UUIDs the single-report read, update, delete, clone, dataset and tiles calls need.", - "Rows are summaries: `reports`, `widgets` and `filters` come back null even when totalWidget > 0, and source may be null. Read the report by id (get_report) for its widgets and filters." - ], - "responses": { - "200": { - "$response": "ReportRecordListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report", - "method": "GET", - "path": "/api/v1/reports/{id}", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The report UUID." - } - ], - "intent": "Load one saved report's full definition \u2014 name, level, filters and widget/report configuration \u2014 to render or edit it.", - "returns": [ - "data" - ], - "guidance": [ - "Scoped to your own group; a report id belonging to another group reads as not found (DASHBOARD_NOT_FOUND).", - "The dashboard-only filter rewrites and the automation-state widget transform are deliberately skipped for reports, so you read back the raw saved definition.", - "The report sits under data; its widgets are under data.reports for a report-typed record (data.widgets for a dashboard-typed one)." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "update_report", - "method": "PUT", - "path": "/api/v1/reports/{id}", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The report UUID." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "New name; re-checked for (type, level) uniqueness when it changes. Omit to keep the current name." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility 1 = private, 2 = group." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100 - }, - { - "name": "desc", - "type": "string", - "maxLength": 100 - }, - { - "name": "filters", - "type": "object", - "description": "Replacement global filters. Same shape/wire-key quirks as create (globalFilters, filterOrder, buildName, branches, tcmTestPlanIds). Omit to keep existing filters." - }, - { - "name": "reports", - "type": "array", - "description": "Replacement widget set for a report-typed record. Omit to keep the existing widgets. Item shape as in create.", - "fields": [ - { - "name": "dashboardFilters", + "description": "One execution point in the Suite Health trend series (TrendDataPoint, @JsonInclude NON_NULL \u2014 null fields are omitted).", + "properties": { + "color": { + "type": "string", + "description": "Status colour for the run." + }, + "workflowStatus": { + "type": "string", + "description": "Workflow status (workflowStatusV2), or \"Unknown\"." + }, + "y": { + "type": "integer", + "description": "Run duration (ms); 0 when absent." + }, + "id": { + "type": "integer", + "description": "Test-run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id (display build number)." + }, + "buildName": { + "type": "string" + }, + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewlyFailed": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + }, + "flakyReason": { + "type": "integer", + "description": "Flaky-status code; 0 when absent." + }, + "creationType": { + "type": "string", + "description": "Upper-cased creation type (defaults to AUTOMATION)." + } + } + }, + "SuiteHealthTestRun": { "type": "object", - "json_path": "/reports/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - }, - { - "name": "widgets", - "type": "array", - "description": "Replacement widget set for a dashboard-typed record. Omit to keep the existing widgets.", - "fields": [ - { - "name": "dashboardFilters", + "description": "One test execution in the Suite Health run-history table (TestStabilityAndPerformanceDetails). Smart-tag flags are under historyAggregate, not top-level.", + "properties": { + "id": { + "type": "integer", + "description": "Test-run id." + }, + "name": { + "type": "string", + "description": "Composed display name (build name + re-run/manual suffix + #serialId)." + }, + "originalName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "workflowStatusEditedBy": { + "type": "string" + }, + "state": { + "type": "string" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "finishedAt": { + "type": "string", + "format": "date-time" + }, + "duration": { + "type": "integer", + "description": "Run duration (ms)." + }, + "buildNumber": { + "type": "integer" + }, + "normalisedName": { + "type": "string", + "description": "URL-encoded build name." + }, + "flakyReason": { + "type": "integer" + }, + "creationType": { + "type": "string" + }, + "testCreationType": { + "type": "string" + }, + "isAutoDetectedName": { + "type": "boolean" + }, + "isPlatformError": { + "type": "boolean" + }, + "thProjectId": { + "type": "integer" + }, + "tcmTestRunId": { + "type": "integer" + }, + "tcmTestRunIdentifier": { + "type": "string" + }, + "historyAggregate": { + "type": "object", + "description": "Smart-tag flags for the run.", + "properties": { + "isFlaky": { + "type": "boolean" + }, + "isAlwaysFailing": { + "type": "boolean" + }, + "isNewFailure": { + "type": "boolean" + }, + "isPerformanceAnomaly": { + "type": "boolean" + } + } + }, + "browser": { + "type": "object", + "description": "Browser display object." + }, + "os": { + "type": "object", + "description": "Platform display object." + }, + "device": { + "type": "string" + }, + "ciBuildData": { + "type": "object", + "description": "CI context (best-effort; empty when CI info absent).", + "properties": { + "buildUrl": { + "type": "string" + }, + "buildNumber": { + "type": "string" + }, + "jobName": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "versionControlInfo": { + "type": "object", + "description": "VCS context (best-effort).", + "properties": { + "url": { + "type": "string" + }, + "commitId": { + "type": "string" + } + } + }, + "pmToolDetails": { + "type": "array", + "description": "Linked PM-tool defects.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + }, + "smartTagSettings": { + "type": "object", + "description": "The smart-tag settings resolved for this run from its build's setting version." + } + } + }, + "ObsSessionVideo": { "type": "object", - "json_path": "/widgets/globalFilters" - }, - { - "name": "config", - "type": "object" - } - ] - } - ], - "intent": "Update an existing saved report in place \u2014 rename, change visibility level, change global filters, or replace its widget set.", - "returns": [ - "data" - ], - "guidance": [ - "No version history \u2014 the previous name, filters and widgets are overwritten with no way to restore them (there is no undo).", - "Name uniqueness is re-checked only when the name, type or level changes.", - "Widget replacement is whole-set: report-typed records replace from reports[], dashboard-typed from widgets[]; omit the list and the existing widgets are kept.", - "For a GCD-disabled user, persisted ClickHouse widgets omitted from the payload are merged back in rather than dropped.", - "In the demo-sandbox environment the built-in default report cannot be updated (400).", - "The lookup is scoped to your user and group; a report you cannot see is reported as not found." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "clone_report", - "method": "POST", - "path": "/api/v1/reports/{id}/clone", - "mode": "write", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "The SOURCE report UUID to duplicate." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100, - "description": "Name for the copy. Must be unique for the (type, level)." - }, - { - "name": "level", - "type": "integer", - "minimum": 1, - "maximum": 2, - "values": [ - 1, - 2 - ], - "description": "Visibility for the copy: 1 = private, 2 = group." - }, - { - "name": "type", - "type": "string", - "minLength": 4, - "maxLength": 100 - }, - { - "name": "desc", - "type": "string", - "maxLength": 100 - } - ], - "intent": "Duplicate an existing report under a new name \u2014 'Make a copy' in the Reports UI \u2014 copying the source report's saved filters and widgets.", - "returns": [ - "data" - ], - "guidance": [ - "The copy takes its filters and widgets from the SOURCE report, not from your body \u2014 the body essentially only supplies the new name and level. A filters/widgets payload here is ignored.", - "Give a name that is unique for the report's (type, level), or the clone is rejected.", - "The source report's widget config is validated first, so a corrupt source is refused rather than copied through.", - "The new report comes back under data with its own new UUID id." - ], - "responses": { - "200": { - "$response": "ReportRecordResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_dataset_rows", - "method": "POST", - "path": "/api/v1/reports/{id}/datasets", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Rows per page." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Bound but not used for dataset computation." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body, not an error. Missing reportType is 400 'Report type is required'." - }, - { - "name": "dashboardFilters", - "type": "object", - "description": "Widget global filters. Wire key is globalFilters.", - "json_path": "/globalFilters" - }, - { - "name": "config", - "type": "object", - "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", - "fields": [ - { - "name": "DATASETS", + "description": "Session video descriptor with playback offsets. Sourced from SessionVideo in observability-api. Offsets/flags can be null when the video is not yet processed; status_code 0 is normal.", + "properties": { + "status_code": { + "type": "integer" + }, + "url": { + "type": "string", + "nullable": true + }, + "offsetUrl": { + "type": "string", + "nullable": true + }, + "startTime": { + "type": "number", + "nullable": true + }, + "startTimeStamp": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "startOffset": { + "type": "integer", + "description": "ms offset of video start relative to the test/session, for aligning playback with the log timeline.", + "nullable": true + }, + "finishOffset": { + "type": "integer", + "nullable": true + }, + "launchingAppTime": { + "type": "integer", + "description": "VM/browser boot lead-in in seconds.", + "nullable": true + }, + "isIos": { + "type": "boolean", + "nullable": true + }, + "isV2": { + "type": "boolean", + "nullable": true + } + } + }, + "ObsHistoryTab": { "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "type", - "type": "string", - "description": "WidgetTypeEnum." - }, - { - "name": "layout", - "type": "object" - }, - { - "name": "id", - "type": "string", - "maxLength": 100 - }, - { - "name": "name", - "type": "string", - "minLength": 1, - "maxLength": 100 - } - ], - "intent": "Run a report widget's query and get its paginated result rows \u2014 the actual table data behind a tabular test-runs report tile.", - "returns": [ - "data", - "summary", - "sourceFencingFlags" - ], - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", - "Only reportType TABULAR_TEST_RUNS is implemented; any other type returns a 200 with a null body rather than erroring.", - "The {id} in the path is ignored entirely \u2014 everything comes from the body, so the path id is just a placeholder.", - "Widget global filters are sent under the key globalFilters, NOT dashboardFilters.", - "Rows come back under data with a summary; summary.totalDatasets is -1 (not a real total), so page through using page / per_page and summary.hasNext rather than trusting a count.", - "A validation failure other than a known ObservabilityException is only logged, not surfaced \u2014 a 200 with empty data can mask a silently dropped dataset." - ], - "responses": { - "200": { - "$response": "ReportDatasetResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_stat_tiles", - "method": "POST", - "path": "/api/v1/reports/{id}/tiles", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "id", - "type": "string", - "format": "uuid", - "required": true, - "description": "IGNORED by the handler \u2014 the widget definition comes entirely from the body. Any placeholder UUID works." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "description": "Bound but IGNORED \u2014 tiles are not paginated." - }, - { - "name": "per_page", - "type": "integer", - "description": "Bound but IGNORED \u2014 tiles are not paginated." - }, - { - "name": "source", - "type": "string", - "values": [ - "TO", - "TCM" - ], - "description": "Bound but not used for tiles." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "description": "Widget report type. Only TABULAR_TEST_RUNS is implemented; any other value returns a 200 with a null body. Missing reportType is 400 'Report type is required'." - }, - { - "name": "dashboardFilters", - "type": "object", - "description": "Widget global filters. Wire key is globalFilters.", - "json_path": "/globalFilters" - }, - { - "name": "config", - "type": "object", - "description": "WidgetsConfig with UPPER_SNAKE section keys; the report reads DATASETS.", - "fields": [ - { - "name": "DATASETS", + "description": "One past execution of the same test across builds. Sourced from HistoryTab in observability-api; startedAt is used for ordering and is not serialized.", + "properties": { + "testRunId": { + "type": "integer", + "description": "Integer id of the historical run (serialized from the internal Id field)." + }, + "status": { + "type": "string" + }, + "serialId": { + "type": "integer" + }, + "isHittingRetentionQuota": { + "type": "boolean" + }, + "testRunUuid": { + "type": "string" + }, + "usageQuotaStatus": { + "type": "string" + }, + "workflowStatus": { + "type": "string" + }, + "creationType": { + "type": "string" + }, + "buildUuid": { + "type": "string" + }, + "thProjectId": { + "type": "integer" + } + } + }, + "TestRunHistoryEntry": { "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - } - ], - "intent": "Get the summary stat tiles (aggregate KPI numbers) for a report widget \u2014 the headline figures shown above the report table.", - "returns": [ - "data", - "sourceFencingFlags" - ], - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'. The path {id} is ignored but must still be a UUID (any placeholder UUID works).", - "Same body as the dataset call but returns aggregates instead of rows; only TABULAR_TEST_RUNS is implemented, other types return a 200 with a null body.", - "page / per_page / source are accepted and ignored here \u2014 tiles are not paginated.", - "The {id} path variable is unused; the widget definition comes entirely from the body.", - "Tiles come back under data as {meta, value} pairs (totalUniqueTests, flakyTestsCount, newFailureTestsCount, executions counts, \u2026); totalIssuesCount is added only when configuredColumns includes 'openIssues', and activeRunCount / closedRunCount / totalTestCasesCount appear only when present." - ], - "responses": { - "200": { - "$response": "ReportStatTilesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_report_custom_fields", - "method": "GET", - "path": "/api/v1/reports/custom-fields", - "mode": "read", - "entity": "report", - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Page number." - }, - { - "name": "per_page", - "type": "integer", - "default": 300, - "description": "Page size, default 300 (the upstream max, i.e. the whole list in one call). The picker is not meant to paginate; values above 300 are clamped by the upstream, not rejected." - }, - { - "name": "search", - "type": "string", - "description": "Substring filter on field name." - }, - { - "name": "project_ids", - "type": "array", - "description": "Observability project ids (List, converted to TestHub ids for you). Omit for the whole group. Ids that resolve to nothing return an empty list, not an error." - } - ], - "intent": "List the custom-field / column definitions usable as report columns and filters, optionally narrowed to given projects \u2014 feeds the report builder's column picker and resolves stored field ids back to display names.", - "returns": [ - "customFields", - "info" - ], - "guidance": [ - "per_page defaults to 300 (the upstream max) because the picker is meant to load the whole list in one call; requests above 300 are clamped upstream, not rejected.", - "project_ids are observability project ids and are converted to TestHub project ids for you; omit them for the whole group.", - "A saved report stores columns by numeric fieldId \u2014 use this to map those ids back to fieldName. The same field name can appear twice, distinguished only by entityType (TestCase vs TestExecution)." - ], - "responses": { - "200": { - "$response": "ReportCustomFieldsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_report_static_builds", - "method": "GET", - "path": "/api/v1/reports/{projectNormalisedName}/builds", - "mode": "read", - "entity": "report", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "The project's normalised NAME (not its id). A report UUID and a project name share the /reports/{...} space, so this route matches only when the segment is a project name." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "minimum": 1, - "default": 1 - }, - { - "name": "per_page", - "type": "integer", - "default": 10 - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to]. Required (@NotNull @Size(2)); omitted -> 400 'dateRange must not be null'." - }, - { - "name": "buildStatuses", - "type": "array", - "description": "Explicit build display statuses. When present, wins outright (the inherited 'status' is not unioned in)." - }, - { - "name": "status", - "type": "array", - "description": "Interpreted here as build DISPLAY statuses: used only when buildStatuses is empty, and only values that case-insensitively map to a build display status are kept \u2014 the rest are silently ignored." - }, - { - "name": "frameworks", - "type": "array" - }, - { - "name": "buildOwners", - "type": "array", - "description": "User ids (List)." - }, - { - "name": "buildStates", - "type": "array" - }, - { - "name": "buildGroupIds", - "type": "array" - }, - { - "name": "testPlanIds", - "type": "array" - }, - { - "name": "requirements", - "type": "array" - }, - { - "name": "buildName", - "type": "array" - }, - { - "name": "buildIds", - "type": "array" - }, - { - "name": "projects", - "type": "array" - }, - { - "name": "creationType", - "type": "string" - }, - { - "name": "isArchived", - "type": "boolean" - }, - { - "name": "excludeLocked", - "type": "string" - }, - { - "name": "search", - "type": "string" - }, - { - "name": "ordering", - "type": "string" - } - ], - "intent": "List a project's builds that match a report's build-level filter set \u2014 used to populate the build picker / static build scope when building a report against a fixed set of builds.", - "returns": [ - "data", - "summary", - "sourceFencingFlags" - ], - "guidance": [ - "The path segment is the project's normalised NAME, not its id.", - "status is treated as a build DISPLAY status here (the filter object is shared with the stability/performance request): explicit buildStatuses wins; otherwise status values that map to a build display status are back-filled and non-matching values are ignored.", - "Builds come back under data as build rows (not test-run rows), with a summary whose totalDatasets is -1 \u2014 page with page / per_page and read summary.hasNext rather than a total.", - "An empty match returns a 200 with empty data (plus sourceFencingFlags), not a 404." - ], - "responses": { - "200": { - "$response": "ReportDatasetResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_report_test_stats", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/stats", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, taken from a row of the report table." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence. Defaults to TO (Test Observability); TCM narrows to test-management data." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params: project ids to scope the window." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter: build ids to scope the window." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq filter. On this build-level report, status values that map to a build-display-status are used and non-matching values are ignored; an explicit buildStatuses set takes precedence and is not widened by status." - } - ], - "intent": "Get the headline stats for one test case in the tabular test-runs report - its reliability (failure ratio), average duration, and failed/total occurrence counts over the filtered window - for the detail drawer.", - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "responses": { - "200": { - "description": "Headline stats for the test, or an empty object when the test has no matching runs in the window.", - "schema": { - "$schema": "ReportTestStats" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "When the test has zero matching runs in the window the body is an empty JSON object `{}` - not zeroed stats - so branch on emptiness before reading `reliability`.", - "`reliability` is the failure ratio as a percentage (failed*100/total), `duration` is the average duration, and `failureOccurrence` carries the raw `{failed, total}` counts.", - "Requires the REPORT_VIEW project permission. Filters are read from GetReportsFilterRq query params, not a body, even though sibling report calls post a body.", - "The `globalTestHash` is the test's cross-run hash from a report-table row, not a numeric test id." - ] - }, - { - "name": "list_report_test_runs", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/testsLists", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, from a report-table row." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number. Defaults to 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Page size. Defaults to 10. No server-enforced ceiling was found in the handler - the value is passed through to the backend unclamped." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq status filter (build-display-status resolution as on the stats call)." - } - ], - "intent": "List the individual test runs behind one row of the tabular test-runs report - every execution of that test case in the window, with status, duration, build and settings-version detail - for the drill-down view.", - "returns": [ - "data", - "summary" - ], - "responses": { - "200": { - "description": "The runs for this test, plus a pagination summary. An empty result returns an object with null data and summary.", - "schema": { - "$schema": "TabularTestRunsReportResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Rows are under `data` (each a report row: test/build ids, workflow status, avg duration in ms, build name/tags/number, project id, started-at, smart-tag settings). Paging state is under `summary` - `summary.hasNext` is true when the page came back full, and `summary.totalDatasets` is -1 (not a real total) on this route.", - "When there are no matching runs the body is an object with `data` and `summary` both null, rather than an empty array - check for null before iterating.", - "This call enriches each row with a build settings-version lookup, so it is heavier than a plain list; keep `per_page` modest.", - "`workflowStatus`/`status` are normalised to a known status or the literal 'unknown'.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "get_report_test_trend", - "method": "GET", - "path": "/api/v1/reports/view/{globalTestHash}/details/trend", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "globalTestHash", - "type": "string", - "required": true, - "description": "Global hash identifying one test case across runs, from a report-table row." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "projects", - "type": "array", - "description": "GetReportsFilterRq filter bound from query params." - }, - { - "name": "buildIds", - "type": "array", - "description": "GetReportsFilterRq filter." - }, - { - "name": "status", - "type": "array", - "description": "GetReportsFilterRq status filter." - } - ], - "intent": "Get the per-run trend series for one test case in the tabular test-runs report - duration per execution plus each point's workflow status and colour, keyed by build - to draw the test's history chart.", - "returns": [ - "keys", - "data" - ], - "responses": { - "200": { - "description": "Trend series for the test.", - "schema": { - "$schema": "ReportTestTrendResponse" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`keys` is the x-axis: build started-at epochs, index-aligned to `data`. `data` is the point series.", - "Each point's `y` is the run duration in MILLISECONDS, `color` is derived from the workflow status, and `buildId`/`buildName`/`creationType` let the chart link a point back to its run.", - "Point fields that are null are omitted (the point serialises non-null only), so do not assume every optional flag is present.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "run_tabular_test_runs_report", - "method": "POST", - "path": "/api/v1/reports/view/{id}/search", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Ignored by the handler - the report is defined by the body, not this id. Any value satisfies the route; a placeholder is fine." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, default 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 10, - "description": "Page size, default 10. No server-enforced ceiling found in the handler." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "values": [ - "TABULAR_TEST_RUNS" - ], - "description": "Report type. Required (missing/null reportType is a 400 'Report type is required'). Only TABULAR_TEST_RUNS is implemented; any other value falls through the switch and returns a null body with 200." - }, - { - "name": "config", - "type": "object", - "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", - "fields": [ - { - "name": "DATASETS", + "description": "One test run's (or hook run's) execution-history entry from get_test_runs_history_details. Only the keys present for that run appear.", + "properties": { + "buildId": { + "type": "integer" + }, + "serialId": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "issueType": { + "$schema": "TestRunIssueType", + "nullable": true, + "description": "Null on passed runs." + }, + "duration": { + "type": "integer", + "description": "Run duration." + }, + "isFlaky": { + "type": "boolean", + "nullable": true + }, + "isAlwaysFailing": { + "type": "boolean", + "nullable": true + }, + "isNewlyFailed": { + "type": "boolean", + "nullable": true + }, + "isPerformanceAnomaly": { + "type": "boolean", + "nullable": true + }, + "flakyReason": { + "type": "integer", + "nullable": true + }, + "flakySetting": { + "type": "object", + "description": "Flaky configuration for the run (opaque).", + "nullable": true + }, + "creationType": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Test tags." + }, + "logs": { + "$schema": "TestRunRetryLogStruct" + }, + "workflowStatus": { + "type": "string", + "nullable": true + }, + "workflowStatusEditedBy": { + "type": "string", + "nullable": true + }, + "workflowStatusEditedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "TestRunRetryLogStruct": { "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "reportGlobalFilters", - "type": "object", - "description": "Global filters (WidgetFiltersValue) merged into each dataset before the query runs." - }, - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Dashboard filters block (the DTO field `dashboardFilters` serialises as `globalFilters`); projects/date-range etc." - }, - { - "name": "configuredColumns", - "type": "array", - "description": "Column keys to include; e.g. adding 'openIssues' also surfaces an issues count in the tiles sibling call." - }, - { - "name": "id", - "type": "string", - "maxLength": 100, - "description": "Optional widget/report id echoed for telemetry." - } - ], - "intent": "Run a configured report and return its paginated table - post the full report definition (datasets, segments, global filters) and get back the tabular test-runs result set.", - "returns": [ - "data", - "summary" - ], - "responses": { - "200": { - "description": "The report table page. Null body when reportType is not TABULAR_TEST_RUNS; an object with null data when the definition yields nothing or all datasets conflict.", - "schema": { - "$schema": "TabularTestRunsReportResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", - "This is a read despite being a write-shaped call: the body is the query, not new state, and the route is marked read-only.", - "The `{id}` in the address is never read - the report is defined entirely by the body, so pass any placeholder.", - "`reportType` is required and only TABULAR_TEST_RUNS works; any other type returns a 200 with a NULL body (not an error), so a null response can mean 'unsupported report type', not 'no data'.", - "When every dataset mutually conflicts the body comes back empty rather than erroring; distinct from the null-body case above.", - "Rows are under `data`, paging under `summary`; `summary.totalDatasets` is -1 on this route, use `summary.hasNext` to page.", - "Requires the REPORT_VIEW project permission (public-link-token callers are also accepted for shared reports)." - ] - }, - { - "name": "get_tabular_test_runs_report_tiles", - "method": "POST", - "path": "/api/v1/reports/view/{id}/search/tiles", - "mode": "read", - "entity": "report_view", - "path_params": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "Ignored by the handler; any value satisfies the route." - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - } - ], - "body": [ - { - "name": "reportType", - "type": "string", - "required": true, - "values": [ - "TABULAR_TEST_RUNS" - ], - "description": "Required (missing is a 400). Only TABULAR_TEST_RUNS is implemented; other types return a null body with 200." - }, - { - "name": "config", - "type": "object", - "description": "Report definition (WidgetsConfig). Keys are UPPER_SNAKE sections; DATASETS is the one the report reads.", - "fields": [ - { - "name": "DATASETS", + "description": "Serialized log/attachment structure for a run or retry attempt. Each key is present only when that evidence exists.", + "properties": { + "TEST_FAILURE": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Failure text: the message followed by the stack, as an array of strings." + }, + "TEST_LOG": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Screenshot / attachment URLs; fetch each directly from storage (not proxied through this API)." + }, + "BROWSERSTACK_ERROR": { + "type": "object", + "description": "BrowserStack-side error log, when the run failed on infra." + } + } + }, + "TestRunSdkLogEntry": { "type": "object", - "description": "Datasets (WidgetSegments): {value:[{name, dataSetType?, filters:{projects:[id], dateRange?:{lowerBound,upperBound}, ...}}]}. Wire key is UPPER_SNAKE DATASETS; lowercase datasets is ignored and the call 500s." - } - ] - }, - { - "name": "reportGlobalFilters", - "type": "object", - "description": "Global filters merged into each dataset." - }, - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Dashboard filters block (DTO field `dashboardFilters`)." - }, - { - "name": "configuredColumns", - "type": "array", - "description": "Including 'openIssues' adds a totalIssuesCount tile." - } - ], - "intent": "Get the summary stat tiles that sit above a report's table (totals and aggregates for the same report definition) so the header cards can load independently of the paginated rows.", - "returns": [ - "data", - "sourceFencingFlags" - ], - "responses": { - "200": { - "description": "Stat tiles for the report. When all datasets conflict, or no stats are returned, `data` is empty and only the source-fencing flags are populated.", - "schema": { - "$schema": "ReportStatTilesResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections (@JsonProperty): send config.DATASETS.value = [{name, filters:{projects:[]}}] at minimum. A lowercase `datasets` key is silently dropped, the handler dereferences a null DATASETS and returns 500 'Error while fetching report data'.", - "Read-only despite being write-shaped; same body as the table call, but no pagination.", - "Each tile in `data` is `{meta, value}` where `meta` is the tile name (totalUniqueTests, flakyTestsCount, newFailureTestsCount, alwaysFailingTestsCount, totalPassedExecutions, totalFailedExecutions, totalSkippedExecutions, totalUnknownExecutions, grandTotalExecutions) and `value` is a Long count. Note the key is `meta`, not `name`.", - "Extra tiles appear only conditionally: totalIssuesCount only when `configuredColumns` contains 'openIssues'; activeRunCount, closedRunCount and totalTestCasesCount only when the backend returns them.", - "When all datasets conflict, or the backend has no statistics, `data` is empty/absent and `sourceFencingFlags` (all false) is still returned - an empty tiles body is not an error.", - "Non-TABULAR_TEST_RUNS report types return a null body with 200.", - "Requires the REPORT_VIEW project permission." - ] - }, - { - "name": "list_widget_filter_values", - "method": "GET", - "path": "/api/v1/dashboards/widgets/filters", - "mode": "read", - "entity": "widget", - "query": [ - { - "name": "key", - "type": "string", - "required": true, - "description": "Which filter's values to list.", - "values": [ - "buildStatus", - "buildName", - "users", - "teams", - "frameworks", - "folders", - "buildTags", - "hostName", - "os", - "browser", - "device", - "tags", - "branches", - "projects", - "testCaseRequirements", - "testCasePriorities", - "testCaseStatuses", - "testCaseTypes", - "testCaseTags", - "testCaseOwners", - "testCaseAutomationStatuses", - "testCaseFolders", - "buildTypes", - "buildOwners", - "workflowStatus", - "custom", - "customFields", - "issueType", - "testCaseAutomationStates" - ] - } - ], - "intent": "Look up the available values for a single widget filter key (build status, users, teams, frameworks, build tags, build name, branches, ...) as a flat list for a widget configuration dropdown.", - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "Flat list of filter values for the key.", - "schema": { - "$schema": "WidgetFilterValueList" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the simple/legacy flat-list variant. For search, paging, and suggested-vs-others buckets, use `search_widget_filter_values` (same path, richer request/response) instead.", - "`data` is a flat array of `{label, value, ...}` structs; there is no paging on this route.", - "Values come from Elasticsearch aggregations with deleted projects excluded.", - "The request binds `key` from query params only - there is no body.", - "Values are account-wide (every visible project), not scoped to one project. Each item is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null for most keys." - ] - }, - { - "name": "search_widget_filter_values", - "method": "POST", - "path": "/api/v1/dashboards/widgets/filters", - "mode": "read", - "entity": "widget", - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "Page number, default 1." - }, - { - "name": "per_page", - "type": "integer", - "default": 30, - "description": "Page size, default 30. No server-enforced ceiling found in the handler." - }, - { - "name": "search", - "type": "string", - "description": "Free-text filter over the candidate values." - }, - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO. TCM forces manual+automated inclusion." - } - ], - "body": [ - { - "name": "widgetKey", - "type": "string", - "required": true, - "json_path": "/widgetKey", - "description": "The widget type the filter is being configured for (WidgetTypeEnum). REQUIRED. NOTE the JSON key is `widgetKey`, not `widget` - the DTO field `widget` is renamed on the wire; sending `widget` is dropped and the request fails @NotNull validation. Exact WidgetTypeEnum constant; any other value -> 400 'Invalid request payload'.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - }, - { - "name": "filterKey", - "type": "string", - "required": true, - "description": "Which filter's values to fetch (same value set as the GET `key`). REQUIRED (@NotNull)." - }, - { - "name": "filters", - "type": "object", - "description": "Already-applied filters (WidgetFiltersValue: projects, dateRange, ...) so the returned values are context-dependent. Defaults to empty when omitted.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids; for ClickHouse-powered widgets these are rewritten to TestHub project ids server-side." - }, - { - "name": "dateRange", + "description": "One test run in a build that has SDK-captured logs/attachments.", + "properties": { + "logId": { + "type": "integer" + }, + "url": { + "type": "string", + "description": "Direct attachment/log URL, fetched from storage." + }, + "testRunId": { + "type": "integer" + }, + "testRunName": { + "type": "string" + }, + "attachmentsCount": { + "type": "integer" + }, + "screenshotsCount": { + "type": "integer" + } + } + }, + "TestRunCountEntry": { "type": "object", - "description": "Optional date-range scope; only applied when the widget/caller opts in." - } - ] - } - ], - "intent": "Fetch matching values for a widget filter, split into suggested and others with search and paging - the lookup that powers the dependent dropdowns in a widget's configuration panel.", - "returns": [ - "data", - "fullList", - "info", - "suggestedAfterKey", - "othersAfterKey" - ], - "responses": { - "200": { - "description": "Filter values bucketed by suggested/others, with paging cursors.", - "schema": { - "$schema": "WidgetEnhancedFilterResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Read-only despite being a POST; same path as the flat GET variant but a different request and response shape - do not treat them as interchangeable.", - "The widget field is sent as `widgetKey` on the wire, NOT `widget`; both `widgetKey` and `filterKey` are required or the request 400s on validation.", - "`data` is a map keyed by bucket (suggested / others), each a list of value structs; page onward with `suggestedAfterKey` / `othersAfterKey` cursors rather than by page number alone.", - "Behaviour is context-dependent: the TEST_EXECUTION_DISTRIBUTION widget forces only-manual-builds, source=TCM forces manual+automated, and ClickHouse-powered widgets have their project ids translated to TestHub ids first.", - "`customFields` values are restricted to a hardcoded allow-list of group ids per environment, so a custom-field filter may return nothing for a group not on that list.", - "`fullList` and `info` can come back null; rely on suggestedAfterKey / othersAfterKey. Valid widgetKey values are the WidgetTypeEnum constants; filterKey names follow the widget's filter keys (e.g. projects) - an unknown pair is an opaque 400." - ] - }, - { - "name": "get_org_dashboard_widget_data", - "method": "POST", - "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}", - "mode": "read", - "entity": "widget", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "Not read by the handler on this route; the projects come from the body. Any value satisfies the route." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "The widget to compute (WidgetTypeEnum). Determines the response shape. Bound by exact enum constant name; any other value is a 400.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Optional window start (epoch ms). If omitted, the window is resolved from the widget's own date-range filter." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Optional window end (epoch ms)." - } - ], - "body": [ - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Filters block (DTO field `dashboardFilters`, serialised as `globalFilters`). Carries `projects[]` - the org/multi-project scope for the widget. For ClickHouse-powered widgets these project ids are translated to TestHub ids.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids scoping this org-level widget." - }, - { - "name": "dateRange", + "description": "Per-unique-test rerun count within a build.", + "properties": { + "testHash": { + "type": "string", + "description": "Stable hash identifying the unique test across runs." + }, + "count": { + "type": "integer", + "description": "Number of times the test ran across the build and its re-runs." + }, + "isDifferentPlatformsUsed": { + "type": "boolean", + "description": "True when those runs spanned more than one platform." + } + } + }, + "TestRunIssueType": { "type": "object", - "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." - } - ] - }, - { - "name": "config", - "type": "object", - "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", - "fields": [ - { - "name": "FILTERS", + "description": "A failure category (issue type or sub-type) as returned on test-run endpoints.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "locator": { + "type": "string", + "description": "Stable locator code for the issue type (e.g. a category code)." + }, + "abbreviation": { + "type": "string" + }, + "color": { + "type": "string" + }, + "groupId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "displayName": { + "type": "string", + "description": "Present only when set (JsonInclude NON_NULL)." + }, + "issueGroup": { + "type": "object", + "description": "Parent issue group (opaque; carries the group/enum this type sits under)." + } + } + }, + "BuildTopErrorStatsPanel": { "type": "object", - "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." - } - ] - }, - { - "name": "type", - "type": "string", - "description": "Widget type enum on the body; the path widgetType is what actually selects the computation." - } - ], - "intent": "Compute the data for one widget on a multi-project (org-level) dashboard, from the widget definition plus the global filters and time window supplied in the body.", - "returns": [ - "(widget-type-specific)" - ], - "responses": { - "200": { - "description": "Widget data. The shape is polymorphic per widget type (line chart, stat card, distribution, table, ...), so it is not a single fixed envelope.", - "schema": { - "type": "object", - "description": "Widget-type-specific payload; read the widgetType you requested to know the shape. Common CH-powered widgets embed a `sourceFencingFlags` object." - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", - "Projects come from the body's `globalFilters.projects` (this is the org-level route), NOT from the address - the `{dashboardId}` is ignored here.", - "The response envelope depends on `widgetType`; there is no single fixed shape, so switch on the type you asked for.", - "For ClickHouse-powered widget types the project ids you send are translated to TestHub ids before the query runs.", - "Special case: group 2 requesting the ACTIVE_BUILDS widget is forced to a 7-day window unless it explicitly asked for 1 day.", - "Unlike the project-view variant of this route, this org-level call does NOT apply the FLAKINESS / ALWAYS_FAILING / NEW_FAILURES feature gate.", - "Read-only despite being a POST." - ] - }, - { - "name": "get_widget_drilldown_records", - "method": "POST", - "path": "/api/v1/dashboards/{dashboardId}/widgets/{widgetType}/drilldown", - "mode": "read", - "entity": "widget", - "path_params": [ - { - "name": "dashboardId", - "type": "string", - "required": true, - "description": "Not read by the handler; any value satisfies the route." - }, - { - "name": "widgetType", - "type": "string", - "required": true, - "description": "The widget whose data point is being drilled into (WidgetTypeEnum). Bound by exact enum constant name; any other value is a 400.", - "values": [ - "SUMMARY_BANNER", - "BUILD_RUN_SUMMARY", - "FLAKINESS", - "STABILITY", - "MUTED_TESTS", - "FAILED_TESTS", - "PERFORMANCE", - "ALWAYS_FAILING", - "NEW_FAILURES", - "TEST_EXECUTIONS", - "TEST_STATUS_TREND", - "UNIQUE_TEST_CASES", - "DEFECTS_TREND", - "FAILURE_CATEGORIES", - "TOP_UNIQUE_ERRORS", - "UNIQUE_ERROR_TIMELINE", - "FAILURE_RATE_BY_FOLDERS", - "TOP_UNSTABLE_TEST_EXECUTIONS", - "TOP_TIME_TAKING_TEST_EXECUTIONS", - "DEFECTS_TABLE", - "PLATFORMS_COVERAGE", - "ALERTS", - "COMPONENTS_SUMMARY", - "GLOBAL_FILTER", - "TEST_CASE_STATISTICS_CARD", - "TEST_CASE_TYPE", - "TEST_CASE_TREND", - "ACTIVE_BUILDS", - "CLOSED_BUILDS", - "CLOSED_BUILDS_RESULTS", - "DEFECTS_LOGS", - "UNIQUE_BUILDS", - "BUILD_FREQUENCY", - "RELEASE_READINESS", - "AUTOMATION_COVERAGE", - "TABULAR_TEST_RUNS", - "BUILD_COMPARISON", - "TEST_EXECUTION_DISTRIBUTION", - "PIVOT_TABLE_WIDGET" - ] - } - ], - "query": [ - { - "name": "source", - "type": "string", - "default": "TO", - "values": [ - "TO", - "TCM" - ], - "description": "Data source fence, default TO." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Optional window start (epoch ms)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Optional window end (epoch ms)." - } - ], - "body": [ - { - "name": "globalFilters", - "type": "object", - "json_path": "/globalFilters", - "description": "Filters block (DTO field `dashboardFilters`) carrying the drill-down selection: projects, date range, page number, and drill-down date type.", - "fields": [ - { - "name": "projects", - "type": "array", - "description": "Project ids for the selection (translated to TestHub ids for CH-powered widgets)." - }, - { - "name": "pageNumber", - "type": "integer", - "description": "Page of drill-down rows." - }, - { - "name": "dateRange", + "description": "InsightStatsPanel for a build's top unique errors.", + "properties": { + "overview": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "count": { + "type": "integer" + }, + "insights": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "text": { + "type": "string" + } + } + } + }, + "description": "Defaults to the 'Analysis yet to run' overview when analysis has not completed." + }, + "data": { + "type": "array", + "description": "Error clusters sorted by count desc; each: id, title (the error), count, impactedTests, testRuns[], aiRcaTestRunIds[]. Empty while clustering is incomplete." + }, + "ready": { + "type": "boolean", + "description": "False while the auto-analyser is still running." + }, + "commentCount": { + "type": "integer" + }, + "message": { + "type": "string", + "description": "Set (e.g. 'Unique Errors are being detected') when not ready; the only meaningful field in the not-ready state.", + "nullable": true + } + } + }, + "ResolvedBuildUuid": { "type": "object", - "description": "DateRangeFilter {key, lowerBound, upperBound}. key: days1|days7|days14|days30|months3|months6|year1|year2|allTime|custom (custom uses the epoch-ms bounds)." - } - ] - }, - { - "name": "config", - "type": "object", - "description": "Widget definition (WidgetsConfig, UPPER_SNAKE section keys). Send at least {\"FILTERS\":{\"value\":{}}}; some types need more sections.", - "fields": [ - { - "name": "FILTERS", + "description": "Build uuid resolved from identifiers.", + "properties": { + "uuid": { + "type": "string", + "description": "The resolved build uuid, or null (with HTTP 200) when nothing matched." + } + } + }, + "SidebarBuildRuns": { "type": "object", - "description": "WidgetFilters {value:{...WidgetFiltersValue}, filterOrder?, match?}. {value:{}} = no widget-level filter." - } - ] - } - ], - "intent": "Return the underlying row-level records behind a widget data point - the 'click a bar to see the builds/tests it represents' drill-down for a dashboard widget.", - "returns": [ - "data", - "insights", - "isConflicting", - "isEmptyNumerator", - "isEmptyDenominator" - ], - "responses": { - "200": { - "description": "Drill-down rows plus insight and conflict flags.", - "schema": { - "$schema": "WidgetDrillDownData" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`config` keys are UPPER_SNAKE WidgetsConfig sections. Send config.FILTERS.value (an empty object is fine): 33 widget handlers dereference config.FILTERS.value, so a missing or empty config is a bare 500. Some types read more sections too (FLAKINESS also reads SEGMENTS.value), and a missing one is again a 500. Verified minimal body for ACTIVE_BUILDS: {globalFilters:{projects:[], dateRange:{key:\"days30\"}}, config:{FILTERS:{value:{}}}}.", - "There is no separate project-view drill-down route: drilling down from a project view uses THIS route with the projects supplied in the body.", - "`data` rows carry project identity (projectId/thProjectId/projectName/normalisedName) plus widget-specific fields; `insights` is a `{value, meta}` summary and the boolean flags report a conflicting or empty numerator/denominator selection.", - "Same project-id translation for ClickHouse-powered widgets and same TCM manual+automated behaviour as the widget-data call.", - "Read-only despite being a POST; the dashboard id in the address is ignored." - ] - }, - { - "name": "get_parallel_queue_usage_summary", - "method": "GET", - "path": "/api/v1/parallels/queues/usageSummary", - "mode": "read", - "entity": "parallel_queue", - "query": [ - { - "name": "product", - "type": "string", - "description": "Product to report live queue usage for (e.g. automate / app-automate). OPTIONAL: omit it and only the test-execution usage block is returned, with no live parallel-queue fetch." - } - ], - "intent": "Get the top-level parallel-queue summary for the signed-in user's group: plan test-execution usage plus current parallels-in-use and queued sessions for the requested product.", - "returns": [ - "summary", - "testExecutionsUsage" - ], - "responses": { - "200": { - "description": "Group-level queue summary; `summary` present only when a product was supplied.", - "schema": { - "$schema": "ParallelQueueUsageSummary" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only works for JWT/session-token callers - it reads the authenticated user straight from the security context, not from a supplied credential.", - "Omitting `product` returns just the `testExecutionsUsage` block; the live `summary` (parallels/queued) is populated only when a product is given.", - "The internal userDetails block is stripped out before the response is returned.", - "`summary` is a list of per-product SummaryDetails (totalParallelsUsed, maximumParallelsAllowed, totalQueuedSessions, maximumQueueAllowed, with per-pool breakdowns)." - ] - }, - { - "name": "list_parallel_queue_team_usage", - "method": "GET", - "path": "/api/v1/parallels/queues/teams", - "mode": "read", - "entity": "parallel_queue", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product to break down (e.g. automate / app-automate). Required and non-empty, else 400; resolved to a per-product backend endpoint, so an unknown product fails downstream." - } - ], - "intent": "Break the group's parallel-session usage down by team and member - parallels used vs allowed, queued vs queue limit, with per-pool breakdowns - so an admin can see who is consuming the plan's concurrency.", - "returns": [ - "summary", - "teams" - ], - "responses": { - "200": { - "description": "Per-team and per-member concurrency usage, plus a group summary.", - "schema": { - "$schema": "ParallelQueueTeamUsage" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`product` is required and non-empty (empty/missing throws a 400); it is resolved to a per-product backend endpoint, so an unrecognised product fails there, not here.", - "Passes through to the billing backend with the caller's own BrowserStack username/access key, then reshapes and enriches members with user details.", - "`teams[].members[]` carry per-user parallelsUsed / sessionQueued and pool breakdowns; `showRunningBuildsButton` indicates whether a member has running builds to drill into (via `list_user_running_builds`)." - ] - }, - { - "name": "list_user_running_builds", - "method": "GET", - "path": "/api/v1/parallels/queues/running/builds/{userId}", - "mode": "read", - "entity": "parallel_queue", - "path_params": [ - { - "name": "userId", - "type": "integer", - "required": true, - "description": "The user whose currently-running builds to list. Taken from the path and NOT checked against the caller's own group." - } - ], - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "values": [ - "automate", - "app-automate" - ], - "description": "Product to query. Required and non-empty, else 400. Any value other than 'automate' is treated as App Automate." - } - ], - "intent": "Show which builds a given user currently has running for Automate or App Automate, with their projects and how long the oldest has been going - the drill-down from the parallel-queue view to who is holding the parallels.", - "returns": [ - "data" - ], - "responses": { - "200": { - "description": "The user's currently-running builds.", - "schema": { - "$schema": "RunningBuildsResponse" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`userId` is read from the path and is NOT checked against the caller's group, so this can surface another user's running builds.", - "`product` is required; anything other than the exact string 'automate' is treated as App Automate rather than rejected.", - "Backed by an Elasticsearch query (size 10000, locked builds excluded), so it returns up to that many rows without paging.", - "`data` is a list of build summaries (the standard build-summary shape)." - ] - }, - { - "name": "get_test_execution_usage_count", - "method": "GET", - "path": "/api/v1/usage", - "mode": "read", - "entity": "usage", - "query": [ - { - "name": "lowerBound", - "type": "integer", - "required": true, - "description": "Window start (epoch). Required." - }, - { - "name": "upperBound", - "type": "integer", - "required": true, - "description": "Window end (epoch). Required." - }, - { - "name": "userId", - "type": "integer", - "description": "User to count for. Optional. At least one of userId/groupId must be supplied; if both are given, userId wins." - }, - { - "name": "groupId", - "type": "integer", - "description": "Group to count for. Optional. Used only when userId is absent." - } - ], - "intent": "Count test executions reported to Observability in a time window for a user or group, split into total tests and tests that ran on BrowserStack infrastructure - the raw numbers behind usage/consumption reporting.", - "returns": [ - "totalTestCount", - "browserstackTestCount" - ], - "responses": { - "200": { - "description": "Execution counts for the window. When BOTH userId and groupId are missing, the body is instead the plain string 'Missing userId or groupId' with a 200.", - "schema": { - "$schema": "TestExecutionUsageCount" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`userId`/`groupId` are taken straight from the query with NO check that they belong to the caller - any authenticated caller can read another user's or group's counts.", - "Supply at least one of `userId`/`groupId`; if both are present `userId` takes precedence and `groupId` is ignored.", - "When BOTH are omitted the call returns HTTP 200 with the plain-text body `Missing userId or groupId` (not JSON, not a 400) - handle that string before parsing JSON.", - "`totalTestCount` is all tests reported; `browserstackTestCount` is the subset that carry a sessionId (ran on BrowserStack infra).", - "The action name behind this route mentions always-failing tests but that is a copy-paste artefact - this endpoint has nothing to do with always-failing tests." - ] - }, - { - "name": "get_group_plan_usage_details", - "method": "GET", - "path": "/api/v1/usageDetails", - "mode": "read", - "entity": "usage", - "intent": "Load the plan/usage panel: the group's plan info and plan details plus non-BrowserStack test-execution consumption - used vs total executions for the team and the whole group, usage percentages, BrowserStack-run count, and the billing reset date.", - "returns": [ - "planInfo", - "planDetails", - "nonBrowserstackTotalTestExecutions", - "nonBrowserstackUsedTestExecutions", - "nonBrowserstackPercentageOfTestExecutionUsage", - "billingResetDate", - "browserstackUsedTestExecutions", - "totalTestExecutions", - "nonBrowserstackPercentageOfGroupTestExecutionUsage", - "nonBrowserstackGroupTotalTestExecutions" - ], - "responses": { - "200": { - "description": "Plan info/details plus usage figures, all counts as compacted strings. Usage fields are null when the group has no execution limit.", - "schema": { - "$schema": "GroupPlanUsageDetails" - } - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes no parameters; scoping comes from the authenticated session, so this needs a session/JWT caller (it reads the UserDetail from the security context).", - "All the execution counts come back as compacted DISPLAY STRINGS (e.g. '1.2K'), not numbers - do not do arithmetic on them.", - "This returns `planDetails` (not `userDetails`); it is the same underlying data as the usage block of the init call but requested with plan details included.", - "When the group has no execution limit configured, every usage figure is nulled out. Note the null-branch uses the key `nonBrowserstackTestExecutions` where the populated branch uses `nonBrowserstackUsedTestExecutions` - the key set differs between the two shapes.", - "`billingResetDate` is an epoch-millis timestamp." - ] - }, - { - "name": "get_device_tier_usage", - "method": "GET", - "path": "/api/v1/tier/info", - "mode": "read", - "entity": "usage", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product whose device tiers to report (e.g. automate / app-automate). Required and non-empty, else IllegalArgumentException (400)." - } - ], - "intent": "Get the group's mobile device-tier entitlement picture for a product - per tier, the devices in it, the device/parallel limits and how much of each is in use - for the device-tier/queue capacity view.", - "returns": [ - "tierInfo", - "devices" - ], - "responses": { - "200": { - "description": "Aggregated tier usage and device list for the product.", - "schema": { - "$schema": "DeviceTierUsageInfo" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Only MOBILE tiers are returned; non-mobile tiers are filtered out.", - "`product` is required and non-empty; missing/empty is a 400 (IllegalArgumentException).", - "Server-side the handler resolves the caller's own BrowserStack username + selenium access key and calls the public backend with them - no extra caller-supplied credential is needed.", - "This is a pass-through aggregation over two backend endpoints (tier usage + devices); `tierInfo` and `devices` are raw JSON trees, so read them defensively." - ] - }, - { - "name": "search_entities", - "method": "POST", - "path": "/api/v1/search", - "mode": "read", - "entity": "search", - "body": [ - { - "name": "query", - "type": "string", - "required": true, - "minLength": 3, - "description": "Free-text query. Must be non-blank and at least 3 characters or the request 400s. ClickHouse dollar-quote tokens ($q$) are stripped from it server-side." - }, - { - "name": "entity", - "type": "string", - "required": true, - "values": [ - "Build", - "BuildTestCaseExecutionResult", - "Project", - "All" - ], - "description": "Entity type to search. Must be exactly one of the supported values (CASE-SENSITIVE); any other value 400s." - }, - { - "name": "pageNumber", - "type": "integer", - "default": 1, - "minimum": 1, - "description": "1-based page. Values <= 0 or absent default to 1." - }, - { - "name": "days", - "type": "integer", - "default": 30, - "description": "Look-back window in days. Defaults to 30." - }, - { - "name": "filters", - "type": "object", - "description": "Per-entity filter block (build / buildTestCaseExecutionResult / project / all). Only recognised sub-fields are forwarded; e.g. build.onlyLocked, build.isArchived, build.userId, test.isFlaky/isMuted/failureCategories, project.userId.", - "fields": [ - { - "name": "build", + "description": "A page of unique build runs for the sidebar rail with bidirectional cursor paging.", + "properties": { + "data": { + "type": "array", + "description": "SidebarBuildSummary rows: uuid, id, name, originalName, normalisedName, status, displayStatus, startedAt, finishedAt, buildNumber, tags[], isArchived, usageQuotaStatus (retention vs pro-quota), qualityGateStatus, creationType, statusStats, source, selfHealedCount, etc." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasPrev": { + "type": "boolean" + }, + "hasNext": { + "type": "boolean" + }, + "beforeKey": { + "type": "string" + }, + "afterKey": { + "type": "string" + } + } + } + } + }, + "BuildQualityGateRecalcAck": { "type": "object", - "description": "Build filters: onlyLocked, isCIRun, isArchived, userId, status." - }, - { - "name": "buildTestCaseExecutionResult", + "description": "Acknowledgement that quality-gate recalculation was queued (QualityGateSummary with only message set).", + "properties": { + "message": { + "type": "string", + "description": "'Quality Gate result will be available after recalculation finishes'." + } + } + }, + "BuildReportPolling": { "type": "object", - "description": "Test-run filters: isMuted, isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly, failureCategories[], userId, status." - } - ] - }, - { - "name": "overlay", - "type": "boolean", - "description": "When true, returns the quick-overlay page (size 10) and OMITS pagingParams and totalCount. When false/absent, returns the full page (size 30) with paging and total count." - } - ], - "intent": "Global search across the product: given a free-text query and an entity type, return matching entities (builds, tests, projects) for the search bar or quick overlay, paginated and enriched with user names.", - "returns": [ - "response", - "pagingParams", - "totalCount" - ], - "responses": { - "200": { - "description": "Search results. `pagingParams` and `totalCount` are present only when overlay is off.", - "schema": { - "$schema": "GlobalSearchResult" - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`entity` is case-sensitive and must be exactly Build, BuildTestCaseExecutionResult, Project, or All; anything else 400s. `query` must be >= 3 non-blank characters.", - "Sort is fixed to updated_at-desc and cannot be changed by the caller.", - "Page size is NOT caller-controlled: overlay=true fixes it to 10 and omits `pagingParams`/`totalCount`; overlay off fixes it to 30 and includes them. Page through with `pageNumber` and `pagingParams.has_next`.", - "This is a thin pass-through to the backend global-search API; TRA only validates/sanitises the query, fixes the sort, and sets the page size.", - "Each result row carries its own `id`, entity `type` discriminator, and a `userName` resolved from the record's user id.", - "Keep `days` at or below 90: on a large group, windows of 365 or more returned HTTP 500 'Internal System Exception' instead of results.", - "entity=\"Build\" is the only cross-project way to find a build by NAME or by CI run id: each hit carries `projectName`/`projectNormalizedName`, `uuid`, `serialId` (the #N) and `buildRunId` (the CI run id). Use it when a user names a build or a run number without a project.", - "entity=\"Project\" is unreliable for name lookup (an existing project can return zero hits); find projects by name with list_projects_lite_paginated `search` instead." - ] - }, - { - "name": "create_self_healing_pr", - "entity": "self_healing", - "method": "POST", - "path": "/api/v1/aiHealing/builds/{buildUuid}/createPR", - "mode": "write", - "intent": "Open a GitHub pull request that rewrites broken selectors to their AI-healed values \u2014 pick the healed locators you accept from a build's healing report and ask BrowserStack to raise the PR in the customer's repo.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$", - "description": "The build whose healing report the selectors were chosen from." - } - ], - "body": [ - { - "name": "selectors", - "type": "array", - "required": true, - "minItems": 1, - "maxItems": 200, - "description": "The healed selectors to apply. 1..200 items. Each carries `using`/`value` (NOT `type`/`value`) for both the original and healed locator \u2014 note this differs from the verdict endpoint, which uses `type`. There is no `elementIdentifier` here.", - "fields": [ - { - "name": "id", - "type": "string", - "required": true, - "description": "The healed-selector id from the healing report; must be non-blank. Used to group files and to derive the head branch name." - }, - { - "name": "originalLocator", + "description": "BuildReport polling envelope (@JsonInclude NON_NULL). May be a NULL body when the build cannot be resolved.", + "properties": { + "reportStatus": { + "type": "string", + "description": "IN_PROGRESS | TEST_AVAILABLE | COMPLETED | NOT_AVAILABLE | DATA_NOT_AVAILABLE." + }, + "retryCount": { + "type": "integer", + "description": "How many more times to poll (present with IN_PROGRESS)." + }, + "pollingInterval": { + "type": "integer", + "description": "Interval to wait before the next POLL request." + }, + "report": { + "type": "object", + "properties": { + "richHtml": { + "type": "string" + }, + "richCss": { + "type": "string" + }, + "basicHtml": { + "type": "string" + }, + "plainText": { + "type": "string" + } + }, + "description": "Populated only on terminal statuses." + }, + "errorReason": { + "type": "string", + "description": "BUILD_NOT_FOUND | MULTIPLE_BUILD_FOUND | DATA_NOT_AVAILABLE | SOMETHING_WENT_WRONG." + } + } + }, + "BuildHistoryWithDefectCounts": { "type": "object", - "required": true, - "description": "The broken locator to find and replace in source.", - "fields": [ - { - "name": "using", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Locator strategy, e.g. xpath / css. Non-blank, max 64 chars." - }, - { - "name": "value", - "type": "string", - "required": true, - "maxLength": 4000, - "description": "The original locator string. Non-blank, max 4000 chars." - } - ] - }, - { - "name": "healedLocator", + "description": "Agent-facing build history: anchor build + prior same-name runs, each with status and defect count summaries.", + "properties": { + "builds": { + "type": "array", + "description": "Newest first; each entry: buildUuid, buildNumber (serialId), originalName, status, startedAt, finishedAt, durationMs, current (true for the anchor build), statusStats{passed,failed,skipped,total}, defectStats{automationBug,productBug,toBeInvestigated,notDetermined,environmentIssue}, flakyCount, alwaysFailingCount, newFailureCount." + } + } + }, + "PublicBuildDetails": { "type": "object", - "required": true, - "description": "The replacement locator to write in.", - "fields": [ - { - "name": "using", - "type": "string", - "required": true, - "maxLength": 64 - }, - { - "name": "value", - "type": "string", - "required": true, - "maxLength": 4000 - } - ] - }, - { - "name": "sessionId", - "type": "string", - "required": true, - "maxLength": 64, - "description": "Session the selector broke in. Non-blank, max 64. Distinct sessionIds across the batch must be <= 25 (each triggers one source-file lookup); over that the whole request is rejected." - } - ] - }, - { - "name": "baseBranch", - "type": "string", - "required": false, - "description": "Branch to open the PR against. Optional \u2014 defaults to the build's recorded VCS branch; if neither is present the call fails as missing VCS info." - }, - { - "name": "headBranch", - "type": "string", - "required": false, - "maxLength": 200, - "pattern": "^(?!/)(?!.*//)(?!.*\\.\\.)[A-Za-z0-9._/-]+(?- when blank. Must be a valid git ref: no '..', no leading/trailing '/', no '//'." - }, - { - "name": "repository", - "type": "string", - "required": false, - "maxLength": 200, - "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", - "description": "Target repo in `owner/repo` form. Optional \u2014 auto-derived from the build's GitHub origin remote when blank. Auto-derivation is GitHub-only; a GitHub Enterprise / non-github.com host MUST pass this explicitly or the call fails as unparseable remote." - }, - { - "name": "title", - "type": "string", - "required": false, - "minLength": 10, - "maxLength": 200, - "description": "PR title. Optional \u2014 a default is generated. If supplied it must be 10..200 chars; it is prefixed with '[BrowserStack Heal] ' unless it already starts with that." - }, - { - "name": "description", - "type": "string", - "required": false, - "maxLength": 4000, - "description": "PR body. Optional \u2014 a default is generated. Control characters are stripped (newlines/tabs kept)." - } - ], - "returns": [ - "healingPrId", - "status" - ], - "guidance": [ - "Asynchronous: returns HTTP 202 with only `{healingPrId, status:\"IN_PROGRESS\"}`. The PR is NOT open yet \u2014 there is no prUrl in this response. Capture healingPrId and poll get_self_healing_pr_status for the outcome and the PR URL.", - "The job id lives only in Redis with a short TTL and is not listed anywhere, so if you lose it or stop polling in time it is gone \u2014 store it from this response immediately.", - "Preconditions that reject synchronously before any work: an operational kill switch may disable the feature (global or per-group); the AI feature must be within plan/entitlement and the build must not be plan-locked; the build must carry VCS info and resolve to a GitHub owner/repo; at least one selector; <= 25 distinct sessions; and after source-file resolution <= 100 files.", - "Rate limited per user (25/hr) and per group (100/hr). Over-limit calls are rejected; a transient downstream failure refunds the token rather than burning your quota.", - "This is GitHub-only. A non-GitHub origin remote is silently skipped during auto-derivation (you then get an unparseable-remote error) \u2014 pass `repository` explicitly for those.", - "Each selector's locator uses `using`/`value`. Do not send `type` here (that is the verdict endpoint's field); an object missing `using` or `value` fails validation.", - "No API undo: this opens a real PR in the customer's repo. Closing/reverting is done in GitHub, not here.", - "Live 2026-09-30: the healing worker's integration key 'github-scm' is the GitHub 'Source Code View' integration (integrations page \u2192 Source Code View \u2192 GitHub), connected per user. Without it the job FAILS at fetch_files with 401 'Invalid credentials for the integration'. The account-wide GitHub (issues) connection does not satisfy it." - ], - "responses": { - "202": { - "$response": "CreateHealingPrAccepted" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "429": { - "description": "Rate limited (per-user 25/hr or per-group 100/hr) via HEALING_PR_RATE_LIMITED.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_self_healing_pr_status", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/healingPrs/{healingPrId}/status", - "mode": "read", - "intent": "Poll a self-healing PR job started by create_self_healing_pr \u2014 get its in-progress/terminal state and, once ready, the created pull request URL.", - "path_params": [ - { - "name": "healingPrId", - "type": "string", - "required": true, - "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", - "description": "The job id returned by create_self_healing_pr (strict lowercase 8-4-4-4-12 hex UUID)." - } - ], - "returns": [ - "healingPrId", - "status", - "prUrl", - "prNumber", - "headSha", - "perFileReport", - "errorMessage" - ], - "guidance": [ - "`status` is one of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED. Treat PARTIAL and NO_CHANGES_NEEDED as successful terminal outcomes, not failures.", - "prUrl / prNumber / headSha are null until a terminal, PR-producing state; perFileReport (per-file applied/skipped detail) and errorMessage appear depending on the outcome. Do not expect a PR URL while IN_PROGRESS.", - "Status is stored only in Redis under a group-scoped key with a TTL. An unknown or EXPIRED id returns HEALING_PR_NOT_FOUND \u2014 that is not a terminal failure state, just a lapsed/foreign id.", - "Only visible to the group that initiated the PR." - ], - "responses": { - "200": { - "$response": "HealingPrStatusResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "description": "HEALING_PR_NOT_FOUND \u2014 the id is unknown or its Redis entry expired, or it belongs to another group.", - "schema": { - "$schema": "ErrorResponse" + "description": "Public build record (models/external BuildDetails, or BuildDetailsV2 when new build listing is enabled). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are JsonView-gated, so the exact key set varies by flag and plan.", + "properties": { + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "id": { + "type": "integer", + "description": "Integer build id." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "status_stats": { + "type": "object", + "description": "Per-status counts (V2: workflow-status counts), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "V2 shape only." + }, + "failure_categories": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "smart_tags": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "unique_errors": { + "type": "object", + "description": "Pro-only (JsonView)." + }, + "alerts": { + "type": "array", + "description": "Pro-only (JsonView)." + }, + "re_run": { + "type": "boolean" + }, + "build_error": { + "type": "object" + }, + "is_archived": { + "type": "boolean" + }, + "observability_version": { + "type": "object" + }, + "vcs_info": { + "type": "object" + }, + "ci_info": { + "type": "object" + }, + "host_info": { + "type": "object" + }, + "run_information": { + "type": "array" + }, + "observability_url": { + "type": "string" + }, + "tcmTestRunIdentifier": { + "type": "string", + "description": "The one camelCase key." + }, + "app_details": { + "type": "object" + } + } } - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "submit_self_healing_verdict", - "entity": "self_healing", - "method": "POST", - "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", - "mode": "write", - "intent": "Record a reviewer's verdict on one AI-healed locator ('accurate' or 'inaccurate', with optional feedback) to feed the human-in-the-loop healing loop.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$" - } - ], - "body": [ - { - "name": "healedLocator", - "type": "object", - "required": true, - "description": "The healed locator being judged. Note the field is `type` here (xpath|cssPath), NOT `using` as in create_self_healing_pr.", - "fields": [ - { - "name": "type", - "type": "string", - "required": true, - "values": [ - "xpath", - "cssPath" - ], - "description": "Locator strategy; must be exactly 'xpath' or 'cssPath'." - }, - { - "name": "value", - "type": "string", - "required": true, - "description": "The healed locator string. Non-blank." - } - ] - }, - { - "name": "verdict", - "type": "string", - "required": true, - "values": [ - "accurate", - "inaccurate" - ], - "description": "The review outcome; must be exactly 'accurate' or 'inaccurate'." - }, - { - "name": "elementIdentifier", - "type": "string", - "required": true, - "description": "Identifier of the element the locator targets; part of the hash that keys the verdict in TCG. Non-blank." - }, - { - "name": "baseUrl", - "type": "string", - "required": false, - "description": "Web automate: the page URL; required for the web hash (baseUrl + elementIdentifier + groupId). Leave null/empty for app automate." - }, - { - "name": "screenIdentifier", - "type": "string", - "required": false, - "description": "App automate only: native screen name used in the app hash (elementIdentifier + groupId + screenIdentifier). Null for web automate." - }, - { - "name": "client", - "type": "string", - "required": false, - "description": "Platform client type: 'app-automate' selects the app hash formula; empty/null means web automate." - }, - { - "name": "feedback", - "type": "object", - "required": false, - "description": "Optional free-form feedback (arbitrary JSON)." - }, - { - "name": "region", - "type": "string", - "required": false, - "maxLength": 64, - "description": "Optional session region code; only a lookup key into the TCG region-URL map (a miss is skipped, not an error). Max 64." - } - ], - "returns": [ - "elementHash", - "healedLocator", - "verdict", - "feedback", - "reviewedByUserId", - "reviewedAt", - "isCarryover", - "carryoverBuildSerialId" - ], - "guidance": [ - "Gated on the human-in-the-loop healing feature per group: when it is OFF this is refused outright (FORBIDDEN_OPERATION) \u2014 unlike list_self_healing_verdicts, which silently returns []. Check the feature before submitting.", - "Requires a full product role (Product Admin, Product User, Admin or User); Viewer / Read Only is rejected.", - "Dual-writes to observability storage AND the TCG service (keyed by a hash of the element identifier), so a TCG outage can fail the call even though it looks like a local write.", - "Unknown body keys are ignored. For web tests supply baseUrl; for app tests supply screenIdentifier and client='app-automate' \u2014 the wrong pairing computes a hash that won't match the element." - ], - "responses": { - "200": { - "$response": "SubmitHealingVerdictResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_self_healing_verdicts", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/builds/{buildUuid}/healingVerdicts", - "mode": "read", - "intent": "List the human-in-the-loop verdicts (accurate/inaccurate) already recorded against a build's AI-healed locators, to show which healed selectors have been reviewed.", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$" - } - ], - "returns": [ - "elementHash", - "healedLocator", - "verdict", - "feedback", - "reviewedByUserId", - "reviewedAt", - "isCarryover", - "carryoverBuildSerialId" - ], - "guidance": [ - "Returns a bare JSON array of verdict objects (no wrapper envelope).", - "Feature-flagged: when the human-in-the-loop healing feature is OFF for the group this returns an empty array with HTTP 200 \u2014 NOT an error. Do not read [] as 'no verdicts recorded' without knowing the feature is on.", - "`isCarryover` is true when the verdict was first recorded against a different (earlier) build; `carryoverBuildSerialId` names that build. Join `elementHash` with the healing report to line each verdict up with its selector.", - "A build UUID outside the caller's group is rejected as build-not-found." - ], - "responses": { - "200": { - "$response": "HealingVerdictListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_self_heal_summary", - "entity": "self_healing", - "method": "GET", - "path": "/api/v1/aiHealing/builds/{uuid}/selfHealSummary", - "mode": "read", - "intent": "Get a build's self-healing headline numbers for the build-insights page \u2014 how many tests and locators were healed, with counts and percentages.", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "pattern": "^[a-zA-Z0-9-]{1,64}$", - "description": "Build UUID." - } - ], - "returns": [ - "testSummary", - "locatorSummary", - "buildSummary" - ], - "guidance": [ - "Numbers come from search aggregations over the build's runs: testSummary (totalTests, healedTests, percentHealedTests), locatorSummary (totalLocators, healedLocators, percentHealedLocators) and buildSummary (totalBuild, healedBuilds, percentHealedBuilds) - buildSummary can be null.", - "Refused with FORBIDDEN_OPERATION / plan-feature-not-allowed when the build is locked by plan quota \u2014 you get an error, not zeroes.", - "In the demo-sandbox environment this returns hardcoded dummy stats, not real data." - ], - "responses": { - "200": { - "$response": "SelfHealSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_comparison_aggregation", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/aggregation", - "mode": "read", - "intent": "Get the headline counts for a base-vs-target build comparison \u2014 additional, dropped, no-change, newly failed, started passing, and failed-with-a-different-reason \u2014 the summary strip at the top of the Build Comparison page.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID (top-level parent build). Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID (top-level parent build). Empty/missing \u2192 build-not-found." - }, - { - "name": "filterParams", - "type": "object", - "required": false, - "description": "Standard V2 test filters bound from the query string (same filter keys as the build test listing), applied to both builds before comparing." - } - ], - "returns": [ - "newlyFailed", - "failedWithDifferentReason", - "startedPassing", - "additionalTests", - "droppedTests", - "noChange", - "sourceFencingFlags" - ], - "guidance": [ - "Plan-gated on the build-comparison feature: FORBIDDEN_OPERATION / plan-feature-not-allowed otherwise. A successful call also bumps feature-usage counters.", - "Both builds must be top-level parents and supported: a child/archived/locked build is rejected as unsupported (BUILD_COMPARISON_BUILD_NOT_SUPPORTED). Normalise UUIDs with resolve_build_comparison_parent_uuids first.", - "A cross-source (incompatible) pair is NOT an error: you get 200 with all six counts at 0 and `sourceFencingFlags` populated. Check sourceFencingFlags before trusting the zeroes.", - "Expensive: resolves both builds' child builds and runs search + aggregation across them.", - "The controller method is internally named getBuildUuid but it returns counts, not a UUID \u2014 ignore the name." - ], - "responses": { - "200": { - "$response": "BuildComparisonAggregationResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "check_build_comparison_available", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/checkComparison", - "mode": "read", - "intent": "Pre-flight check of whether two builds can actually be compared (and why not), so the UI can show a 'comparison unavailable' state before firing the expensive aggregation.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "status", - "message", - "sourceFencingFlags" - ], - "guidance": [ - "Same build-comparison plan gate as the aggregation \u2014 refused when the plan lacks the feature.", - "For an incompatible-source pair it returns `sourceFencingFlags` and NO availability payload (status/message null); otherwise status/message describe whether the comparison is available. Check the flags first.", - "Same unsupported-build rejection as the other comparison endpoints for child/archived/locked builds.", - "Use this before get_build_comparison_aggregation / list_build_comparison_bucket_tests to avoid the heavy call when the pair can't be compared." - ], - "responses": { - "200": { - "$response": "BuildComparisonCheckResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "resolve_build_comparison_parent_uuids", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/getActual", - "mode": "read", - "intent": "Normalise two build UUIDs (which may be re-run/child builds) to their top-level parent build UUIDs \u2014 call this first so a rerun UUID pasted into the comparison picker is compared at the parent level.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID to resolve (may be a child/rerun UUID). Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID to resolve. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "base", - "target", - "sourceFencingFlags" - ], - "guidance": [ - "Returns `{base, target}` where each is the resolved top-level PARENT build UUID. Feed these into the other comparison calls.", - "NOT plan-gated (unlike the other four comparison endpoints) \u2014 works even without the build-comparison feature.", - "Any resolution failure is collapsed into a single BUILD_NOT_FOUND naming whichever of the two UUIDs appeared in the underlying error message; you cannot tell from the error which side genuinely failed.", - "sourceFencingFlags is part of the envelope but is not set on the normal success path here." - ], - "responses": { - "200": { - "$response": "BuildComparisonActualResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_comparison_bucket_tests", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/list", - "mode": "read", - "intent": "List the actual tests inside one bucket of a build comparison (e.g. newly-failed, started-passing, dropped, additional), grouped for display \u2014 the drill-down when a user clicks an aggregation tile.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "bucket", - "type": "string", - "required": true, - "description": "Comparison bucket, as the exact BucketEnum constant name (case-sensitive). The aggregation's camelCase keys (newlyFailed ...) are NOT accepted: any other value -> 500 'No enum constant'.", - "values": [ - "NEWLY_FAILED_TEST", - "STARTED_PASSING_TEST", - "DROPPED_TEST", - "ADDITIONAL_TEST", - "NO_CHANGE", - "FAILED_WITH_DIFFERENT_REASON" - ] - }, - { - "name": "fileOffset", - "type": "integer", - "required": true, - "description": "Pagination cursor (Long) over the grouped result; start at 0 and advance it to page through groups. Required." - }, - { - "name": "filterParams", - "type": "object", - "required": false, - "description": "Standard V2 test filters bound from the query string, applied before matching." - } - ], - "returns": [ - "bucketName", - "groups", - "sourceFencingFlags" - ], - "guidance": [ - "Plan-gated on the build-comparison feature.", - "Returns `{bucketName, groups}` where each group bundles a file/browser/device/os and its `scopes`, each scope holding base/target element pairs. A cross-source pair returns an empty `groups` with `sourceFencingFlags` set rather than an error.", - "Expensive: pulls test-run ids for both builds (and their child builds) from search with a large per-build ceiling (~10k) before matching.", - "`fileOffset` is a cursor, not a page number; carry the last value forward to continue. The page size / total is not returned as a simple count \u2014 walk until a page comes back empty." - ], - "responses": { - "200": { - "$response": "BuildComparisonListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_comparison_meta", - "entity": "build_comparison", - "method": "GET", - "path": "/api/v1/buildComparison/meta", - "mode": "read", - "intent": "Get the two build summary headers shown side by side on the Build Comparison page \u2014 name, status, timings, framework and status counts for both the base and the target build.", - "query": [ - { - "name": "base", - "type": "string", - "required": true, - "description": "Base build UUID. Empty/missing \u2192 build-not-found." - }, - { - "name": "target", - "type": "string", - "required": true, - "description": "Target build UUID. Empty/missing \u2192 build-not-found." - } - ], - "returns": [ - "base", - "target", - "sourceFencingFlags" - ], - "guidance": [ - "Returns `{base, target}`, each a full build summary object. A cross-source pair returns the envelope with `sourceFencingFlags` set and base/target null.", - "NOT explicitly plan-gated (unlike aggregation/list/checkComparison), but it DOES apply the same unsupported-build rejection and source-fencing behaviour.", - "The status counts are AMR-corrected: it re-runs the same dedup/coalesce aggregation the buckets use so the header agrees with them, and silently falls back to raw automation counts if that recompute fails. This makes it heavier than a plain build-summary fetch." - ], - "responses": { - "200": { - "$response": "BuildComparisonMetaResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_comments", - "entity": "comment", - "method": "GET", - "path": "/api/v1/projects/{normalizedName}/comments", - "mode": "read", - "intent": "Fetch the comments to render inline on a build-insights or test listing page \u2014 comments for a build (and a window of preceding build numbers), optionally narrowed to one test, to your own comments/mentions, and optionally excluding re-runs.", - "path_params": [ - { - "name": "normalizedName", - "type": "string", - "required": true, - "description": "Project normalised name (resolved within the caller's group)." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "description": "Comment page type (same enum as create_comment, by NAME). REQUIRED in practice \u2014 omitting it returns [] with 200.", - "values": [ - "TESTS", - "ALWAYS_FAILING", - "FLAKINESS", - "NEW_FAILURES", - "MUTED", - "BUILD_SUMMARY", - "BUILD_HISTORY", - "UNIQUE_ERRORS", - "BUILD_STABILITY", - "RUN_SUMMARY", - "ALERTS", - "FAILURE_CATEGORIES", - "DEFECT_STATS", - "BUILD_RERUN_STATS", - "TOP_ERROR_STATS", - "QUALITY_GATES", - "FAILURES_BY_FOLDERS", - "ATTACHMENTS", - "BUILD_OPTIMISATION", - "SELF_HEALING" - ] - }, - { - "name": "buildSerialId", - "type": "integer", - "description": "The build's numeric id (build_id, e.g. 160459727) - NOT its build_number. REQUIRED in practice \u2014 omitting it returns [] with 200 (not an error). The read looks back over a fixed window of preceding ids from this value." - }, - { - "name": "buildUuid", - "type": "string", - "description": "Build UUID to scope to when no runUuid is given." - }, - { - "name": "runUuid", - "type": "string", - "description": "When set (and not in re-run mode), scopes to this run instead of the whole build." - }, - { - "name": "testHash", - "type": "string", - "description": "Required when type=TESTS \u2014 type=TESTS without a testHash returns [] with 200. Also used to scope re-run/expand-all test queries." - }, - { - "name": "expandAll", - "type": "boolean", - "description": "Expand the full set for the context rather than a collapsed view; changes how buildUuid/testHash filtering is applied." - }, - { - "name": "showMyComments", - "type": "boolean", - "default": false, - "description": "Restrict to comments you authored or were mentioned in." - }, - { - "name": "showReRunComments", - "type": "boolean", - "default": false, - "description": "Include comments across re-run builds. REQUIRES parentBuildUuid \u2014 set without it throws INVALID_PARAMETERS_FOR_COMMENTS." - }, - { - "name": "parentBuildUuid", - "type": "string", - "description": "Parent build UUID; mandatory whenever showReRunComments=true." - } - ], - "returns": [ - "id", - "type", - "threadId", - "userId", - "groupId", - "commentNode", - "projectId", - "buildUuid", - "buildSerialId", - "runUuid", - "testHash", - "mentions", - "createdAt", - "modifiedAt" - ], - "guidance": [ - "Returns a bare JSON array of comment objects (no envelope). Note the array items carry `commentNode` but NOT the `content` HTML.", - "Silent empties: missing buildSerialId, missing type, or type=TESTS without testHash all return [] with HTTP 200 \u2014 easy to misread as 'no comments'. Supply buildSerialId and type (and testHash for TESTS).", - "showReRunComments=true without parentBuildUuid is a hard error (INVALID_PARAMETERS_FOR_COMMENTS), not an empty list.", - "Only looks back a FIXED number of preceding build serial ids from buildSerialId (so recurring discussion carries over), not all history, and is further bounded by the plan's data-retention window." - ], - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_comment_threads", - "entity": "comment", - "method": "GET", - "path": "/api/v1/projects/{normalizedName}/builds/{buildId}/comments", - "mode": "read", - "intent": "Power the build-level comments side panel \u2014 list the comment threads across a build and all its re-runs, grouped by thread and ordered by most recent activity, with each thread's page type and test-run uuid for deep-linking.", - "path_params": [ - { - "name": "normalizedName", - "type": "string", - "required": true, - "description": "Project normalised name (resolved within the caller's group)." - }, - { - "name": "buildId", - "type": "string", - "required": true, - "description": "PARENT build UUID. Threads are aggregated across all builds sharing this parent uuid \u2014 passing a re-run's own uuid returns nothing." - } - ], - "query": [ - { - "name": "showMyComments", - "type": "boolean", - "description": "Restrict to threads you authored or were mentioned in. REQUIRED in practice: omitted -> 500 (null Boolean unboxed). Send false for all threads.", - "required": true - } - ], - "returns": [ - "threadId", - "name", - "browser", - "os", - "device", - "type", - "testRunUuid", - "commentCount", - "testHash" - ], - "guidance": [ - "Returns a bare JSON array of thread summaries (threadId, commentCount, the thread's page type and testRunUuid for deep-linking), not the individual comments \u2014 fetch a thread's comments via list_comments.", - "`buildId` must be the PARENT build uuid; a re-run's uuid yields an empty result, not an error.", - "Thread aggregation is capped at 1000 threads, and results are bounded by the plan's data-retention window so older threads silently drop out.", - "Always send showMyComments (false for every thread): the server unboxes it without a null check, so omitting it returns 500 Internal System Exception." - ], - "responses": { - "200": { - "$response": "CommentThreadsSidePanelResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_comments", - "entity": "comment", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/comments", - "mode": "read", - "intent": "Public API: fetch the discussion thread on a test run \u2014 the comments testers left against that failure, with author info.", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Numeric test-run id (Long)." - } - ], - "returns": [ - "id", - "type", - "threadId", - "userId", - "groupId", - "commentNode", - "projectId", - "buildUuid", - "buildSerialId", - "runUuid", - "testHash", - "mentions", - "createdAt", - "modifiedAt" - ], - "guidance": [ - "Public /ext/v1 route: authenticate with the customer username + access key, not the internal session.", - "Read-only \u2014 there is NO external endpoint to post a comment; use the internal create_comment for that.", - "A run not in the caller's group returns TEST_RUN_NOT_FOUND.", - "Returns a bare JSON array of comment objects (same shape as list_comments items)." - ], - "responses": { - "200": { - "$response": "CommentListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_unique_errors", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors", - "mode": "read", - "entity": "sp_error", - "intent": "List the Suite Health unique errors for a project \u2014 test failures clustered by error signature over a date window, each cluster with its occurrence count, impacted-test count and a sample of the error text \u2014 so a team can attack the few root errors behind many failing tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], sent as a repeated query param (dateRange=&dateRange=). Required \u2014 missing, or not exactly two values, is a 400. lowerBound + upperBound singles are the only alternative." - }, - { - "name": "search", - "type": "string", - "description": "Free-text match against the error signature / sample text." - }, - { - "name": "status", - "type": "array", - "description": "Test-status filter values. Note the list is failed-only regardless." - }, - { - "name": "sources", - "type": "array", - "description": "Product-source filter.", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array", - "description": "Failure-category ids to include." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Restrict to these unique build names." - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array", - "description": "Owner user ids (integers)." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (integers)." - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "branches", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "description": "Muted-tests visibility. Unmuted is the default; an unrecognised value is a 400 (IllegalArgumentException).", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the cluster-id constraint is dropped, so results span all builds of a test rather than one error cluster." - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column. Ignored (forced to errorCount) for free / non-pro accounts.", - "values": [ - "errorCount", - "testCount" - ] - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "minimum": 0, - "default": 0, - "description": "Zero-based page index into the clustered result. Page size is fixed at 15. The response's pagingParams.pageNumber is the NEXT index to request (requested + 1), not the one you sent." - } - ], - "returns": [ - "pagingParams", - "errors", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "responses": { - "200": { - "$response": "UniqueErrorListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Take each row's `id` (a numeric string, the errorClusterId) \u2014 that is the cluster id every per-error detail view wants in its path.", - "Page with `pageNumber` (0-based) and a fixed size of 15. Walk pages while the response `pagingParams.hasNext` is true; note `pagingParams.pageNumber` is already the NEXT page to request, not the one you asked for.", - "An empty `errors` with a 200 is the honest answer for a demo tenant or a project with no builds \u2014 it does NOT mean the project is missing. A truly absent project is a 404.", - "Before concluding coverage is complete, check automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 source fencing may have silently dropped whole product families from the clustering.", - "Free / non-pro accounts get exactly one error back (the top one) and the sort is forced to errorCount desc; do not read that single row as the only error that exists.", - "Heavy filter combinations run under an Elasticsearch time budget and can be cut short, so a smaller-than-expected result on an expensive query may be a budget cut rather than the true count.", - "Any unrecognised extra query params are collected into customParams rather than rejected, so a mis-typed filter name is silently ignored, not errored." - ] - }, - { - "name": "get_unique_error_summary", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/info", - "mode": "read", - "entity": "sp_error", - "intent": "Get the header/summary block for one unique error \u2014 its test name, the sample error text (split into lines), and the OS/browser/device combinations it was seen on \u2014 to describe what the error actually is on the detail page.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the summary aggregates across all failed runs of the test (by stability hash) rather than this one cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "browser", - "type": "string", - "description": "Optional single-value browser narrowing (pro accounts only)." - }, - { - "name": "os", - "type": "string", - "description": "Optional single-value OS narrowing (pro accounts only)." - }, - { - "name": "device", - "type": "string", - "description": "Optional single-value device narrowing (pro accounts only)." - } - ], - "returns": [ - "name", - "error", - "combinations" - ], - "responses": { - "200": { - "$response": "UniqueErrorSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Pass the cluster id as a numeric `errorId`. A non-numeric id is NOT a 400 here \u2014 it is a raw 500 from Long.parseLong, because this handler does not validate or wrap exceptions the way its siblings do.", - "If no failing run matches the filters+window, the handler dereferences the first (absent) hit and 500s rather than returning an empty summary \u2014 treat a 500 here as possibly 'no sample found', and widen the window.", - "`error` is the sample text already split on newlines into an array of lines, not a single string.", - "`combinations` lists the platform combos the error appeared on ({os, browser} where browser also carries a `device`); combos that are NA on both os and browser are dropped from the list." - ] - }, - { - "name": "get_unique_error_occurrence_count", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/errorCount", - "mode": "read", - "entity": "sp_error", - "intent": "Get the total number of times one unique error occurred in the window, plus a per-day count series \u2014 the headline count and sparkline for the error detail panel.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the count spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "errorCount", - "chartData" - ], - "responses": { - "200": { - "$response": "UniqueErrorOccurrenceCountResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "`chartData` is a flat array of per-day occurrence counts with NO timestamps \u2014 reconstruct the x-axis yourself from the requested window (one bucket per calendar day, extended-bounds padded to the window).", - "`errorCount` is the total matching-run count for the window, independent of the bucket array length.", - "Buckets are fixed at one calendar day; the interval is not requestable." - ] - }, - { - "name": "get_unique_error_build_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/trend", - "mode": "read", - "entity": "sp_error", - "intent": "Get the occurrence trend of one unique error across builds over time \u2014 matching runs (timestamp, status, duration) grouped by status series and enriched with build info \u2014 to plot when and in which builds the error appeared.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped and the trend spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "data", - "keys" - ], - "responses": { - "200": { - "$response": "UniqueErrorBuildTrendResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Hard-capped at 5000 matching runs: for a very common error the trend is silently truncated to the first 5000 runs by start time, so totals here can understate the real count \u2014 use get_unique_error_occurrence_count for the true total.", - "Heavier than the other detail calls \u2014 it does a second relational lookup to enrich each run with build info \u2014 so expect higher latency.", - "`data` is a list of status series ({name, color, data[]}); `keys` is the parallel list of run start timestamps (epoch). A truly missing project is re-thrown as a 404; other failures surface as 500." - ] - }, - { - "name": "list_unique_error_builds", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds", - "mode": "read", - "entity": "sp_error", - "intent": "List the builds in which one unique error occurred, cursor-paginated, so a user can jump from an error cluster to the actual runs and builds where it appeared.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "searchAfter", - "type": "array", - "description": "Cursor: pass back the `pagingParams.searchAfter` array from the previous page verbatim. Follow this cursor rather than a page number; page size is fixed at 15." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped so all failed runs of the test are listed, not just this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "pagingParams", - "builds" - ], - "responses": { - "200": { - "$response": "UniqueErrorBuildsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Cursor pagination only \u2014 there is no page number. Take `pagingParams.searchAfter` from the response and send it back as the `searchAfter` query array for the next page; page size is fixed at 15.", - "Stop when the response has no further rows: `pagingParams.hasNext` is set to false only on an empty page, so an empty `builds` (or hasNext=false) is the end of the list.", - "Each build carries isFailedWithSameError, saying whether that build's run failed with THIS exact cluster or merely contains the test; use it to distinguish same-error recurrences from unrelated runs.", - "Some fields are exposed only through computed getters, not raw columns: versionControlInfo ({url, commitId}), issueType ({isAutoAnalyzed, value}, null unless the run is failed) and historyAggregate ({isAlwaysFailing, isFlaky, isNewlyFailed})." - ] - }, - { - "name": "get_unique_error_platform_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/platforms", - "mode": "read", - "entity": "sp_error", - "intent": "Break one unique error down by environment \u2014 which OS / browser / device combinations it occurred on and how often \u2014 so a team can tell whether the error is platform-specific.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - }, - { - "name": "errorId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it. Ids exceed 2^53, so a JSON number loses precision. Parsed as a Long.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "Stability hash of the test the error occurred in (id from get_unique_error_impacted_tests). Required: omitted -> 500. Alias: testId." - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "When true the errorClusterId constraint is dropped so the breakdown spans all failed runs of the test rather than this cluster." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "browser", - "os", - "chartData", - "totalFailure", - "errorCount" - ], - "responses": { - "200": { - "$response": "UniqueErrorPlatformBreakdownResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "These error-detail reads are per TEST: send stabilityHash (the test's id from get_unique_error_impacted_tests for this cluster) alongside errorId. The handler filters on stabilityHash unconditionally, so omitting it returns 500. Pass errorId as the digit string list_unique_errors returned.", - "Returns a FLAT array of platform buckets, not a nested os->browser->device tree; each entry is one {browser (with a `device`), os} combination.", - "Per entry, `totalFailure` is the occurrence doc count and `errorCount` is the distinct impacted-test count; `chartData` is per-day occurrence counts with no timestamps (reconstruct from the window).", - "Combos that are NA on both os and browser are dropped from the list.", - "The breakdown is a single composite aggregation page (up to 1000 combinations) and is not paginated, so a project with a very large platform matrix could be truncated." - ] - }, - { - "name": "get_unique_error_impacted_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/errors/{clusterId}/breakdown", - "mode": "read", - "entity": "sp_error", - "intent": "Expand one unique-error cluster into the individual tests that hit it \u2014 each with the browsers/devices/platforms it failed on and an occurrence series \u2014 the timeline/detail view behind a row in the unique-errors list.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Project name path variable, resolved within the caller's group." - }, - { - "name": "clusterId", - "type": "string", - "required": true, - "description": "Error cluster id as a digit STRING, exactly as list_unique_errors returns it (ids exceed 2^53; a JSON number loses precision). The error cluster id. It reaches the query only because Spring binds the URI variable onto the command object's `clusterId` setter \u2014 a query param of the same name would be overridden by this path value, so set it here.", - "pattern": "^[0-9]+$" - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "uniqueBuildNames", - "type": "array", - "description": "Restrict the breakdown to these unique build names (pro accounts only)." - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - }, - { - "name": "orderValue", - "type": "string", - "description": "Sorts the returned tests by occurrence count.", - "values": [ - "asc", - "desc" - ] - } - ], - "returns": [ - "id", - "title", - "filePath", - "scopeList", - "browsers", - "platforms", - "device", - "errorCount", - "testRunId", - "chartData" - ], - "responses": { - "200": { - "$response": "UniqueErrorImpactedTestsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response is a bare JSON array of impacted-test objects (no envelope). Each test's `id` is a stability hash (the test signature), not a numeric id; `testRunId` is one representative failing run.", - "Set the cluster id in the PATH \u2014 the {clusterId} path variable overrides any clusterId query param of the same name, because the handler never reads the path variable itself and relies on request binding.", - "The full Suite Health filter surface is accepted and narrows the matched runs, but pro-account filters (build names, flaky, etc.) are skipped for free / non-pro users.", - "It pages internally through all composite buckets under a loop deadline, so for a very large cluster the returned list can be truncated when the deadline is hit rather than erroring.", - "A genuinely absent project is re-thrown as a 404; other failures surface as a 500. `chartData` is per-day occurrence counts with no timestamps." - ] - }, - { - "name": "get_unique_error_timeline_charts", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/errors/metrics/chart", - "mode": "read", - "entity": "sp_error", - "intent": "Get the Suite Health unique-errors timeline in one call \u2014 three day-bucketed time series over the window (unique errors, unique impacted tests, total impacted executions), each with its headline total \u2014 for the project's failed tests.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group. No permission check beyond project visibility." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "search", - "type": "string" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array" - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "allBuilds", - "type": "boolean" - } - ], - "returns": [ - "uniqueErrorChart", - "uniqueTestChart", - "impactedTestChart" - ], - "responses": { - "200": { - "$response": "UniqueErrorTimelineChartsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Response is a map with exactly three keys: uniqueErrorChart, uniqueTestChart, impactedTestChart. Each is {data, insights}, where data is a single-entry map whose series key differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", - "Read the headline number from each chart's `insights.value` (a STRING, e.g. '42') with `insights.metric` as its label \u2014 not by summing the points.", - "Each series point carries x (bucket epoch), y (value, may be null), t and pointRange; the bucket interval is derived from the window, not requestable.", - "Only failed tests are counted; archived and usage-locked builds are excluded, and the failed-status field used depends on the group's new-build-listing flag, so totals can shift when that flag changes." - ] - }, - { - "name": "get_unique_error_filters", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters", - "mode": "read", - "entity": "sp_error", - "intent": "Build the whole filter sidebar for a project's Unique Errors page in one call \u2014 the currently applied selections echoed back as display fields, the static dropdown option sets (failure categories, statuses, platforms, users, teams, sources, folders...), and the project's default hooks visibility.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required. Scopes which option values exist. lowerBound + upperBound singles are the only fallback." - }, - { - "name": "search", - "type": "string" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "values": [ - "TO", - "AUT", - "AA", - "ATS", - "TM", - "LCNC", - "ALLY", - "LTS" - ] - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array" - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "branches", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "muted", - "unmuted", - "true", - "false" - ] - } - ], - "returns": [ - "applied", - "staticFilters", - "hooksVisibility" - ], - "responses": { - "200": { - "$response": "UniqueErrorFiltersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`applied` echoes the caller's current selections resolved into display objects (each option is {id, value, label, count, pId}); `staticFilters` is the full set of selectable option lists for the page.", - "Options are fetched from Elasticsearch and TestHub concurrently and degrade to EMPTY dropdowns on timeout or rejection rather than failing the request \u2014 an empty option list does NOT mean there is no data.", - "This is the errors-page filter surface; do not confuse it with the general build/test sidebar filters. Use the sibling typeahead capabilities (branches / build names / build tags / host names / test tags) for search-as-you-type option lookups." - ] - }, - { - "name": "search_unique_error_filter_branches", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/branches", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the branch filter on the Unique Errors page \u2014 return git branch names in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; applied as a leading+trailing Elasticsearch wildcard." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (this is the QueryRq shape, NOT the full error filter). lowerBound/upperBound singles are NOT accepted here." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Whether manual (Test Management) runs are considered. Defaults to true on this endpoint (unlike build-names, which forces it false)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Uses the compact QueryRq body (query + dateRange + isManuallyOverridden + includeManualRuns) \u2014 NOT the full Unique Errors filter set. dateRange must be exactly two bounds; there is no lowerBound/upperBound fallback.", - "Returns a BOUNDED set under `data` (each {id, value, label, count, pId}), matched by wildcard on `query`; it is a typeahead, not the full branch list.", - "Source fencing is applied, so branches from fenced-off product families may not appear." - ] - }, - { - "name": "search_unique_error_filter_build_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildNames", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the build-name filter on the Unique Errors page \u2014 return unique build names in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "IGNORED / silently forced to false on this endpoint \u2014 manual (Test Management) runs never appear in build-name suggestions whatever you send." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Unlike its sibling typeahead endpoints this one FORCES includeManualRuns=false server-side, so manual (Test Management) build names are always excluded regardless of the value you pass.", - "It delegates to the shared build-filter service rather than the suite-health filter service, so its result set can differ subtly from the other error-filter typeaheads.", - "Returns a bounded set under `data` (each {id, value, label, count, pId}); it is a typeahead, not the full list. Uses the compact QueryRq shape (no lowerBound/upperBound fallback)." - ] - }, - { - "name": "search_unique_error_filter_build_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/buildTags", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the build-tag filter on the Unique Errors page \u2014 return build tags used in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; applied as an Elasticsearch wildcard terms lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true; this endpoint does NOT force it false (only build-names does)." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard terms lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape (query + dateRange + isManuallyOverridden + includeManualRuns); dateRange must be exactly two bounds with no lowerBound/upperBound fallback." - ] - }, - { - "name": "search_unique_error_filter_host_names", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/hostNames", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the host-name (CI machine / runner) filter on the Unique Errors page \u2014 return matching host names within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; Elasticsearch wildcard lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false).", - "Runs without a recorded host name aggregate into one option with empty id/value/label (and a count) - skip it when building a host filter." - ] - }, - { - "name": "search_unique_error_filter_test_tags", - "method": "GET", - "path": "/api/v1/projects/{projectNormalisedName}/snp/v3/errors/filters/testTags", - "mode": "read", - "entity": "sp_error", - "intent": "Typeahead for the test-tag filter on the Unique Errors page \u2014 return tags attached to tests in the project matching a substring within the date window.", - "path_params": [ - { - "name": "projectNormalisedName", - "type": "string", - "required": true, - "description": "Project normalised name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "query", - "type": "string", - "description": "Substring to match; Elasticsearch wildcard lookup." - }, - { - "name": "dateRange", - "type": "array", - "required": true, - "minItems": 2, - "maxItems": 2, - "description": "Window as exactly two epoch-millisecond bounds [from, to], repeated query param. Required (QueryRq shape). No lowerBound/upperBound fallback." - }, - { - "name": "isManuallyOverridden", - "type": "boolean" - }, - { - "name": "includeManualRuns", - "type": "boolean", - "default": true, - "description": "Defaults to true. Despite an in-code 'force automated runs only' comment, this handler does NOT set it false \u2014 only build-names does." - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "FieldSearchResponse__w3_sp_error" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Elasticsearch wildcard lookup with source fencing applied; returns a bounded set under `data` (each {id, value, label, count, pId}).", - "Uses the compact QueryRq shape; dateRange must be exactly two bounds. includeManualRuns defaults to true and is honoured here (not forced false)." - ] - }, - { - "name": "trigger_bulk_test_rca", - "method": "POST", - "path": "/api/v1/ai/builds/{buildUuid}/bulkTestRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "The build's UUID. All failed tests of this build (optionally narrowed by the filter body) are queued for AI RCA." - } - ], - "body": [ - { - "name": "filters", - "type": "object", - "description": "Optional. Same test-listing filter shape (FilterParamsV2) the build's test list uses \u2014 narrows which failed tests are analysed. Omit to target the build's failures as a whole.", - "fields": [ - { - "name": "status", - "type": "array", - "description": "Test statuses to include (e.g. failed). Part of the shared build test-listing filter." - } - ] - }, - { - "name": "limit", - "type": "integer", - "description": "Optional cap on how many failed runs are queued in this request." - } - ], - "intent": "Kick off AI root-cause analysis for all failed tests of a build in one go (the 'Analyse all failures' action) \u2014 optionally narrowed by the test-listing filters and capped by a limit.", - "returns": [ - "state", - "buildUuid", - "bulkRcaUuid", - "trialQuota" - ], - "responses": { - "200": { - "$response": "BulkTestRcaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: this only queues work. The reply's bulkRcaUuid is the handle for the batch and each run's finished RCA arrives later \u2014 poll the individual test runs' RCA (get_test_rca_data) rather than expecting analyses in this response.", - "Plan-fenced: a free group that is not test-level-trial eligible is refused with 403 (PLAN_FEATURE_NOT_ALLOWED). It also requires the group's AI opt-in consent and the test-runs-RCA feature on the AI token; a source-fenced build is refused with AI_FEATURE_FENCED.", - "Needs the caller's own BrowserStack username + Selenium access key, resolved server-side from the caller's user record; a caller whose record lacks them gets 400 'User details are not available'.", - "Disabled entirely in the demo-sandbox environment.", - "The body is optional \u2014 POST with no body queues the build's failures with no extra filtering." - ] - }, - { - "name": "get_test_rca_data", - "method": "GET", - "path": "/api/v1/ai/testRuns/{testRunId}/testRca/data", - "mode": "read", - "entity": "test_rca", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Read an already-generated AI RCA for a test run with NO side effects \u2014 the safe counterpart to get_test_rca that never creates a record or queues a generation.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "bulkRcaId", - "isAgentic", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pure read: no AI consent/feature checks and no generation queued. Use this to poll for a result after triggering one elsewhere.", - "When nothing has been generated yet this is a 400 (BAD_REQUEST, 'Test Run RCA not found for the given test run ID'), NOT a 404 or an empty 200 \u2014 treat that 400 as 'not generated yet', not as a bad request.", - "Group-scoped: the run is looked up within the caller's group, so a foreign run id also surfaces as not-found.", - "404 'RCA not found' means no RCA has been generated for this test run yet (not a bad id): generating one is a write (get_test_rca / trigger_test_rca) and needs user confirmation." - ] - }, - { - "name": "get_test_rca", - "method": "GET", - "path": "/ext/v1/testRun/{test_run_id}/testRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "peek", - "type": "boolean", - "default": false, - "description": "peek=true makes the call strictly read-only: it returns the current RCA state and never enqueues a generation. With the default peek=false the GET is get-or-create and CAN queue work \u2014 treat that form as a trigger, not a read." - } - ], - "intent": "Public-API fetch of a failed test run's AI root-cause analysis; by default get-or-create (queues a generation and returns the pending state if none exists), or a strict read with peek=true.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "isLive", - "pendingMessageId", - "alreadyTracked", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the public-API twin of get_test_rca and accepts username + access-key auth. Behaviour splits on peek: peek=true is safe (never enqueues); peek=false (the default) is get-or-create and has a side effect.", - "To poll without triggering, always pass peek=true. The peek-only fields isLive / pendingMessageId / alreadyTracked describe an in-flight generation; gate a re-trigger on isLive (alreadyTracked only reflects whether a chat row holds a handle and is null for web/MCP-started runs).", - "Requires AI opt-in consent and AI enablement; source-fenced groups get AI_FEATURE_FENCED. Resolves the caller's BrowserStack username/access key server-side and 400s if missing.", - "To register an https callback instead of polling, use the POST twin trigger_test_rca.", - "peek=true on a run with NO RCA returns 200 with every field null (id, state, isAgentic, ...), not a 404 - check id/state for null. isAgentic can read false on the first (Pending) response and true once generation starts." - ] - }, - { - "name": "trigger_test_rca", - "method": "POST", - "path": "/ext/v1/testRun/{test_run_id}/testRca", - "mode": "write", - "entity": "test_rca", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "body": [ - { - "name": "webhook_url", - "type": "string", - "maxLength": 2048, - "description": "Optional https callback URL the pipeline POSTs the finished RCA to when a generation is actually queued. https only; localhost/.internal/.local/loopback/private/link-local/multicast hosts are rejected (SSRF-guarded at accept time and re-checked at dispatch). Absent/blank means no callback." - } - ], - "intent": "Public-API trigger for a test run's AI RCA with a fire-and-forget callback: same get-or-queue behaviour as the GET, plus an https webhook the pipeline notifies when the analysis is ready.", - "returns": [ - "id", - "testRunId", - "rcaData", - "thinkingData", - "state", - "isLive", - "createdAt", - "updatedAt" - ], - "responses": { - "200": { - "$response": "TestRunsRcaDataResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "With no body this behaves exactly like get_test_rca with peek=false (get-or-queue). The only added capability is registering webhook_url.", - "The callback fires ONLY when a generation is actually queued \u2014 if the RCA already exists (or is already in flight) no callback is sent, so still read the 200 body's state.", - "webhook_url must be https and pass the SSRF host checks both at accept time and again at dispatch; a URL that resolves to a blocked host is rejected with a 400 naming the reason.", - "The field is named webhook_url on the wire (snake_case), not webhookUrl." - ] - }, - { - "name": "list_build_groups", - "method": "GET", - "path": "/api/v1/projects/{projectId}/build-groups", - "mode": "read", - "entity": "build_group", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "TestHub project id (NOT the observability project id) used by the build-group backend." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "description": "1-based page number." - }, - { - "name": "pageSize", - "type": "integer", - "default": 30, - "description": "Rows per page (passed through to TestHub; server default 30)." - }, - { - "name": "name", - "type": "string", - "description": "Substring name search over run groups." - }, - { - "name": "ids", - "type": "string", - "description": "Comma-separated build-group ids to fetch a specific set instead of the full listing." - } - ], - "intent": "List a project's run groups (build groups) with paging and name search \u2014 the source of run-group ids for linking, closing, editing or deleting; can also fetch a specific set by ids.", - "returns": [ - "data", - "info" - ], - "responses": { - "200": { - "$response": "BuildGroupListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The groups are at the top-level `data` array (there is no `success` wrapper on this route); paging is under `info` (page, pageSize, count, prev, next).", - "By default closed groups are excluded from this listing (the controller requests excludeClosed=true).", - "projectId in the path is the TestHub project id, not the observability project id \u2014 using the wrong one returns an empty/foreign result.", - "Each row's `state` is derived: ACTIVE until closed, then CLOSED. Take the integer `id` for the link/close/edit/delete calls." - ] - }, - { - "name": "create_build_group", - "method": "POST", - "path": "/api/v1/projects/{projectId}/build-groups", - "mode": "write", - "entity": "build_group", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "TestHub project id." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "maxLength": 255, - "description": "Run-group name. Required and must not be blank; the reserved name 'Ungrouped' (any case) is rejected with 400 'Please enter a valid group name'." - }, - { - "name": "description", - "type": "string", - "maxLength": 1000, - "description": "Optional description." - } - ], - "intent": "Create a new run group (build group) in a project so builds can be grouped for a release or test cycle.", - "returns": [ - "success", - "data", - "message" - ], - "responses": { - "200": { - "$response": "BuildGroupOperationResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Requires the project's BUILD_ADD_TO_RUN_GROUP permission.", - "The created group is at `data` (a build-group object with its integer `id`); `success` is true and `message` is 'Build group created successfully'.", - "Creation actually happens in TestHub and is mirrored back \u2014 the returned id is the TestHub group id used by the other build-group calls.", - "'Ungrouped' is reserved and rejected even though it is otherwise a valid name." - ] - }, - { - "name": "list_build_accessibility_test_runs", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/a11y-testRuns", - "mode": "read", - "entity": "accessibility", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Build UUID." - } - ], - "query": [ - { - "name": "status", - "type": "string", - "description": "Filter by test status." - }, - { - "name": "searchQuery", - "type": "string", - "description": "Substring search over test name." - }, - { - "name": "run", - "type": "array", - "description": "Restrict to specific run ids.", - "fields": [ - { - "name": "", - "type": "integer", - "description": "A run id." - } - ] - }, - { - "name": "page", - "type": "integer", - "description": "Page number (see guidance on 0/1 indexing)." - }, - { - "name": "limit", - "type": "integer", - "description": "Rows per page." - } - ], - "intent": "List the accessibility-scanned tests of one build for the build's Accessibility tab \u2014 per test: name, file, status, OS/browser, a11y score, page/component counts and a critical/serious/moderate/minor issue breakdown, with paging.", - "returns": [ - "testCases", - "pagingParams" - ], - "responses": { - "200": { - "$response": "AccessibilityTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } }, - "guidance": [ - "The build must have FINISHED (or TIMEOUT) and carry accessibility data, else a typed 4xx: A11Y_BUILD_NOT_FOUND (no build/no a11y), A11Y_BUILD_STATUS_INVALID (no status), or A11Y_BUILD_NOT_COMPLETED (still running).", - "Feature-flag fork: when the App-Accessibility flag is on for the group AND the build's source is the App-Accessibility automation source, the whole call is proxied to a separate Accessibility service and the RESPONSE SHAPE IS THAT SERVICE'S, not the one described here. Do not assume this shape for App-Accessibility builds.", - "Tests with no a11y summary/severity breakdown are SILENTLY DROPPED from the list, so the row count can be lower than the build's total tests.", - "Paging is re-based to 0-index for the frontend: pagingParams.nextRoot.page is (upstream next page - 1), and pagingParams.hasNext says whether to continue.", - "Accepts a public-link token in addition to the normal auth types (public dashboards)." - ] - }, - { - "name": "get_test_run_accessibility_meta", - "method": "GET", - "path": "/api/v1/testRuns/{btcerId}/a11y-testMeta", - "mode": "read", - "entity": "accessibility", - "path_params": [ - { - "name": "btcerId", - "type": "integer", - "required": true, - "description": "TestHub build-test-case-execution-result (btcer) id \u2014 a plain integer, NOT a test-run TR-NNN identifier." - } - ], - "query": [ - { - "name": "page", - "type": "integer", - "default": 1, - "description": "Page number. Page size is fixed server-side at 10 and cannot be changed by the caller." - } - ], - "intent": "Accessibility detail for a single test on the Test Details page \u2014 that test's a11y score, total issues and severity breakdown, and its report URL.", - "returns": [ - "totalIssues", - "severityBreakdown", - "reportURL", - "score", - "testCaseUuid" - ], - "responses": { - "200": { - "$response": "AccessibilityTestMetaResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Error mapping is LOSSY: every failure except A11Y_SERVICE_USER_UNAUTHORIZED and A11Y_SERVICE_NOT_CONFIGURED is rewritten to A11Y_TESTCASE_RESULTS_NOT_FOUND \u2014 so a genuine upstream error surfaces as 'no results'. Do not read 'not found' as definitely empty.", - "App-Accessibility feature-flag fork again: when the flag is on and the run's source is the App-Accessibility automation source, the call is proxied to a separate Accessibility service and returns that service's shape, not the one described here.", - "Page size is fixed at 10 server-side; only `page` is caller-controllable.", - "reportURL points to the a11y report to fetch elsewhere; the response itself does not embed the full report." - ] - }, - { - "name": "update_code_fix_preference", - "method": "PUT", - "path": "/api/v1/ai/projects/{projectId}/codeFixPreferences/{id}", - "mode": "write", - "entity": "ai_insight", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Observability project id. Must belong to the caller's group (IDOR-checked) and must match the group+project the preference row was created under." - }, - { - "name": "id", - "type": "integer", - "required": true, - "description": "Integer id of the code-fix preference row to edit. The row is located by this id alone; buildName in the body does not select it." - } - ], - "body": [ - { - "name": "buildName", - "type": "string", - "required": true, - "description": "Required and must be non-blank (validation), but its value is IGNORED on update \u2014 the row is located by the path id and buildName is never re-written. Send any non-blank string (echo the preference's existing build name)." - }, - { - "name": "instruction", - "type": "string", - "maxLength": 5000, - "description": "Free-text code-fix guidance. Omit (null) to leave the stored instruction unchanged. There is no way to send null to clear it; to end up with no instruction you must keep at least one file attached, else the call is 400." - }, - { - "name": "fileAttachments", - "type": "array", - "maxLength": 1, - "description": "TRI-STATE. Omit (null) = leave existing attachments untouched. Send [] = remove ALL existing attachments. Send a non-empty array = REPLACE them. On replace or remove the previously attached S3 objects are permanently deleted (no undo, no version history). At most ONE attachment (MAX_PREFERENCE_FILES=1).", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "maxLength": 512, - "description": "S3 key of the already-uploaded file from a prior code-fix-preference presign+upload. Confirmed against this group/project; an s3Key that is not confirmable is rejected." - }, - { - "name": "fileName", - "type": "string", - "required": true, - "maxLength": 200 - }, - { - "name": "contentType", - "type": "string", - "required": true, - "maxLength": 100 - } - ] - } - ], - "intent": "Edit an existing AI code-fix preference for a project/build \u2014 change the instruction text and/or replace (or remove) its attached context file.", - "guidance": [ - "fileAttachments is tri-state: null (omit) leaves attachments untouched, [] removes all of them, a non-empty array replaces them. Replacing or removing permanently deletes the old S3 objects \u2014 there is no undo and preferences keep no version history, so an overwritten instruction or removed file is gone for good.", - "The final state must still carry either an instruction or at least one file: a request that would leave the row empty is rejected 400 'At least one of instruction or fileAttachments is required' before any S3 work runs.", - "buildName is required by validation on every update but its value is silently ignored \u2014 the update targets the row named by the path id and never rewrites buildName. Do not use this call to rename the build a preference is bound to.", - "instruction omitted (null) means 'unchanged'; only a non-null instruction overwrites it. Max 5000 characters.", - "Only one attachment is allowed (max 1). Each must reference an s3Key produced by the code-fix-preference presign+upload flow; a key not confirmable for this group/project fails the request.", - "The path id must belong to the SAME group and project as the path projectId, else 400 'Preference does not belong to this project' (not 404). A missing id is 404 code-fix-preferences-not-found.", - "Plan-gated: 403 plan-feature-not-allowed when the AI-TFA fence trips, and per-user AI must be enabled for the caller." - ], - "returns": [ - "id", - "projectId", - "buildName", - "instruction", - "fileAttachments", - "createdBy", - "updatedBy", - "createdAt", - "updatedAt" - ], "responses": { - "200": { - "description": "Updated code-fix preference. fileAttachments[].downloadUrl is a freshly presigned GET URL, omitted when its generation failed.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "projectId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "instruction": { - "type": "string" - }, - "fileAttachments": { - "type": "array", - "items": { + "Unauthorized": { + "description": "401 - no valid credential was accepted by the authenticator chain.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Forbidden": { + "description": "403 - authenticated but lacks the required permission/plan/role, or access-denied.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BadRequest": { + "description": "400 - invalid or missing parameters; also the catch-all for many client errors.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "NotFound": { + "description": "404 - the addressed resource does not exist or is not visible to this group.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "InternalServerError": { + "description": "500 - unhandled server error; carries only errorTraceId + message.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "Conflict": { + "description": "409 - the request conflicts with current state, e.g. an operation already in progress.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "BuildSummaryListResponse": { + "description": "Top-level array of build summaries.", + "schema": { + "type": "array", + "items": { + "$schema": "BuildSummary" + } + } + }, + "BuildAggregateMapResponse": { + "description": "Top-level object keyed by build name; each value is a BuildAggregate.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "BuildAggregate" + } + } + }, + "OverallDailySummaryResponse": { + "description": "Aggregated daily-summary report across projects.", + "schema": { + "$schema": "OverallDailySummary" + } + }, + "PublicLinkSettingResponse": { + "description": "Public-link on/off setting for a dashboard or project. Built inline by the handler; defaults to false when no token row exists yet.", + "schema": { + "type": "object", + "properties": { + "publicLink": { + "type": "boolean", + "description": "Whether public link sharing is enabled. false is the default for a dashboard/project that has never been shared." + } + } + } + }, + "QualityGateSettings": { + "description": "A project's quality-gate settings and mapped profiles.", + "schema": { + "$schema": "QualityGateResponse" + } + }, + "QualityProfileDetail": { + "description": "One quality profile in full (rules, filters, status). Null fields omitted.", + "schema": { + "$schema": "QualityProfile" + } + }, + "BuildQualityGateSummary": { + "description": "Readiness-aware build gate summary; exactly one of qgResult/message/state is present.", + "schema": { + "$schema": "QualityGateSummary" + } + }, + "BuildQualityProfileResults": { + "description": "Bare JSON array of per-profile result summaries for a build (no wrapper).", + "schema": { + "type": "array", + "items": { + "$schema": "QualityProfileResultLiteDto" + } + } + }, + "BuildQualityProfileResultDetail": { + "description": "One profile's per-rule results for a build.", + "schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$schema": "QualityProfileRuleResult" + } + }, + "ruleStatus": { + "type": "string", + "enum": [ + "pass", + "fail" + ] + } + } + } + }, + "CiQualityGateResult": { + "description": "CI-facing pass/fail quality gate result for a build. Null fields omitted while running.", + "schema": { + "$schema": "ExtQualityGateResult" + } + }, + "QualityProfileFilterValues": { + "description": "Filter values for the quality-profile filter builder, with an afterKey cursor.", + "schema": { + "$schema": "QGFilterResponseData" + } + }, + "PercentTimelineCardResponse": { + "description": "A Testing Trends card: timeline series plus optional headline insight.", + "schema": { + "$schema": "PercentTimelineCard" + } + }, + "FailureCategoriesResponse": { + "description": "Failure counts broken down by defect category over time.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FailureCategorySeries" + } + } + } + } + }, + "CbtResponse": { + "description": "Cross-browser coverage: primary platform groups plus their drill-down breakdowns.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "CbtDataPoint" + } + }, + "drillDownData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Primary group key this drill-down belongs to." + }, + "data": { + "type": "array", + "items": { + "$schema": "CbtDataPoint" + } + } + } + } + } + } + } + }, + "BuildFrequencyResponse": { + "description": "Run counts per build name for the window. sourceFencingFlags exists on the model but is not populated on this path.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildFrequencyStats" + } + } + } + } + }, + "BuildNamesResponse": { + "description": "Distinct build names within the plan retention window, wrapped under data.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "StabilityLeaderboardResponse": { + "description": "Paged stability leaderboard. Pagination is nested under `pagingParam` (singular).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "StabilityLeaderboardRow" + } + }, + "pagingParam": { + "$schema": "TrendPagingParams" + } + } + } + }, + "PerformanceLeaderboardResponse": { + "description": "Paged performance leaderboard. Pagination is nested under `pagingParam` (singular).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "PerformanceLeaderboardRow" + } + }, + "pagingParam": { + "$schema": "TrendPagingParams" + } + } + } + }, + "StabilityChartResponse": { + "description": "Stability-over-time series with the window median.", + "schema": { + "type": "object", + "properties": { + "median": { + "type": "number", + "description": "Overall pass-rate percentage for the window." + }, + "data": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + } + } + } + }, + "PerformanceChartResponse": { + "description": "Performance-over-time: barData is average duration per bucket, lineData is test count per bucket (lineData pointRange is null).", + "schema": { + "type": "object", + "properties": { + "barData": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + }, + "lineData": { + "type": "array", + "items": { + "$schema": "TrendTimelineDataPoint" + } + } + } + } + }, + "RecentRunsResponse": { + "description": "Recent builds picker. Pagination is nested under `pagingParams` (plural).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TrendBuildSummary" + } + }, + "pagingParams": { + "$schema": "TrendPagingParams" + }, + "currentQGOverrideStatus": { + "type": "boolean", + "description": "Project's quality-gate override flag." + } + } + } + }, + "WebhookProjectMappingResponse": { + "description": "Project picker / mapping list for the webhook editor.", + "schema": { + "type": "object", + "properties": { + "projectMapping": { + "$schema": "WebhookProjectMapping" + } + } + } + }, + "RcaRetriggerResponse": { + "description": "New RCA conversation turn id (and trial quota for trial users).", + "schema": { + "$schema": "RcaRetrigger" + } + }, + "BuildAccessibilityMetaResponse": { + "description": "Build accessibility summary header (web/Automate shape).", + "schema": { + "$schema": "BuildAccessibilityMeta" + } + }, + "SimilarTestRunsResponse": { + "description": "Reference run and the runs with a similar failure.", + "schema": { + "$schema": "SimilarTestRunRs" + } + }, + "SingleMetricSummaryResponse": { + "description": "A single build-insight tile metric.", + "schema": { + "$schema": "SingleMetricSummary" + } + }, + "BuildAISummaryResponse": { + "description": "AI-generated build failure summary; soft-fails with status NOT_AVAILABLE.", + "schema": { + "$schema": "BuildAISummary" + } + }, + "BuildAttemptRunStatsResponse": { + "description": "Per-attempt result breakdown plus an aggregate summary.", + "schema": { + "$schema": "BuildAttemptRunStatsResponse" + } + }, + "BuildHistoryResponse": { + "description": "Trend over the ~10 most recent builds of the same name.", + "schema": { + "$schema": "BuildHistory" + } + }, + "BuildIdFromCoordinatesResponse": { + "description": "The resolved build UUID for the given coordinates.", + "schema": { + "$schema": "BuildIdFromCoordinates" + } + }, + "BuildCoordinatesResponse": { + "description": "URL-safe coordinates and project ids for a build UUID.", + "schema": { + "$schema": "BuildCoordinates" + } + }, + "BuildReRunResponse": { + "description": "Flat (legacy) re-run summary for a build.", + "schema": { + "$schema": "BuildReRun" + } + }, + "BuildReRunV2Response": { + "description": "Split-build-aware grouped re-run summary with an aggregate.", + "schema": { + "$schema": "BuildReRunV2" + } + }, + "FieldSearchResponse": { + "description": "Type-ahead facet options for a per-project build filter. `data` is a set of {id, value, label} option objects.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterField" + } + } + } + } + }, + "SidebarSearchResponse": { + "description": "Sidebar facet options. `data` is a list of BARE VALUES \u2014 plain strings for branches, build names and tags. Capped at 100 entries by the underlying ES aggregation.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "BuildListFiltersResponse": { + "description": "Per-project build-listing filter bar: applied selection resolved plus the static option catalogue.", + "schema": { + "type": "object", + "properties": { + "applied": { + "$schema": "BuildFilterApplied" + }, + "staticFilters": { + "$schema": "BuildFilterStatic" + } + } + } + }, + "BuildSidebarFiltersResponse": { + "description": "Cross-project build-listing sidebar filters: applied selection resolved (with the projects it was scoped to) plus the static option catalogue. frameworkList here is the hardcoded master list, not a per-project presence list.", + "schema": { + "type": "object", + "properties": { + "applied": { + "allOf": [ + { + "$schema": "BuildFilterApplied" + } + ], + "properties": { + "projects": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "The project ids this filter view was scoped to." + } + } + }, + "staticFilters": { + "$schema": "BuildFilterStatic" + } + } + } + }, + "BuildTestFiltersResponse": { + "description": "Filter panel for a build's TEST listing. `staticFilters` is a map keyed by filter name to a list of {label, value} options. `proFilters` names plan-locked filter keys (non-empty only for freemium).", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "The caller's applied test filters, echoed back resolved (folders, testTags, status, priority, caseType, automationStatus, owner, lastExecutedBy, deviceList, osList, browserList, failureCategories, dateRange, and the boolean history/flaky flags)." + }, + "staticFilters": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "FilterStruct__w2_build_filter1" + } + }, + "description": "Catalogue of selectable test-filter values, keyed by filter name (browserList, osList, status, testTags, folders, runs, deviceList, failureCategories, history, flaky, ...)." + }, + "hooksVisibility": { + "type": "string", + "description": "One of all / failed / beforeFailed / none." + }, + "proFilters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter keys locked behind a paid plan; empty for a paid workspace, else names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted." + } + } + } + }, + "RunGroupFilterOptionsResponse": { + "description": "Run-group (build-group) sidebar facet. `data` is a list of slim build-group rows; `info` is page metadata but paging is fixed at page 1, size 100 and cannot be advanced through this endpoint.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "description": "Slim build-group row. Casing is mixed: `updated_at` is snake_case.", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isClosed": { + "type": "boolean" + }, + "state": { + "type": "string", + "nullable": true + }, + "updated_at": { + "type": "string" + } + } + } + }, + "info": { + "type": "object", + "description": "Page metadata (fixed page 1 / size 100).", + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "prev": { + "type": "integer", + "nullable": true + }, + "next": { + "type": "integer", + "nullable": true + } + } + } + } + } + }, + "DashboardResponse": { + "description": "A single dashboard, wrapped in `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "Dashboard" + } + } + } + }, + "DashboardsListResponse": { + "description": "List of dashboards, wrapped in `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "Dashboard" + } + } + } + } + }, + "DashboardMetaResponse": { + "description": "Ids resolved to display names.", + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$schema": "MetaItem" + } + }, + "users": { + "type": "array", + "items": { + "$schema": "MetaItem" + } + } + } + } + }, + "UniqueErrorSearchResponse": { + "description": "Top-level JSON array of unique-error search hits (max 10). There is no wrapper object.", + "schema": { + "type": "array", + "items": { + "$schema": "ErrorSearchResult" + } + } + }, + "WidgetCustomFieldFiltersResponse": { + "description": "Custom-field filters. Shape depends on the request: with NO page/per_page/search it is a bare JSON ARRAY of FilterDetails; with any of them it is the paginated object {data, info}.", + "schema": { + "oneOf": [ + { + "type": "array", + "items": { + "$schema": "FilterDetails" + }, + "description": "Unpaginated: returned when page/per_page/search are all omitted." + }, + { + "type": "object", + "description": "Paginated: returned when any of page/per_page/search is present.", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "FilterDetails" + } + }, + "info": { + "$schema": "DashboardPageInfo" + } + } + } + ] + } + }, + "ReportListResponse": { + "description": "Reports/dashboards listing. On an invalid `source` this same 200 carries a top-level `error` string and empty `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "ReportSummary" + } + }, + "my_report_count": { + "type": "integer" + }, + "total_report_count": { + "type": "integer" + }, + "info": { + "$schema": "ReportListPageInfo" + }, + "error": { + "type": "string", + "description": "Present only on the invalid-source 200 refusal." + } + } + } + }, + "ProjectAutomationCoverageResponse": { + "description": "All-time automation-coverage figures for one project. Null fields are omitted (NON_NULL).", + "schema": { + "type": "object", + "properties": { + "project_id": { + "type": "integer" + }, + "project_name": { + "type": "string" + }, + "total_test_case_count": { + "type": "integer" + }, + "automated_test_case_count": { + "type": "integer" + }, + "automation_coverage_percent": { + "type": "number" + }, + "calculated_at": { + "type": "string", + "format": "date-time", + "description": "When this figure was computed." + } + } + } + }, + "ProjectListingExternalResponse": { + "description": "One page (up to 10) of the caller's projects, plus the cursor for the next page.", + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$schema": "ProjectExternal" + } + }, + "pagination": { + "$schema": "ExternalPagination" + } + } + } + }, + "HomeUniqueBuildsResponse": { + "description": "Latest runs grouped by unique build name, with dual offset/cursor pagination params.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildSummary" + }, + "description": "One BuildSummary per unique build name (its most recent run)." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "pageNumber": { + "type": "integer" + }, + "afterKey": { + "type": "string", + "description": "search_after cursor to carry into the next request." + } + } + } + } + } + }, + "HomeRecentViewsResponse": { + "description": "The user's up-to-5 recently-viewed items.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "RecentView" + } + } + } + } + }, + "HomeTakeawaysResponse": { + "description": "Older Home takeaways variant: aggregate time-saved metric. The v2 timeSavingV2Metric key is present but null on this path.", + "schema": { + "type": "object", + "properties": { + "timeSavingsData": { + "$schema": "TimeSavingMetric" + }, + "takeAwayData": { + "$schema": "TakeAwayData" + }, + "timeSavingV2Metric": { + "description": "Always null on this older variant (the key is present, the value is not).", + "nullable": true + } + } + } + }, + "HomeTakeawaysNoiseBreakdownResponse": { + "description": "Newer Home takeaways variant: failure-noise breakdown. timeSavingsData is explicitly null on this path.", + "schema": { + "type": "object", + "properties": { + "timeSavingV2Metric": { + "$schema": "TimeSavingV2Metric" + }, + "timeSavingsData": { + "type": "null", + "description": "Always null through this endpoint." + }, + "takeAwayData": { + "$schema": "TakeAwayData" + } + } + } + }, + "ProjectBuildGroupListingResponse": { + "description": "A project's builds consolidated by build group, plus per-group aggregates and cursor pagination.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "isHittingRetentionQuota": { + "type": "boolean" + }, + "builds": { + "type": "array", + "items": { + "$schema": "BuildSummary" + }, + "nullable": true, + "description": "Never populated on this route (always null); the per-group rows are in buildGroupAggResult." + }, + "buildGroupAggResult": { + "type": "array", + "items": { + "$schema": "BuildGroupAggResult" + }, + "description": "Per-build-group rolled-up aggregates." + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "searchAfter": { + "description": "Cursor (array or integer) to carry into the next request." + } + }, + "nullable": true, + "description": "Never populated on this route (always null); all groups come back in one response." + }, + "activeCount": { + "type": "integer" + }, + "closedCount": { + "type": "integer" + }, + "isArchiving": { + "type": "boolean" + }, + "isClosing": { + "type": "boolean" + }, + "manualBuildCount": { + "type": "integer" + }, + "automationBuildCount": { + "type": "integer" + }, + "invalidQgBuildCount": { + "type": "integer" + }, + "currentQGOverrideStatus": { + "type": "boolean", + "nullable": true + } + } + } + }, + "SPTestFilterOptions": { + "description": "Suite Health test-list filter sidebar: the applied filters resolved into display fields, the static option catalogue, and the project default hooks visibility.", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "The currently applied filters echoed back: list filters resolved into Field objects (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList/osList/browserList, branches, pmToolStatus), scalar/tri-state flags (search, isFlaky, isNewFailure, isAlwaysFailing, hasPmToolDefects, isManuallyOverridden, isCiRun, isLatest), dateRange as {lowerBound, upperBound}, isMuted and hooksVisibility. Filters you did not send come back null or [] (inconsistently per key)." + }, + "staticFilters": { + "type": "object", + "description": "The choosable option catalogue: osList, browserList, deviceList, status, folders, failureCategories, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan - each a list of Field objects. The run-level lists (typeOfRun, assignedTo, state, testPlan) come from an upstream service and are null or [] when it has nothing or times out, rather than erroring." + }, + "hooksVisibility": { + "type": "string", + "description": "The project's default hooks visibility (all / failed / beforeFailed / none)." + } + } + } + }, + "FieldSearchResults": { + "description": "A set of filter suggestions/options. Shared by the branch, build-name, build-tag, host-name and test-tag type-ahead routes.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "description": "Suggestion items (deduplicated set).", + "items": { + "$schema": "Field" + } + } + } + } + }, + "SPTestMetricsChart": { + "description": "Suite Health page charts. A top-level map keyed by chart name (no data wrapper).", + "schema": { + "type": "object", + "properties": { + "failureChart": { + "$schema": "SPChart" + }, + "durationChart": { + "$schema": "SPChart" + }, + "failureRateChart": { + "$schema": "SPChart" + } + } + } + }, + "SPTestBaseInfo": { + "description": "Identifying header info for one test in the Test Health detail view. Flat object, no data wrapper.", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Test display name; empty string when the hash matches nothing in the window." + }, + "combinations": { + "type": "array", + "description": "OS/browser/device environment combinations the test ran on.", + "items": { + "type": "object", + "properties": { + "os": { + "type": "object", + "description": "Formatted platform descriptor." + }, + "browser": { + "type": "object", + "description": "Formatted browser descriptor, with a nested `device` field." + } + } + } + } + } + } + }, + "SPTestEnvironmentBreakdown": { + "description": "Per-environment stability breakdown for one test. A BARE JSON array (no object wrapper).", + "schema": { + "type": "array", + "items": { + "$schema": "SPTestBreakdownRow" + } + } + }, + "TestRunDetailResponse": { + "description": "Full detail of a single test run.", + "schema": { + "$schema": "TestRunDetail" + } + }, + "TestRunDetailMapResponse": { + "description": "A MAP keyed by test run id; each value is that run's detail ({ testCode, testMetadata }). Empty object when nothing matched \u2014 not an error.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestRunDetail" + } + } + }, + "BuildInsightsResponse": { + "description": "Build-level insights surrounding a test run.", + "schema": { + "$schema": "BuildInsights" + } + }, + "BuildTestRunListingResponse": { + "description": "A build's test runs as a nested hierarchy, plus pagination. Keys are snake_case (TestListingResponse / V2 by account flag); V2 adds is_manually_overridden and test_summary holds workflow-status counts.", + "schema": { "type": "object", "properties": { - "s3Key": { - "type": "string" - }, - "fileName": { - "type": "string" - }, - "contentType": { - "type": "string" - }, - "downloadUrl": { - "type": "string", - "description": "Presigned GET URL for the attachment. Present only when it could be generated (NON_NULL)." - } + "hierarchy": { + "type": "array", + "description": "Nested nodes (suites/folders/tests), each with details and children. On freemium/source-fenced builds analysis keys are stripped from node details.", + "items": { + "type": "object" + } + }, + "project_id": { + "type": "integer" + }, + "name": { + "type": "string", + "description": "Project name." + }, + "group_id": { + "type": "integer" + }, + "build_id": { + "type": "string", + "description": "The build's hashed uuid." + }, + "build_name": { + "type": "string" + }, + "build_number": { + "type": "integer" + }, + "original_name": { + "type": "string" + }, + "test_summary": { + "type": "object", + "description": "Per-status counts: StatusAggregate (older envelope) or WorkflowStatusAggregate (newer), same key." + }, + "is_manually_overridden": { + "type": "boolean", + "description": "Newer envelope only." + }, + "is_archived": { + "type": "boolean" + }, + "app_details": { + "type": "object" + }, + "pagination": { + "type": "object", + "description": "snake_case keys (models/external/Pagination).", + "properties": { + "has_next": { + "type": "boolean" + }, + "next_page": { + "type": "string", + "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." + } + } + } } - } - }, - "createdBy": { - "type": "integer" - }, - "updatedBy": { - "type": "integer" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "improve_test_rca", - "method": "POST", - "path": "/ext/v1/ai/testRuns/{test_run_id}/rcaContext/retrigger", - "mode": "write", - "entity": "ai_insight", - "path_params": [ - { - "name": "test_run_id", - "type": "integer", - "required": true, - "description": "Integer test-run id. IDOR-scoped to the caller's group via its build." - } - ], - "body": [ - { - "name": "instruction", - "type": "string", - "maxLength": 5000, - "description": "Extra user instruction to steer the improved RCA. Optional." - }, - { - "name": "attachments", - "type": "array", - "maxLength": 3, - "description": "Up to 3 context files, each already uploaded via the RCA-context presign flow.", - "fields": [ - { - "name": "s3Key", - "type": "string", - "required": true, - "maxLength": 512 - }, - { - "name": "fileName", - "type": "string", - "required": true, - "maxLength": 200 - }, - { - "name": "contentType", - "type": "string", - "required": true, - "maxLength": 100 - }, - { - "name": "description", - "type": "string", - "maxLength": 500 - } - ] - }, - { - "name": "usePreferences", - "type": "boolean", - "description": "Whether to fold the project's stored code-fix preferences into the retrigger." - }, - { - "name": "webhook_url", - "type": "string", - "maxLength": 2048, - "description": "Optional https callback the pipeline POSTs the finished RCA to. SSRF-validated at accept time and again at dispatch: https only, and NOT localhost/private/link-local/.internal/.local hosts \u2014 a violation is 400. Absent/blank means no callback." - } - ], - "intent": "Re-run ('improve') a test's RCA with an extra instruction and optional attachments, on behalf of an agent acting for the customer \u2014 returns the new conversation message id to poll.", - "guidance": [ - "Precondition-heavy: requires an existing RCA row for the test run, AI opt-in consent, and RCA-v2 eligibility, and the test run must not exceed the age limit \u2014 otherwise it is refused before any generation.", - "Refused with FORBIDDEN_OPERATION when the run is already owned by the MCP-driven agent flow.", - "webhook_url must be https and must not target localhost/private/link-local/.internal/.local hosts; it is validated when accepted and re-validated at dispatch, so a bad URL is a 400 up front.", - "instruction is capped at 5000 chars and at most 3 attachments are accepted; each attachment needs an s3Key from a prior RCA-context presign+upload.", - "Asynchronous: the body carries only messageId (a UUID) \u2014 poll the RCA conversation/turn-status read for the outcome; the RCA itself is delivered elsewhere (the polled read, or the webhook when supplied). trialQuota.used appears only for trial tenants.", - "This is the external mirror of the session-auth internal retrigger; authenticate with the customer's BrowserStack username + access key." - ], - "returns": [ - "messageId", - "trialQuota" - ], - "responses": { - "200": { - "description": "Retrigger accepted; poll the returned messageId.", - "schema": { - "type": "object", - "properties": { - "messageId": { - "type": "string", - "description": "UUID of the new conversation turn to poll." - }, - "trialQuota": { - "type": "object", - "description": "Present (NON_NULL) only for trial tenants.", - "properties": { - "used": { - "type": "integer" + }, + "BuildTestRunsWithAttachmentsResponse": { + "description": "Trimmed test-run rows that have attachments/screenshots, with a numeric cursor.", + "schema": { + "type": "object", + "properties": { + "test_runs": { + "type": "array", + "items": { + "$schema": "BuildTestRunWithAttachments" + } + }, + "next_cursor": { + "type": "integer", + "description": "Numeric cursor; null on the last page. Pass back as `cursor`." + } } - } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_split_build_legs", - "method": "GET", - "path": "/api/v1/builds/{uuid}/getSplitBuildDetails", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The PARENT build's 40-char uuid. The child legs are resolved as builds whose parentId is this build's id." - } - ], - "query": [ - { - "name": "pageLimit", - "type": "integer", - "default": 10, - "description": "Page size (child legs per page). Defaults to 10 when omitted. Bound from the query string despite the DTO marking it @JsonIgnore (that only affects JSON body binding, not query binding)." - }, - { - "name": "searchAfter", - "type": "string", - "pattern": "^[0-9a-zA-Z]{40}$", - "description": "Cursor for the NEXT page: the build UUID of the last leg you received, NOT an offset or page number. Must be a 40-char alphanumeric build uuid or the request is rejected. Echoed back to you in pagingParams.searchAfter." - } - ], - "intent": "List the individual legs (child builds) of a split / parallel build with their ids and uuids, so you can offer a leg switcher or aggregate stats across the whole split run.", - "returns": [ - "id", - "name", - "builds", - "pagingParams" - ], - "responses": { - "200": { - "$response": "SplitBuildLegsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Pagination is CURSOR-based, keyed by build uuid: read `pagingParams.searchAfter` (the last leg's uuid) from the response and pass it as the next call's `searchAfter`. There is no offset/page-number paging here.", - "Walk pages while `pagingParams.hasNext` is true; hasNext is set from whether the page came back full (>= pageLimit), so a final short page ends the walk.", - "An unknown parent uuid is ObservabilityException(BUILD_NOT_FOUND), not a 200 with an empty list.", - "Each leg in `builds` is a full build-summary object (same shape as get_build_details), so you get every leg's status and counts without a per-leg call.", - "`builds` are the CHILD legs only; the parent build itself is addressed by get_build_details." - ] - }, - { - "name": "get_build_notification_summary", - "method": "GET", - "path": "/api/v1/builds/{id}/summaryLite", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "id", - "type": "integer", - "required": true, - "description": "The NUMERIC build id (not the 40-char uuid). This route takes the integer id; passing a uuid will not resolve." - } - ], - "intent": "Get the compact build summary used to render the build-summary email / notification card \u2014 name, project, run-by, status/issue-type/history aggregates, muted and total counts, build alerts and quality-gate status.", - "returns": [ - "uuid", - "name", - "projectName", - "status", - "totalCount", - "mutedCount", - "qualityGateStatus", - "statusStats", - "issueTypeAggregate", - "historyAggregate", - "alerts", - "buildNumber", - "colorMapping" - ], - "responses": { - "200": { - "$response": "BuildNotificationSummaryResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Takes the INTEGER build id, unlike almost every other build endpoint (which take the uuid). Get the integer id from a build read or listing first.", - "An unknown id returns HTTP 200 with a NULL body, not a 404 \u2014 treat an empty/null response as 'build not found', not as an empty summary.", - "This is NOT group-scoped in the lookup: it resolves the build by id alone. Do not rely on it to enforce workspace ownership.", - "This is a different payload from get_build_details / get_build_summary_stats \u2014 it is the trimmed 'email card' shape (BuildSummaryEmail) with formatted duration/date helpers, not the full detail object.", - "`accessibility` is present only for APP_ALLY_AUT builds (populated fail-open) and omitted entirely otherwise; do not treat its absence as a11y failure.", - "`startedAt` is not serialised as a raw timestamp \u2014 the payload exposes formatted date/time/duration helper fields instead." - ] - }, - { - "name": "get_build_stability_tile", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildStabilityStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Any extra query params not bound to the FilterParamsV2 fields are collected into a custom-params map and forwarded to the query. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the stability tile for a build \u2014 the share of tests that ran cleanly versus flaky / always-failing / new-failure noise, with a percentile insight versus recent builds \u2014 for the build insights page.", - "returns": [ - "data", - "keys", - "overview", - "median", - "commentCount" - ], - "responses": { - "200": { - "$response": "BuildStabilityTileResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403 (FORBIDDEN_OPERATION / plan feature not allowed) \u2014 the stability tile is a gated feature.", - "An unknown uuid is BUILD_NOT_FOUND, not an empty tile.", - "This is the stability TILE (single build, based on the last ~30 runs, `overview.meta` says how many) \u2014 not the historical stability chart (that is get_build_stability_chart).", - "This route accepts the full FilterParamsV2 filter set as query params plus arbitrary customParams; filters narrow which of the build's tests feed the stability number.", - "`overview.count` is a percentage string (e.g. \"92.3\") and `overview.insights` is a positive/negative/default verdict versus recent builds \u2014 it is only populated once there are >= 10 comparison runs." - ] - }, - { - "name": "get_build_summary_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildSummary", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the headline test-count strip for a build under the currently applied filters \u2014 total / passed / failed / skipped / pending roll-ups plus AI-RCA and session counts \u2014 the summary strip at the top of the build insights page.", - "returns": [ - "data", - "aiRcaStats", - "isArchived", - "commentCount", - "hooksVisibility", - "manuallyOverriddenCount", - "sessionStats" - ], - "responses": { - "200": { - "$response": "BuildSummaryStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Name-collision trap: this is `/{uuid}/buildSummary` (headline counters, action getBuildStats). It is NOT `/{uuid}` build metadata (get_build_details) and NOT `/{uuid}/getBuildStats` (get_build_non_sdk_test_counts). The similar names back completely different payloads.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "`data` is an array of colored metric tiles (each `{id, name, value, color, testsCount, hooksCount, ...}`), not a flat counts object \u2014 read each tile's `value`.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the counters reflect the applied filters, so an unfiltered total needs an unfiltered call." - ] - }, - { - "name": "get_build_timeline_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/buildTimelineStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "intent": "Get the build's execution timeline \u2014 per-status series (passed / failed / pending=running) over the build's lifetime \u2014 for the timeline visualisation on the build page.", - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "BuildTimelineStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No filters apply on this route \u2014 it takes only the uuid; any query params are ignored.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "The payload is a single top-level `data` array of three series (ids passed, failed, pending - pending is the running count), each carrying `[x, value]` pairs where x is milliseconds since the first test started, not an epoch timestamp. Only passed/failed runs are counted." - ] - }, - { - "name": "get_build_details", - "method": "GET", - "path": "/api/v1/builds/{uuid}", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "run", - "type": "integer", - "required": false, - "description": "Optional attempt / run number to scope the summary to a specific re-run attempt. Omit for the latest." - } - ], - "intent": "Get the main build-detail payload for one build \u2014 status, run counts, framework/CI metadata, cross-product and test-plan info \u2014 plus the per-build UI gates (whether to show the Self-Healing tab, the Authoring Agent tab, and whether App Accessibility is enabled).", - "returns": [ - "uuid", - "id", - "name", - "status", - "displayStatus", - "duration", - "statusStats", - "issueTypeAggregate", - "historyAggregate", - "projectId", - "qualityGateStatus", - "showHealingReport", - "showAuthoringReport", - "appAccessibilityEnabled", - "tags", - "tcmTestPlans", - "workflowStatusStats" - ], - "responses": { - "200": { - "$response": "BuildDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "An unknown uuid throws ObservabilityException(BUILD_NOT_FOUND) (a 404-equivalent), not a 200 with an empty body.", - "`showAuthoringReport` and `showHealingReport` are UI gates derived from an S3 key-existence check on the build metadata \u2014 they say whether the report EXISTS to show, not whether you may access it. On demo-sandbox environments `showAuthoringReport` is forced true regardless.", - "`appAccessibilityEnabled` is a per-group feature flag and fails CLOSED (false) if the flag lookup errors \u2014 do not read false as 'definitely no a11y data'.", - "`duration` is the consolidated duration when present, otherwise the raw duration \u2014 the field already resolves this, so use it directly.", - "`displayStatus` is the settled pipeline verdict and is preferred over `status` for the final outcome; `status` can still reflect a live aggregation mid-run.", - "Use the `run` query param to inspect a specific re-run attempt; omitting it returns the latest attempt's summary." - ] - }, - { - "name": "get_build_failure_category_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/defectStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the failure-category (defect) breakdown for a build \u2014 how many failures fall into each issue type/category, how many have AI RCA available, and the comment count \u2014 for the 'Failure Categories' panel.", - "returns": [ - "data", - "aiRcaStats", - "commentCount", - "message" - ], - "responses": { - "200": { - "$response": "BuildFailureCategoryStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "While the build is still PENDING (or has no status yet) this returns HTTP 200 with an EMPTY `data` array, zeroed `aiRcaStats`/`commentCount`, and `message` = 'Failure Categories will be available after your build finishes'. That is a valid 200, not an error \u2014 check `message`/`data` before concluding there are no defects.", - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Each `data` entry is a category with a `value` count and optional nested `categories` sub-buckets; `aiRcaStats.completedCount` is how many of these failures already have AI RCA.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams; the breakdown reflects the applied filters." - ] - }, - { - "name": "get_build_failures_by_module", - "method": "GET", - "path": "/api/v1/builds/{uuid}/failureByModules", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get a build's failures grouped by test module / file path, so you can see which modules or suites contribute most of the failures.", - "returns": [ - "data", - "commentCount", - "isConflicting", - "sourceFencingFlags" - ], - "responses": { - "200": { - "$response": "BuildFailuresByModuleResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Each `data` entry is a module node with a `value` (total) and `failedTests` count, plus `parent`/`customPathSplit` so you can rebuild the folder/module tree \u2014 the path separator is inferred per node (`/` or `\\`).", - "`sourceFencingFlags` is present only when the group's source-fencing feature excludes the build's product; when present it names which products' tests were skipped from the aggregation, so counts may be partial.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_filter_values", - "method": "GET", - "path": "/api/v1/builds/{buildId}/getFilters", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (despite the `buildId` name, this is the 40-char uuid, resolved via getBuildbyUuId)." - } - ], - "intent": "Get the distinct filter values available for one build's test listing (browsers, OSes, tags, issue types, statuses, plus static history/flaky options) so the UI can render the filter dropdowns.", - "returns": [ - "filters" - ], - "responses": { - "200": { - "$response": "BuildFilterValuesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "This is the OLDER filter shape. The richer replacement is get_build_test_filters (route `/{buildUuid}/filters`), which additionally returns the currently-applied filters, hooks visibility, split-build children and freemium pro-gating. Prefer that one unless you specifically need this compact shape.", - "Not group-scoped in the lookup (resolves the build by uuid alone).", - "The payload is a single `filters` map keyed by facet name (browser, os, issueType, tags, status, folder, history, flaky), each a list of `{label, value}` options; `history` and `flaky` are static option lists always present.", - "Values are computed from the build's LATEST test runs, so a facet a filter narrowed away will not appear as an option here." - ] - }, - { - "name": "get_build_flaky_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/flakyStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the flaky-tests tile for a build \u2014 how many of the build's tests were detected as flaky (with a 'out of total' meta) plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary: count/meta/commentCount/autoUnmuteCount) as get_build_muted_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the flaky count; `meta` carries the 'out of N' context string.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "list_build_test_folders", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/folder", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (despite the `buildId` name it is the 40-char uuid). WARNING: there is no not-found guard \u2014 an unknown uuid NPEs into a 500, not a 404." - } - ], - "query": [ - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Absolute folder path within the build's test listing. OMIT it (or send blank) to get the paginated ROOT folders with pass/fail roll-ups; provide it to get that folder's details and its immediate sub-folders." - }, - { - "name": "p", - "type": "integer", - "default": 1, - "description": "Page number, used ONLY on the root-folder (no folder_path) branch. Ignored on the sub-folder branch." - }, - { - "name": "size", - "type": "integer", - "default": 30, - "description": "Page size, used ONLY on the root-folder branch. The sub-folder branch ignores it and uses default paging." - }, - { - "name": "after_key", - "type": "string", - "required": false, - "description": "Composite-aggregation cursor for the root-folder branch. Ignored on the sub-folder branch. NOTE: the response's `after_key` is returned null even on the root branch (see guidance)." - }, - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded." - } - ], - "intent": "Browse the folder-tree view of a build's test listing: with no folder_path, list the paginated root folders (with pass/fail roll-ups); with a folder_path, get that folder's details and its sub-folders. All the build's test-listing filters apply.", - "returns": [ - "project_id", - "folders", - "name", - "folder_path", - "cases_count", - "total_cases_count", - "sub_folders_count", - "sub_folders", - "pagingParams", - "after_key" - ], - "responses": { - "200": { - "$response": "BuildTestFoldersResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The response SHAPE depends on the branch. No `folder_path` -> root shape `{project_id, folders[], pagingParams, after_key}`. With `folder_path` -> folder-detail shape `{project_id, name, folder_path, cases_count, total_cases_count, sub_folders_count, sub_folders[], pagingParams}`. Do not expect `folders` on the detail branch or `sub_folders` on the root branch.", - "There is NO build-not-found guard on this route: an unknown uuid throws an NPE that surfaces as a 500, not a clean 404. Validate the uuid (e.g. via get_build_details) first.", - "Root-folder pagination is composite-aggregation based; walk it with `pagingParams` (and `after_key`/`p`). Note the top-level `after_key` in the response is set to null even on the root branch \u2014 rely on `pagingParams` to page.", - "Passing a `folder_path` on this route ignores `p`/`size`/`after_key` entirely and returns that folder with default paging.", - "The full FilterParamsV2 test-listing filter set (status, tags, users, dateRange, isFlaky, ...) is bound from query params and applied to the folder roll-ups; delegates to TestHub's folder-hierarchy API.", - "A blank/whitespace `folder_path` is treated as 'no path' (root branch); a non-blank but unresolvable path on the detail branch is a 400 'Invalid folder_path'." - ] - }, - { - "name": "get_build_muted_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/mutedStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the muted-tests tile for a build \u2014 how many of the build's tests are muted (with a 'out of total' meta) plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_new_failure_stats \u2014 only the metric differs. `count` is the muted count; `autoUnmuteCount` is how many are scheduled to auto-unmute.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_new_failure_stats", - "method": "GET", - "path": "/api/v1/builds/{uuid}/newFailureStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid." - } - ], - "query": [ - { - "name": "customParams", - "type": "object", - "description": "Extra query params outside the FilterParamsV2 fields are collected into a custom-params map and forwarded. Unknown keys are carried, not rejected." - } - ], - "intent": "Get the new-failures tile for a build \u2014 how many tests failed in this build that were not failing before \u2014 plus the tile's comment count.", - "returns": [ - "count", - "meta", - "commentCount", - "autoUnmuteCount" - ], - "responses": { - "200": { - "$response": "SingleMetricSummaryResponse__w4_build2" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Plan-locked builds return HTTP 403; an unknown uuid is BUILD_NOT_FOUND.", - "Same response shape (SingleMetricSummary) as get_build_flaky_stats and get_build_muted_stats \u2014 only the metric differs. `count` is the new-failure count; `meta` carries the 'out of N' context.", - "Accepts the full FilterParamsV2 filter set plus arbitrary customParams." - ] - }, - { - "name": "get_build_non_sdk_test_counts", - "method": "GET", - "path": "/api/v1/builds/{uuid}/getBuildStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "The 40-char build uuid. The counts include the build's split child builds." - } - ], - "intent": "Get data-hygiene counters for a build (including its split child builds) \u2014 how many test runs are untitled, how many are unmarked, and how many have no failure reason \u2014 used to nudge non-SDK / untitled instrumentation setup.", - "returns": [ - "untitled_test_count", - "unmarked_test_count", - "missing_failure_reason_count" - ], - "responses": { - "200": { - "$response": "BuildNonSdkTestCountsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Name-collision trap: this is `/{uuid}/getBuildStats` (data-hygiene counters, action getNonSdkUntitledTestCount). It is NOT the build headline counters \u2014 those are `/{uuid}/buildSummary` (get_build_summary_stats).", - "The body is a JSON *string* (a Gson JsonObject serialised via toString), not a JSON object \u2014 the caller must parse the string to read the three keys `untitled_test_count`, `unmarked_test_count`, `missing_failure_reason_count`.", - "Counts span the parent build AND its split child builds combined; there is no per-leg breakdown here." - ] - }, - { - "name": "get_build_insights", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/insights", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build UUID. Looked up group-scoped; an unknown uuid is BUILD_NOT_FOUND (404)." - } - ], - "intent": "One-call failure summary for a build, built for agents and CI bots \u2014 status metrics (test vs hook counts), failure categorisation (new failures / always-failing / flaky), defect stats and a top-errors analysis for the run.", - "returns": [ - "buildId", - "buildName", - "status", - "startedAt", - "finishedAt", - "duration", - "summary", - "failureCategorization", - "failureContext", - "topErrorsAnalysis", - "defectStats" - ], - "guidance": [ - "This is the build-keyed sibling of the test-run buildInsights call. Because no test run is supplied, `failureContext` (the file / parent-module drill-down) is ALWAYS null on this route \u2014 use the test-run variant if you need it.", - "`topErrorsAnalysis.status` is one of ANALYSIS_PENDING / ANALYSIS_COMPLETE / ANALYSIS_NOT_STARTED. When it is not COMPLETE, `uniqueErrors` may be empty even though the build has failures \u2014 the clustering has not finished, so re-read rather than concluding there are no errors.", - "`summary.statusMetrics` splits every status into `testsCount` vs `hooksCount`; `summary.totalTests` and `summary.totalHooks` are the run-wide totals.", - "`topErrorsAnalysis.uniqueErrors[].truncatedError` is only the first ~200 chars of the error \u2014 an overview string, not the full message. Carry `clusterId` to the unique-errors endpoints for the full text.", - "`defectStats` is keyed by issueType, each with per-category failure test/hook counts." - ], - "responses": { - "200": { - "$schema": "BuildInsights__w4_build4" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_latest_build", - "method": "GET", - "path": "/ext/v1/builds/latest", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "project_name", - "type": "string", - "required": true, - "description": "Project name (normalised internally). Missing \u2192 400 'Parameter project_name is required'. Resolved within the caller's group." - }, - { - "name": "build_name", - "type": "string", - "description": "Exact original build name to match." - }, - { - "name": "user_name", - "type": "string", - "description": "Triggering user's username. If it resolves to no user id, the whole call is BUILD_NOT_FOUND (404) \u2014 not a validation error." - }, - { - "name": "build_tags", - "type": "string", - "description": "Comma-separated build tags; split and trimmed server-side into a tag set." - }, - { - "name": "framework", - "type": "string", - "description": "Test framework name; enum-validated. An unrecognised framework is reported as BUILD_NOT_FOUND (404), not a 400." - } - ], - "intent": "Find the most recent build in a project matching optional filters (build name, triggering user, build tags, framework) and get its full build-details payload \u2014 the entry point for 'the latest run of X' when you don't know the build uuid.", - "returns": [ - "build_id", - "id", - "name", - "build_number", - "status", - "started_at", - "finished_at", - "duration", - "user", - "tags", - "status_stats", - "observability_url", - "tcmTestRunIdentifier" - ], - "guidance": [ - "Always returns a SINGLE build (the page limit is fixed at 1), not a list \u2014 the newest match. No match for the given filters is BUILD_NOT_FOUND (404).", - "The requester must hold automated-test-run view permission on the project (service-account project permission is enforced); otherwise the call is refused.", - "The response shape is feature-flagged per group: the newer build-listing flag returns the V2 build-details envelope, otherwise the v1 one. Both are FURTHER field-gated by the account's plan through the external JsonView wrapper \u2014 a field absent from the body may be plan-gated rather than empty, so do not treat a missing field as 'no value'.", - "Carry the returned `build_id` (the hashed uuid; keys are snake_case and there is no `uuid` key) to the per-build endpoints (insights, reports, history); the display `tcmTestRunIdentifier` is not a build id." - ], - "responses": { - "200": { - "$schema": "ExternalBuildDetails" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_project_builds_by_project_id", - "method": "GET", - "path": "/ext/v1/projects/{project_id}/builds", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "project_id", - "type": "integer", - "required": true, - "description": "Numeric TRA project id." - } - ], - "query": [ - { - "name": "unique_build_names", - "type": "array", - "description": "Filter to these build names." - }, - { - "name": "build_tags", - "type": "array", - "description": "Filter to builds carrying any of these tags." - }, - { - "name": "users", - "type": "array", - "description": "Triggering user ids (numeric)." - }, - { - "name": "frameworks", - "type": "array", - "description": "Test framework names; enum-validated. An invalid framework value is a 400." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (numeric)." - }, - { - "name": "build_status", - "type": "array", - "description": "Build display statuses; enum-validated. An invalid status value is a 400." - }, - { - "name": "branches", - "type": "array", - "description": "VCS branch names." - }, - { - "name": "is_archived", - "type": "boolean", - "description": "Restrict to archived (true) or non-archived builds." - }, - { - "name": "is_ci_run_only", - "type": "boolean", - "description": "Restrict to CI-triggered builds." - }, - { - "name": "exclude_lock", - "type": "boolean", - "description": "Exclude locked builds." - }, - { - "name": "date_range", - "type": "array", - "description": "Exactly two epoch-millisecond values [start, end]. OPTIONAL despite the DTO's @NotNull \u2014 this route binds it without validation, so when omitted it DEFAULTS to the last 7 days. Provide both to widen the window.", - "fields": [ - { - "name": "(item)", - "type": "integer" - } - ] - }, - { - "name": "next_page", - "type": "string", - "description": "Opaque base64 pagination cursor from the previous page's `pagination.next_page`. Omit for the first page." - } - ], - "intent": "List a project's builds over the public REST API with status, counts and a deep link back to the TRA UI \u2014 filterable like the UI build listing (names, users, teams, tags, branches, frameworks, statuses, date range) and cursor-paginated.", - "returns": [ - "id", - "name", - "builds", - "pagination" - ], - "guidance": [ - "Cursor pagination only: read `pagination.has_next` and pass `pagination.next_page` back as `next_page` (snake_case keys). There is NO caller-settable page size; walk the cursor until `has_next` is false.", - "The item shape is feature-flagged per group: with the 'new build listing' flag on, `status_stats` holds workflow-status counts and items add `is_manually_overridden`; otherwise v1 status counts. Same keys otherwise.", - "When `date_range` is omitted the window is the LAST 7 DAYS \u2014 older builds simply will not appear unless you pass an explicit [start,end] in epoch millis.", - "Within a page builds come back OLDEST-first: the handler reverses the fetched page (Lists.reverse) before returning.", - "Requires automated-test-run view permission on the project.", - "Each build carries an `observability_url` deep link and `tcmTestRunIdentifier`; use `build_id` (the hashed uuid; there is no `uuid` key) for follow-up per-build calls.", - "For 'the latest / last N builds' prefer list_project_builds_by_name (newest-first, `pageLimit`, project-scoped `search`): this route is oldest-first within a page, 10 per page and defaults to the last 7 days, so 'last 10 builds' can cost several calls.", - "Each item carries `build_number` (the serial #N) and, when the CI set one, the CI run id; a user's 'run ' may be the CI run id rather than `build_number`.", - "The filters are NOT the UI set: this route takes users, build_tags, unique_build_names, is_ci_run_only and exclude_lock, while list_project_builds_by_name takes search, sortOrder, statuses, sources and types. Pick the route by the filter you need." - ], - "responses": { - "200": { - "$schema": "ExternalProjectBuildListing" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_project_builds_by_name", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/builds/v2", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "Project's normalised name." - } - ], - "query": [ - { - "name": "statuses", - "type": "array", - "description": "Build display statuses to include (enum)." - }, - { - "name": "sources", - "type": "array", - "description": "Product sources (e.g. Automate / App Automate) (enum)." - }, - { - "name": "types", - "type": "array", - "description": "Build types (enum)." - }, - { - "name": "teams", - "type": "array", - "description": "Team ids (numeric)." - }, - { - "name": "frameworks", - "type": "array", - "description": "Test framework names (enum)." - }, - { - "name": "buildTags", - "type": "array", - "description": "Build tags to filter on." - }, - { - "name": "branches", - "type": "array", - "description": "VCS branch names." - }, - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond values [start, end] (@Size min=2,max=2).", - "fields": [ - { - "name": "(item)", - "type": "integer" - } - ] - }, - { - "name": "search", - "type": "string", - "description": "Free-text search over builds." - }, - { - "name": "searchAfter", - "type": "string", - "description": "Cursor for the next page (from the previous response's paging params)." - }, - { - "name": "pageLimit", - "type": "integer", - "description": "Page size. Defaults to 10 when omitted. No hard maximum is enforced in this handler \u2014 see could_not_determine." - }, - { - "name": "sortOrder", - "type": "string", - "values": [ - "Asc", - "Desc" - ], - "description": "Sort direction on the build ordering." - }, - { - "name": "state", - "type": "string", - "description": "Build state filter (defaults to `active`)." - }, - { - "name": "isArchived", - "type": "boolean", - "description": "Restrict to archived / non-archived builds." - } - ], - "intent": "The main build-listing endpoint: a filtered, sorted, cursor-paginated page of a project's builds with their status and run counts, for the builds table.", - "returns": [ - "id", - "name", - "builds", - "pagingParams", - "activeCount", - "closedCount" - ], - "guidance": [ - "PREFERRED build listing for agents: newest-first, `pageLimit` sets the page size (keep it at or below 50 \u2014 each row is large and a big page overflows the tool output), `search` filters by build name, and rows carry buildNumber, `buildRunId` (CI run id), statusStats, historyAggregate (flaky / newFailure / alwaysFailing counts) and qualityGateStatus, so one call answers 'last N builds with status'.", - "Takes the project's NORMALISED NAME in the path, not a numeric id. Uses the same large filter object as the build-groups listing.", - "SIDE EFFECT: this call marks the project as subscribed for the calling user \u2014 a read that quietly changes the user's subscription state. It also applies the group's custom data-retention window for the duration of the request, so builds older than the retention window will not appear regardless of `dateRange`.", - "Cursor pagination via `pagingParams` / `searchAfter`; page through until the paging params report no next cursor. `pageLimit` defaults to 10.", - "Each build row is a rich BuildSummary (status, statusStats, run/session counts, quality-gate status, tags, etc.), not the trimmed external shape.", - "This route does NOT accept username+access-key auth (it is not on the /ext/v1 public surface)." - ], - "responses": { - "200": { - "$schema": "ProjectBuildListing" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_running_builds_on_device", - "method": "GET", - "path": "/api/v1/tier/running/builds", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "product", - "type": "string", - "required": true, - "description": "Product; mapped to Automate ('automate') vs App Automate (anything else). Missing/empty \u2192 400." - }, - { - "name": "os", - "type": "string", - "required": true, - "description": "Operating system. Missing/empty \u2192 400." - }, - { - "name": "osVersion", - "type": "string", - "required": true, - "description": "OS version. A trailing '.0' is also matched without it. Missing/empty \u2192 400." - }, - { - "name": "device", - "type": "string", - "required": true, - "description": "Device name. Missing/empty \u2192 400." - } - ], - "intent": "Show which of the group's builds are currently queued/running on a specific device+OS combination, with the number of parallel sessions each consumes \u2014 used to explain why a device tier is saturated.", - "returns": [ - "data" - ], - "guidance": [ - "All four query params (product, os, osVersion, device) are REQUIRED \u2014 any missing/empty one is a 400.", - "Only counts test runs in `pending` status for the caller's group; a build with no pending runs on that device+OS will not appear.", - "Returns `{data: []}` (empty list) when nothing matches \u2014 that is a normal 'tier not saturated' result, not an error.", - "Each build in `data` carries `parallelsUsed` \u2014 the count of unique parallel sessions that build is consuming on the combination.", - "This route does NOT accept username+access-key auth.", - "Live 2026-09-30: a running non-SDK App Automate (plain Appium) session on the queried device/OS did NOT appear (data:[]) \u2014 the listing counts TRA test runs in pending status, which non-SDK sessions do not create." - ], - "responses": { - "200": { - "$schema": "RunningBuildsOnDevice" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_sidebar_user_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/users", - "mode": "read", - "entity": "build_filter", - "intent": "List the users to offer in the build-list sidebar's Users facet - the people who actually triggered builds under the current filter, returned as {id, name} and searchable by name.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the projects of the caller's sub-group.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Substring searched against each user's FULL NAME (wrapped as a *query* wildcard inside Elasticsearch); special characters are escaped. Blank/absent returns the top users by build count." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time. Only projects, query and dateRange from the body affect this route.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is the start, element 1 the end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarUserFilterResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Only `projects`, `query` and `dateRange` from the body affect the result on this route. Every other build-filter field you send (teams, statuses, buildTags, branches, sources, buildIds, ...) is accepted and SILENTLY IGNORED here - it does not narrow the users returned.", - "Each element is an object {id, name} where `name` is the user's full name - NOT a bare string. `id` is the user id you would pass back as a `users` filter value.", - "`query` matches the full name as a substring; ids are aggregated from the builds index, then names are resolved from the database.", - "Results are an unordered, de-duplicated set capped at 100 options. There is no paging on this route, so a filter matching more than 100 users returns only the first 100 the aggregation surfaces.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_tag_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/tags", - "mode": "read", - "entity": "build_filter", - "intent": "List build-tag options for the build-list sidebar's Tags facet - the top tags on builds under the current filter, or the tags matching a search string.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Tag search substring (wrapped as *query* wildcard, special chars escaped). Blank/absent switches to the top-tags-by-frequency path instead of a search." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is start, element 1 end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarSearchResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Two different code paths depending on `query`: a blank/absent query returns the top build tags by frequency (terms aggregation), a non-blank query runs a wildcard substring tag search with highlighting. Elements are bare tag strings either way.", - "Only `projects`, `query` and `dateRange` from the body affect the result. Every other build-filter field in the body is accepted and SILENTLY IGNORED on this route.", - "Results are an unordered, de-duplicated set capped at 100 options; there is no paging, so more than 100 matches returns only the first 100.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_team_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/teams", - "mode": "read", - "entity": "build_filter", - "intent": "List the team options for the build-list sidebar's Teams facet - the BrowserStack teams whose sub-groups own builds under the current filter, returned as {id, name} and optionally filtered by name.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids to scope the facet to (array of integers). Omit or send empty to default to the caller's sub-group projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - }, - { - "name": "query", - "type": "string", - "description": "Team-name search substring. NOTE it is applied by the BrowserStack teams service AFTER the sub-group aggregation, not inside Elasticsearch - see guidance." - }, - { - "name": "dateRange", - "type": "array", - "description": "Optional [startEpochMillis, endEpochMillis] bounding builds by start time.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Epoch milliseconds; element 0 is start, element 1 end." - } - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "SidebarTeamFilterResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "A request body is REQUIRED even when every field is optional: calling with no body is a 400 'Invalid request payload'. Send at least {} (or {projects:[...]}).", - "POST but read-only - the body is a build filter but nothing is created or changed.", - "Two-step and `query` does NOT filter at the Elasticsearch stage: the sub-group ids that own matching builds are aggregated first (capped at 100), then the BrowserStack teams service resolves those ids to team names and applies `query` to the names. So a very large group can have owning sub-groups beyond the 100 aggregated that never appear.", - "Each element is an object {id, name} (team id + team name) - NOT a bare string. Names come from central/rails team data, not the builds index.", - "Only `projects`, `query` and `dateRange` from the body affect the result; every other build-filter field is accepted and SILENTLY IGNORED here.", - "When `projects` is omitted the scope defaults to the caller's sub-group projects." - ] - }, - { - "name": "list_sidebar_test_case_status_filter_options", - "method": "POST", - "path": "/api/v2/builds/filters/testCaseStatuses", - "mode": "read", - "entity": "build_filter", - "intent": "List the manual test-case status options (Test Management's TestResult custom-field values) configured for the selected projects, to offer manual test-case statuses as a build-list sidebar filter.", - "body": [ - { - "name": "projects", - "type": "array", - "description": "Observability project ids. The ONLY body field this route reads; they are translated to Test Management project ids before the lookup. Omit/empty to default to the sub-group projects, then to the group's first 100 projects.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "Observability project id." - } - ] - } - ], - "returns": [], - "responses": { - "200": { - "$response": "SidebarTestCaseStatusesResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "POST but read-only. Only `projects` is read - `query`, `dateRange` and every other body field are ignored, and the result is NOT scoped by the current build filter at all; it returns the configured status options for the projects regardless of which builds exist.", - "Pure pass-through to Test Management's custom-fields API for entity `TestResult`: the response body is the RAW TestHub JSON, not the {data:[...]} envelope the other sidebar facets return. Do not assume a `data` array here.", - "Observability project ids are converted to Test Management project ids first; a project with no Test Management mapping contributes nothing.", - "Unlike the other sidebar facets this route is not exposed to public-link tokens." - ] - }, - { - "name": "list_home_filter_options", - "method": "GET", - "path": "/api/v1/home/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List the selectable values for one dropdown of the Observability home/overview page's personal filters (build status, product source, framework, build tag, unique build name, or folder).", - "query": [ - { - "name": "key", - "type": "string", - "description": "Which filter dropdown's values to return. Required - omitting it fails the call. One of the UserFilterKey values.", - "values": [ - "buildTags", - "uniqueBuildNames", - "folders", - "sources", - "buildStatus", - "frameworks" - ] - } - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "HomeUserFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "`key` is required and must be one of the six UserFilterKey values; a value outside the enum is a 400, and omitting `key` entirely fails the request.", - "`buildStatus` and `sources` are computed purely from enums with no backend call, so they always return the full option set.", - "`frameworks` FAILS OPEN: on an internal error (ES or the fence setup) it returns the full hardcoded framework list rather than erroring. On success it lists only frameworks seen in the caller's builds (plus the saved selection), which can be EMPTY.", - "The build-backed keys (`buildTags`, `uniqueBuildNames`, `folders`) aggregate Elasticsearch scoped to the caller's sub-group, and to visible projects only when UDAC is enabled, returning up to 10000 values. There is no paging - the whole set comes back in one call.", - "Each element is {id, label, value, count, pId, meta, projectId, projectName, thProjectId}; meta and the project fields are null here. `folders` labels embed the count ('Language(45)') and can repeat a value across projects, and folders span every project in scope, not one." - ] - }, - { - "name": "list_project_webhook_filter_options", - "method": "GET", - "path": "/api/v1/projects/{projectName}/settings/webhooks/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List type-ahead/facet values for a project's webhook rule builder (build names, tags, test names, folders, and other trigger-condition fields), with search and cursor paging - the values behind 'fire only when is ...'.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "The project whose values to aggregate. Scope is this project only on this route." - } - ], - "query": [ - { - "name": "type", - "type": "string", - "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", - "values": [ - "build_name", - "build_tag", - "qg_status", - "build_status", - "test_name", - "folder", - "alert_name", - "failure_category", - "smart_tags", - "browser", - "os", - "device", - "alert_severity", - "test_tag", - "test_status" - ] - }, - { - "name": "projectIds", - "type": "string", - "description": "IGNORED on this route - the scope is the path `projectName` only. Use the group-level webhook filter route to scope by projectIds." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response to get the next page." - } - ], - "returns": [ - "data", - "afterKey" - ], - "responses": { - "200": { - "$response": "WebhookFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "The value set returned depends entirely on `type`. Only build_name, build_tag, test_name, test_tag, folder, failure_category, alert_name, os, browser, device and test_status are wired; the remaining enum values (qg_status, build_status, smart_tags, alert_severity) return empty `data`.", - "`projectIds` in the query is IGNORED here - scope is the path `projectName`. This is the opposite of the group-level route, which reads projectIds and ignores any project path.", - "Cursor-paged via a composite aggregation: pass the response's `afterKey` back as the `afterKey` query param for the next page. afterKey is set even on the final page, so stop when a page returns fewer rows than the page size or none.", - "Per-page size is 10 for most types and up to 1000 for `folder`. `query` and `afterKey` are each capped at 100 characters.", - "Each element is {label, value, pId, count}. For `folder`, `label` is 'name(count)', `value` is the full path, and `pId` is the parent path; for other types `pId` echoes the value." - ] - }, - { - "name": "list_group_webhook_filter_options", - "method": "GET", - "path": "/api/v1/settings/webhooks/filters", - "mode": "read", - "entity": "build_filter", - "intent": "List type-ahead/facet values for the group-level webhook rule builder across selected projects (build names, tags, and the other supported condition fields), with search and cursor paging.", - "query": [ - { - "name": "type", - "type": "string", - "description": "Which condition field's values to return. The returned set depends ENTIRELY on this. Types qg_status, build_status, smart_tags and alert_severity have no aggregation wired and return empty `data`.", - "values": [ - "build_name", - "build_tag", - "qg_status", - "build_status", - "test_name", - "folder", - "alert_name", - "failure_category", - "smart_tags", - "browser", - "os", - "device", - "alert_severity", - "test_tag", - "test_status" - ] - }, - { - "name": "projectIds", - "type": "string", - "description": "Comma-separated TestHub/Test Management project ids (thProjectId, NOT the TRA project id - a TRA id gives an empty 200). Each is translated to a TO project id before the query. A non-numeric entry fails the request; blank resolves to no projects and empty `data`." - }, - { - "name": "query", - "type": "string", - "maxLength": 100, - "description": "Case-insensitive substring to filter the returned options (applied after the aggregation)." - }, - { - "name": "afterKey", - "type": "string", - "maxLength": 100, - "description": "Composite-aggregation paging cursor. Pass back the `afterKey` from the previous response for the next page." - } - ], - "returns": [ - "data", - "afterKey" - ], - "responses": { - "200": { - "$response": "WebhookFilterOptionsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Gated by a controller-wide entitlement: WEBHOOKS (non-TM groups) or GROUP_WEBHOOKS (TM groups) must be accessible, otherwise the whole controller is refused as feature-inaccessible.", - "`projectIds` is a comma-separated string of TestHub/Test Management project ids (a project's thProjectId, not the TRA id - a TRA id silently yields empty `data`), mapped to TO project ids before the aggregation. A non-numeric entry throws (parse failure) and fails the call; an empty/blank value yields no projects and empty `data`.", - "The value set returned depends entirely on `type`; qg_status, build_status, smart_tags and alert_severity return empty `data`.", - "Cursor-paged like the project route: pass the response's `afterKey` back for the next page. afterKey is set even on the last page, so stop when a page has fewer than the page size (10 for most types, 1000 for `folder`) or is empty. `query` and `afterKey` are capped at 100 characters.", - "Each element is {label, value, pId, count}; for `folder`, `pId` is the parent path and `label` is 'name(count)'." - ] - }, - { - "name": "list_project_build_names", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/buildNames", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug). It is URL-decoded and every '+' is turned back into a space before lookup, so a project named 'My App' is addressed as 'My+App'. A name that matches no project returns 404." - } - ], - "intent": "List the distinct build names seen in a project \u2014 the values that populate the build-name filter dropdown on the build listing.", - "returns": [], - "guidance": [ - "The 200 body is a bare JSON array of strings (the build names), with no wrapper object \u2014 not {success, ...}.", - "Names are drawn only from builds inside the account's retention window, so a very old build's name may not appear even though the build once existed.", - "No product-source filter is applied on this route, so names from every source (Automate, App Automate, SDK, etc.) are merged into one list.", - "The path takes the normalised name, not the numeric project id; '+' in the slug is decoded to a space." - ], - "responses": { - "200": { - "description": "Distinct build names for the project.", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_non_sdk_summary", - "method": "GET", - "path": "/api/v1/projects/{normalisedName}/nonSdkSummary", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "normalisedName", - "type": "string", - "required": true, - "description": "The project's normalised name (URL slug); '+' is decoded to a space. Resolved to the project first, so an unknown name returns 404." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "description": "Exactly two epoch-millisecond longs, [lowerBound, upperBound]. OPTIONAL despite the DTO marking it required: when the range (or either bound) is absent the handler substitutes the last 7 days. If you do send it, send both bounds \u2014 a one-element range is rejected.", - "fields": [ - { - "name": "lowerBound", - "type": "integer", - "description": "dateRange[0], epoch millis, inclusive start." - }, - { - "name": "upperBound", - "type": "integer", - "description": "dateRange[1], epoch millis, inclusive end." - } - ] - } - ], - "intent": "Get, over a date range, how many of a project's builds were ingested WITHOUT the BrowserStack SDK plus the resulting data-quality gaps (untitled tests, unmarked tests, tests with no failure reason) \u2014 the numbers behind the 'switch to the SDK' nudge.", - "returns": [ - "nonSDKBuildCount", - "unnamedTestCount", - "unmarkedTestCount", - "missingFailureReasonCount" - ], - "guidance": [ - "Elasticsearch-backed and scoped to a date range: with no dateRange it reports the last 7 days, so a zero here can just mean 'nothing in the last week', not 'nothing ever' \u2014 use get_project_non_sdk_build_count for the unbounded count.", - "Deleted and archived builds (and their tests) are excluded from every count, so the numbers track the live project view rather than raw ingest.", - "The three test-quality counts (unnamedTestCount, unmarkedTestCount, missingFailureReasonCount) come from a separate aggregation over the test index; each is the count of non-SDK tests hitting that gap in the window.", - "The other QueryRq fields (query, isManuallyOverridden, includeManualRuns) are not consulted on this route \u2014 only the date range matters." - ], - "responses": { - "200": { - "description": "Non-SDK build count and data-quality gap counts for the window.", - "schema": { - "type": "object", - "properties": { - "nonSDKBuildCount": { - "type": "integer", - "description": "Builds in the window ingested without the SDK." - }, - "unnamedTestCount": { - "type": "integer", - "description": "Non-SDK tests with no title in the window." - }, - "unmarkedTestCount": { - "type": "integer", - "description": "Non-SDK tests left unmarked in the window." - }, - "missingFailureReasonCount": { - "type": "integer", - "description": "Non-SDK tests with no failure reason in the window." + }, + "BugReportDraftResponse": { + "description": "Pre-filled bug-report draft composed from a failed run. Read-only; not a stored bug.", + "schema": { + "type": "object", + "properties": { + "buildName": { + "type": "string" + }, + "buildId": { + "type": "integer", + "description": "The build NUMBER (e.g. 1), not the numeric build_id." + }, + "ciInfo": { + "type": "object", + "nullable": true + }, + "testDetails": { + "type": "string", + "description": "Composed test hierarchy/description text for the ticket body." + }, + "testUrl": { + "type": "string", + "nullable": true + }, + "videoUrl": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "platform": { + "type": "string", + "nullable": true + }, + "browser": { + "type": "string", + "nullable": true + }, + "hostInfo": { + "type": "object", + "nullable": true + }, + "logStruct": { + "type": "object", + "description": "Image/log attachment references keyed by log type (e.g. TEST_FAILURE: array of strings); empty when the run has no screenshots." + }, + "retries": { + "type": "array", + "items": { + "type": "object" + } + }, + "issueType": { + "type": "string", + "nullable": true + }, + "isAutoAnalyzed": { + "type": "boolean" + }, + "duration": { + "type": "integer" + }, + "aiRca": { + "type": "object", + "description": "AI RCA snippet ({data: ...}); null when no RCA is available.", + "nullable": true + } + } } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_non_sdk_build_count", - "method": "GET", - "path": "/api/v1/projects/{projectId}/nonSDKBuildCount", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The observability project id (numeric). Note this route takes the numeric id, not the normalised name that the summary/build-names routes take." - } - ], - "intent": "Get just the count of non-SDK builds in a project \u2014 a cheap, all-time yes/no signal for whether to show SDK-onboarding prompts.", - "returns": [ - "project_id", - "non_sdk_build_count" - ], - "guidance": [ - "The 200 body is a JSON STRING, not a JSON object: the payload {\"project_id\":..,\"non_sdk_build_count\":..} is serialised to a string and returned as-is, so you must JSON-parse the response body a second time before reading the fields.", - "Unbounded in time \u2014 unlike get_project_non_sdk_summary there is no date range, so this counts every non-SDK build ever (excluding deleted/archived builds).", - "Keys are snake_case here (project_id, non_sdk_build_count), unlike the camelCase summary route.", - "The count falls back to 0 rather than erroring when the search returns no total." - ], - "responses": { - "200": { - "description": "A JSON-encoded STRING of {project_id, non_sdk_build_count}. Parse the string to get the object.", - "schema": { - "type": "string" - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_project_by_id_or_name", - "method": "GET", - "path": "/api/v1/projects/{idOrName}", - "mode": "read", - "entity": "project", - "path_params": [ - { - "name": "idOrName", - "type": "string", - "required": true, - "description": "Either the numeric project id OR the (normalised) project name \u2014 the handler resolves both. Because this is a catch-all mapping, the literal segments 'lite', 'lite/v2', 'pusherAuth' and any other declared path win first, so you cannot address a project whose name collides with a reserved segment through this route." - } - ], - "intent": "Fetch one project's full resource representation by either numeric id or normalised name \u2014 the project header/settings context (id, name, entry type, configuration, permissions).", - "returns": [ - "projectId", - "projectName", - "entryType", - "configuration", - "organization", - "permissions" - ], - "guidance": [ - "Accepts either the numeric id or the normalised name in the one path segment \u2014 carry whichever you have, no separate lookup needed.", - "configuration.attributes is always present; configuration.subTypes and configuration.alertConfiguration are omitted when null (the DTO is NON_NULL), so treat their absence as 'none configured', not an error.", - "permissions is a JSON array node reflecting the caller's rights on this project \u2014 read it to decide which mutating project actions to offer.", - "The numeric id must be the TRA project id (e.g. from list_projects_lite_paginated `id`), NOT the TestHub/Test Management id (`thProjectId`): a TestHub id gives 404 and an unknown id can give a bare 500 'Internal System Exception'.", - "`permissions` reflects the API role, which can be broader than what the UI shows for the same user; do not tell the user they can edit settings based on it alone." - ], - "responses": { - "200": { - "description": "Single project resource.", - "schema": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "entryType": { - "type": "string" - }, - "configuration": { - "type": "object", - "properties": { - "attributes": { - "type": "object", - "description": "Map of project attribute key -> value." - }, - "subTypes": { - "type": "object", - "description": "Map of issue type -> list of issue sub-type resources. Omitted when null." - }, - "alertConfiguration": { - "type": "object", - "description": "Project alert config. Omitted when null." - } - } - }, - "organization": { - "type": "string" - }, - "permissions": { - "type": "array", - "description": "Caller's permission entries for this project." + }, + "LinkBugToTestRunResponse": { + "description": "Result of linking a filed bug to a run. 200 carries the created integration id; a 204 (no body) means the bug key was already tracked.", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Created integration id; null if none was recorded." + } + } + } + }, + "FlatTestRunListingResponse": { + "description": "Flat list of a build's test runs with search_after pagination.", + "schema": { + "type": "object", + "properties": { + "testRuns": { + "type": "array", + "items": { + "$schema": "FlatTestRun" + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object" + }, + "searchAfter": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cursor to pass back as searchAfter for the next page." + } + } + } + } + } + }, + "BuildAIFailuresResponse": { + "description": "Build-level AI failure analysis: failure themes and workflows, sorted by failure share. Serialized from AIFailuresResponse; trialQuota is omitted (NON_NULL) unless the account is on the build-level trial.", + "schema": { + "type": "object", + "properties": { + "buildId": { + "type": "integer" + }, + "buildThemes": { + "type": "array", + "description": "Failure themes, sorted by testPercentage descending.", + "items": { + "type": "object", + "properties": { + "themeId": { + "type": "string" + }, + "buildFailureThemeId": { + "type": "integer", + "description": "Integer id used to drill into this theme's test runs." + }, + "themeData": { + "type": "object", + "description": "Opaque theme detail map." + }, + "affectedWorkflows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "workflowData": { + "type": "object" + } + } + } + }, + "affectedWorkflowCount": { + "type": "integer" + }, + "testRunCount": { + "type": "integer" + }, + "testPercentage": { + "type": "string", + "description": "Share of failures as a string (may include a % sign)." + }, + "isNewTheme": { + "type": "boolean" + } + } + } + }, + "buildWorkflows": { + "type": "array", + "description": "Failure workflows, sorted by testPercentage descending.", + "items": { + "type": "object", + "properties": { + "workflowIdentifier": { + "type": "string" + }, + "buildFailureWorkflowId": { + "type": "integer", + "description": "Integer id used to drill into this workflow's test runs." + }, + "workflowData": { + "type": "object", + "description": "Opaque workflow detail map." + }, + "testRunCount": { + "type": "integer" + }, + "testPercentage": { + "type": "string" + }, + "themesIdentified": { + "type": "array", + "items": { + "type": "object", + "properties": { + "buildRunThemeId": { + "type": "integer" + }, + "themeData": { + "type": "object" + }, + "isNewTheme": { + "type": "boolean" + } + } + } + }, + "themeIdentifiedCount": { + "type": "integer" + } + } + } + }, + "buildThemeWorkflow": { + "type": "object", + "description": "Analysis-run status envelope.", + "properties": { + "status": { + "type": "string", + "description": "e.g. SUCCESS, PENDING, RETRIED_AND_FAILED. Non-terminal means results still being produced." + }, + "summary": { + "type": "string" + } + } + }, + "stats": { + "type": "object", + "properties": { + "totalThemes": { + "type": "integer" + }, + "newThemes": { + "type": "integer" + }, + "existingThemes": { + "type": "integer" + }, + "totalWorkflows": { + "type": "integer" + }, + "totalTestRuns": { + "type": "integer" + } + } + }, + "trialQuota": { + "type": "object", + "description": "Present only for build-level trial accounts.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + } + }, + "BuildAIFailureTriggerResponse": { + "description": "Get-or-trigger response for build-level AI failure analysis. The AIFailuresResponse fields are unwrapped at the TOP level (JsonUnwrapped) alongside triggered; when analysis was freshly triggered they are absent/null and only triggered (and trialQuota) are present.", + "schema": { + "type": "object", + "properties": { + "triggered": { + "type": "boolean", + "description": "true when analysis was enqueued (results not in this response); false when existing terminal analysis is returned inline." + }, + "buildId": { + "type": "integer", + "description": "Present only when existing analysis is returned; null/absent on a fresh trigger." + }, + "buildThemes": { + "type": "array", + "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildThemes.", + "items": { + "type": "object" + } + }, + "buildWorkflows": { + "type": "array", + "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildWorkflows.", + "items": { + "type": "object" + } + }, + "buildThemeWorkflow": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Set to PENDING when a non-terminal analysis is retriggered." + }, + "summary": { + "type": "string" + } + } + }, + "stats": { + "type": "object", + "description": "Same shape as BuildAIFailuresResponse.stats." + }, + "trialQuota": { + "type": "object", + "description": "Present only for build-level trial accounts.", + "properties": { + "used": { + "type": "integer" + } + } + } + } + } + }, + "FlatAIFailureTestRunsResponse": { + "description": "Flat, cursor-paginated list of the test runs behind an AI failure cluster (or an explicit id set). Serialized from FlatTestResponse. Detail fields are null when the run has no such data (os/browser/issueType on SDK-less runs, the analyser flags before analysis).", + "schema": { + "type": "object", + "properties": { + "testRuns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "scopeList": { + "type": "array", + "items": { + "type": "string" + } + }, + "details": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "os": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "nullable": true + }, + "browser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "nullable": true + }, + "device": { + "type": "string", + "nullable": true + }, + "isRealDevice": { + "type": "boolean", + "nullable": true + }, + "filePath": { + "type": "string", + "nullable": true + }, + "vcFileUrl": { + "type": "string", + "nullable": true + }, + "issueType": { + "type": "object", + "nullable": true + }, + "aiRcaState": { + "type": "string", + "nullable": true + }, + "isFlaky": { + "type": "boolean", + "nullable": true + }, + "isPerformanceAnomaly": { + "type": "boolean", + "nullable": true + }, + "isNewFailure": { + "type": "boolean", + "nullable": true + }, + "isAlwaysFailing": { + "type": "boolean", + "nullable": true + }, + "isMuted": { + "type": "boolean", + "nullable": true + }, + "isAutoAnalyzed": { + "type": "boolean", + "nullable": true + }, + "isAutoAnalyzerRunning": { + "type": "boolean", + "nullable": true + }, + "isUniqueErrorsRunning": { + "type": "boolean", + "nullable": true + }, + "isFailingLast5Builds": { + "type": "boolean", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "pagingParams": { + "type": "object", + "description": "Cursor paging state. Feed searchAfter back to fetch the next page; stop when hasNext is false.", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "description": "Opaque map, may be null.", + "nullable": true + }, + "searchAfter": { + "description": "Opaque cursor: an array (or integer) to pass back as the searchAfter query param." + } + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_full", - "method": "GET", - "path": "/api/v1/projects", - "mode": "read", - "entity": "project", - "intent": "List the caller's group's projects as full records with complete metadata (attributes, issue types, alerts) \u2014 the heavyweight variant to use when you need full project detail rather than a picker payload.", - "returns": [ - "id", - "name", - "userId", - "groupId", - "thProjectId", - "normalisedName" - ], - "guidance": [ - "This is the heaviest of the project-list variants (full entity records, no paging). For pickers, sidebars and dashboards prefer list_projects_lite, list_projects_lite_paginated or list_projects_for_sidebar instead.", - "The group is taken from the session \u2014 there are no parameters; you get exactly the projects the caller's group owns.", - "The 200 body is a bare JSON array of project objects, with no wrapper and no pagination envelope, so the whole group is returned in one response." - ], - "responses": { - "200": { - "description": "All of the group's projects as full records.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "normalisedName": { - "type": "string", - "description": "Derived slug: spaces become '+', '#' becomes '%23', but case and characters such as '&' are kept, so it is not URL-safe - encode it before putting it in a path." - }, - "projectAttributes": { - "type": "array", - "description": "Project attribute rows." - }, - "projectIssueTypes": { - "type": "array", - "description": "Issue-type rows configured on the project." - }, - "alerts": { - "type": "array", - "description": "Alert rows attached to the project." - }, - "appLogFilter": { + }, + "QueuedSessionsResponse": { + "description": "Sessions currently sitting in the automation queue for a user and product.", + "schema": { "type": "object", - "description": "App-log filter settings: {apps, envs}, each null when unset." - }, - "latestAlerts": { - "type": "array", - "description": "Most recent alert rows for the project." - } + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "buildUuid": { + "type": "string" + }, + "sessionName": { + "type": "string" + }, + "buildName": { + "type": "string", + "description": "Null when the build is outside the caller's group." + }, + "projectName": { + "type": "string", + "description": "Null when the build is outside the caller's group." + }, + "queueReason": { + "type": "string", + "description": "Why the session is queued, e.g. no_terminal_available." + }, + "capabilities": { + "type": "object", + "description": "Resolved caps; shape depends on caps_format (selenium / appium / default)." + }, + "duration": { + "type": "string", + "description": "How long the session has waited, computed at read time." + } + } + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_default_project", - "method": "GET", - "path": "/api/v1/projects/lite/default", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "normalisedName", - "type": "string", - "description": "Optional. If given and it resolves, that project is returned; if it does not resolve the call silently falls back to the group's most recently updated project rather than 404-ing." - } - ], - "intent": "Resolve which project the UI should land on: the named project if normalisedName is given and exists, otherwise the group's most recently updated project \u2014 for app load and deep links where the named project may no longer exist.", - "returns": [ - "id", - "name", - "thProjectId", - "normalisedName", - "userId", - "groupId", - "createdAt", - "finishedAt" - ], - "guidance": [ - "Fault-tolerant by design: a normalisedName that does not resolve does NOT 404 \u2014 it falls back to the last-updated project. So a 200 does not confirm the name you asked for exists; compare the returned normalisedName if you need to know.", - "If the group has no projects at all, this returns HTTP 200 with a null body (not 404). Guard for a null/empty response.", - "Returns a single lightweight project object, not a list." - ], - "responses": { - "200": { - "description": "The resolved landing project (lightweight), or null when the group has no projects.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "normalisedName": { - "type": "string" + }, + "SessionAppProfilingResponse": { + "description": "Opaque upstream app-profiling JSON (CPU/memory/etc.). An empty object means the report is still generating or was never captured.", + "schema": { + "type": "object", + "description": "Opaque app-profiling payload from the App Automate product API; {} when not yet available." } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "rename_project", - "method": "PUT", - "path": "/api/v1/projects/{projectId}", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "The observability project id to rename." - } - ], - "body": [ - { - "name": "name", - "type": "string", - "required": true, - "description": "The new project name. Trimmed server-side; a blank or whitespace-only name is rejected with 400 ('Project name cannot be empty.'). Sent flat at the top level of the body." - } - ], - "intent": "Rename a project \u2014 use this to change a project's display name (and, as a side effect, its URL slug).", - "returns": [ - "message", - "normalisedName" - ], - "guidance": [ - "Renaming CHANGES THE URL SLUG: the new normalised name becomes the project's slug, so existing links and any saved normalisedName keyed on the old name stop resolving. There is no automatic redirect \u2014 update stored slugs from the returned normalisedName.", - "No true undo: to reverse it you must rename back to the old name, and links broken in the meantime are not restored \u2014 treat it as a write with no history.", - "Blocked in demo-sandbox environments: returns 400 'Cannot rename default project' there.", - "The rename happens in TestHub and is mirrored locally; TestHub's own 400/401/404 are translated into observability errors, so a 404 here can mean TestHub could not find the project.", - "The 200 body carries a human-readable message and the new normalisedName \u2014 read normalisedName, not the message, to rebuild links." - ], - "responses": { - "200": { - "description": "Rename succeeded.", - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "normalisedName": { - "type": "string", - "description": "The project's new URL slug." + }, + "SessionCapabilitiesResponse": { + "description": "Opaque session capabilities JSON. Normally the upstream sessionParams object; on upstream failure a fallback {outputCapabilities:{capabilities:...}} built from stored session caps.", + "schema": { + "type": "object", + "description": "Opaque capabilities object; key shape varies between the upstream sessionParams and the stored-caps fallback." } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "assign_issue_types_to_test_runs", - "method": "PUT", - "path": "/api/v1/projects/{projectId}/updateIssueTypes", - "mode": "write", - "entity": "project", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "Project id scoping the test runs. Despite the project-scoped path this call updates TEST RUNS, not the project. In TM the id is interpreted as a TestHub project id." - } - ], - "body": [ - { - "name": "issues", - "type": "array", - "required": true, - "description": "The list of (test run -> issue type) assignments to apply. Sent flat at the top level as 'issues'.", - "fields": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "The test run's integer id to categorise. The wire key is 'testRunId' \u2014 a plain 'id' will not bind." - }, - { - "name": "issueTypeId", - "type": "integer", - "required": true, - "description": "The issue-type / failure-category id to assign. The wire key is 'issueTypeId' \u2014 not 'issueType'." - } - ] - } - ], - "intent": "Bulk-assign a failure category / issue type to a list of test runs (the 'mark failure reason' action on the failure-analysis screens) and get back how many rows were updated.", - "returns": [ - "updatedCount" - ], - "guidance": [ - "Despite the action's project-scoped path and 'updateProject'-style naming, this updates TEST RUNS \u2014 it does not modify the project itself.", - "Each item's wire keys are testRunId and issueTypeId; a body using id/issueType (the shape some callers assume) silently fails to bind those fields. Send exactly testRunId and issueTypeId.", - "Reversible: re-run with a different issueTypeId to re-categorise, so this is a write, not destructive. There is no per-call undo, though.", - "Rejected with 400 while a project archive is in progress \u2014 retry after the archive completes rather than treating it as bad input.", - "The 200 body is just {updatedCount}: the number of test-run rows actually changed. A count lower than the number you sent means some ids did not match \u2014 it is not an error." - ], - "responses": { - "200": { - "description": "Count of test runs updated.", - "schema": { - "type": "object", - "properties": { - "updatedCount": { - "type": "integer", - "description": "Number of test-run rows whose issue type was updated." - } - } - } - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_for_sidebar", - "method": "POST", - "path": "/api/v2/projects/lite", - "mode": "read", - "entity": "project", - "body": [ - { - "name": "queryStr", - "type": "string", - "description": "Optional case-insensitive project-name substring (ILIKE)." - }, - { - "name": "projects", - "type": "array", - "description": "Optional list of project ids (Long) to EXCLUDE from the page - the already-selected projects the picker shows separately (SQL `p.id NOT IN (:selectedProjectIds)`). It does not pin them in. Nulls are dropped.", - "fields": [ - { - "name": "(item)", - "type": "integer", - "description": "A selected project id to leave out." - } - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 1, - "description": "1-based page number. Values <= 0 (and absent) default to 1." - } - ], - "intent": "Populate the build-list sidebar project picker: a page of lightweight project records for the group ordered by most recent build activity, with optional name search and the ability to exclude the already-selected project ids from the page.", - "returns": [ - "data", - "pagingParams" - ], - "guidance": [ - "POST but READ-ONLY \u2014 the body is only search/paging/selection state; nothing is mutated.", - "hasNext is inferred from 'the page came back non-empty', so the last full page still reports has_next=true and next_page = pageNumber+1; the empty page after it is how you learn you are past the end. Stop when data is empty, not when has_next flips.", - "Page size is fixed server-side at 10 records and cannot be changed; a short page (under 10 rows) is the last one, even though has_next is still true.", - "Pagination keys are snake_case here (has_next, next_page under pagingParams) \u2014 different from the GET lite/aggregate routes which use hasNext/searchAfter.", - "Each row is the sidebar-lite shape (thProjectId, id, displayName, normalisedName) \u2014 note displayName, not name. This is distinct from list_projects_lite_paginated (a GET with offset paging and no id-pinning).", - "`projects[]` EXCLUDES the listed ids from the result (the picker renders selected projects itself); pass it only to leave those out. Combining a search with a selected id that matches it returns an empty page." - ], - "responses": { - "200": { - "description": "A page of sidebar-lite projects plus pagination.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { + }, + "SessionDetailsResponse": { + "description": "Session header/metadata for the Test Details page (serialized from TestLogsInfo). customMetadata and buildId are always null through this path.", + "schema": { "type": "object", "properties": { - "thProjectId": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "displayName": { - "type": "string" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "pagingParams": { - "type": "object", - "properties": { - "has_next": { - "type": "boolean", - "description": "True whenever the page was non-empty; not a true 'more rows exist' signal." - }, - "next_page": { - "type": "string", - "description": "pageNumber+1 as a string, or null when has_next is false." + "videoLogs": { + "$schema": "ObsSessionVideo", + "nullable": true, + "description": "Session video handle (url, offsetUrl, status_code, startTime/startTimeStamp, startOffset/finishOffset, launchingAppTime, isIos, isV2); null if the media fetch failed." + }, + "audioLogs": { + "type": "object", + "description": "Opaque audio log node; null if unavailable.", + "nullable": true + }, + "browserstackSessionUrl": { + "type": "string", + "description": "Deep link to the session on the Automate/App-Automate dashboard." + }, + "percyBuildUrl": { + "type": "string", + "description": "Linked Percy build URL, extracted from build metadata; may be null.", + "nullable": true + }, + "isSdk": { + "type": "boolean", + "description": "Whether the build was produced by the observability SDK." + }, + "device": { + "type": "string", + "description": "Device name; an EMPTY string for desktop sessions (\"NA\" on some other routes)." + }, + "framework": { + "type": "string", + "description": "Short framework name, e.g. \"mocha\" (not the build's full framework/version string)." + }, + "customMetadata": { + "type": "object", + "description": "Always null through this path.", + "nullable": true + }, + "testStartedAt": { + "type": "string", + "description": "ISO-8601 session start time; drives the network-logs 30-day-expired empty state. May be null.", + "nullable": true + }, + "buildId": { + "type": "string", + "description": "Always null through this path despite the field name.", + "nullable": true + } } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_lite_paginated", - "method": "GET", - "path": "/api/v2/projects/lite/v2", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "search", - "type": "string", - "description": "Optional project-name search." - }, - { - "name": "searchAfter", - "type": "integer", - "default": 1, - "description": "Despite the cursor-style name this is a PLAIN OFFSET (integer). Defaults to 1 when absent. In demo-sandbox environments it is forced to 0. Advance it using the value returned in pagingParams.searchAfter." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route \u2014 paging is driven by searchAfter." - } - ], - "intent": "Get a paginated, optionally name-searched list of the group's projects in lightweight form (id, name, normalised name, TestHub id) for pickers and listing pages that do not need per-project metrics.", - "returns": [ - "projectLiteList", - "pagingParams" - ], - "guidance": [ - "PREFERRED route to resolve a project by name: pass `search` with the user's words; the exact match is usually on the first page. The row carries the TRA `id` (for integer-id routes), `normalisedName` (for name routes) and `thProjectId` (TestHub/TM id, NOT accepted by TRA routes).", - "searchAfter is an OFFSET, not a cursor \u2014 the name is misleading. Page by reading pagingParams.searchAfter back into the next request; hasNext is computed by over-fetching one row.", - "Page size is hardcoded at 100 and cannot be changed.", - "In demo-sandbox environments the offset is forced to 0, so you always get the first page there regardless of searchAfter.", - "Ordered by most recent build activity (build_updated_at desc). The 200 wraps the rows under projectLiteList with pagingParams alongside \u2014 not a bare array.", - "Overlaps heavily with list_projects_for_sidebar (POST /api/v2/projects/lite): that one pins selected ids and pages at size 10; this one does neither and pages at 100." - ], - "responses": { - "200": { - "description": "A page of lite projects plus pagination.", - "schema": { - "type": "object", - "properties": { - "projectLiteList": { - "type": "array", - "items": { + }, + "SessionTestCodeResponse": { + "description": "Test source code captured for an Automate session, one entry per test executed.", + "schema": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "normalisedName": { - "type": "string" - } - } - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "The offset to send as searchAfter on the next request." - }, - "nextRoot": { - "type": "object" + "session_id": { + "type": "string" + }, + "build_uuid": { + "type": "string" + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "test_run_id": { + "type": "integer" + }, + "code": { + "type": "string", + "description": "Captured source; may be truncated server-side without any flag in the response." + }, + "language": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_projects_with_build_aggregates_paginated", - "method": "GET", - "path": "/api/v2/projects/v2", - "mode": "read", - "entity": "project", - "query": [ - { - "name": "search", - "type": "string", - "description": "Optional project-name search." - }, - { - "name": "searchAfter", - "type": "integer", - "default": 1, - "description": "A PLAIN OFFSET (integer) despite the cursor-style name. Defaults to 1 when absent; forced to 0 in demo-sandbox. Advance using pagingParams.searchAfter from the response." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Optional JSON-map cursor param carried through PagingParams; effectively inert on this offset-paged route." - } - ], - "intent": "Back the Projects landing page: the same paginated project list as the lite paginated variant but enriched with per-project build aggregates (build counts/status roll-ups, stability) from Elasticsearch, so each project card can show its health at a glance.", - "returns": [ - "projectWithAggregates", - "pagingParams" - ], - "guidance": [ - "Heavier than list_projects_lite_paginated because it runs an aggregate pass over builds for the page \u2014 use it only when you need the per-project numbers.", - "Same paging quirks as the lite paginated route: searchAfter is an offset (not a cursor), page size is hardcoded at 100, hasNext is computed by over-fetching one row, and the offset is forced to 0 in demo-sandbox.", - "When the project page is empty the response still returns 200 with an empty projectWithAggregates list (no aggregate pass is run).", - "The 200 wraps rows under projectWithAggregates with pagingParams alongside \u2014 not a bare array." - ], - "responses": { - "200": { - "description": "A page of projects with build aggregates plus pagination.", - "schema": { - "type": "object", - "properties": { - "projectWithAggregates": { - "type": "array", - "items": { - "$schema": "ProjectWithAggregates" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "The offset to send as searchAfter on the next request." - }, - "nextRoot": { - "type": "object" + }, + "BuildLinkNotReady": { + "description": "Retryable 425: the session is not yet linked to its build. Retry shortly rather than treating as fatal.", + "schema": { + "$schema": "ErrorResponse" + } + }, + "CheckConfigurationResponse": { + "description": "A repository's check-run configurations. On the internal empty-fallback path the repoName/ownerName fields may be populated in swapped order.", + "schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "checks": { + "type": "array", + "items": { + "$schema": "CheckConfigurationItem" + } + } } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_health_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests", - "mode": "read", - "entity": "sp_test", - "intent": "List a project's tests on the legacy Test Health surface \u2014 one row per unique test (stability hash) over a date range, with its reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find the least reliable tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "description": "Window as EXACTLY two epoch-millis values [start, end]. NOT validated on this legacy route (no @Valid), so omitting it does not 400 \u2014 the window then falls back to lowerBound/upperBound, and with neither the query is unbounded and slow. Prefer dateRange.", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis). Used only when dateRange is absent." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis). Used only when dateRange is absent." - }, - { - "name": "search", - "type": "string", - "description": "Substring match on test name." - }, - { - "name": "status", - "type": "array", - "description": "Test statuses to include." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "hostNames", - "type": "array" - }, - { - "name": "users", - "type": "array", - "description": "Numeric user ids." - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Numeric user ids of last executor. Capped at 500 entries (@Size max 500) \u2014 more is a 400.", - "maxItems": 500 - }, - { - "name": "teams", - "type": "array", - "description": "Numeric team ids." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "When true (pro accounts only), keeps only tests with a non-zero flaky count." - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "isMuted", - "type": "string", - "description": "Muted-tests visibility. One of the three tokens below; anything else is treated as ALL.", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column. Honoured only for pro accounts; free accounts are forced to reliability. Recognised: reliability, average (duration), totalFailures." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. 10 tests per page." - }, - { - "name": "offset", - "type": "integer", - "description": "Accepted but internally capped at 100 (getRestrictedOffset)." - } - ], - "intent_notes": "", - "returns": [ - "pagingParams", - "tests", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "guidance": [ - "This is the OLDER Test Health generation. For current behaviour (validated window, public-link sharing, workflow-status-aware failure counts) use list_suite_health_tests instead.", - "reliability is a FAILURE percentage (failed*100/total) \u2014 HIGHER means LESS stable. duration is the average run duration. Neither is a 0-100 stability score.", - "Free (non-pro) accounts get a truncated result: only the single least-reliable test is returned, and orderKey/orderValue are ignored (forced to reliability desc).", - "Paged by pageNumber at 10 per page; walk pages until pagingParams.hasNext is false. There is no per-page size control (offset is capped at 100 and does not set the page size).", - "isMuted is a plain query token, not a boolean: send \"true\"/\"false\"/\"all\".", - "Auth: central-auth, obs-token, service-key, rails-basic or speedboat-token. Unlike the Suite Health list this route is NOT reachable with a public-link token.", - "ES-aggregation backed and internally paginated (loops pages until the slice is filled), so wide date ranges are slow.", - "browsers and platforms in each row are empty on this listing path \u2014 read browserList and platformList for the platform combinations." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_suite_health_tests", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests", - "mode": "read", - "entity": "sp_test", - "intent": "List a project's tests on the current Suite Health surface \u2014 one row per unique test (stability hash) over a required date range, with reliability (failure %), average duration, execution counts, flakiness and platform signals \u2014 to find flaky, always-failing or slow tests.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name, resolved within the caller's group." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as EXACTLY two epoch-millis values [start, end]. REQUIRED and validated here (@NotNull @Size(min=2,max=2)) \u2014 missing it, or a length other than 2, is a 400.", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "search", - "type": "string", - "description": "Substring match on test name." - }, - { - "name": "buildTags", - "type": "array" - }, - { - "name": "uniqueBuildNames", - "type": "array" - }, - { - "name": "testTags", - "type": "array" - }, - { - "name": "folders", - "type": "array" - }, - { - "name": "status", - "type": "array" - }, - { - "name": "sources", - "type": "array", - "description": "Product sources to include." - }, - { - "name": "users", - "type": "array" - }, - { - "name": "lastExecutedBy", - "type": "array", - "description": "Numeric user ids; capped at 500 (@Size max 500).", - "maxItems": 500 - }, - { - "name": "teams", - "type": "array" - }, - { - "name": "isFlaky", - "type": "boolean" - }, - { - "name": "isNewFailure", - "type": "boolean" - }, - { - "name": "isAlwaysFailing", - "type": "boolean" - }, - { - "name": "failureCategories", - "type": "array" - }, - { - "name": "osList", - "type": "array" - }, - { - "name": "browserList", - "type": "array" - }, - { - "name": "deviceList", - "type": "array" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "hooksVisibility", - "type": "string", - "description": "Whether test hooks are included." - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, failure counts are sourced from the recorded automation status rather than workflow status." - }, - { - "name": "orderKey", - "type": "string", - "description": "Sort column (pro only). Recognised: reliability, average (duration), totalFailures." - }, - { - "name": "orderValue", - "type": "string", - "values": [ - "asc", - "desc" - ] - }, - { - "name": "pageNumber", - "type": "integer", - "default": 0, - "description": "Zero-based page index. 10 tests per page." - } - ], - "returns": [ - "pagingParams", - "tests", - "automateTestsSkipped", - "appAutomateTestsSkipped", - "lcncTestsSkipped" - ], - "guidance": [ - "Any query key this route does not recognise is SWALLOWED into customParams, never rejected \u2014 so a misspelled filter name is silently ignored and widens the result instead of erroring. Check spelling against the parameter list.", - "reliability is a FAILURE percentage (failed*100/total), HIGHER = less stable; duration is the average run duration.", - "A build-less-but-existing project still resolves (it falls back to its Postgres id and returns an honest empty result). Only a genuinely absent project is a 404 PROJECT_NOT_FOUND.", - "When source feature-fencing hides tests, the response sets automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped \u2014 an empty-looking list may just be fenced, not truly empty.", - "Free (non-pro) accounts get only the single least-reliable test, with sorting forced to reliability desc.", - "Paged by pageNumber at 10 per page; stop when pagingParams.hasNext is false. No per-page size control.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token, and (unlike the Test Health list) public-link-token \u2014 so this listing can back a shared public link.", - "browsers and platforms per row are empty on this path; use browserList and platformList." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestListResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_health_stats", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/stats", - "mode": "read", - "entity": "sp_test", - "intent": "Get the aggregate stat tiles for one test on the legacy Test Health detail view \u2014 reliability (failure %), average duration and failed/total run counts \u2014 optionally narrowed to one browser/OS/device.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (the id field from a test-listing row)." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ] - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis)." - }, - { - "name": "buildName", - "type": "string", - "description": "Restrict to a single build name." - } - ], - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "guidance": [ - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "reliability is a failure percentage (higher = worse); duration is the average run duration; failureOccurrence carries {failed, total} raw counts.", - "Failure count on this legacy route is always the raw status count. The Suite Health equivalent (get_suite_health_stats) instead uses the workflow-status count when new build listing is enabled, so the two can disagree for the same test.", - "Any backend error is rethrown as a bare RuntimeException and surfaces as HTTP 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." - ], - "responses": { - "200": { - "$response": "SuiteHealthStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_stats", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/stats", - "mode": "read", - "entity": "sp_test", - "intent": "Get the aggregate stat tiles for one test on the Suite Health detail page \u2014 reliability (failure %), average duration and failed/total run counts over the selected window.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash sent in the query." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "reliability", - "duration", - "failureOccurrence" - ], - "guidance": [ - "stabilityHash comes from the PATH and overrides any value in the query string.", - "Failure count uses the workflow-status aggregation when new build listing is enabled for the group, otherwise the raw status count \u2014 so it can differ from the legacy get_test_health_stats for the same test and window.", - "reliability is a failure percentage (higher = worse); duration is average run duration; failureOccurrence = {failed, total}.", - "Unknown query keys are swallowed into customParams rather than rejected.", - "Any backend error surfaces as a bare 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthStatsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_health_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v2/tests/{stabilityHash}/details/trend", - "mode": "read", - "entity": "sp_test", - "intent": "Get the per-execution trend line for one test on the legacy Test Health detail view \u2014 each run's status colour, duration and build context over the window \u2014 to plot stability and duration over time.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (read from the path on this route)." - } - ], - "query": [ - { - "name": "isMuted", - "type": "string", - "required": true, - "description": "REQUIRED muted-test visibility, as the enum constant ALL / MUTED / UNMUTED (case-sensitive). all/true/false -> 400 'Invalid request payload'.", - "values": [ - "ALL", - "MUTED", - "UNMUTED" - ] - }, - { - "name": "lowerBound", - "type": "integer", - "description": "Window start (epoch millis)." - }, - { - "name": "upperBound", - "type": "integer", - "description": "Window end (epoch millis)." - }, - { - "name": "buildName", - "type": "string", - "description": "Restrict to one build name." - }, - { - "name": "browser", - "type": "string" - }, - { - "name": "os", - "type": "string" - }, - { - "name": "device", - "type": "string" - }, - { - "name": "allBuilds", - "type": "boolean", - "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." - }, - { - "name": "isFlaky", - "type": "boolean", - "description": "ACCEPTED but IGNORED \u2014 the controller never forwards it to the service, so it has no effect." - } - ], - "returns": [ - "keys", - "data" - ], - "guidance": [ - "Response is {keys, data}: keys is the parallel array of startedAt epochs, data is the points. Each point is MINIMAL \u2014 {color, y (duration), id (test-run id), buildId (serial id), buildName}. There are NO smart-tag flags and no workflow status here; use get_suite_health_trend for those.", - "allBuilds and isFlaky are accepted by the endpoint but silently dropped (never passed to the service) \u2014 do not rely on them to filter.", - "isMuted is REQUIRED and binds straight to the MutedTestsVisibility enum by constant name: send ALL, MUTED or UNMUTED. The listing's all/true/false spellings fail conversion and return a generic 400 'Invalid request payload'.", - "Points are ordered oldest-first (startedAt asc) and capped at 5000 executions with no paging.", - "Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token (no public-link)." - ], - "responses": { - "200": { - "$response": "TestHealthTrendResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_trend", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/trend", - "mode": "read", - "entity": "sp_test", - "intent": "Get the per-execution time series for one test on the Suite Health detail page \u2014 each run's status, workflow status, duration, build context and smart-tag flags (flaky / newly failed / always failing / performance anomaly) \u2014 for the trend chart.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. This handler declares no @PathVariable and never calls setStabilityHash \u2014 the {stabilityHash} path segment is bound into the filter object by ExtendedServletRequestDataBinder (it binds URI path vars onto the command object). Supply it in the path as the route requires." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "browser", - "type": "string" - }, - { - "name": "os", - "type": "string" - }, - { - "name": "device", - "type": "string" - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, only the most recent run per build is kept in the series." - } - ], - "returns": [ - "keys", - "data" - ], - "guidance": [ - "Response is {keys, data}: keys is the parallel array of startedAt epochs; data points are RICH \u2014 {color, workflowStatus, y (duration), id (test-run id), buildId (serial id), buildName, isFlaky, isAlwaysFailing, isNewlyFailed, isPerformanceAnomaly, flakyReason, creationType}. Null-valued fields are omitted (JsonInclude NON_NULL).", - "Unlike get_test_health_trend, this carries the smart-tag flags and workflow status \u2014 prefer it when you need them.", - "This handler relies on the data binder for stabilityHash rather than an explicit @PathVariable; the source inventory's claim that the path segment is ignored does not hold under ExtendedServletRequestDataBinder. Pass stabilityHash in the path.", - "Points are ordered oldest-first and capped at 5000 executions with no paging. With isAutomationLatest=true the series is deduped to the latest run per build.", - "Unknown query keys are swallowed into customParams, not rejected. Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthTrendResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_test_info", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/info", - "mode": "read", - "entity": "sp_test", - "intent": "Get the header metadata for one test's Suite Health detail page \u2014 the test's display name plus the list of OS / browser / device combinations it has executed on \u2014 used to populate the platform filter.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "name", - "combinations" - ], - "guidance": [ - "Returns {name, combinations}. name is the test's display name from a sample run \u2014 an empty string \"\" when no run matches the window (not null, not an error).", - "combinations lists the platform combos the test ran on, each {os, browser} where browser also nests its device. Combos whose browser and device are both non-displayable are filtered out, so the list can be shorter than the raw platform set.", - "stabilityHash from the path overrides anything sent in the query. Unknown query keys are swallowed into customParams.", - "Any backend error surfaces as a bare 500.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token.", - "This is the Suite Health platform/identity header \u2014 distinct from the get_test_base_info capability." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestInfoResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_suite_health_test_platform_breakdown", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/breakdown", - "mode": "read", - "entity": "sp_test", - "intent": "Break one test's stability and performance down per OS / browser / device combination, so you can see which platform is driving its flakiness or slowness.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash. Taken from the path; it overrides any stabilityHash in the query string." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - } - ], - "returns": [ - "[]" - ], - "guidance": [ - "The 200 body is a BARE JSON ARRAY (no wrapping object), one row per OS/browser/device combination, sorted by reliability descending.", - "Each row's id is the composite \"-\" string, and carries reliability (failure %), duration (average), totalFailures {failed,total}, plus os / browser / browserDevice objects for display.", - "Rows whose browser and device are both non-displayable are dropped, so the array can be shorter than the raw combination set.", - "stabilityHash from the path overrides the query string. Unknown query keys are swallowed into customParams.", - "Any service-level error is rethrown as a bare RuntimeException \u2192 HTTP 500 with no typed body.", - "Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthPlatformBreakdownResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_suite_health_test_runs", - "method": "GET", - "path": "/api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds", - "mode": "read", - "entity": "sp_test", - "intent": "List the individual executions of one test across builds \u2014 per-run status, duration, smart tags (flaky / newly failed / always failing / performance anomaly) and build context \u2014 for the run-history table on the Suite Health detail page.", - "path_params": [ - { - "name": "projectName", - "type": "string", - "required": true, - "description": "Normalised project name." - }, - { - "name": "stabilityHash", - "type": "string", - "required": true, - "description": "The test's stability hash (set from the path onto the filter object)." - } - ], - "query": [ - { - "name": "dateRange", - "type": "array", - "required": true, - "description": "Window as exactly two epoch-millis values [start, end].", - "fields": [ - { - "name": "[]", - "type": "integer" - } - ] - }, - { - "name": "start", - "type": "integer", - "description": "Pagination cursor (epoch millis). Pass the pagingParams.start returned by the previous page to fetch the next, older page; runs are returned newest-first and start acts as the exclusive upper time bound. Omit for the first page." - }, - { - "name": "browser", - "type": "string", - "description": "Narrow to one browser." - }, - { - "name": "os", - "type": "string", - "description": "Narrow to one OS." - }, - { - "name": "device", - "type": "string", - "description": "Narrow to one device." - }, - { - "name": "isMuted", - "type": "string", - "values": [ - "all", - "true", - "false" - ] - }, - { - "name": "isAutomationLatest", - "type": "boolean", - "description": "When true, keeps only the latest run per build within the page." - } - ], - "returns": [ - "pagingParams", - "builds" - ], - "guidance": [ - "Cursor-paged, NOT page-numbered: 20 runs per page, newest-first. To get the next (older) page, pass the pagingParams.start from the previous response as the `start` query param.", - "The offset field inside pagingParams is a fixed placeholder (always 20) kept only for the FE contract \u2014 the backend does not read it; drive paging with start.", - "pagingParams.hasNext is true whenever the page returned any rows; a subsequent empty page is how you know you have reached the end.", - "Smart-tag flags for each run are NOT top-level \u2014 they are under the run's historyAggregate object {isFlaky, isAlwaysFailing, isNewFailure, isPerformanceAnomaly}, resolved per run from that build's setting version.", - "Two-stage internally: Elasticsearch resolves the matching test-run ids, then per-run detail (status, duration, CI/VCS info, PM-tool links) is read from the test-run store.", - "Unknown query keys are swallowed into customParams. Auth: central-auth, obs-token, service-key, rails-basic, speedboat-token and public-link-token." - ], - "responses": { - "200": { - "$response": "SuiteHealthTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_overview", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testOverview", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id (the numeric BTCer id, not a TR-NNN string)." - } - ], - "intent": "Get the Overview-tab payload for a single test run \u2014 framework/SDK and platform context (browser, OS, device), the BrowserStack session id, user and BrowserStack tags, and the run's start time.", - "guidance": [ - "runInfo is a NESTED object ({duration, browser, os, device}); duration, browser, os and device do NOT appear at the top level even though they read like headline attributes \u2014 read them under runInfo.", - "sessionId is not always a plain BrowserStack Automate session id: for App Automate non-SDK frameworks it is resolved differently, so do not assume it addresses the Automate session API directly.", - "bsTags is the BrowserStack-side tag list (array of objects); userTags is the plain string array the user set \u2014 they are two different fields.", - "An unknown id is TEST_RUN_NOT_FOUND; a run whose build is retention-locked is refused with FORBIDDEN_OPERATION rather than returning partial data.", - "An unknown or non-existent testRunId returns HTTP 500 'Internal System Exception', not 404: do not pass a user-supplied number straight in; resolve the test run id from the build's test-run listing first (a 'run number' the user quotes is almost always a build serial or CI run id, never a testRunId)." - ], - "returns": [ - "runInfo", - "sessionId", - "userTags", - "bsTags", - "isRealDevice", - "startedAt", - "unmuteConditionConfig" - ], - "responses": { - "200": { - "description": "Overview payload for the test run.", - "schema": { - "type": "object", - "properties": { - "runInfo": { - "type": "object", - "description": "Nested platform block; duration/browser/os/device live here, not at top level.", - "properties": { - "duration": { - "type": "integer", - "description": "Run duration in ms." - }, - "browser": { - "type": "object", - "description": "Formatted browser descriptor." - }, - "os": { - "type": "object", - "description": "Formatted platform/OS descriptor." - }, - "device": { - "description": "Device name, or null for non-real-device runs." + }, + "CheckConfigurationItemResponseWrap": { + "description": "A single check-run configuration (returned by update and toggle on success).", + "schema": { + "$schema": "CheckConfigurationItem" + } + }, + "VcsCheckMappingResponse": { + "description": "A single registered PR-check mapping (vcs_ingestion row with its joined check configuration).", + "schema": { + "$schema": "VcsCheckMapping" + } + }, + "ViewResponse": { + "description": "A single saved view.", + "schema": { + "$schema": "View" + } + }, + "ViewListResponse": { + "description": "A bare JSON array of saved views (no envelope, not paginated).", + "schema": { + "type": "array", + "items": { + "$schema": "View" } - } - }, - "sessionId": { - "type": "string", - "description": "BrowserStack session id; resolved differently (not a plain Automate id) for App Automate non-SDK frameworks." - }, - "userTags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "User-set tags." - }, - "bsTags": { - "type": "array", - "items": { - "type": "object" - }, - "description": "BrowserStack-side tag objects." - }, - "isRealDevice": { - "type": "boolean" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "unmuteConditionConfig": { - "type": "object", - "description": "Auto-unmute condition config for the run, when set." } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_network_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "default": false, - "description": "When true, keep only HAR entries whose startedDateTime falls within the test's start/finish window. Default false returns all session entries." - } - ], - "intent": "Get a test run's network (HAR) entries parsed server-side \u2014 obs-api downloads the HAR, filters it to the test window and returns the request entries so the Network tab renders without the browser downloading the raw file.", - "guidance": [ - "Returns {log:{entries:[\u2026HAR entries\u2026]}, responseOrigin} \u2014 NOT the raw HAR envelope. When the HAR exceeds the network-log size limit the download is skipped: entries comes back EMPTY and a meta:{isTruncated:true} flag is added \u2014 fall back to get_test_run_network_log_url for oversized logs.", - "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration \u2014 treat that as 'no logs', not an error.", - "This is the server-parsed counterpart of get_test_run_network_log_url; prefer the URL read when you only need the pointer or the file may be large.", - "In non-prod a Redis key can make it return mocked network logs." - ], - "returns": [ - "log", - "responseOrigin", - "meta" - ], - "responses": { - "200": { - "description": "Parsed network logs, or null when the run has no BrowserStack integration.", - "schema": { - "type": "object", - "properties": { - "log": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "type": "object" - }, - "description": "HAR entries; empty when the file was oversized (see meta.isTruncated) or absent." + }, + "DashboardViewResponse": { + "description": "Single dashboard view wrapped under `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "DashboardView" + } } - } - }, - "responseOrigin": { - "type": "string", - "description": "Where the logs were sourced from, e.g. 'observability' or 'bstack'." - }, - "meta": { - "type": "object", - "description": "Present ONLY when the HAR was too big to parse.", - "properties": { - "isTruncated": { - "type": "boolean" + } + }, + "WidgetFilterValuesResponse": { + "description": "Paginated widget filter-value picker results (WidgetEnhancedFilterResp).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Map keyed by bucket - `suggested` and `others` - each a list of value structs (not keyed by filter name).", + "additionalProperties": { + "type": "array", + "items": { + "$schema": "WidgetFilterValue" + } + } + }, + "fullList": { + "type": "boolean", + "description": "True when the whole value set fit in this page.", + "nullable": true + }, + "info": { + "type": "object", + "description": "Page metadata (PageInfo).", + "nullable": true + }, + "suggestedAfterKey": { + "type": "string", + "nullable": true + }, + "othersAfterKey": { + "type": "string", + "nullable": true + } } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_network_log_url", - "method": "GET", - "path": "/api/v1/testRuns/v2/{testRunId}/networkLogs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "trim", - "type": "boolean", - "default": false, - "description": "When true, ALSO return testStartedAt/testFinishedAt so the client can window the HAR itself. It does NOT filter the file server-side (that is get_test_run_network_logs)." - } - ], - "intent": "Get the pointer to a test run's network (HAR) log rather than its content \u2014 the S3/BrowserStack URL plus status code and message, so the client fetches (or skips) the file itself.", - "guidance": [ - "Returns {networkLogs:, status_code, message}. The URL key is `networkLogs`, NOT `url`.", - "When the file is oversized a meta:{isTruncated:true, fileSize:} block is added so the UI can show the long-session notice instead of downloading \u2014 the URL is still returned alongside it.", - "trim=true adds testStartedAt/testFinishedAt to the body but does not trim the file; use get_test_run_network_logs when you want the entries filtered server-side.", - "Returns null (HTTP 200 with an empty body) when the run has no BrowserStack integration. In non-prod a Redis key can make it return mocked network logs." - ], - "returns": [ - "networkLogs", - "status_code", - "message", - "testStartedAt", - "testFinishedAt", - "meta" - ], - "responses": { - "200": { - "description": "Network-log descriptor, or null when the run has no BrowserStack integration.", - "schema": { - "type": "object", - "properties": { - "networkLogs": { - "type": "string", - "description": "S3/BrowserStack URL of the raw HAR (the log content is fetched from here)." - }, - "status_code": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "testStartedAt": { - "type": "string", - "nullable": true, - "description": "Present only when trim=true. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." - }, - "testFinishedAt": { - "type": "string", - "nullable": true, - "description": "Present only when trim=true; null when the run has not finished. Java Date.toString format (e.g. 'Tue Sep 29 18:35:02 UTC 2026'), NOT ISO-8601." - }, - "meta": { - "type": "object", - "description": "Present ONLY when the HAR is oversized.", - "properties": { - "isTruncated": { - "type": "boolean" - }, - "fileSize": { - "type": "integer", - "description": "HAR size in bytes." + }, + "IgnoredFilterValuesResponse": { + "description": "Report of which saved widget filter values are still valid vs ignored (TestHub IgnoredFilterValuesResponse).", + "schema": { + "type": "object", + "properties": { + "filterMatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filterType": { + "type": "string" + }, + "matchedValuesCount": { + "type": "integer" + }, + "ignoredValuesCount": { + "type": "integer" + }, + "isMismatched": { + "type": "boolean" + } + } + } + }, + "ignoredFiltersCount": { + "type": "integer", + "description": "Count of filters completely ignored." + }, + "isMismatched": { + "type": "boolean" + } } - } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_detail_tabs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/tabs", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "List which tabs the test-detail page should render for a run \u2014 Overview, Behaviour, Logs, Network, Test code, Test cases and so on \u2014 computed from the run's data, the build's framework and whether it is a BDD build.", - "guidance": [ - "The tab list is DERIVED per run, not stored; each element is {label, value} and the set varies from run to run.", - "A missing/unknown test run surfaces here as a 500 (an internal null-pointer), NOT a typed TEST_RUN_NOT_FOUND \u2014 do not read the 500 as an outage.", - "Use this as a UI hint: a tab's presence implies the corresponding data read is worth calling for this run, its absence that the run has nothing for it." - ], - "returns": [ - "label", - "value" - ], - "responses": { - "200": { - "description": "Ordered list of tabs to render.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Display label for the tab." - }, - "value": { - "type": "string", - "description": "Tab key (Overview, Behaviour, Logs, Network, TestCode, TestCases, \u2026)." - } - } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_linked_test_cases", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testCases", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "searchAfter", - "type": "integer", - "description": "Offset cursor. Pass the searchAfter value returned in the previous response's pagingParams. Defaults to 1 server-side when omitted; forced to 0 in the demo sandbox." - }, - { - "name": "nextRoot", - "type": "string", - "description": "Opaque JSON paging token; echo back the previous response's pagingParams.nextRoot unchanged. Optional." - } - ], - "intent": "List the Test Management test cases linked to a test run (its BTCer test cases), paginated, for the Test cases tab of the test-detail page.", - "guidance": [ - "Page size is FIXED at 100 server-side \u2014 there is no page-size parameter. Walk pages while pagingParams.hasNext is true.", - "The cursor is an OFFSET that advances by 1 per page (the response's pagingParams.searchAfter = current offset + 1), NOT by the page size of 100 \u2014 echo the returned pagingParams rather than computing your own offset.", - "In the demo sandbox the offset is forced to 0, so paging is effectively disabled there.", - "The linked cases are under `testCases`; `pagingParams` carries hasNext, searchAfter and nextRoot for the next call." - ], - "returns": [ - "pagingParams", - "testCases" - ], - "responses": { - "200": { - "description": "A page of linked test cases plus the cursor for the next page.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "type": "integer", - "description": "Offset for the next page (current offset + 1)." - }, - "nextRoot": { - "type": "object", - "description": "Opaque paging token map.", - "nullable": true + }, + "WidgetDataResponse": { + "description": "Computed data for one dashboard-view widget. The concrete shape is untyped and depends entirely on the widgetType requested \u2014 inspect per widget type.", + "schema": { + "type": "object", + "description": "Widget-type-specific payload (charts, stat cards, trend series, drilldown data, etc.)." + } + }, + "ReportRecordResponse": { + "description": "A single report record wrapped under `data`.", + "schema": { + "type": "object", + "properties": { + "data": { + "$schema": "ReportRecord" + } } - } - }, - "testCases": { - "type": "array", - "items": { + } + }, + "ReportRecordListResponse": { + "description": "A flat list of report records under `data` (no pagination).", + "schema": { "type": "object", - "description": "BtcerTestCases entry \u2014 a TM test case linked to the run (identifier, name, status and TM links; keys vary)." - } + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "ReportRecord" + } + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_test_code", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testCode", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Get the source location of the test that produced a run \u2014 the file name and a deep link into the connected VCS (GitHub/GitLab/etc.) built from the build's VCS info \u2014 for the Test code tab.", - "guidance": [ - "The `url` deep link depends on VCS-integration data captured on the build; without it only `filename` is meaningful and `url` may be empty.", - "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build is refused with FORBIDDEN_OPERATION.", - "`code`, `language` and `type` are populated only when the source was captured; often only `filename` is set. `testRunId` comes back null - keep the id you asked with." - ], - "returns": [ - "language", - "code", - "filename", - "url", - "testRunId", - "type" - ], - "responses": { - "200": { - "description": "Test-code descriptor for the run.", - "schema": { - "type": "object", - "properties": { - "language": { - "type": "string", - "nullable": true - }, - "code": { - "type": "string", - "description": "Source of the test, when available.", - "nullable": true - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string", - "description": "VCS deep link; empty when the build has no VCS integration." - }, - "testRunId": { - "type": "integer", - "nullable": true, - "description": "Null on this route (not an echo of the requested id)." - }, - "type": { - "type": "string", - "nullable": true + }, + "ReportDatasetResponse": { + "description": "Report dataset rows (or static build rows) with a paging summary. Can be null when the report type is not TABULAR_TEST_RUNS.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunReportRow" + } + }, + "summary": { + "$schema": "PaginationSummary" + }, + "sourceFencingFlags": { + "type": "object" + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_media_logs", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testDetails", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "Get the media/session block for a test run's detail page \u2014 the session video (with playback offsets), audio log, the BrowserStack session and Percy build URLs, framework, whether the run is SDK-based, plus custom metadata.", - "guidance": [ - "Despite the /testDetails route this returns the MEDIA + session-link block (videoLogs, audioLogs, browserstackSessionUrl, percyBuildUrl, framework, isSdk, customMetadata), NOT the run's metadata \u2014 use get_test_run_meta for identity/status/history.", - "videoLogs carries startOffset/finishOffset/startTimeStamp for aligning playback with the log timeline; audioLogs and percyBuildUrl are present only when the run actually has them.", - "testStartedAt drives the FE's 30-day-expired empty state and buildId feeds the long-session cURL fallback \u2014 they are context flags, not log content. buildId can be null even for a real SDK run.", - "TEST_RUN_NOT_FOUND / BUILD_NOT_FOUND on bad ids; a retention-locked build returns FORBIDDEN_OPERATION / PLAN_FEATURE_NOT_ALLOWED rather than partial data." - ], - "returns": [ - "videoLogs", - "audioLogs", - "browserstackSessionUrl", - "percyBuildUrl", - "isSdk", - "device", - "framework", - "customMetadata", - "testStartedAt", - "buildId" - ], - "responses": { - "200": { - "description": "Media/session block for the test run.", - "schema": { - "type": "object", - "properties": { - "videoLogs": { - "$schema": "ObsSessionVideo" - }, - "audioLogs": { - "description": "Audio-log descriptor; null when absent.", - "nullable": true - }, - "browserstackSessionUrl": { - "type": "string" - }, - "percyBuildUrl": { - "type": "string", - "description": "Percy build URL; null when the run has no Percy build.", - "nullable": true - }, - "isSdk": { - "type": "boolean" - }, - "device": { - "type": "string" - }, - "framework": { - "type": "string" - }, - "customMetadata": { - "description": "Opaque custom metadata attached to the run.", - "nullable": true - }, - "testStartedAt": { - "type": "string", - "description": "Drives the FE 30-day-expired empty state for network logs.", - "nullable": true - }, - "buildId": { - "type": "string", - "description": "Hashed BrowserStack build id for the long-session cURL fallback; can be null even for an SDK Automate run with a session.", - "nullable": true + }, + "ReportStatTilesResponse": { + "description": "Aggregate stat tiles for a report widget. `data` is null when the report type is not TABULAR_TEST_RUNS.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "StatTile" + } + }, + "sourceFencingFlags": { + "type": "object" + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_meta", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testMeta", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "query": [ - { - "name": "excludeHistory", - "type": "boolean", - "default": false, - "description": "When true, skip execution-history assembly (much cheaper). Use when only the header is needed; fetch history separately via list_test_run_execution_history." - } - ], - "intent": "Get the primary metadata blob for a single test run \u2014 identity (name, file/test-case identifier), status, flakiness/mute flags, issue type and RCA data, retries, build context and, unless excluded, its recent execution history \u2014 feeding the header of the test-detail drawer.", - "guidance": [ - "excludeHistory=true skips history assembly and is much cheaper \u2014 use it when you only need the header; the history is separately available from list_test_run_execution_history.", - "The test-case identifier is OVERWRITTEN with the third-party (TCM/Jira) identifier when the case has one, so testCase.identifier may not be the native TC-NNN id.", - "`status` comes back NULL when the run is retention-locked (the run itself is still returned) \u2014 do not read a null status as 'no status recorded'.", - "`history` is [] when excludeHistory=true and `retries` is null when the test has no retries \u2014 both are absent-as-empty, not errors." - ], - "returns": [ - "testName", - "status", - "testHash", - "buildId", - "buildName", - "history", - "retries", - "issueType", - "testCase", - "buildDetails", - "observabilityVersion", - "testRunRcaData", - "isMuted", - "isFlaky" - ], - "responses": { - "200": { - "description": "Test-run metadata blob.", - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "'HOOK' or 'TEST'." - }, - "isHookAsTest": { - "type": "boolean" - }, - "errorClusterId": { - "type": "string", - "description": "Unique-error cluster id; only resolved for FAILED runs." - }, - "history": { - "type": "array", - "items": { - "$schema": "ObsHistoryTab" - }, - "description": "Recent executions across builds; [] when excludeHistory=true." - }, - "retries": { - "description": "List of retry-attempt metadata, or null when there are none." - }, - "testName": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Null when the run is retention-locked." - }, - "isFlaky": { - "type": "boolean" - }, - "testHash": { - "type": "string" - }, - "buildId": { - "type": "string", - "description": "Build UUID." - }, - "buildName": { - "type": "string" - }, - "commentCount": { - "type": "integer" - }, - "isMuted": { - "type": "boolean" - }, - "isLatest": { - "type": "boolean" - }, - "origin": { - "type": "string" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "issueType": { - "type": "object", - "description": "Failure issue type with resolved displayName." - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "isAutoAnalyzerRunning": { - "type": "boolean" - }, - "isUniqueErrorsRunning": { - "type": "boolean" - }, - "buildDetails": { - "type": "object", - "description": "Build context: status, state, reRun eligibility, issueTypes, ciBuildData, isArchived, finishedAt, id." - }, - "observabilityVersion": { - "type": "object", - "description": "Framework/SDK version info for the build." - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "workflowStatusEditedBy": { - "type": "integer" - }, - "workflowStatusEditedAt": { - "type": "string", - "format": "date-time" - }, - "testCaseVersionId": { - "type": "integer" - }, - "testCaseId": { - "type": "integer" - }, - "testCase": { - "type": "object", - "description": "Linked TM test case; identifier is the third-party (TCM/Jira) id when one exists." - }, - "thProjectId": { - "type": "integer" - }, - "testRunRcaData": { - "type": "object", - "description": "RCA data with resolved failureTypeDisplayName." - }, - "projectId": { - "type": "integer" - }, - "device": { - "type": "string" - }, - "cbtBrowser": { - "type": "string" - }, - "cbtPlatform": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "type": "string" - } - }, - "meta": { - "description": "Opaque JSON meta blob for the run." - }, - "source": { - "type": "string" - }, - "testCaseCount": { - "type": "integer" - }, - "note": { - "description": "Session note (banner parity); null when absent." - }, - "sdkNudge": { - "description": "SDK nudge; null when absent." - }, - "nudges": { - "description": "Mobile-CTM upsell nudges for the current viewer; {} when ineligible." + }, + "ReportCustomFieldsResponse": { + "description": "Custom-field / column definitions plus page info.", + "schema": { + "type": "object", + "properties": { + "customFields": { + "type": "array", + "items": { + "$schema": "ReportCustomFieldItem" + } + }, + "info": { + "$schema": "ReportPageInfo" + } + } } - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_test_run_execution_history", - "method": "GET", - "path": "/api/v1/testRuns/{testRunId}/testMetaHistory", - "mode": "read", - "entity": "test_detail", - "path_params": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Integer test-run id." - } - ], - "intent": "List the recent execution history of the same test across previous builds (the history strip on the test-detail page) so you can tell whether a failure is new, flaky or long-standing.", - "guidance": [ - "The same data is embedded in get_test_run_meta unless excludeHistory=true \u2014 this endpoint exists so the header can load fast and the history be fetched separately.", - "Each row carries testRunId, status, serialId, testRunUuid, buildUuid, workflowStatus, creationType, usageQuotaStatus, isHittingRetentionQuota and thProjectId; startedAt is used for ordering but is not returned.", - "An unknown id returns TEST_RUN_NOT_FOUND." - ], - "returns": [ - "testRunId", - "status", - "serialId", - "testRunUuid", - "buildUuid", - "workflowStatus", - "creationType", - "isHittingRetentionQuota" - ], - "responses": { - "200": { - "description": "List of past executions of the same test.", - "schema": { - "type": "array", - "items": { - "$schema": "ObsHistoryTab" - } - } - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_runs_history_details", - "method": "POST", - "path": "/api/v1/builds/testRuns/historyDetails", - "mode": "read", - "entity": "test_run", - "intent": "For a batch of test runs (or hook runs), get each one's execution history across previous builds together with its logs and failure/issue-type info \u2014 the data behind the test details 'history' panel, used to judge flakiness and whether a failure is new.", - "body": [ - { - "name": "testRunIds", - "type": "array", - "required": true, - "description": "Integer ids of the test runs to fetch history for. When `isHook` is true these are HOOK run ids, resolved against a different backing store \u2014 mixing test-run and hook-run ids in one call returns history for whichever set matches the `isHook` flag.", - "fields": [ - { - "name": "item", - "type": "integer", - "description": "A test run id (or hook run id when isHook=true)." - } - ] - }, - { - "name": "isHook", - "type": "boolean", - "json_path": "/isHook", - "description": "Routes the whole request. false (default) treats `testRunIds` as test run ids via the test-run service; true reinterprets them as hook run ids and resolves them through the hook-run service instead. Bound from the JSON key `isHook` exactly (a differently cased key is ignored and defaults to false)." - } - ], - "guidance": [ - "This is a POST that only carries a filter body \u2014 it reads and changes nothing (routed to a read replica), so it is safe to call freely.", - "`isHook: true` switches the ENTIRE lookup to hook runs: the ids you send are then hook run ids, not test run ids. Sending test run ids with isHook=true returns history for the hook runs (if any) with those ids, not your tests.", - "The response is a JSON OBJECT keyed by the id you asked for (`{ \"\": {\u2026history\u2026} }`), not an array. An id with no history is simply absent from the map \u2014 iterate the ids you sent rather than assuming every one comes back.", - "Each entry's `logs` is an object with only the keys that exist for that run: `TEST_FAILURE` (an ARRAY of strings - message then stack), `TEST_LOG` (an array of screenshot/attachment URLs the caller fetches directly from storage), and `BROWSERSTACK_ERROR`. `logs` is {} for passed runs. Several flags (isNewlyFailed, isPerformanceAnomaly, flakySetting) can be null.", - "Scoped to the caller's group in the repository query \u2014 ids outside the caller's group are dropped from the result, not errored." - ], - "returns": [ - "buildId", - "serialId", - "status", - "issueType", - "duration", - "isFlaky", - "isAlwaysFailing", - "isNewlyFailed", - "isPerformanceAnomaly", - "flakyReason", - "flakySetting", - "creationType", - "tags", - "logs", - "workflowStatus", - "workflowStatusEditedBy", - "workflowStatusEditedAt" - ], - "responses": { - "200": { - "$response": "TestRunHistoryDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_test_run_retry_logs", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRuns/{testRunUuid}/retryLogs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "Declared in the route but NOT read by the handler \u2014 any value satisfies routing; the run is located by testRunUuid alone." - }, - { - "name": "testRunUuid", - "type": "string", - "format": "uuid", - "required": true, - "description": "UUID of the retry attempt / test run whose evidence to fetch. This route keys off the UUID, unlike the sibling /testRuns/{testRunId} routes which take the numeric id. A malformed UUID throws before any lookup." - } - ], - "intent": "Get the screenshot/log attachment structure captured for a single retry attempt of a test, so the retries tab can render that attempt's evidence.", - "guidance": [ - "Identify the run by UUID here \u2014 the numeric id that the other /testRuns/{\u2026} endpoints take will not resolve on this route.", - "The response is a log-structure object with only the keys that exist: `TEST_FAILURE` (failure backtrace), `TEST_LOG` (an array of screenshot URLs \u2014 fetch each from storage yourself; they are not proxied through this API), and `BROWSERSTACK_ERROR`. An attempt with no captured evidence throws `IllegalArgumentException(\"retry doesn't exist\")` rather than returning an empty object, so treat that error as 'no screenshot data', not a missing run.", - "Reads through the group-scoped repository factory, so a run in another group behaves as absent." - ], - "returns": [ - "TEST_FAILURE", - "TEST_LOG", - "BROWSERSTACK_ERROR" - ], - "responses": { - "200": { - "$response": "TestRunRetryLogsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_run_sdk_logs", - "method": "GET", - "path": "/api/v1/builds/{buildId}/testRunLogs", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID (NOT a numeric build id), resolved by (uuid, groupId). A UUID outside the caller's group 404s with BUILD_NOT_FOUND." - } - ], - "query": [ - { - "name": "cursor", - "type": "integer", - "required": false, - "description": "Opaque forward cursor \u2014 pass back `cursorInfo.nextCursor` from the previous page. Omit it for the first page; the per-build `totalAttachments` count is computed ONLY on that first (cursor-absent) page and is absent on later pages." - } - ], - "intent": "Page through the test runs in a build that have SDK-captured logs/attachments, with the per-build attachment total \u2014 backs the build's log/attachment viewer.", - "guidance": [ - "Page size is fixed at 15 and cannot be changed; walk pages by feeding `cursorInfo.nextCursor` back as `cursor` until `cursorInfo.hasMore` is false.", - "`totalAttachments` is returned only on the first page (when `cursor` is omitted). Read it there and carry it; do not expect it on subsequent pages.", - "Each row's `url` is a direct attachment/log URL fetched from storage, not through this API. `attachmentsCount` and `screenshotsCount` are per-test-run totals.", - "Path variable is named buildId but is bound to the build UUID string \u2014 sending a numeric id yields BUILD_NOT_FOUND." - ], - "returns": [ - "data", - "cursorInfo", - "totalAttachments" - ], - "responses": { - "200": { - "$response": "TestRunSdkLogsListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "assign_test_run_failure_categories", - "method": "PUT", - "path": "/api/v1/builds/{projectId}/update", - "mode": "write", - "entity": "test_run", - "path_params": [ - { - "name": "projectId", - "type": "integer", - "required": true, - "description": "A PROJECT id, despite the /builds/ route prefix. Interpreted per product: when the caller's CAD product is TM it is a TestHub project id and mapped to the observability project; otherwise it is the observability project id directly." - } - ], - "body": [ - { - "name": "issues", - "type": "array", - "required": true, - "description": "The per-run category assignments to apply. Required and validated (@NotNull); each item is validated too.", - "fields": [ - { - "name": "testRunId", - "type": "integer", - "required": true, - "description": "Id of the test run to categorise. Bound from the JSON key `testRunId` (maps to the internal `id`); required per item." - }, - { - "name": "issueTypeId", - "type": "integer", - "required": true, - "description": "Id of the issue type / sub-type to assign to that run. Required per item." - } - ] - } - ], - "intent": "Bulk-assign failure categories (issue types / sub-types) to a set of test runs \u2014 the 'categorise these failures' action on the test listing; returns the resulting issue types.", - "guidance": [ - "Requires the BUILD_UPDATE_FAILURE_CATEGORIES permission on the (resolved) project; otherwise AccessDenied. The path id is a project id, not a build id \u2014 do not pass a build id.", - "This overwrites each run's current category; there is no version history, so the previous category is not recoverable through this API. Read the runs first if you need to record what they were.", - "A 409 (AUTO_ANALYSIS_IN_PROGRESS) means auto-analysis is running \u2014 it is SAFE to retry, because the whole batch is validated up front and nothing is written until it passes, so a 409 wrote nothing.", - "The whole batch is pre-checked before any write, so an invalid item fails the entire request rather than partially applying.", - "Response is a flat array of the resulting IssueType objects, not a per-run map." - ], - "returns": [ - "id", - "name", - "locator", - "abbreviation", - "color", - "groupId", - "userId", - "displayName", - "issueGroup" - ], - "responses": { - "200": { - "$response": "AssignTestRunFailureCategoriesResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "409": { - "$response": "Conflict" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_bstack_error_sessions", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/bstackErrorSessions", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID. Looked up with findOneByUuid WITHOUT the group scoping the other endpoints on this controller apply \u2014 do not rely on cross-group isolation here." - } - ], - "intent": "List the sessions in a build that failed because of BrowserStack-side infrastructure errors, so the test listing can separate platform problems from genuine test failures.", - "guidance": [ - "SDK builds only. A non-SDK build \u2014 and the demo-sandbox environment \u2014 returns an EMPTY array, so `[]` does not mean 'no infra errors occurred'; confirm the build is an SDK build before concluding there were none.", - "The response is a raw JSON array of session objects straight from the session repository (no envelope, no fixed schema) \u2014 treat the element fields as opaque and read defensively.", - "Unlike the sibling listing endpoints, the build here is resolved without group scoping (findOneByUuid)." - ], - "returns": [], - "responses": { - "200": { - "$response": "BstackErrorSessionsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_flat_filtered", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/flatTestRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "headers": [ - { - "name": "Public-Link-Token", - "type": "string", - "required": false, - "description": "Public-link token; when present (or via X-Public-Link-Token) the response is restricted \u2014 pending/in-progress tests are excluded from shared reports." - }, - { - "name": "X-Public-Link-Token", - "type": "string", - "required": false, - "description": "Alternate public-link token header, same effect as Public-Link-Token." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "lastExecutedBy", - "type": "array", - "required": false, - "description": "User ids; capped at 500 (@Size max 500)." - }, - { - "name": "isFlaky", - "type": "boolean", - "required": false, - "description": "Pro filter. On a freemium account, applying any pro filter (isFlaky / hasPerformanceAnomaly / isNewFailure / isAlwaysFailing) returns an EMPTY list rather than erroring." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token (bound via a custom setter)." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level entries; echo it back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Elasticsearch search_after cursor; echo it back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Requested run-count flag, but FORCED to false outside the demo-sandbox environment regardless of what you send \u2014 do not rely on it in production." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as a flat, filterable, sortable, cursor-paged list (no suite/file nesting) \u2014 the build's test-listing table, with the full V2 filter surface and public-link sharing support.", - "guidance": [ - "This is the flat listing on the newer surface: it takes the full FilterParamsV2 filter set, sorting, folder scoping, cursor paging (nextRoot + searchAfter) and public-link tokens. The older flat listing (list_build_test_runs_flat) has none of that; pick this one when you need filtering or sorting.", - "Any unrecognised query params are collected and forwarded as custom filter params \u2014 a misspelled filter name is NOT rejected, it is silently passed through and ignored, so verify the filter took effect against the result.", - "`showRunCount` is forced to false outside the demo sandbox, so run counts will be absent in production even if you ask for them.", - "On a freemium account, applying a pro filter (isFlaky, hasPerformanceAnomaly, isNewFailure, isAlwaysFailing) returns an empty `testRuns` with `pagingParams.hasNext=false` \u2014 an empty result there is a plan gate, not 'no matches'.", - "Page by feeding `pagingParams` (nextRoot + searchAfter) back; the per-page size is a server default that is not caller-configurable here.", - "Send the public-link token via Public-Link-Token or X-Public-Link-Token to get the shared-report view (pending/in-progress hidden)." - ], - "returns": [ - "testRuns", - "pagingParams", - "isArchived", - "smartTagSettings", - "status", - "workflowStatus", - "folder_path" - ], - "responses": { - "200": { - "$response": "FlatTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_hierarchy", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "headers": [ - { - "name": "Public-Link-Token", - "type": "string", - "required": false, - "description": "Public-link token; when present (or via X-Public-Link-Token) pending/in-progress tests are hidden from the shared report." - }, - { - "name": "X-Public-Link-Token", - "type": "string", - "required": false, - "description": "Alternate public-link token header, same effect." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "lastExecutedBy", - "type": "array", - "required": false, - "description": "User ids; capped at 500." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level suite/file roots; echo back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "search_after cursor; echo back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Forced to false outside the demo-sandbox environment regardless of what you send." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as the hierarchical suite/file/test tree the build detail page renders, with full filtering, sorting and cursor paging; this is the variant that honours public-link sharing and forwards custom filter params.", - "guidance": [ - "This is the canonical hierarchical listing: use it for shared/public reports (it reads the Public-Link-Token / X-Public-Link-Token headers and hides pending/in-progress tests for public callers) and when you want arbitrary extra query params forwarded as custom filters. Its twin list_build_test_runs_hierarchy_without_public_link does neither.", - "Unrecognised query params are silently collected and passed through as custom filters \u2014 a misspelled filter name is not rejected; verify it took effect against the tree.", - "Paging is by top-level ROOT nodes, not individual tests: the page holds 5 roots (just 1 for builds over 2000 runs). Walk pages by feeding `pagingParams` (nextRoot + searchAfter) back until it reports no next.", - "`showRunCount` is forced false outside the demo sandbox.", - "The tree sits under the top-level `hierarchy` array (each element is a suite/file/test node); aggregates come back under `status` and `workflowStatus`." - ], - "returns": [ - "hierarchy", - "pagingParams", - "status", - "workflowStatus", - "isArchived", - "smartTagSettings", - "source", - "folder_path" - ], - "responses": { - "200": { - "$response": "HierarchyTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_runs_hierarchy_without_public_link", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns/hierarchy", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "query": [ - { - "name": "search", - "type": "string", - "required": false, - "description": "Free-text search over test name/error." - }, - { - "name": "status", - "type": "array", - "required": false, - "description": "Test status filter." - }, - { - "name": "issueType", - "type": "array", - "required": false, - "description": "Failure category / issue-type locator filter." - }, - { - "name": "testTags", - "type": "array", - "required": false, - "description": "Test tag filter." - }, - { - "name": "isMuted", - "type": "string", - "required": false, - "description": "Muted-tests visibility token." - }, - { - "name": "nextRoot", - "type": "string", - "required": false, - "description": "Cursor for the next page of top-level roots; echo back from the previous response's pagingParams." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "search_after cursor; echo back from the previous response's pagingParams." - }, - { - "name": "sortBy", - "type": "object", - "required": false, - "description": "Sort spec ({sortParam, sortOrder}); validated." - }, - { - "name": "showRunCount", - "type": "boolean", - "required": false, - "description": "Forced to false outside the demo-sandbox environment." - }, - { - "name": "has_folder", - "type": "boolean", - "required": false, - "description": "Enable folder-scoped filtering. With true and no folder_path, falls back to the build's first folder." - }, - { - "name": "folder_path", - "type": "string", - "required": false, - "description": "Folder path to scope results to; only applied when non-blank." - } - ], - "intent": "List a build's tests as the same hierarchical suite/file/test tree, for session-authenticated callers that do not need public-link sharing or custom-param pass-through.", - "guidance": [ - "Identical tree to list_build_test_runs_hierarchy (same underlying query) with two deliberate differences: this route does NOT read the Public-Link-Token / X-Public-Link-Token headers, and it does NOT collect arbitrary query params as custom filters. Prefer this only for internal/session views; use the public-link-aware twin for shared reports.", - "Because it ignores the public-link headers, a public-link caller hitting this route is NOT given the pending/in-progress suppression \u2014 do not use it to serve shared/public reports.", - "Only the declared query params are honoured here; unknown params are dropped rather than forwarded.", - "Paging is by top-level ROOT nodes (5 per page, or 1 for builds over 2000 runs); walk `pagingParams` (nextRoot + searchAfter). `showRunCount` is forced false outside the demo sandbox." - ], - "returns": [ - "hierarchy", - "pagingParams", - "status", - "workflowStatus", - "isArchived", - "smartTagSettings", - "source", - "folder_path" - ], - "responses": { - "200": { - "$response": "HierarchyTestRunListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "list_build_test_run_counts", - "method": "GET", - "path": "/api/v1/builds/v2/{buildId}/testRuns/count", - "mode": "read", - "entity": "test_run", - "path_params": [ - { - "name": "buildId", - "type": "string", - "required": true, - "description": "The build UUID, resolved by (uuid, groupId). BUILD_NOT_FOUND if not in the caller's group." - } - ], - "intent": "For every unique test in a build (keyed by test hash), get how many times it ran across the build and its re-runs, and whether those runs spanned different platforms \u2014 what the UI uses for rerun/retry badges.", - "guidance": [ - "Returns per-test rows under a top-level `data` array (`{testHash, count, isDifferentPlatformsUsed}`), NOT a single total. Sum `count` yourself if you need a build total.", - "Counts are aggregated over the PARENT build id, so a re-run chain (a build and its re-runs) is counted as one build, not per re-run.", - "`isDifferentPlatformsUsed` is true when the run count for that test hash spanned more than one platform." - ], - "returns": [ - "data" - ], - "responses": { - "200": { - "$response": "TestRunCountListResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - }, - { - "name": "get_build_top_error_stats", - "method": "GET", - "path": "/api/v1/builds/{buildUuid}/topErrorStats", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "buildUuid", - "type": "string", - "required": true, - "description": "Build uuid." - } - ], - "query": [ - { - "name": "filter", - "type": "object", - "required": false, - "description": "FilterParamsV2 listing-filter set (statuses, frameworks, tags, date range, search, etc.) bound from flat query params; the panel is computed over the tests these match." - }, - { - "name": "customParams", - "type": "object", - "required": false, - "description": "Any extra query params are collected verbatim into a custom-params map and folded into the filter." - } - ], - "intent": "Get the 'Top unique errors' insight panel for a build \u2014 the most frequent error clusters with their test-run counts, respecting the active listing filters.", - "returns": [ - "overview", - "data", - "ready", - "commentCount", - "message" - ], - "responses": { - "200": { - "$response": "BuildTopErrorStatsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Handle the not-ready state: while the auto-analyser is still running you get ready:false with only a `message` ('Unique Errors are being detected') and the default 'Analysis yet to run' overview \u2014 not an error and not the final data.", - "While clustering is incomplete the panel comes back with an empty `data` set; poll again rather than concluding there are no errors.", - "`data` is sorted by count descending; each row carries the cluster id, title (the error, often a full stack trace), count, testRuns and aiRcaTestRunIds. impactedTests can be null and testRuns can be empty even for a ready panel - use aiRcaTestRunIds (populated) to reach the runs.", - "Build lookup is group-scoped, so a build uuid from another group reads as not found." - ] - }, - { - "name": "recalculate_build_quality_gate", - "method": "POST", - "path": "/api/v1/builds/{uuid}/recalculate-quality-gate", - "mode": "write", - "entity": "build", - "path_params": [ - { - "name": "uuid", - "type": "string", - "required": true, - "description": "Build uuid." - } - ], - "body": [ - { - "name": "close", - "type": "boolean", - "required": false, - "description": "Part of the RecalculateQGBody shape; effectively ignored on this route \u2014 the handler forces the recalculation branch regardless." - }, - { - "name": "reCalculateQG", - "type": "boolean", - "required": false, - "description": "Ignored \u2014 the handler forces reCalculateQG=true. The body must be PRESENT (a valid RecalculateQGBody), but its contents do not change behaviour; unknown keys are ignored." - } - ], - "intent": "Re-run the quality-gate evaluation for a build (e.g. after profile rules or a status override changed) \u2014 use after quality-gate inputs change to refresh the build's gate result.", - "returns": [ - "message" - ], - "responses": { - "200": { - "$response": "BuildQualityGateRecalcAckResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Asynchronous: responds immediately with 200 {message:'Quality Gate result will be available after recalculation finishes'} \u2014 the result is NOT ready yet. Poll the build's quality-gate summary read for the outcome.", - "This OVERWRITES the build's previous quality-gate result; there is no undo for the prior result.", - "A request body must be sent (an object; {} is fine) even though its fields are ignored \u2014 the handler forces the recalculation.", - "403 PLAN_FEATURE_NOT_ALLOWED when the build is plan-locked.", - "Live 2026-09-30: results can take a few minutes to reappear; if they stay empty, one enabled profile in the project may be un-evaluable (see create_quality_profile) \u2014 it blanks every profile's result, not just its own." - ] - }, - { - "name": "resolve_build_uuid_from_identifiers", - "method": "GET", - "path": "/api/v2/builds/buildUuid", - "mode": "read", - "entity": "build", - "query": [ - { - "name": "creationType", - "type": "string", - "required": true, - "description": "Build.CreationTypeEnum. AUTOMATION uses the name/serial identifiers below; any other (manual/TCM) type uses ONLY tcmTestRunId and ignores the name/serial params." - }, - { - "name": "buildNormalisedName", - "type": "string", - "required": false, - "default": "", - "description": "Normalised build name (AUTOMATION path)." - }, - { - "name": "projectNormalisedName", - "type": "string", - "required": false, - "default": "", - "description": "Project normalised name (AUTOMATION path); pair with buildNormalisedName + buildSerialId." - }, - { - "name": "buildSerialId", - "type": "integer", - "required": false, - "default": 0, - "description": "Per-project sequential build number (AUTOMATION path). On a serial-id collision the first match wins (logged)." - }, - { - "name": "tcmTestRunId", - "type": "integer", - "required": false, - "default": 0, - "description": "TCM test-run id \u2014 the only identifier used for non-AUTOMATION creation types." - }, - { - "name": "thProjectId", - "type": "integer", - "required": false, - "default": 0, - "description": "TestHub project id, an alternative to projectNormalisedName for scoping the AUTOMATION lookup." - } - ], - "intent": "Resolve a build UUID from human/CI-facing identifiers \u2014 project name (or TestHub project id) + build name + serial id for automation builds, or a TCM test-run id for manual/TCM runs \u2014 so a caller can deep-link to a build without already knowing its uuid.", - "returns": [ - "uuid" - ], - "responses": { - "200": { - "$response": "ResolvedBuildUuidResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "No match returns HTTP 200 {uuid:null} \u2014 NOT a 404. Check the uuid value, not the status code.", - "For non-AUTOMATION creation types only tcmTestRunId is consulted; buildNormalisedName / projectNormalisedName / buildSerialId are ignored on that path.", - "Serial-id collisions are possible \u2014 the first matching build wins (a pager marker is logged)." - ] - }, - { - "name": "list_sidebar_build_runs", - "method": "POST", - "path": "/api/v2/builds", - "mode": "read", - "entity": "build", - "body": [ - { - "name": "projects", - "type": "array", - "required": false, - "description": "Project ids to scope to. When null and UDAC is on, the accessible project list is resolved for the caller's sub-group." - }, - { - "name": "query", - "type": "string", - "required": false, - "description": "Free-text search over build names/identifiers." - }, - { - "name": "afterKey", - "type": "string", - "required": false, - "description": "Forward cursor \u2014 pass the response's pagingParams.afterKey to page down." - }, - { - "name": "beforeKey", - "type": "string", - "required": false, - "description": "Backward cursor \u2014 pass the response's pagingParams.beforeKey to page up." - }, - { - "name": "selectedBuildUuid", - "type": "string", - "required": false, - "description": "Centre the returned window on this build (roughly half a page above it, the rest below) so a deep-linked build appears in context." - }, - { - "name": "includeManualRuns", - "type": "boolean", - "required": false, - "description": "Include manual/TCM runs in the listing (default true)." - }, - { - "name": "isManuallyOverridden", - "type": "boolean", - "required": false, - "description": "Optional filter on the manual-override flag." - }, - { - "name": "buildNames", - "type": "array", - "required": false, - "description": "Part of the inherited GetBuildFilterRqV2 filter surface: build-name filter. The body also accepts users, teams, frameworks, statuses, sources, tags, branches, dateRange, search, etc." - }, - { - "name": "isArchived", - "type": "boolean", - "required": false, - "description": "Filter to archived vs live builds (inherited filter field)." - } - ], - "intent": "List a page of unique build runs for the build sidebar/list rail, matching a large filter body, with bidirectional cursor paging and optional centring on a deep-linked build.", - "returns": [ - "data", - "pagingParams" - ], - "responses": { - "200": { - "$response": "SidebarBuildRunsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "OBSERVED LIVE: `buildNames` and `query` are IGNORED by the product (200 with the unfiltered page, even when `projects` is set). Do not use this route to find a build by name; use list_project_builds_by_name (project-scoped, `search`) or search_entities (cross-project).", - "Only the body fields declared here are sent; the inherited filter surface mentioned in the field description (users, teams, statuses, tags, dateRange, search...) is not bindable through this capability.", - "POST but a pure READ \u2014 the body is a filter; nothing is written.", - "Page size is fixed server-side (BUILDS_PAGESIZE); it is NOT caller-controllable, so there is no page-size body field. Page with the afterKey/beforeKey cursors from pagingParams (hasNext/hasPrev tell you when to stop).", - "Honours UDAC project scoping: with projects null and UDAC on, only the caller's accessible projects are returned.", - "Each row's usageQuotaStatus splits retention-accessible builds from 'pro quota' ones \u2014 a build can appear but be gated by quota.", - "Use selectedBuildUuid to keep a deep-linked build visible in the window rather than paging to find it." - ] - }, - { - "name": "generate_build_report", - "method": "POST", - "path": "/ext/v1/builds/buildReport", - "mode": "read", - "entity": "build", - "body": [ - { - "name": "requestingCi", - "type": "string", - "required": true, - "description": "The CI requesting the report (e.g. the Jenkins plugin). Missing \u2192 error." - }, - { - "name": "requestType", - "type": "string", - "required": true, - "values": [ - "FIRST", - "POLL" - ], - "description": "FIRST for the initial request, POLL for follow-ups. An unknown value raises a 400 'Invalid request_type'. Missing \u2192 error." - }, - { - "name": "reportFormat", - "type": "array", - "required": true, - "description": "Requested formats: richHtml, basicHtml and/or plainText (case-sensitive). Other values render nothing -> 400 'Build Report request format is invalid'.", - "values": [ - "richHtml", - "basicHtml", - "plainText" - ] - }, - { - "name": "buildUuid", - "type": "string", - "required": false, - "description": "Optional hint passed with originalBuildName (+buildStartedAt); on its own it does not resolve the build." - }, - { - "name": "originalBuildName", - "type": "string", - "required": true, - "description": "Build's original name. REQUIRED: resolution only runs when it is present; buildUuid alone -> 400 'Original build name not found' (40021)." - }, - { - "name": "buildStartedAt", - "type": "integer", - "required": false, - "description": "Build start time in epoch milliseconds; pairs with originalBuildName for resolution." - }, - { - "name": "userTimeout", - "type": "integer", - "required": false, - "description": "Caller's overall timeout budget, used to bound the polling protocol." - } - ], - "intent": "Ask for a build's summary report in the requested formats \u2014 the CI-plugin endpoint. It is a polling protocol: the first request usually returns IN_PROGRESS with the retry count and interval to use, and later poll requests return the finished report once the build's data has settled.", - "returns": [ - "reportStatus", - "retryCount", - "pollingInterval", - "report", - "errorReason" - ], - "responses": { - "200": { - "$response": "BuildReportPollingResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Polling contract, not a single call: on reportStatus IN_PROGRESS you also get retryCount and pollingInterval \u2014 wait that interval and POST again with requestType=POLL until reportStatus is COMPLETED/TEST_AVAILABLE (or NOT_AVAILABLE/DATA_NOT_AVAILABLE).", - "Body is an untyped map validated by hand: requestingCi, requestType and reportFormat are each required and their absence is a distinct error; an unknown requestType is a 400.", - "Resolve the build by originalBuildName (+ buildStartedAt; buildUuid optional). Without originalBuildName the handler skips resolution and returns 400 'Original build name not found'. If the resolved build row is missing you get a 200 with a NULL body: handle that as 'not found', not a report.", - "The report itself (report.richHtml/richCss/basicHtml/plainText) is only populated on the terminal statuses; the envelope is @JsonInclude(NON_NULL) so absent fields are omitted.", - "In the demo-sandbox environment this returns HTTP 404 with body 'NOT FOUND'." - ] - }, - { - "name": "get_build_summary", - "method": "GET", - "path": "/ext/v1/builds/{build_id}", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build uuid. A re-run uuid resolves through its parent, so you get the parent build's aggregate." - } - ], - "intent": "Get one build's public record by uuid \u2014 identity and naming, status and per-status test counts, plus the pro-plan smart tags and unique errors. Narrower than get_build_details, which is the same build seen from inside the product.", - "returns": [ - "build_id", - "id", - "project_id", - "project_name", - "name", - "build_number", - "status", - "status_stats" - ], - "responses": { - "200": { - "$response": "PublicBuildDetailsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Enforces the TEST_RUN_AUTOMATED_VIEW service-account project permission on the build's project.", - "Passing a re-run uuid returns the PARENT build's aggregate, not the re-run's own numbers.", - "A build in 'deleted' state raises BUILD_ID_DELETED, not a plain 404 \u2014 distinguish 'deleted' from 'never existed'.", - "The response SHAPE is feature-flagged: when new build listing is enabled it is the V2 build-details shape, otherwise the legacy one \u2014 do not hard-code one envelope.", - "Pro-only fields (smart_tags, unique_errors, failure_categories, alerts) are stripped by JsonView for non-pro plans, so their absence reflects the plan, not the build.", - "`status` here is the build's LIFECYCLE ('finished'), not its outcome ('failed') as get_build_details reports it \u2014 the two routes answer different questions with the same field name.", - "project_id, project_name and created_at can come back null on this route even when get_build_details has them; read those from the internal route when they matter.", - "Each route carries what the other lacks: this one has smart_tags and unique_errors, while get_build_details has the quality-gate status and AI RCA counts." - ] - }, - { - "name": "list_build_history_with_defect_counts", - "method": "GET", - "path": "/ext/v1/builds/{build_id}/history", - "mode": "read", - "entity": "build", - "path_params": [ - { - "name": "build_id", - "type": "string", - "required": true, - "description": "Build uuid the history is anchored on." - } - ], - "query": [ - { - "name": "limit", - "type": "integer", - "required": false, - "default": 10, - "minimum": 1, - "maximum": 10, - "description": "How many builds to return (this build plus prior runs of the same name). Silently CLAMPED to 1..10 \u2014 asking for more than 10 is ignored, not an error." - } - ], - "intent": "Get the trend context for a build for an automated failure agent \u2014 this build plus up to N earlier runs of the same build name, newest first, each with number, status, timings and a pass/fail/skip plus defect-type count summary.", - "returns": [ - "builds" - ], - "responses": { - "200": { - "$response": "BuildHistoryWithDefectCountsResponse" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - }, - "guidance": [ - "Not a general history read \u2014 distinct from the internal build-history capability: this is the agent-facing trend view with per-build defect and status counts.", - "limit is clamped server-side to 1..10; there is no paging beyond 10 runs.", - "Only the anchor build and EARLIER runs of the same name are returned; later runs of the same name are filtered out. The anchor build is flagged current:true.", - "Build lookup is group-scoped (the IDOR gate), so a foreign uuid is not found.", - "Per-build counts (statusStats + defectStats: automation bug / product bug / to-be-investigated / not-determined / environment issue) come from a single Elasticsearch aggregation; per-entity recurrence (flaky/theme names) is intentionally not included." - ] - }, - { - "name": "list_build_ai_failure_cluster_test_runs", - "method": "GET", - "path": "/api/v1/ai/failures/{buildUUID}/flat", - "mode": "read", - "entity": "ai_failure", - "path_params": [ - { - "name": "buildUUID", - "type": "string", - "required": true, - "description": "Build UUID (opaque string)." - } - ], - "query": [ - { - "name": "testRunIds", - "type": "array", - "required": false, - "description": "Comma-separated integer test-run ids to fetch directly, skipping the theme/workflow lookup. Max 200 (more => 400). Sending this selects the by-ids behaviour; omit it to scope by cluster or whole build.", - "fields": [ - { - "name": "item", - "type": "integer" - } - ] - }, - { - "name": "buildFailureThemeId", - "type": "integer", - "required": false, - "description": "Integer id of a failure THEME to drill into (from get_build_ai_failures). Give this OR buildFailureWorkflowId to scope to one cluster. Ignored if testRunIds is sent." - }, - { - "name": "buildFailureWorkflowId", - "type": "integer", - "required": false, - "description": "Integer id of a failure WORKFLOW to drill into (from get_build_ai_failures). Ignored if testRunIds is sent." - }, - { - "name": "limit", - "type": "integer", - "required": false, - "description": "ACCEPTED BUT IGNORED. Page size is fixed by the backend's Elasticsearch default; do not rely on it. Use searchAfter to page." - }, - { - "name": "searchAfter", - "type": "string", - "required": false, - "description": "Opaque search-after cursor. Pass the previous response's pagingParams.searchAfter (JSON-encoded) to fetch the next page." - } - ], - "intent": "List a build's AI-failure test runs as a flat, cursor-paginated list with full details \u2014 fetch an explicit set of test-run ids, drill into one failure theme or workflow cluster, or omit both to span the whole build.", - "returns": [ - "testRuns", - "pagingParams" - ], - "guidance": [ - "ONE route, three modes chosen by which query params you send: testRunIds -> fetch exactly those runs (skips the theme/workflow lookup); buildFailureThemeId OR buildFailureWorkflowId -> drill into one cluster (ids from get_build_ai_failures); neither -> the whole build.", - "testRunIds is comma-separated integer ids, capped at 200; more returns 400 naming testRunIds.", - "Group-scoped / IDOR-safe: results filter on buildId, so ids or clusters outside the caller's build simply do not match \u2014 a short or empty testRuns (with hasNext=false) is not an error.", - "limit is ACCEPTED BUT IGNORED (fixed backend page size). Paginate with searchAfter and stop when pagingParams.hasNext is false.", - "Not plan-fenced; a plan-downgraded account can still read prior content." - ], - "responses": { - "200": { - "$response": "FlatAIFailureTestRunsResponse" - }, - "400": { - "$response": "BadRequest" - }, - "401": { - "$response": "Unauthorized" - }, - "403": { - "$response": "Forbidden" - }, - "404": { - "$response": "NotFound" - }, - "500": { - "$response": "InternalServerError" - } - } - } - ], - "schemas": { - "ErrorResponse": { - "type": "object", - "description": "Standard TRA error body built by GlobalExceptionHandler.", - "properties": { - "errorTraceId": { - "type": "string", - "description": "UUID correlating the error to server logs." - }, - "message": { - "type": "string", - "description": "Human-readable error message; for relayed TestHub errors this is the upstream message." - }, - "code": { - "type": "string", - "description": "Application/TestHub error code. Present on many 4xx; absent on generic 500s." - }, - "classification": { - "type": "string", - "description": "Blame classification when stamped, e.g. USER_ISSUE. Never present on /ext responses.", - "example": "USER_ISSUE" - }, - "hasAccess": { - "type": "boolean", - "description": "Present (false) on access-denied 403s.", - "example": false - } - } - }, - "BuildGroupAggResult": { - "type": "object", - "description": "One build-group row in the grouped build listing (models/BuildGroupAggResult).", - "properties": { - "buildGroupId": { - "type": "integer", - "description": "Build group id." - }, - "id": { - "type": "integer", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "buildCount": { - "type": "integer" - }, - "closedBuildCount": { - "type": "integer" - }, - "closingBuildCount": { - "type": "integer", - "nullable": true - }, - "isGroupClosing": { - "type": "boolean", - "nullable": true - }, - "manualBuildCount": { - "type": "integer" - }, - "automationBuildCount": { - "type": "integer" - }, - "latestCreatedAt": { - "type": "number", - "description": "Epoch millis of the group's newest build (a double)." - }, - "buildGroup": { - "type": "object", - "nullable": true, - "description": "The TestHub build-group record (id, name, closedBy, closedByName, ...), when the group exists in TestHub." - } - } - }, - "SourceFencingFlags": { - "type": "object", - "description": "Cross-source feature-fencing flags; embedded in many widget/build responses. A true flag means results for that product were skipped for this caller.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - }, - "PageInfo": { - "type": "object", - "description": "Standard pagination info envelope.", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer", - "description": "Previous page number, null on the first page." - }, - "next": { - "type": "integer", - "description": "Next page number, null on the last page." - } - } - }, - "BuildAggregate": { - "type": "object", - "description": "Run counts by status for one build name.", - "properties": { - "count": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "BuildSummary": { - "type": "object", - "description": "Build summary (models/response/BuildSummary). Every field except uuid/id/name/status can be null - nulls are serialised, not omitted. About 30 further keys are also returned (alertCount, buildError, creationType, workflowStatusStats, aiHealingMeta, origins, testCaseCount, parallelsUsed, ...); only the ones agents normally need are declared.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "derivedName": { - "type": "string", - "nullable": true - }, - "originalName": { - "type": "string", - "nullable": true - }, - "isAutoDetectedName": { - "type": "boolean", - "nullable": true - }, - "normalisedName": { - "type": "string", - "nullable": true - }, - "description": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string", - "description": "Lifecycle status (e.g. finished); the pass/fail verdict is displayStatus." - }, - "duration": { - "type": "integer", - "nullable": true - }, - "consolidatedDuration": { - "type": "integer", - "nullable": true - }, - "finishedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "startedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "sessionCount": { - "type": "integer", - "nullable": true - }, - "hostInfo": { - "type": "object", - "nullable": true - }, - "observabilityVersion": { - "type": "object", - "nullable": true - }, - "ciBuildData": { - "type": "object", - "nullable": true - }, - "user": { - "type": "string", - "nullable": true - }, - "userId": { - "type": "integer", - "nullable": true - }, - "closedBy": { - "type": "string", - "nullable": true - }, - "closedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "metaData": { - "type": "string", - "nullable": true, - "description": "JSON-encoded string." - }, - "percyBuildUrl": { - "type": "string", - "nullable": true - }, - "requestlyRunUrl": { - "type": "string", - "nullable": true, - "description": "Not serialised on every route (absent from the daily-summary by-ids listing)." - }, - "source": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "issueTypes": { - "type": "array", - "nullable": true - }, - "statusStats": { - "type": "object", - "description": "StatusAggregate: pending/passed/failed/skipped/unknown counts, plus an integer `aggregate` total.", - "nullable": true - }, - "aiRcaCompletedCount": { - "type": "integer", - "nullable": true - }, - "selfHealedCount": { - "type": "integer", - "nullable": true - }, - "showHealingReport": { - "type": "boolean", - "nullable": true - }, - "showAuthoringReport": { - "type": "boolean", - "nullable": true - }, - "appAccessibilityEnabled": { - "type": "boolean", - "nullable": true - }, - "issueTypeAggregate": { - "type": "object", - "nullable": true - }, - "historyAggregate": { - "type": "object", - "nullable": true - }, - "buildNumber": { - "type": "integer", - "nullable": true - }, - "sdKVersion": { - "type": "string", - "nullable": true - }, - "displayStatus": { - "type": "string", - "nullable": true, - "description": "Build verdict shown in the UI (passed/failed/...); prefer this over status." - }, - "state": { - "type": "string", - "nullable": true, - "description": "Build lifecycle state, e.g. NEW or CLOSED." - }, - "projectId": { - "type": "integer", - "nullable": true, - "description": "TRA project id." - }, - "thProjectId": { - "type": "integer", - "nullable": true, - "description": "TestHub project id." - }, - "projectName": { - "type": "string", - "nullable": true - }, - "qualityGateStatus": { - "type": "string", - "nullable": true - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "reRun": { - "type": "boolean", - "nullable": true - }, - "tcmTestRunIdentifier": { - "type": "string", - "nullable": true, - "description": "Linked Test Management run identifier (TR-...), when any." - }, - "runInfos": { - "type": "array", - "nullable": true, - "items": { - "type": "object" - } - }, - "versionControlInfo": { - "type": "object", - "description": "{url, name, commitId, branch}; always an object, fields null when no VCS data.", - "properties": { - "url": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string", - "nullable": true - }, - "commitId": { - "type": "string", - "nullable": true - }, - "branch": { - "type": "string", - "nullable": true - } - } - } - } - }, - "BuildRunsSummary": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "BuildAggregateV2": { - "type": "object", - "description": "Top failed build entry, optionally enriched with best-effort AI summaries (omitted when null).", - "properties": { - "name": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "serialId": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "aiRcaSummary": { - "type": "string", - "description": "Best-effort; may be absent if enrichment failed." - }, - "aiRcaStatus": { - "type": "string" - }, - "themeCount": { - "type": "integer" - }, - "workflowCount": { - "type": "integer" - }, - "selfHealingSummary": { - "type": "string", - "description": "Best-effort; omitted when null." - }, - "authoringSummary": { - "type": "string", - "description": "Best-effort; omitted when null." - } - } - }, - "FailedTestSummary": { - "type": "object", - "properties": { - "uniqueErrors": { - "type": "integer" - }, - "flaky": { - "type": "integer" - }, - "totalFailed": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "newlyFailing": { - "type": "integer" - } - } - }, - "IssueTypeSummary": { - "type": "object", - "description": "Failure-category counts (pb=Product Bug, tbi=To be Investigated, ei=Environment Issue, ab=Automation Bug, nd=No Defect).", - "properties": { - "pb": { - "type": "integer" - }, - "tbi": { - "type": "integer" - }, - "ei": { - "type": "integer" - }, - "ab": { - "type": "integer" - }, - "nd": { - "type": "integer" - } - } - }, - "ProjectSummary": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string", - "nullable": true, - "description": "Can be null in the daily-summary payload; key projects by id." - }, - "normalisedName": { - "type": "string", - "nullable": true - }, - "buildRunsSummary": { - "$schema": "BuildRunsSummary" - }, - "topFailedBuilds": { - "type": "array", - "items": { - "$schema": "BuildAggregateV2" - } - }, - "failedTestSummary": { - "$schema": "FailedTestSummary" - }, - "issueTypeSummary": { - "$schema": "IssueTypeSummary" - } - } - }, - "OverallDailySummary": { - "type": "object", - "description": "Aggregated daily-summary report across projects.", - "properties": { - "filterQueries": { - "type": "object", - "description": "Fixed canned query strings (dateFilter, buildRuns, topFailedBuilds, failedTests, issueTypes) used to deep-link filtered views." - }, - "projects": { - "type": "array", - "items": { - "$schema": "ProjectSummary" - } - } - } - }, - "QualityProfile": { - "type": "object", - "description": "A quality-gate profile: a named rule set plus the build filters it applies to. Serialized with null fields omitted (JSON include NON_NULL). Note the @JsonProperty renames: the wire fields are `isGlobalProfile` (internal isRootProfile) and `applicableBuilds` (internal buildFilters).", - "properties": { - "id": { - "type": "integer", - "description": "Internal DB id; changes on every edit because an edit writes a new version row." - }, - "uuid": { - "type": "string", - "format": "uuid", - "description": "Public profile identifier. NOTE: a new UUID is minted on every edit, so it is not stable across updates." - }, - "name": { - "type": "string" - }, - "ruleStatus": { - "type": "string", - "enum": [ - "pass", - "fail" - ], - "description": "'pass' = profile passes only when all rules hold; 'fail' = profile fails when any rule holds." - }, - "rules": { - "type": "array", - "items": { - "$schema": "QualityProfileRule" - } - }, - "applicableBuilds": { - "$schema": "BuildFilter", - "description": "Wire name for the internal buildFilters." - }, - "isGlobalProfile": { - "type": "boolean", - "description": "Wire name for the internal isRootProfile. True = applies group-wide." - }, - "hooksVisibility": { - "type": "string", - "enum": [ - "all", - "failed", - "beforeFailed", - "none" - ] - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "isLatest": { - "type": "boolean", - "description": "Whether this is the current version of the profile; a retired/deleted version has this false." - } - } - }, - "QualityProfileRule": { - "type": "object", - "description": "One rule inside a quality profile. Serialized with null fields omitted.", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Client-generated rule UUID; required on write." - }, - "type": { - "type": "string", - "enum": [ - "TEST_STATUS", - "TEST_STATUS_THRESHOLD", - "STABILITY", - "TEST_EXECUTIONS_THRESHOLD", - "BUILD_DURATION", - "SMART_TAGS", - "SMART_TAGS_THRESHOLD", - "FAILURE_CATEGORY", - "FAILURE_CATEGORY_THRESHOLD", - "ALERTS", - "UNIQUE_ERROR_THRESHOLD", - "NEW_UNIQUE_ERROR", - "UNIQUE_ERROR_STACK_TRACE" - ] - }, - "operator": { - "type": "string", - "enum": [ - "has", - "triggered", - "not_triggered", - "is", - "is_in", - "is_not", - "less_than", - "less_than_equals", - "equals", - "greater_than", - "greater_than_equals" - ] - }, - "lhs": { - "$schema": "RuleLHS" - }, - "rhs": { - "$schema": "RuleRHS" - } - } - }, - "RuleLHS": { - "type": "object", - "description": "Left-hand side of a rule. Null fields omitted.", - "properties": { - "value": { - "type": "string", - "enum": [ - "ALL_TESTS", - "SELECTED_TESTS", - "OVERALL_BUILD", - "ALL_ALERTS", - "SELECTED_ALERTS", - "SHOULD_BE", - "SHOULD_NOT_BE" - ] - }, - "helperConfig": { - "type": "object", - "description": "Scoping for SELECTED_* rules: {regex, testTags[], folders[], alert}.", - "properties": { - "regex": { - "type": "string" - }, - "testTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "folders": { - "type": "array", - "items": { - "type": "string" - } - }, - "alert": { - "type": "object" - } - } - }, - "with": { - "type": "string", - "maxLength": 500, - "description": "Operand (test status, smart tag, failure-category key, ...)." - }, - "displayWith": { - "type": "string", - "description": "Human display name for a failure-category operand; populated only on the single-profile detail reads for FAILURE_CATEGORY / FAILURE_CATEGORY_THRESHOLD rules." - }, - "denominator": { - "type": "string" - } - } - }, - "RuleRHS": { - "type": "object", - "description": "Right-hand side / threshold of a rule. Null fields omitted.", - "properties": { - "type": { - "type": "string", - "enum": [ - "number", - "percentage", - "string" - ] - }, - "value": { - "type": "string", - "maxLength": 500 - } - } - }, - "BuildFilter": { - "type": "object", - "description": "Which builds a profile is evaluated against.", - "properties": { - "allBuilds": { - "type": "boolean", - "description": "Required. True = every build; otherwise scope with buildNames/buildTags." - }, - "buildTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "buildNames": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "QualityProfileLiteDto": { - "type": "object", - "description": "Compact profile row in the settings/list response.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "rulesCount": { - "type": "integer" - }, - "applicableBuilds": { - "$schema": "BuildFilter", - "description": "Wire name for buildFilters." - }, - "isGlobalProfile": { - "type": "boolean", - "description": "Wire name for isRootProfile." - }, - "enabled": { - "type": "boolean" - } - } - }, - "QualityGateResponse": { - "type": "object", - "description": "A project's quality-gate settings: the project-level switches plus the profiles mapped to it.", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether Quality Gates is on for the project (from a project attribute)." - }, - "shouldOverrideBuildStatus": { - "type": "boolean", - "description": "Whether a failing gate also marks the build failed (from a project attribute)." - }, - "qualityProfiles": { - "type": "array", - "items": { - "$schema": "QualityProfileLiteDto" - }, - "description": "Default profile sorted to the front." - } - } - }, - "QualityProfileResultStats": { - "type": "object", - "description": "Per-status rule counts for a profile on a build.", - "properties": { - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - } - } - }, - "QualityProfileResultLiteDto": { - "type": "object", - "description": "Per-profile result summary for a build.", - "properties": { - "uuid": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Computed profile outcome: 'passed', 'failed' or 'skipped'." - }, - "statusStats": { - "$schema": "QualityProfileResultStats" - } - } - }, - "QualityGateSummary": { - "type": "object", - "description": "Readiness-aware build gate summary. Exactly one of qgResult / message / state is populated per response.", - "properties": { - "qgResult": { - "type": "array", - "items": { - "$schema": "QualityProfileResultLiteDto" - }, - "description": "Per-profile results when ready." - }, - "message": { - "type": "string", - "description": "Present instead of results when the build is pending or the auto-analyser is still running." - }, - "state": { - "type": "string", - "description": "A cached evaluation state string, present instead of results/message when one is cached." - } - } - }, - "QualityProfileRuleResult": { - "type": "object", - "description": "One rule's outcome for a build in the profile detail drawer.", - "properties": { - "rule": { - "$schema": "QualityProfileRule" - }, - "ruleStatus": { - "type": "string", - "description": "Per-build status of this rule (e.g. running/passed/failed)." - }, - "ruleResult": { - "type": "string", - "description": "Rendered result/value for this rule on the build." - } - } - }, - "QualityRuleResult": { - "type": "object", - "description": "One rule's evaluated result inside the CI quality-gate result. Null fields omitted.", - "properties": { - "condition": { - "type": "string", - "description": "Human-rendered rule condition text." - }, - "value": { - "type": "string", - "description": "The rule's per-build status." - }, - "result": { - "type": "string" - }, - "regex": { - "type": "string" - }, - "testTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "folders": { - "type": "array", - "items": { - "type": "string" - } - }, - "alert": { - "type": "string" - } - } - }, - "QualityProfiles": { - "type": "object", - "description": "A profile block inside the CI quality-gate result.", - "properties": { - "id": { - "type": "string", - "description": "Profile UUID." - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "description": "Rendered as ' if', e.g. 'pass if'." - }, - "result": { - "type": "string", - "description": "'passed', 'failed' or 'skipped'." - }, - "rules": { - "type": "array", - "items": { - "$schema": "QualityRuleResult" - } - } - } - }, - "ExtQualityGateResult": { - "type": "object", - "description": "CI-facing quality gate result for a build. Serialized with null fields omitted (JSON include NON_NULL), so while running only build_uuid/status/quality_gate_result appear.", - "properties": { - "build_uuid": { - "type": "string", - "format": "uuid" - }, - "build_url": { - "type": "string", - "description": "Deep link to the build in the product; absent while the build is still running." - }, - "status": { - "type": "string", - "description": "'running' while unfinished/analysing, otherwise 'passed'/'failed'/'skipped'." - }, - "quality_gate_result": { - "type": "string", - "description": "While running, a human 'try again' message; once done, the same value as status." - }, - "quality_profiles": { - "type": "array", - "items": { - "$schema": "QualityProfiles" - }, - "description": "Per-profile breakdown; absent while running." - } - } - }, - "FilterStruct": { - "type": "object", - "description": "One filter value option for the quality-profile filter builder.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true - }, - "pId": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "displayName": { - "type": "string", - "description": "Present only when set (JSON include NON_NULL)." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - } - } - }, - "QGFilterResponseData": { - "type": "object", - "description": "Filter values for the quality-profile filter builder, with a cursor.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterStruct" - } - }, - "afterKey": { - "type": "string", - "description": "The last key of this page. Still set on a final short page, so stop when fewer than the page size come back.", - "nullable": true - }, - "query": { - "type": "string", - "description": "Echo of the search prefix; null when not sent.", - "nullable": true - } - } - }, - "TrendTimelineDataPoint": { - "type": "object", - "description": "One point of a Testing Trends timeline series. `y` is the metric value (null - or 0 on count cards - when the bucket had no runs). `t` is the total unique tests in the bucket, null on most cards. `pointRange` is [bucketStart, bucketStart+interval] epoch ms, or null on series that do not set it (e.g. performance-chart lineData) - the key is always present.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket start, epoch milliseconds." - }, - "y": { - "type": "number", - "description": "Metric value for the bucket (count, percentage or duration depending on the card) as a JSON number. Null (percent/duration cards) or 0 (count cards such as test executions) for an empty bucket.", - "nullable": true - }, - "t": { - "type": "integer", - "description": "Total unique tests in the bucket; null on most cards (observed null on the test-executions and performance cards).", - "nullable": true - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "[bucketStart, bucketEnd] epoch ms; null (not omitted) on series that do not set it, e.g. performance-chart lineData.", - "nullable": true - } - } - }, - "TrendInsight": { - "type": "object", - "description": "Headline card summary. Fields are computed on serialisation: `count` is the headline number as a STRING (a percentage for flakiness/new-failures/always-failing, a raw total for test-executions); `meta` is a human sentence ('N of M unique tests are ') or null; `metric` is the metric label or null.", - "properties": { - "metric": { - "type": "string", - "description": "Metric label (e.g. 'flaky', 'new failures'); null when not applicable.", - "nullable": true - }, - "meta": { - "type": "string", - "description": "Human summary sentence; null when there is no metric.", - "nullable": true - }, - "count": { - "type": "string", - "description": "Headline value as a string - a percentage (one decimal) for the gated cards, or a raw count for test-executions." - } - } - }, - "PercentTimelineCard": { - "type": "object", - "description": "Standard Testing Trends card: a timeline series plus an optional headline insight. `insights` is null on the test-growth card.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - }, - "insights": { - "$schema": "TrendInsight" - } - } - }, - "FailureCategorySeries": { - "type": "object", - "description": "One defect/failure category with its own timeline.", - "properties": { - "name": { - "type": "string", - "description": "Defect/issue category name." - }, - "color": { - "type": "string", - "description": "Display colour from the group's defect-type mapping." - }, - "data": { - "type": "array", - "description": "Per-bucket counts. Each point's y is null when no tests ran in that bucket (distinct from 0 failures).", - "items": { - "type": "object", - "properties": { - "x": { - "type": "integer", - "description": "Bucket start, epoch ms." - }, - "y": { - "type": "integer", - "description": "Failure count in the bucket; null when the bucket had no runs." + }, + "CreateHealingPrAccepted": { + "description": "Healing-PR job accepted (async). Only healingPrId + status (IN_PROGRESS) are set.", + "schema": { + "$schema": "CreateHealingPrResponse" } - } - } - } - } - }, - "CbtDataPoint": { - "type": "object", - "description": "A cross-browser coverage slice. Every point carries name/value/percentage/drilldown/color/os/browser/device; os/browser/device are null on primary points, drilldown is null on secondary points.", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "integer", - "description": "Raw execution count for the slice." - }, - "percentage": { - "type": "string", - "description": "Share as a formatted string (two decimals)." - }, - "drilldown": { - "type": "string", - "description": "Key linking a primary slice to its drill-down entry; null on secondary points.", - "nullable": true - }, - "color": { - "type": "string" - }, - "os": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - }, - "browser": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - }, - "device": { - "type": "string", - "description": "Set only on secondary points; the key is present as null on primary points.", - "nullable": true - } - } - }, - "StabilityLeaderboardRow": { - "type": "object", - "description": "One build's row in the stability leaderboard.", - "properties": { - "id": { - "type": "string", - "description": "Row id: \"all\" for the project-wide row, otherwise \"buildName-\"." - }, - "percentage": { - "type": "string", - "description": "Pass-rate / stability percentage as a formatted string (one decimal)." - }, - "buildName": { - "type": "string", - "description": "Build name, or \"All\" for the project-wide row." - }, - "chartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-bucket pass-rate series; entries may be null for empty buckets." - } - } - }, - "PerformanceLeaderboardRow": { - "type": "object", - "description": "One build's row in the performance leaderboard.", - "properties": { - "id": { - "type": "string", - "description": "Row id, \"buildName-\"." - }, - "buildName": { - "type": "string" - }, - "avgDuration": { - "type": "number", - "description": "Average execution duration over the window." - }, - "testCount": { - "type": "integer", - "description": "Average test-run count." - }, - "lineChartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-bucket test COUNTS (null for empty buckets)." - }, - "barChartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-bucket average DURATIONS (null for empty buckets)." - } - } - }, - "TrendPagingParams": { - "type": "object", - "description": "Fixed-page pagination echo. Note the leaderboards nest this under the key `pagingParam` (singular) while recent-runs uses `pagingParams` (plural).", - "properties": { - "hasNext": { - "type": "boolean", - "description": "True when another page exists." - }, - "pageNumber": { - "type": "integer", - "description": "One-based page number just returned (the requested zero-based pageNumber + 1)." - } - } - }, - "BuildFrequencyStats": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Positional index into the list, NOT a build id." - }, - "buildName": { - "type": "string" - }, - "runs": { - "type": "integer", - "description": "Number of runs of this build name in the window." - } - } - }, - "TrendBuildSummary": { - "type": "object", - "description": "Standard build summary as used by the recent-runs picker. Large object; the salient fields are listed. Serialised with default (nulls included) inclusion; vcsInfo is write-only and not returned.", - "properties": { - "uuid": { - "type": "string", - "description": "Build's stable uuid identifier." - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "duration": { - "type": "integer" - }, - "startedAt": { - "type": "string", - "description": "Timestamp." - }, - "finishedAt": { - "type": "string", - "description": "Timestamp." - }, - "sessionCount": { - "type": "integer" - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "statusStats": { - "type": "object", - "description": "Aggregated pass/fail counts for the build." - }, - "source": { - "type": "string" - }, - "qualityGateStatus": { - "type": "string" - }, - "currentQGOverrideStatus": { - "type": "boolean" - } - } - }, - "WebhookProjectMapping": { - "type": "object", - "description": "Projects a rule is linked to, with pagination info. Hydrated from TestHub.", - "properties": { - "info": { - "$schema": "WebhookPaginationInfo" - }, - "projects": { - "type": "array", - "items": { - "$schema": "WebhookProjectInfoLite" - } - } - } - }, - "WebhookPaginationInfo": { - "type": "object", - "description": "Project-picker / project-mapping pagination info (distinct from WebhookListInfo).", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "totalProjectsCount": { - "type": "integer" - }, - "linkedProjectsCount": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "WebhookProjectInfoLite": { - "type": "object", - "description": "A project row in the webhook project picker / mapping list.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "identifier": { - "type": "string" - }, - "isSelected": { - "type": "boolean", - "description": "True when this project is already linked to the queried webhook rule." - } - } - }, - "RcaRetrigger": { - "type": "object", - "description": "Result of retrigger_rca_analysis. Asynchronous \u2014 carries only the new turn's id and, for trial users, the quota used.", - "properties": { - "messageId": { - "type": "string", - "format": "uuid", - "description": "Id of the new RCA conversation turn; poll this for the refreshed analysis." - }, - "trialQuota": { - "type": "object", - "description": "Present (non-null) only for test-level trial users; omitted otherwise.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - }, - "BuildAccessibilityMeta": { - "type": "object", - "description": "Web/Automate build-level accessibility summary. Note: App-Automate builds with App Accessibility enabled are proxied and return a different shape.", - "properties": { - "totalIssue": { - "type": "integer", - "description": "Sum of the severity counts that were present." - }, - "severityBreakdown": { - "type": "object", - "description": "Per-severity issue counts as the build summary reports them (zero counts included when reported).", - "properties": { - "critical": { - "type": "integer" - }, - "serious": { - "type": "integer" - }, - "moderate": { - "type": "integer" - }, - "minor": { - "type": "integer" - } - } - }, - "autoEnabledBuild": { - "type": "boolean", - "description": "Whether accessibility auto-scan was enabled for the build." - }, - "autoEnabled": { - "type": "boolean", - "description": "Duplicate of autoEnabledBuild." - }, - "reportUrl": { - "type": "string", - "description": "Deep link into the Accessibility dashboard (not an API endpoint)." - } - } - }, - "SimilarTestRun": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "uuid": { - "type": "string" - }, - "name": { - "type": "string" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "stackTrace": { - "type": "array", - "items": { - "type": "string" - } - }, - "projectNormalisedName": { - "type": "string" - }, - "buildNormalisedName": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - } - } - }, - "SimilarTestRunRs": { - "type": "object", - "properties": { - "similar": { - "type": "array", - "items": { - "$schema": "SimilarTestRun" - } - }, - "current": { - "$schema": "SimilarTestRun" - }, - "message": { - "type": "string" - } - } - }, - "SingleMetricSummary": { - "type": "object", - "properties": { - "count": { - "type": "integer", - "description": "The metric value (e.g. always-failing test count)." - }, - "meta": { - "type": "string", - "description": "Metric metadata, typically the total the count is out of." - }, - "commentCount": { - "type": "integer" - }, - "autoUnmuteCount": { - "type": "integer" - } - } - }, - "BuildAISummary": { - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": "Narrative failure summary; null when status is NOT_AVAILABLE." - }, - "topFailureThemes": { - "type": "array", - "description": "Up to 5 failure themes.", - "items": { - "type": "object", - "properties": { - "themeId": { - "type": "string" - }, - "themeName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failurePercentage": { - "type": "string" + }, + "HealingPrStatusResponse": { + "description": "Current state of a healing-PR job; PR fields populated once terminal.", + "schema": { + "$schema": "CreateHealingPrResponse" } - } - } - }, - "topAffectedWorkflows": { - "type": "array", - "description": "Up to 5 affected workflows.", - "items": { - "type": "object", - "properties": { - "workflowId": { - "type": "string" - }, - "workflowName": { - "type": "string" - }, - "description": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failurePercentage": { - "type": "string" + }, + "SubmitHealingVerdictResponse": { + "description": "The verdict as recorded.", + "schema": { + "$schema": "HealingVerdictResponse" } - } - } - }, - "status": { - "type": "string", - "description": "Availability status; \"NOT_AVAILABLE\" on soft failure." - } - } - }, - "WorkflowStatusAggregate": { - "type": "object", - "description": "Per-workflow-status counts. Null-valued keys are omitted. Statuses use display-cased JSON keys.", - "properties": { - "Passed": { - "type": "integer" - }, - "Failed": { - "type": "integer" - }, - "In Progress": { - "type": "integer" - }, - "Skipped": { - "type": "integer" - }, - "Retest": { - "type": "integer" - }, - "Blocked": { - "type": "integer" - }, - "Untested": { - "type": "integer" - }, - "Pending": { - "type": "integer" - }, - "Unknown": { - "type": "integer" - }, - "otherCount": { - "type": "object", - "description": "Counts for any non-standard statuses, keyed by status name." - } - } - }, - "BuildAttemptRunStats": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "attemptNumber": { - "type": "integer" - }, - "totalAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "BuildAttemptRunStatsResponse": { - "type": "object", - "properties": { - "attempts": { - "type": "array", - "items": { - "$schema": "BuildAttemptRunStats" - } - }, - "summary": { - "type": "object", - "description": "Aggregate across attempts (same fields as an attempt entry, without a label).", - "properties": { - "attemptNumber": { - "type": "integer" - }, - "totalAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - } - } - }, - "BuildHistoryEntry": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Series name (a test/workflow status)." - }, - "data": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-build value of this status, aligned with the response `keys`." - }, - "color": { - "type": "string" - } - } - }, - "BuildHistory": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildHistoryEntry" - }, - "description": "One series per status." - }, - "keys": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Build labels along the x-axis (order of the ~10 recent builds)." - }, - "commentCount": { - "type": "integer" - } - } - }, - "BuildIdFromCoordinates": { - "type": "object", - "properties": { - "buildId": { - "type": "string", - "description": "The resolved build UUID." - } - } - }, - "BuildCoordinates": { - "type": "object", - "properties": { - "buildNormalisedName": { - "type": "string", - "description": "URI-encoded, spaces as '+'." - }, - "buildSerialId": { - "type": "integer" - }, - "projectNormalisedName": { - "type": "string", - "description": "URI-encoded, spaces as '+'." - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "projectFinishedAt": { - "type": "string" - }, - "projectCreatedAt": { - "type": "string" - }, - "toProjectId": { - "type": "integer", - "description": "Observability project id." - }, - "thProjectId": { - "type": "integer", - "description": "TestHub project id." - } - } - }, - "BuildReRunEntry": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "e.g. \"First Run\", \"Re-run1\", \"Manual Run\"." - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "total": { - "type": "integer" - }, - "id": { - "type": "string", - "description": "The leg's integer build id, as a string." - }, - "buildSdkLogs": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "meta": { - "type": "object", - "nullable": true - } - } - }, - "BuildReRun": { - "type": "object", - "properties": { - "retryData": { - "type": "array", - "items": { - "$schema": "BuildReRunEntry" - }, - "description": "Flat list of re-run legs." - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - } - } - }, - "RerunSummary": { - "type": "object", - "properties": { - "totalTestAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - } - } - }, - "SplitResult": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "totalTestAttempts": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "workflowStatusStats": { - "$schema": "WorkflowStatusAggregate" - }, - "metadata": { - "type": "object", - "description": "Build summary metadata for the split." - } - } - }, - "RerunGroup": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "label": { - "type": "string" - }, - "splits": { - "type": "array", - "items": { - "$schema": "SplitResult" - } - }, - "summary": { - "$schema": "RerunSummary" - }, - "metadata": { - "type": "object", - "description": "Build summary metadata for the group." - } - } - }, - "BuildReRunV2": { - "type": "object", - "properties": { - "retryData": { - "type": "array", - "items": { - "$schema": "RerunGroup" - }, - "description": "Re-run groups, each with per-split breakdowns." - }, - "count": { - "type": "integer" - }, - "commentCount": { - "type": "integer" - }, - "summary": { - "$schema": "RerunSummary" - } - } - }, - "FilterField": { - "type": "object", - "description": "A single selectable filter option. `id`/`value` are what you send back as the filter selection; `label` is the display text. For branch/tag options all three are the raw value; for user/team options `id` is the numeric id (stringified) and value/label are the name.", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "string" - }, - "label": { - "type": "string" - }, - "count": { - "type": "integer", - "description": "Match count for this option; usually null on these facet lists.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Optional parent-id grouping token; empty string on these lists.", - "nullable": true - } - } - }, - "BuildFilterStatic": { - "type": "object", - "description": "Static catalogue of selectable build-listing filter options. Each list is the full menu of choices, not a presence list.", - "properties": { - "statusList": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "frameworkList": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "types": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "sources": { - "type": "array", - "items": { - "$schema": "FilterField" - } - }, - "creationType": { - "type": "array", - "items": { - "$schema": "FilterField" - } - } - } - }, - "BuildFilterApplied": { - "type": "object", - "description": "The caller's applied build-listing filter, echoed back in resolved form (ids turned into name-bearing objects). Anything not sent comes back null (not an empty list).", - "properties": { - "uniqueBuildNames": { - "type": "array", - "items": { - "$schema": "FilterField" - }, - "nullable": true - }, - "statuses": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "dateRange": { - "type": "object", - "nullable": true, - "description": "Resolved range {lowerBound, upperBound} (epoch ms); null when not sent.", - "properties": { - "lowerBound": { - "type": "integer" - }, - "upperBound": { - "type": "integer" - } - } - }, - "frameworks": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Raw framework values echoed back.", - "nullable": true - }, - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, - "nullable": true - }, - "assignees": { - "type": "array", - "nullable": true - }, - "teams": { - "type": "array", - "nullable": true - }, - "buildGroupIds": { - "type": "array", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Echo of the request's buildTags (build-level tags)." - }, - "branches": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "search": { - "type": "string", - "nullable": true, - "description": "Echo of the request's `search` field (not `query`)." - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "state": { - "type": "string" - }, - "types": { - "type": "array", - "nullable": true - }, - "sources": { - "type": "array", - "nullable": true - }, - "excludeLocked": { - "type": "boolean", - "nullable": true - }, - "isCiRun": { - "type": "boolean", - "nullable": true - }, - "selfHealed": { - "type": "boolean", - "nullable": true - }, - "aiAuthored": { - "type": "boolean", - "nullable": true - }, - "appCrash": { - "type": "boolean", - "nullable": true - }, - "selfHosted": { - "type": "boolean", - "nullable": true - }, - "creationType": { - "type": "array", - "nullable": true - }, - "testCasesStatuses": { - "type": "array", - "nullable": true - } - } - }, - "FilterStruct__w2_build_filter1": { - "type": "object", - "description": "A static test-filter option: a display label and the value to send back. `pId` is present only for hierarchical options (e.g. folder trees).", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "pId": { - "type": "string" - } - } - }, - "Dashboard": { - "type": "object", - "description": "A custom dashboard's full definition, as returned under `data` by the dashboard read/create/update/clone endpoints.", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "Visibility level (1 or 2)." - }, - "filters": { - "type": "object", - "description": "Saved global filters (DashboardFilters shape).", - "nullable": true - }, - "widgets": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Widget definitions with id/name/type/config/layout.", - "nullable": true - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id." - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer", - "nullable": true - }, - "modifiedByName": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdByName": { - "type": "string" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM.", - "nullable": true - }, - "type": { - "type": "string", - "description": "Dashboard | View | TABULAR_TEST_RUNS | BUILD_COMPARISON." - }, - "isDefault": { - "type": "boolean", - "nullable": true - }, - "isAutomation": { - "type": "boolean", - "nullable": true - }, - "reports": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Report-widget definitions (present for report-type dashboards).", - "nullable": true - } - } - }, - "MetaItem": { - "type": "object", - "description": "An id resolved to its display name.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - } - }, - "ErrorSearchResult": { - "type": "object", - "description": "A unique-error search hit.", - "properties": { - "errorClusterId": { - "type": "string", - "description": "Stable id of the unique-error cluster." - }, - "error": { - "type": "string", - "description": "The error signature text." - } - } - }, - "FilterDetails": { - "type": "object", - "description": "A custom field offered as a dashboard widget filter.", - "properties": { - "id": { - "type": "integer", - "description": "Custom-field id." - }, - "label": { - "type": "string", - "description": "Custom-field name/key." - }, - "type": { - "type": "string", - "description": "Filter type: Basic or Custom (always Custom on this route)." - }, - "fieldType": { - "type": "string", - "description": "The custom field's data type." - } - } - }, - "DashboardPageInfo": { - "type": "object", - "description": "Pagination cursor for the paginated widget-custom-field-filters response.", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "ReportSummary": { - "type": "object", - "description": "One row of the reports/dashboards listing. Many fields are conditionally present (external-integration and project fields only appear for scheduled/linked reports).", - "properties": { - "id": { - "description": "Dashboard/report id (UUID or numeric depending on row)." - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer" - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM." - }, - "type": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "isAutomation": { - "type": "boolean" - }, - "external_report_id": { - "type": "integer" - }, - "report_type": { - "type": "string" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - }, - "frequency": { - "type": "string", - "description": "Schedule frequency (scheduled reports only)." - }, - "next_run_at": { - "type": "string", - "format": "date-time" - }, - "identifier": { - "type": "string" - }, - "is_scheduled": { - "type": "boolean" - }, - "owner": { - "type": "object", - "description": "{id, full_name, email, browserstack_user_id}." - }, - "project_id": { - "type": "integer", - "description": "The TestHub project id (thProjectId), not the TRA project id." - }, - "project_name": { - "type": "string" - }, - "projects": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Linked projects as {project_id, project_name}." - } - } - }, - "ReportListPageInfo": { - "type": "object", - "description": "Pagination cursor for the reports listing.", - "properties": { - "page": { - "type": "integer" - }, - "next": { - "type": "integer", - "nullable": true - }, - "prev": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer" - }, - "page_size": { - "type": "integer" - } - } - }, - "TakeAwayMetric": { - "type": "object", - "description": "A single Home takeaway metric: current value and the previous-window value.", - "properties": { - "value": { - "type": "number" - }, - "prev": { - "type": "number", - "nullable": true, - "description": "Previous-window value; null (time-savings cards) or 0 when the prior 7 days have no data." - } - } - }, - "TakeAwayMetricV2": { - "type": "object", - "description": "A v2 failure-noise category: absolute count and its percentage of total failures.", - "properties": { - "count": { - "type": "integer" - }, - "reduction": { - "type": "number", - "description": "This category as a percentage of totalFailures (for uniqueErrors it is the complementary reduction)." - } - } - }, - "TakeAwayData": { - "type": "object", - "description": "Home key-takeaway cards, each metric comparing the current 7 days to the prior 7 days.", - "properties": { - "stability": { - "$schema": "TakeAwayMetric" - }, - "uniqueTestExecutions": { - "$schema": "TakeAwayMetric" - }, - "newFailures": { - "$schema": "TakeAwayMetric" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetric" - }, - "flakiness": { - "$schema": "TakeAwayMetric" - }, - "failures": { - "$schema": "TakeAwayMetric" - } - } - }, - "TimeSavingMetric": { - "type": "object", - "description": "Aggregate time-saved breakdown returned by the older Home takeaways variant.", - "properties": { - "timeSavings": { - "$schema": "TakeAwayMetric" - }, - "testToVerify": { - "$schema": "TakeAwayMetric" - }, - "uniqueErrors": { - "$schema": "TakeAwayMetric" - }, - "flakiness": { - "$schema": "TakeAwayMetric" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetric" - }, - "autoTagged": { - "$schema": "TakeAwayMetric" - } - } - }, - "TimeSavingV2Metric": { - "type": "object", - "description": "v2 failure-noise breakdown returned by the newer Home takeaways variant. When totalFailures is 0 the per-category objects are omitted.", - "properties": { - "totalFailures": { - "type": "integer" - }, - "flakyFailing": { - "$schema": "TakeAwayMetricV2" - }, - "alwaysFailing": { - "$schema": "TakeAwayMetricV2" - }, - "autoAnalyzedFailure": { - "$schema": "TakeAwayMetricV2" - }, - "uniqueErrors": { - "$schema": "TakeAwayMetricV2" - } - } - }, - "ProjectExternal": { - "type": "object", - "description": "A project as exposed by the public/external API.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "group_id": { - "type": "integer" - }, - "created_by": { - "type": "integer", - "description": "User id of the project owner." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Derived from the project's finishedAt; can be null.", - "nullable": true - }, - "observability_url": { - "type": "string", - "description": "Ready-made deep link to the project's builds page." - } - } - }, - "ExternalPagination": { - "type": "object", - "description": "Cursor pagination envelope for the external API.", - "properties": { - "has_next": { - "type": "boolean", - "description": "True whenever the current page was non-empty; the final full page is followed by one empty page." - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor to pass as next_page for the following page." - } - } - }, - "RecentView": { - "type": "object", - "description": "One recently-viewed item on the Home page.", - "properties": { - "type": { - "type": "string", - "description": "Item kind (e.g. dashboard, build, view)." - }, - "link": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "Field": { - "type": "object", - "description": "A filter option / type-ahead suggestion item as returned across the Suite Health filter routes.", - "properties": { - "id": { - "type": "string", - "description": "Stable identifier for the option (often equal to value)." - }, - "value": { - "type": "string", - "description": "The raw value to send back when applying this option as a filter." - }, - "label": { - "type": "string", - "description": "Human-readable display label." - }, - "count": { - "type": "integer", - "description": "Occurrence count; null on suggestion routes that do not compute it.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Parent id for hierarchical options (e.g. folders); null or empty when not applicable.", - "nullable": true - } - } - }, - "TimelineDataPoint": { - "type": "object", - "description": "One time-bucketed chart point.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket timestamp (epoch ms)." - }, - "t": { - "type": "integer", - "description": "Secondary timestamp/marker for the bucket; may be null." - }, - "y": { - "type": "number", - "description": "Value at the bucket (count, average, or percentage depending on the series); null when the series has no value for this bucket." - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "The [start, end] epoch-ms span the bucket covers (present on the failure-rate series)." - } - } - }, - "SPChart": { - "type": "object", - "description": "A Suite Health chart: named data series plus a summary insight.", - "properties": { - "data": { - "type": "object", - "description": "Map of series name -> array of TimelineDataPoint. Series keys depend on the chart (e.g. totalExecutions/totalFailures, averageDuration, averageFailureRates).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "TimelineDataPoint" - } - } - }, - "insights": { - "type": "object", - "description": "Headline summary for the chart.", - "properties": { - "metric": { - "type": "string", - "description": "Metric label, e.g. 'Total Failures'." - }, - "value": { - "type": "string", - "description": "Formatted headline value, e.g. '12/340' or '3.5%' or '02m 10s'." - }, - "meta": { - "type": "string", - "description": "Optional secondary text; null when not set." - } - } - } - } - }, - "SPTestBreakdownRow": { - "type": "object", - "description": "One environment (OS/browser/device) row in a test's stability breakdown.", - "properties": { - "id": { - "type": "string", - "description": "Composite browser-platform key, e.g. 'chrome-windows'." - }, - "reliability": { - "type": "number", - "description": "Failure percentage (failed*100/total), 2 decimals; 0 when there were no executions." - }, - "duration": { - "type": "number", - "description": "Average duration for this environment." - }, - "os": { - "type": "object", - "description": "Formatted platform descriptor." - }, - "browser": { - "type": "object", - "description": "Formatted browser descriptor, with a nested `device` field." - }, - "browserDevice": { - "type": "object", - "description": "Alternate browser descriptor carrying the full device object." - }, - "totalFailures": { - "type": "object", - "description": "Failure tally for this environment.", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - } - } - }, - "AppLogResponse": { - "type": "object", - "description": "Parsed application-log response from /api/v1/appLogs/query.", - "properties": { - "status": { - "type": "string", - "description": "'success', or 'failure' (with empty logs) when Loki reported a non-success status.", - "values": [ - "success", - "failure" - ] - }, - "appLogQuery": { - "type": "object", - "description": "The echoed Loki query parameters the service built from the run.", - "properties": { - "groups": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Group (tenant) ids the query was scoped to." - }, - "apps": { - "type": "array", - "items": { - "type": "string" - } - }, - "envs": { - "type": "array", - "items": { - "type": "string" - } - }, - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time" - } - } - }, - "logs": { - "type": "array", - "description": "Flat, timestamp-sorted app-log lines (abnormal/error level only). Empty on a clean run or a Loki non-success.", - "items": { - "$schema": "AppLog" - } - } - } - }, - "AppLog": { - "type": "object", - "description": "One parsed application-log line.", - "properties": { - "app": { - "type": "string", - "description": "App name from the Loki stream." - }, - "env": { - "type": "string", - "description": "Environment from the Loki stream." - }, - "level": { - "type": "string", - "description": "Log level; 'unknown' when Loki did not tag a level." - }, - "epochNano": { - "type": "integer", - "description": "Timestamp in epoch nanoseconds; the list is sorted ascending by this." - }, - "line": { - "type": "string", - "description": "Log text after the ' cSep=S ' separator; the raw line when the separator is absent." - } - } - }, - "LogUrls": { - "type": "object", - "description": "Test-run log bundle as URLs plus metadata (from /ext/v1/testRun/{id}/logs and the v2 consolidated-logs route).", - "properties": { - "testStartedAt": { - "type": "string", - "format": "date-time" - }, - "testFinishedAt": { - "type": "string", - "format": "date-time" - }, - "browserstackLogs": { - "$schema": "SessionLogs", - "description": "Per-type BrowserStack session log URLs." - }, - "sdkLogs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "SDK log URLs." - }, - "hookRunLogs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Hook-run log URLs." - }, - "failureLogs": { - "type": "array", - "items": { - "$schema": "GenericLog" - }, - "description": "Inline failure log entries." - }, - "stepLogs": { - "type": "array", - "items": { - "$schema": "GenericLog" - }, - "description": "Inline step log entries." - }, - "framework": { - "type": "string" - }, - "sessionId": { - "type": "string" - } - } - }, - "SessionLogs": { - "type": "object", - "description": "Per-type session log artefacts (URLs / nodes). Null-valued types are omitted (JsonInclude NON_NULL).", - "properties": { - "text": { - "type": "object", - "description": "Raw/text log artefact." - }, - "console": { - "type": "object" - }, - "selenium": { - "type": "object" - }, - "appium": { - "type": "object" - }, - "playwright": { - "type": "object" - }, - "performance": { - "type": "object", - "description": "{urls: [], count}." - }, - "network": { - "$schema": "SessionNetwork" - }, - "device": { - "type": "object" - }, - "video": { - "type": "object", - "description": "{url, offsetUrl, status_code, startTime, startTimeStamp, startOffset, finishOffset, launchingAppTime, isIos, isV2}." - }, - "audio": { - "type": "object" - }, - "screenshots": { - "type": "array", - "items": { - "type": "string" - } - }, - "terminal": { - "type": "object", - "description": "{session: {url}, build: {url}}." - }, - "telemetry": { - "type": "object", - "description": "{isSupported, url?}." - }, - "crash": { - "type": "object" - }, - "appProfiling": { - "type": "object" - }, - "instrumentation": { - "type": "object" - }, - "browserprofiling": { - "type": "object" - }, - "issues": { - "type": "object" - }, - "maestro": { - "type": "object" - }, - "commandLogs": { - "type": "object" - }, - "maestroScreenshot": { - "type": "object" - }, - "config": { - "type": "object" - }, - "cypress_screenshots": { - "type": "object", - "description": "Serialised as cypress_screenshots." - } - } - }, - "SessionNetwork": { - "type": "object", - "description": "A session's network (HAR) log artefact pointer.", - "properties": { - "status_code": { - "type": "integer", - "description": "Upstream fetch status code." - }, - "url": { - "type": "string", - "description": "URL of the HAR file." - }, - "message": { - "type": "string", - "description": "Upstream message, if any." - } - } - }, - "SessionNetworkLogUrlResponse": { - "type": "object", - "description": "Envelope returned by /api/v1/sessions/{sessionId}/networkLogs (unparsed form): the HAR URL plus fetch status.", - "properties": { - "networkLogs": { - "type": "string", - "description": "URL of the session's HAR network log file (NOT the parsed entries)." - }, - "status_code": { - "type": "integer", - "description": "Upstream fetch status code." - }, - "message": { - "type": "string", - "description": "Upstream message, if any." - } - } - }, - "GenericLog": { - "type": "object", - "description": "One log entry in a merged/consolidated log stream. Null fields are omitted (JsonInclude NON_NULL).", - "properties": { - "logLevel": { - "type": "string", - "description": "Log level enum (e.g. ERROR, INFO)." - }, - "logType": { - "type": "string", - "description": "Log source/type enum (e.g. FAILURE, APPLICATION_LOGS)." - }, - "timeStamp": { - "type": "string", - "format": "date-time" - }, - "finishedTimeStamp": { - "type": "string", - "format": "date-time" - }, - "content": { - "type": "string", - "description": "Inline log text (present because content is merged server-side)." - }, - "url": { - "type": "string", - "description": "URL for entries backed by an external artefact rather than inline content." - }, - "duration": { - "type": "number", - "description": "Duration in seconds, formatted to 2 decimals." - }, - "startOffset": { - "type": "integer" - }, - "finishOffset": { - "type": "integer" - }, - "noOfSimilarErrors": { - "type": "integer" - }, - "customHookRunUuid": { - "type": "string" - }, - "customHookType": { - "type": "string" - }, - "failure": { - "type": "string" - }, - "message": { - "type": "string" - }, - "selfHealData": { - "type": "string" - }, - "aiAuthoringData": { - "type": "string" - }, - "logSubType": { - "type": "string" - } - } - }, - "TestDetailResponse": { - "type": "object", - "description": "Per-hook (or per-test) detail bundle: its test code, opaque metadata and any hook code.", - "properties": { - "testCode": { - "$schema": "TestCodeResponse", - "description": "The hook's source/file info; null for SDK-ingested hooks without captured code.", - "nullable": true - }, - "testMetadata": { - "type": "object", - "description": "Test-run metadata: type (BEFORE_ALL/AFTER_ALL...), testName, status, isFlaky, buildId, origin, issueType, observabilityVersion, scopes, isNewFailure, isAlwaysFailing, plus many null keys." - }, - "hooksCode": { - "type": "array", - "items": { - "$schema": "TestCodeResponse" - }, - "description": "Source for each associated hook.", - "nullable": true - } - } - }, - "TestCodeResponse": { - "type": "object", - "description": "A test's source code and location.", - "properties": { - "code": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string", - "description": "VCS URL to the file, built from the run's VCS info." - }, - "language": { - "type": "string" - } - } - }, - "TestRunCode": { - "type": "object", - "description": "Captured source for a test or hook run. code/language are null when nothing was captured for that run; the row is still returned.", - "properties": { - "language": { - "type": "string" - }, - "code": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "testRunId": { - "type": "integer" - }, - "type": { - "type": "string", - "description": "Run type (test vs hook kind)." - } - } - }, - "TestRunDetail": { - "type": "object", - "description": "Full detail of a single test run: its captured code, its metadata, and the code of the hooks that ran around it.", - "properties": { - "testCode": { - "$schema": "TestRunCode", - "nullable": true, - "description": "Captured code; null when nothing was captured (e.g. JUnit-report builds)." - }, - "testMetadata": { - "type": "object", - "description": "Metadata for the run - the broad internal test-meta object (testName, status, buildId, history, retries, testCase, projectId, device/cbtBrowser/cbtPlatform, observabilityVersion, origin, workflowStatus*, errorClusterId, isAutoAnalyzed*, ... many null). Read keys off the actual response." - }, - "hooksCode": { - "type": "array", - "description": "Code of before/after hooks. Populated on get_test_run_details; the key is present but null on the baseline map read.", - "items": { - "$schema": "TestRunCode" - }, - "nullable": true - } - } - }, - "BuildInsights": { - "type": "object", - "description": "Build-level failure/flakiness insights surrounding a test run.", - "properties": { - "buildId": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer" - }, - "summary": { - "type": "object", - "properties": { - "statusMetrics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" + }, + "HealingVerdictListResponse": { + "description": "Recorded verdicts for the build. Empty array (200) when the HITL healing feature is off for the group.", + "schema": { + "type": "array", + "items": { + "$schema": "HealingVerdictResponse" + } + } + }, + "SelfHealSummaryResponse": { + "description": "Build self-heal headline numbers.", + "schema": { + "$schema": "SelfHealSummaryStats" + } + }, + "BuildComparisonAggregationResponse": { + "description": "Build-comparison headline counts.", + "schema": { + "$schema": "BuildComparisonAggregation" + } + }, + "BuildComparisonCheckResponse": { + "description": "Comparison availability pre-check.", + "schema": { + "$schema": "BuildComparisonAvailable" + } + }, + "BuildComparisonActualResponse": { + "description": "base/target resolved to parent build UUIDs.", + "schema": { + "$schema": "BuildComparisonStringEnvelope" + } + }, + "BuildComparisonListResponse": { + "description": "One bucket's grouped tests.", + "schema": { + "$schema": "BuildComparisonList" + } + }, + "BuildComparisonMetaResponse": { + "description": "base/target build summary headers.", + "schema": { + "$schema": "BuildComparisonMetaEnvelope" + } + }, + "CommentListResponse": { + "description": "A bare array of comments (commentNode, no content HTML).", + "schema": { + "type": "array", + "items": { + "$schema": "CommentRes" } - } } - }, - "totalTests": { - "type": "integer" - }, - "totalHooks": { - "type": "integer" - } - } - }, - "failureCategorization": { - "type": "object", - "properties": { - "newFailures": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "flaky": { - "type": "integer" - } - } - }, - "failureContext": { - "type": "object", - "description": "Per-run failure context: file and parent-module failure rates for the run under inspection.", - "properties": { - "file": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failedCount": { - "type": "integer" - }, - "failureRate": { - "type": "number", - "description": "failedCount/testCount * 100" - } - } - }, - "parentModule": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "testCount": { - "type": "integer" - }, - "failedCount": { - "type": "integer" - }, - "failureRate": { - "type": "number" - } - } - } - } - }, - "topErrorsAnalysis": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty/partial unless COMPLETE." - }, - "uniqueErrorCount": { - "type": "integer" - }, - "uniqueErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "clusterId": { - "type": "string" - }, - "occurrences": { - "type": "integer" - }, - "truncatedError": { - "type": "string", - "description": "First ~200 chars of the error for a quick overview." - } - } - } - } - } - }, - "defectStats": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issueType": { - "type": "string" - }, - "totalFailureTestsCount": { - "type": "integer" - }, - "totalFailureHooksCount": { - "type": "integer" - }, - "categories": { - "type": "array", - "items": { + }, + "CommentThreadsSidePanelResponse": { + "description": "A bare array of thread summaries for the build side panel.", + "schema": { + "type": "array", + "items": { + "$schema": "BuildRunComment" + } + } + }, + "UniqueErrorListResponse": { + "description": "Suite Health unique-errors list. An empty `errors` with a 200 is the honest state for a demo tenant or a buildless project.", + "schema": { "type": "object", "properties": { - "category": { - "type": "string" - }, - "failureTestCount": { - "type": "integer" - }, - "failureHooksCount": { - "type": "integer" - } + "pagingParams": { + "type": "object", + "properties": { + "pageNumber": { + "type": "integer", + "description": "The NEXT page index to request (requested pageNumber + 1)." + }, + "hasNext": { + "type": "boolean", + "description": "Whether the page just returned had rows; false marks the end." + } + } + }, + "errors": { + "type": "array", + "items": { + "$schema": "UniqueErrorClusterRow" + } + }, + "automateTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded Automate tests." + }, + "appAutomateTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded App Automate tests." + }, + "lcncTestsSkipped": { + "type": "boolean", + "description": "Source fencing silently excluded low-code / no-code tests." + } } - } - } - } - } - } - } - }, - "BuildTestRunWithAttachments": { - "type": "object", - "description": "Trimmed test-run row for the 'runs having attachments' discovery listing.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "uuid": { - "type": "string" - }, - "build_id": { - "type": "integer" - }, - "attachments_count": { - "type": "integer" - }, - "screenshots_count": { - "type": "integer" - } - } - }, - "FlatTestRun": { - "type": "object", - "description": "One row of the flat build test-run listing. Top-level `id` is the test HASH (string); the numeric run id is `details.id`.", - "properties": { - "id": { - "type": "string", - "description": "Test hash, NOT the numeric run id." - }, - "title": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "details": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Numeric test run id." - }, - "status": { - "type": "string" - }, - "os": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "browser": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "device": { - "type": "string" - }, - "isRealDevice": { - "type": "boolean" - }, - "filePath": { - "type": "string" - }, - "vcFileUrl": { - "type": "string" - }, - "issueType": { - "type": "object" - }, - "aiRcaState": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isMuted": { - "type": "boolean" - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "VcsCheckMapping": { - "type": "object", - "description": "A vcs_ingestion row: a PR check registered against a check configuration for evaluation. Serialised directly from the JPA entity (no wrapper).", - "properties": { - "id": { - "type": "integer", - "description": "Ingestion row id \u2014 the id used by get_checks_mapping." - }, - "prNumber": { - "type": "integer", - "description": "Pull request number." - }, - "commitSha": { - "type": "string" - }, - "callbackUrl": { - "type": "string", - "description": "URL the evaluation pipeline calls back with results." - }, - "checkStates": { - "type": "string", - "description": "Evaluation state; newly created rows are 'Not Started'." - }, - "checkType": { - "type": "string", - "description": "QG_STATUS or BUILD_STATUS, copied from the matched check configuration.", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "retryCount": { - "type": "integer", - "description": "Evaluation retry count; 0 on creation." - }, - "splitRepos": { - "type": "boolean" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id, copied from the matched check configuration." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "commitTimestamp": { - "type": "string", - "format": "date-time", - "description": "Parsed from pr_data.date; may be unset when the date was omitted." - }, - "prMetadataJson": { - "type": "object", - "description": "The pr_data object stored verbatim, with githubCheckId merged in." - }, - "thProjectId": { - "type": "integer", - "description": "Target project id, copied from the matched check configuration." - }, - "buidlParentId": { - "type": "integer", - "description": "Build parent id (field is spelled 'buidlParentId' in the payload); typically null at creation." - }, - "checkConfiguration": { - "$schema": "CheckConfigurationEntity" - } - } - }, - "CheckConfigurationEntity": { - "type": "object", - "description": "A checks_configuration row as embedded inside a vcs_ingestion mapping (the raw JPA entity, camelCase fields).", - "properties": { - "id": { - "type": "integer", - "description": "Check-configuration id." - }, - "checkName": { - "type": "string" - }, - "repoName": { - "type": "string" - }, - "vcsName": { - "type": "string" - }, - "vcsOrgName": { - "type": "string" - }, - "splitRepos": { - "type": "boolean" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "branchName": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "checkType": { - "type": "string", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "commitCheck": { - "type": "boolean" - }, - "isEnabled": { - "type": "boolean" - }, - "buildName": { - "type": "string" - }, - "buildTags": { - "type": "string" - }, - "thProjectId": { - "type": "integer" - } - } - }, - "CheckConfigurationItem": { - "type": "object", - "description": "A single check-run configuration as returned by the check-runs endpoints (CheckConfigurationItemResponse).", - "properties": { - "checkId": { - "type": "integer", - "description": "Check-configuration id \u2014 the id used in the update/toggle/delete path." - }, - "checkName": { - "type": "string" - }, - "repoName": { - "type": "string" - }, - "vcsOrgName": { - "type": "string" - }, - "vcsName": { - "type": "string" - }, - "checkType": { - "type": "string", - "enum": [ - "QG_STATUS", - "BUILD_STATUS" - ] - }, - "splitRepos": { - "type": "boolean" - }, - "projectId": { - "type": "integer", - "description": "Resolved BrowserStack project id for projectName." - }, - "projectName": { - "type": "string" - }, - "branchName": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "buildTags": { - "type": "string" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - }, - "commitCheck": { - "type": "boolean" - }, - "isEnabled": { - "type": "boolean" - } - } - }, - "View": { - "type": "object", - "description": "A saved view (filters row) as returned by the view endpoints.", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "View id (UUID)." - }, - "type": { - "type": "integer", - "description": "Screen type code: 1 builds, 2 testRuns, 3 testHealth, 4 uniqueErrors, 5 buildInsights." - }, - "query": { - "type": "string", - "description": "The stored serialised filter." - }, - "level": { - "type": "integer", - "description": "1 = private, 2 = public/shared.", - "enum": [ - 1, - 2 - ] - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "description": "Optional free-text description; typically null (not settable through create/update)." - }, - "createdBy": { - "type": "integer", - "description": "Creator user id (from the session, not the body)." - }, - "groupId": { - "type": "integer", - "description": "Owning group id (from the session, not the body)." - } - } - }, - "DashboardView": { - "type": "object", - "description": "A dashboard view as returned by the dashboards/views read, create, update and clone endpoints (the object under the response `data` key).", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Dashboard UUID." - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "Visibility level (2 = group/shared by default)." - }, - "filters": { - "type": "object", - "description": "Global dashboard filters (DashboardFilters): dateRange, tags, buildTags, folders, projects, buildName, branches, users, lastExecutedBy, teams, tcmTestPlanIds, match, source, includeManualAndAutomated, and TM display fields." - }, - "widgets": { - "type": "array", - "description": "Widget layout (Widgets objects: id, name, type, globalFilters, desc, config, layout).", - "items": { - "type": "object" - } - }, - "totalWidget": { - "type": "integer", - "description": "Count of widgets on the dashboard." - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer", - "description": "Owner user id." - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer" - }, - "modifiedByName": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "createdByName": { - "type": "string" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM." - }, - "type": { - "type": "string", - "description": "DashboardType." - }, - "isDefault": { - "type": "boolean", - "description": "True for the built-in default dashboard (date range then forced to last 30 days)." - }, - "isAutomation": { - "type": "boolean", - "description": "True for the built-in automation-trend dashboard." - }, - "reports": { - "type": "array", - "description": "Report widgets, when present.", - "items": { - "type": "object" - } - } - } - }, - "WidgetCatalogueEntry": { - "type": "object", - "description": "One entry in the addable-widget catalogue (WidgetDetails).", - "properties": { - "name": { - "type": "string", - "description": "Display name, e.g. 'Test Case Statistics'." - }, - "key": { - "type": "string", - "description": "WidgetTypeEnum value to use as a widget's `type`." - } - } - }, - "CustomFieldFilterDetail": { - "type": "object", - "description": "A custom (user-defined) test-case field usable as a dashboard-widget filter (FilterDetails).", - "properties": { - "id": { - "type": "integer", - "description": "Custom field id." - }, - "label": { - "type": "string", - "description": "Filter key / display label." - }, - "type": { - "type": "string", - "description": "FilterType \u2014 'Custom' for custom-field filters." - }, - "fieldType": { - "type": "string", - "description": "The underlying field data type." - } - } - }, - "ReportRecord": { - "type": "object", - "description": "A saved report (the DashboardsData shape; reports and dashboards share it). Nulls are included. Widgets for a report-typed record are under `reports`; for a dashboard-typed record under `widgets`.", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "level": { - "type": "integer", - "description": "1 = private, 2 = group." - }, - "filters": { - "type": "object", - "description": "Saved global filters (DashboardFilters shape); null in the listing.", - "nullable": true - }, - "widgets": { - "type": "array", - "description": "Widget list for a dashboard-typed record; null for a report-typed one.", - "nullable": true - }, - "reports": { - "type": "array", - "description": "Widget list for a report-typed record ({id, name, type, config, layout, reportType}); null in the listing.", - "nullable": true - }, - "totalWidget": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "modifiedBy": { - "type": "integer", - "nullable": true - }, - "modifiedByName": { - "type": "string", - "nullable": true - }, - "createdByName": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - }, - "source": { - "type": "string", - "description": "TO or TCM.", - "nullable": true - }, - "type": { - "type": "string" - }, - "isDefault": { - "type": "boolean", - "nullable": true - }, - "isAutomation": { - "type": "boolean", - "nullable": true - } - } - }, - "TestRunReportRow": { - "type": "object", - "description": "One row in a tabular test-runs report dataset OR one build row in the static-builds listing (the same TestRunReport class, populated differently per endpoint). A large flat row; only representative fields are listed. For datasets these are test-run rows (testName, status, flakiness, customFields, \u2026); for static-builds these are build rows (buildId, buildName, buildNumber, displayStatus, buildStartedAt, workflowStatusStats, \u2026).", - "properties": { - "uuid": { - "type": "string" - }, - "testName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "buildId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "displayStatus": { - "type": "string", - "description": "Build display status (static-builds rows)." - }, - "projectId": { - "type": "integer" - }, - "customFields": { - "type": "object" - }, - "isFlaky": { - "type": "boolean" - }, - "flakiness": { - "type": "number" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - } - } - }, - "PaginationSummary": { - "type": "object", - "description": "Paging summary for report datasets/builds. totalDatasets is -1 on these routes (not a real total); page using hasNext.", - "properties": { - "totalDatasets": { - "type": "integer" - }, - "paginatedResults": { - "type": "integer" - }, - "hasNext": { - "type": "boolean" - } - } - }, - "StatTile": { - "type": "object", - "description": "One aggregate stat tile: a metric key and its value.", - "properties": { - "meta": { - "type": "string", - "description": "Metric key, e.g. totalUniqueTests, flakyTestsCount, totalPassedExecutions." - }, - "value": { - "type": "integer" - } - } - }, - "ReportCustomFieldItem": { - "type": "object", - "description": "A custom-field / column definition usable in a report.", - "properties": { - "fieldId": { - "type": "integer" - }, - "fieldName": { - "type": "string" - }, - "fieldType": { - "type": "string" - }, - "entityType": { - "type": "string", - "description": "TestCase or TestExecution \u2014 the only thing distinguishing two same-named fields." - } - } - }, - "ReportPageInfo": { - "type": "object", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - } - }, - "ReportTestStats": { - "type": "object", - "description": "Headline stats for one test in the tabular report. An empty object `{}` is returned when the test has no matching runs.", - "properties": { - "reliability": { - "type": "number", - "description": "Failure ratio as a percentage: failed*100/total." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "failureOccurrence": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - } - } - }, - "ReportTrendDataPoint": { - "type": "object", - "description": "One point in a test's trend series. Null fields are omitted.", - "properties": { - "color": { - "type": "string", - "description": "Colour derived from the workflow status." - }, - "workflowStatus": { - "type": "string" - }, - "y": { - "type": "integer", - "description": "Run duration in MILLISECONDS." - }, - "id": { - "type": "integer", - "description": "Test run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id." - }, - "buildName": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "flakyReason": { - "type": "integer" - } - } - }, - "ReportTestTrendResponse": { - "type": "object", - "description": "Per-run trend series for one test.", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Build started-at epochs, index-aligned to `data`." - }, - "data": { - "type": "array", - "items": { - "$schema": "ReportTrendDataPoint" - } - } - } - }, - "TabularReportRow": { - "type": "object", - "description": "One row of the tabular test-runs report as built by the report handler (only the fields this path populates).", - "properties": { - "testId": { - "type": "integer" - }, - "globalTestHash": { - "type": "string" - }, - "testCaseId": { - "type": "integer" - }, - "folderId": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "avgDuration": { - "type": "number", - "description": "Average duration in ms." - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isAutoAnalyzerRunning": { - "type": "boolean" - }, - "buildId": { - "type": "integer" - }, - "buildName": { - "type": "string" - }, - "originalBuildName": { - "type": "string" - }, - "buildTags": { - "type": "array", - "items": { - "type": "string" - } - }, - "buildNumber": { - "type": "integer" - }, - "projectId": { - "type": "integer" - }, - "buildStartedAt": { - "type": "string", - "format": "date-time" - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "normalisedBuildName": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Normalised workflow status, or 'unknown'." - }, - "workflowStatus": { - "type": "string" - }, - "smartTagSettings": { - "type": "object", - "description": "Present only for builds newer than the applied schema date." - } - } - }, - "TabularTestRunsReportResponse": { - "type": "object", - "description": "Paginated tabular test-runs report page. Empty result returns null data and null summary.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TabularReportRow" - } - }, - "summary": { - "type": "object", - "properties": { - "totalDatasets": { - "type": "integer", - "description": "-1 on these routes (not a real total)." - }, - "paginatedResults": { - "type": "integer", - "description": "Count of rows on this page." - }, - "hasNext": { - "type": "boolean", - "description": "True when the page came back full." - } - } - }, - "sourceFencingFlags": { - "type": "object", - "description": "Present only when non-null.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "ReportStatTilesResponse": { - "type": "object", - "description": "Summary tiles for the tabular report.", - "properties": { - "data": { - "type": "array", - "description": "Tiles. Empty/absent when all datasets conflict or no stats were returned.", - "items": { - "type": "object", - "properties": { - "meta": { - "type": "string", - "description": "Tile name (e.g. totalUniqueTests). Note the key is `meta`, not `name`." - }, - "value": { - "type": "integer" } - } - } - }, - "sourceFencingFlags": { - "type": "object", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "WidgetFilterValue": { - "type": "object", - "description": "A single selectable filter value.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true - }, - "pId": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - } - } - }, - "WidgetFilterValueList": { - "type": "object", - "description": "Flat list of values for one widget filter key.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - } - }, - "WidgetEnhancedFilterResponse": { - "type": "object", - "description": "Widget filter values bucketed with paging cursors.", - "properties": { - "data": { - "type": "object", - "description": "Map keyed by bucket (e.g. suggested / others); each value is a list of filter-value structs.", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - }, - "fullList": { - "type": "boolean", - "nullable": true - }, - "info": { - "type": "object", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer" - }, - "next": { - "type": "integer" - } - }, - "nullable": true - }, - "suggestedAfterKey": { - "type": "string", - "description": "Cursor to continue the suggested bucket.", - "nullable": true - }, - "othersAfterKey": { - "type": "string", - "description": "Cursor to continue the others bucket.", - "nullable": true - } - } - }, - "WidgetDrillDownData": { - "type": "object", - "description": "Row-level records behind a widget data point.", - "properties": { - "data": { - "type": "array", - "description": "Drill-down rows. Each carries project identity (projectId, thProjectId, projectName, normalisedName) plus widget-specific fields.", - "items": { - "type": "object", - "properties": { - "projectId": { - "type": "integer" - }, - "thProjectId": { - "type": "integer" - }, - "projectName": { - "type": "string" - }, - "normalisedName": { - "type": "string" + }, + "UniqueErrorSummaryResponse": { + "description": "Header/summary block for one unique error.", + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Sample failing test name." + }, + "error": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sample error text split on newlines." + }, + "combinations": { + "type": "array", + "description": "Platform combos the error was seen on; combos NA on both os and browser are dropped.", + "items": { + "type": "object", + "properties": { + "os": { + "$schema": "SPFilterField" + }, + "browser": { + "type": "object", + "description": "Formatted browser object that also carries a `device`." + } + } + } + } + } } - } - } - }, - "insights": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "meta": { - "type": "string" - } - } - }, - "isConflicting": { - "type": "boolean" - }, - "isEmptyNumerator": { - "type": "boolean" - }, - "isEmptyDenominator": { - "type": "boolean" - } - } - }, - "ParallelQueueSummaryDetails": { - "type": "object", - "description": "Per-product concurrency figures.", - "properties": { - "product": { - "type": "string" - }, - "totalParallelsUsed": { - "type": "integer" - }, - "maximumParallelsAllowed": { - "type": "integer" - }, - "totalQueuedSessions": { - "type": "integer" - }, - "maximumQueueAllowed": { - "type": "integer" - }, - "userLevelQueuedSessions": { - "type": "integer" - }, - "totalParallelsBreakdown": { - "type": "object", - "description": "Per-pool breakdown." - }, - "maximumParallelsBreakdown": { - "type": "object" - }, - "totalQueuedBreakdown": { - "type": "object" - }, - "maximumQueueBreakdown": { - "type": "object" - } - } - }, - "ParallelQueueUsageSummary": { - "type": "object", - "description": "Group-level parallel-queue summary.", - "properties": { - "summary": { - "type": "array", - "description": "Per-product SummaryDetails; present only when a product was supplied.", - "items": { - "$schema": "ParallelQueueSummaryDetails" - } - }, - "testExecutionsUsage": { - "type": "object", - "description": "Plan test-execution usage block." - } - } - }, - "ParallelQueueTeamUsage": { - "type": "object", - "description": "Per-team and per-member parallel-session usage.", - "properties": { - "summary": { - "$schema": "ParallelQueueSummaryDetails" - }, - "teams": { - "type": "array", - "items": { - "type": "object", - "properties": { - "teamName": { - "type": "string" - }, - "totalCount": { - "type": "integer" - }, - "teamId": { - "type": "integer" - }, - "maximumTeamParallelsAllowed": { - "type": "integer" - }, - "members": { - "type": "array", - "items": { + }, + "UniqueErrorOccurrenceCountResponse": { + "description": "Total occurrences of one unique error plus a per-bucket count series.", + "schema": { "type": "object", "properties": { - "userId": { - "type": "integer" - }, - "userName": { - "type": "string" - }, - "parallelsUsed": { - "type": "integer" - }, - "sessionQueued": { - "type": "integer" - }, - "lastBuildRanAt": { - "type": "integer" - }, - "showRunningBuildsButton": { - "type": "boolean" - }, - "parallelsBreakdown": { - "type": "object" - }, - "sessionQueuedBreakdown": { - "type": "object" - } + "errorCount": { + "type": "integer", + "description": "Total matching-run count for the window." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence doc counts with NO timestamps \u2014 reconstruct the x-axis from the window." + } } - } } - } - } - } - } - }, - "RunningBuildsResponse": { - "type": "object", - "description": "A user's currently-running builds.", - "properties": { - "data": { - "type": "array", - "description": "Running builds in the standard build-summary shape (uuid, id, name, status, duration, startedAt, projectId, tags, statusStats, ...).", - "items": { - "$schema": "BuildSummary" - } - } - } - }, - "DeviceTierUsageInfo": { - "type": "object", - "description": "Aggregated mobile device-tier usage and device list for a product. Both fields are raw JSON trees from the backend.", - "properties": { - "tierInfo": { - "type": "array", - "description": "One row per mobile tier: {id, name, maxUsed, maxAvailable}.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Tier id, e.g. Tier1." - }, - "name": { - "type": "string" - }, - "maxUsed": { - "type": "integer" - }, - "maxAvailable": { - "type": "integer" + }, + "UniqueErrorBuildTrendResponse": { + "description": "Occurrence trend of one unique error grouped into status series (hard-capped at 5000 runs).", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Status series name." + }, + "color": { + "type": "string", + "description": "Series colour (defaults to #999999)." + }, + "data": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Enriched run/build info entries for that series." + } + } + } + }, + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Parallel list of run start timestamps (epoch)." + } + } } - } - } - }, - "devices": { - "type": "object", - "description": "Map of tier id (Tier1..Tier4) to the array of devices in that tier (each carrying maxParallels and parallelsUsed).", - "additionalProperties": { - "type": "array", - "items": { - "type": "object" - } - } - } - } - }, - "TestExecutionUsageCount": { - "type": "object", - "description": "Execution counts for a window. NOTE: when both userId and groupId are missing the endpoint instead returns the plain string 'Missing userId or groupId'.", - "properties": { - "totalTestCount": { - "type": "integer", - "description": "All tests reported in the window." - }, - "browserstackTestCount": { - "type": "integer", - "description": "Subset that ran on BrowserStack infra (carry a sessionId)." - } - } - }, - "GroupPlanUsageDetails": { - "type": "object", - "description": "Plan info/details plus non-BrowserStack usage figures. All counts are compacted display strings (e.g. '1.2K'). Usage fields are null when the group has no execution limit; the null-branch uses `nonBrowserstackTestExecutions` in place of `nonBrowserstackUsedTestExecutions`.", - "properties": { - "planInfo": { - "type": "object" - }, - "planDetails": { - "type": "object" - }, - "nonBrowserstackTotalTestExecutions": { - "type": "string" - }, - "nonBrowserstackUsedTestExecutions": { - "type": "string", - "description": "Populated branch only." - }, - "nonBrowserstackTestExecutions": { - "type": "string", - "description": "Null-branch key only (when no execution limit)." - }, - "nonBrowserstackPercentageOfTestExecutionUsage": { - "type": "string" - }, - "billingResetDate": { - "type": "integer", - "description": "Epoch millis; null when no limit." - }, - "browserstackUsedTestExecutions": { - "type": "string" - }, - "totalTestExecutions": { - "type": "string" - }, - "nonBrowserstackPercentageOfGroupTestExecutionUsage": { - "type": "string" - }, - "nonBrowserstackGroupTotalTestExecutions": { - "type": "string" - } - } - }, - "GlobalSearchEntity": { - "type": "object", - "description": "One search result. Concrete shape is per-type (Build / BuildTestCaseExecutionResult / Project), discriminated by `entity`.", - "properties": { - "id": { - "type": "integer" - }, - "entity": { - "type": "string", - "description": "Type discriminator: Build / BuildTestCaseExecutionResult / Project." - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "userName": { - "type": "string", - "description": "Resolved from userId server-side." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "GlobalSearchResult": { - "type": "object", - "description": "Search results. pagingParams and totalCount are omitted in overlay mode.", - "properties": { - "response": { - "type": "array", - "items": { - "$schema": "GlobalSearchEntity" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string" - } - } - }, - "totalCount": { - "type": "integer" - } - } - }, - "CreateHealingPrResponse": { - "type": "object", - "description": "Self-healing PR job state. At create time only healingPrId + status are set (status=IN_PROGRESS); the remaining fields are populated by the background worker and read back via the status endpoint.", - "properties": { - "healingPrId": { - "type": "string", - "description": "The job id (UUID)." - }, - "status": { - "type": "string", - "description": "One of IN_PROGRESS, SUCCEEDED, PARTIAL, FAILED, NO_CHANGES_NEEDED." - }, - "prUrl": { - "type": "string", - "description": "URL of the opened PR; null until a terminal PR-producing state." - }, - "prNumber": { - "type": "integer", - "description": "PR number; null until opened." - }, - "headSha": { - "type": "string", - "description": "Head commit SHA of the PR branch; null until committed." - }, - "perFileReport": { - "type": "object", - "description": "Per-file applied/skipped detail (arbitrary JSON); present on terminal outcomes." - }, - "errorMessage": { - "type": "string", - "description": "Failure detail; present on FAILED/PARTIAL outcomes." - } - } - }, - "HealingVerdictResponse": { - "type": "object", - "description": "A recorded verdict on one AI-healed locator.", - "properties": { - "elementHash": { - "type": "string", - "description": "Hash keying the healed element (used to join with the healing report)." - }, - "healedLocator": { - "type": "object", - "description": "The healed locator that was judged (arbitrary JSON node, e.g. {type, value})." - }, - "verdict": { - "type": "string", - "description": "'accurate' or 'inaccurate'." - }, - "feedback": { - "type": "object", - "description": "Optional free-form feedback (arbitrary JSON); null when none." - }, - "reviewedByUserId": { - "type": "integer" - }, - "reviewedAt": { - "type": "string" - }, - "isCarryover": { - "type": "boolean", - "description": "True when this verdict was first recorded against an earlier build." - }, - "carryoverBuildSerialId": { - "type": "integer", - "description": "The earlier build's serial id when isCarryover is true." - } - } - }, - "SelfHealSummaryStats": { - "type": "object", - "description": "Build-level self-healing headline numbers.", - "properties": { - "testSummary": { - "type": "object", - "properties": { - "totalTests": { - "type": "integer" - }, - "healedTests": { - "type": "integer" - }, - "percentHealedTests": { - "type": "number" - } - } - }, - "locatorSummary": { - "type": "object", - "properties": { - "totalLocators": { - "type": "integer" - }, - "healedLocators": { - "type": "integer" - }, - "percentHealedLocators": { - "type": "number" - } - } - }, - "buildSummary": { - "type": "object", - "properties": { - "totalBuild": { - "type": "integer" - }, - "healedBuilds": { - "type": "integer" - }, - "percentHealedBuilds": { - "type": "number" - } - }, - "nullable": true, - "description": "Build-level roll-up; null on a single-build read without healing data." - } - } - }, - "BuildComparisonAggregation": { - "type": "object", - "description": "Headline counts for a base-vs-target build comparison. On a fenced (cross-source) pair all counts are 0 and sourceFencingFlags is set.", - "properties": { - "newlyFailed": { - "type": "integer" - }, - "failedWithDifferentReason": { - "type": "integer" - }, - "startedPassing": { - "type": "integer" - }, - "additionalTests": { - "type": "integer" - }, - "droppedTests": { - "type": "integer" - }, - "noChange": { - "type": "integer" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags", - "description": "Present only on a fenced (cross-source) pair; OMITTED (not null/false) on a normal pair." - } - } - }, - "BuildComparisonAvailable": { - "type": "object", - "description": "Comparison availability pre-check. On a fenced pair only sourceFencingFlags is set (status/message null).", - "properties": { - "status": { - "type": "string" - }, - "message": { - "type": "string" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonStringEnvelope": { - "type": "object", - "description": "base/target build UUIDs resolved to their parent builds.", - "properties": { - "base": { - "type": "string", - "description": "Resolved parent build UUID for the base side." - }, - "target": { - "type": "string", - "description": "Resolved parent build UUID for the target side." - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonMetaEnvelope": { - "type": "object", - "description": "The two build summary headers, base and target. On a fenced pair base/target are null and sourceFencingFlags is set. Status counts are AMR-corrected.", - "properties": { - "base": { - "$schema": "BuildSummary" - }, - "target": { - "$schema": "BuildSummary" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags", - "description": "Present only on a fenced pair; omitted otherwise." - } - } - }, - "BuildComparisonList": { - "type": "object", - "description": "One comparison bucket's tests, grouped for display. Fenced pairs return an empty groups list with sourceFencingFlags set.", - "properties": { - "bucketName": { - "type": "string" - }, - "groups": { - "type": "array", - "items": { - "$schema": "BuildComparisonListGroup" - } - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" - } - } - }, - "BuildComparisonListGroup": { - "type": "object", - "description": "A file/environment grouping of compared tests.", - "properties": { - "file": { - "type": "string" - }, - "browser": { - "type": "string" - }, - "device": { - "type": "string" - }, - "os": { - "type": "string" - }, - "vcFilepath": { - "type": "string" - }, - "scopes": { - "type": "array", - "items": { - "$schema": "BuildComparisonListGroupScope" - } - } - } - }, - "BuildComparisonListGroupScope": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "elements": { - "type": "array", - "description": "base/target pairs of test elements.", - "items": { - "type": "object", - "properties": { - "base": { - "$schema": "BuildComparisonListGroupScopeElement" - }, - "target": { - "$schema": "BuildComparisonListGroupScopeElement" - }, - "sourceFencingFlags": { - "$schema": "SourceFencingFlags" + }, + "UniqueErrorBuildsResponse": { + "description": "Cursor-paginated list of builds in which a unique error occurred.", + "schema": { + "type": "object", + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean", + "description": "Set to false only on an empty page." + }, + "searchAfter": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Cursor to send back as the next request's searchAfter." + }, + "nextRoot": { + "type": "object", + "description": "Usually absent for this endpoint." + } + } + }, + "builds": { + "type": "array", + "items": { + "$schema": "UniqueErrorBuild" + } + } + } } - } - } - } - } - }, - "BuildComparisonListGroupScopeElement": { - "type": "object", - "description": "One compared test's detail on one side.", - "properties": { - "id": { - "type": "integer" - }, - "testHash": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "isManuallyOverridden": { - "type": "boolean" - }, - "error": { - "type": "string" - }, - "issueType": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isPerformanceAnamoly": { - "type": "boolean" - }, - "fileName": { - "type": "string" - }, - "browser": { - "type": "string" - }, - "device": { - "type": "string" - }, - "os": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "vcFilepath": { - "type": "string" - } - } - }, - "CommentRes": { - "type": "object", - "description": "A comment as returned by the comment list reads. Note it carries `commentNode` but NOT the `content` HTML.", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "nullable": true, - "description": "Comment page type; null in list reads even when filtered by type." - }, - "threadId": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "commentNode": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "projectId": { - "type": "integer" - }, - "buildUuid": { - "type": "string" - }, - "buildSerialId": { - "type": "integer" - }, - "runUuid": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "testHash": { - "type": "string", - "description": "May be the literal STRING \"null\" (not JSON null) when unset." - }, - "mentions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "modifiedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "BuildRunComment": { - "type": "object", - "description": "A comment-thread summary for the build-level side panel.", - "properties": { - "threadId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "browser": { - "type": "object", - "description": "Browser descriptor (name/version)." - }, - "os": { - "type": "object", - "description": "OS descriptor (name/version)." - }, - "device": { - "type": "string" - }, - "type": { - "type": "string", - "description": "The thread's comment page type." - }, - "testRunUuid": { - "type": "string", - "format": "uuid" - }, - "commentCount": { - "type": "integer" - }, - "testHash": { - "type": "string" - } - } - }, - "SPFilterField": { - "type": "object", - "description": "A single filter option / labelled value used across the Unique Errors filter surface.", - "properties": { - "id": { - "type": "string", - "description": "Option key (for OS/browser the comma-encoded value is turned into an underscore id)." - }, - "value": { - "type": "string", - "description": "Backing value; usually equal to id for tags/branches." - }, - "label": { - "type": "string", - "description": "Display text." - }, - "count": { - "type": "integer", - "description": "Occurrence count for the option; usually populated, may be null.", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Parent grouping key (JSON key is `pId`); null or empty for non-hierarchical options.", - "nullable": true - } - } - }, - "UniqueErrorClusterRow": { - "type": "object", - "description": "One error cluster row in the unique-errors list (serialised ErrorResult).", - "properties": { - "id": { - "type": "string", - "description": "The errorClusterId as a numeric string \u2014 the cluster id to pass as errorId / clusterId to the detail views." - }, - "impactedTests": { - "type": "integer", - "description": "Distinct tests affected by this error (cardinality of stabilityHash)." - }, - "errorCount": { - "type": "integer", - "description": "Total occurrences of the error in the window." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps; one bucket per calendar day over the window)." - }, - "error": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample error text split on newlines into lines." - } - } - }, - "UniqueErrorImpactedTest": { - "type": "object", - "description": "One impacted test in an error-cluster breakdown (serialised ErrorTests).", - "properties": { - "id": { - "type": "string", - "description": "Stability hash (test signature), not a numeric id." - }, - "title": { - "type": "string", - "description": "Test name (empty string when absent)." - }, - "filePath": { - "type": "string", - "description": "Source file path; may be null." - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Scope/describe hierarchy; may be null." - }, - "browsers": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Browser objects (each with a formatted browser label and a `device`)." - }, - "platforms": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Formatted OS/platform objects." - }, - "device": { - "type": "string", - "description": "Device label; may be null." - }, - "errorCount": { - "type": "integer", - "description": "Occurrences of the error for this test." - }, - "testRunId": { - "type": "integer", - "description": "A representative failing run id; may be null." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps)." - } - } - }, - "UniqueErrorBuild": { - "type": "object", - "description": "One build in which a unique error occurred (serialised ErrorBuild). Several derived fields are exposed only via getters.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "originalName": { - "type": "string" - }, - "normalisedName": { - "type": "string", - "description": "URL-encoded form of name (getter-derived)." - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "startedAt": { - "type": "string", - "description": "Timestamp." - }, - "finishedAt": { - "type": "string", - "description": "Timestamp." - }, - "duration": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "browser": { - "type": "object", - "description": "Formatted browser object." - }, - "os": { - "type": "object", - "description": "Formatted platform object." - }, - "device": { - "type": "string" - }, - "buildNumber": { - "type": "integer" - }, - "ciBuildData": { - "type": "object", - "description": "CI info; null when the build has none." - }, - "isFailedWithSameError": { - "type": "boolean", - "description": "True when this build's run failed with THIS exact cluster." - }, - "thProjectId": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "testCreationType": { - "type": "string" - }, - "state": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string" - }, - "tcmTestRunId": { - "type": "integer" - }, - "pmToolDetails": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Linked PM-tool (Jira etc.) entries; empty array when none." - }, - "flakyReason": { - "type": "integer" - }, - "smartTagSettings": { - "type": "object", - "description": "Smart-tag settings applied to the build; may be null." - }, - "workflowStatus": { - "type": "string" - }, - "versionControlInfo": { - "type": "object", - "description": "Getter-derived {url, commitId}.", - "properties": { - "url": { - "type": "string" - }, - "commitId": { - "type": "string" - } - } - }, - "issueType": { - "type": "object", - "description": "Getter-derived {isAutoAnalyzed, value}; null unless the run status is failed.", - "properties": { - "isAutoAnalyzed": { - "type": "boolean" - }, - "value": { - "type": "string" - } - } - }, - "historyAggregate": { - "type": "object", - "description": "Getter-derived flags.", - "properties": { - "isAlwaysFailing": { - "type": "boolean" - }, - "isFlaky": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - } - } - } - } - }, - "SPTimelineDataPoint": { - "type": "object", - "description": "One point in a Suite Health chart series.", - "properties": { - "x": { - "type": "integer", - "description": "Bucket epoch." - }, - "y": { - "type": "number", - "description": "Value; may be null." - }, - "t": { - "type": "integer", - "description": "Secondary timestamp; may be null." - }, - "percentage": { - "type": "number", - "description": "Percentage value; may be null." - }, - "pointRange": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "SPChart__w3_sp_error": { - "type": "object", - "description": "A Suite Health chart: one or more named series plus a headline insight.", - "properties": { - "data": { - "type": "object", - "description": "Single-entry map from series name to a list of points. The series name differs per chart (totalUniqueErrors / uniqueImpactedTests / totalImpactedTests).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "SPTimelineDataPoint" - } - } - }, - "insights": { - "type": "object", - "properties": { - "metric": { - "type": "string", - "description": "Human label, e.g. 'Total Unique Errors'." - }, - "value": { - "type": "string", - "description": "Headline total, formatted as a STRING." - }, - "meta": { - "type": "string" - } - } - } - } - }, - "TestRunsRcaData": { - "type": "object", - "description": "The stored AI RCA for a test run. Peek-only fields (pendingMessageId, alreadyTracked, isLive) and NON_NULL fields (failureTypeDisplayName, conversation, trialQuota) appear only when set. rcaData/thinkingData are model output populated once state is Completed. A peek on a run with no RCA returns 200 with every field null.", - "properties": { - "id": { - "type": "integer", - "description": "The stored RCA row id.", - "nullable": true - }, - "testRunId": { - "type": "integer", - "nullable": true - }, - "rcaData": { - "type": "object", - "description": "Generated analysis {root_cause, description, failure_type, failure_sub_type, possible_fix, related_prs, alternatives_considered, unique_id}; {} while Pending.", - "nullable": true - }, - "thinkingData": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "data": {} - } - }, - "description": "Reasoning/thinking trace: an ARRAY of {type, data}." - }, - "state": { - "type": "string", - "description": "Generation state.", - "enum": [ - "Pending", - "FETCHING_LOGS", - "GENERATING_RCA", - "GENERATED_RCA", - "THINKING", - "RETRYING", - "LLM_SERVICE_ERROR", - "LOG_FETCH_ERROR", - "AI_SYSTEM_ERROR", - "UNKNOWN_ERROR", - "TIMEOUT", - "Completed" - ], - "nullable": true - }, - "bulkRcaId": { - "type": "string", - "description": "UUID linking runs analysed together via a bulk RCA request; null for a singly-generated RCA.", - "nullable": true - }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { - "type": "string", - "nullable": true - }, - "isAgentic": { - "type": "boolean", - "description": "Whether this RCA used the agentic flow. Forced false for LTS-product callers.", - "nullable": true - }, - "isMcpDriven": { - "type": "boolean", - "description": "True when the RCA is owned by the MCP-powered chat agent (the web composer is then disabled).", - "nullable": true - }, - "failureTypeDisplayName": { - "type": "string", - "description": "Present only when set." - }, - "pendingMessageId": { - "type": "string", - "description": "Peek-only: messageId of the in-flight PENDING conversation row, if any.", - "nullable": true - }, - "alreadyTracked": { - "type": "boolean", - "description": "Peek-only: whether a build-chat row already tracks pendingMessageId. Null for web/MCP-started generations.", - "nullable": true - }, - "isLive": { - "type": "boolean", - "description": "Peek-only: true when a generation is genuinely running right now \u2014 the field to gate a re-trigger on.", - "nullable": true - }, - "conversation": { - "type": "object", - "description": "Present only for a chat-driven RCA turn; its `state` uses the UPPER-CASE conversation enum (COMPLETED) while the top-level state uses the RCA enum (Completed)." - }, - "trialQuota": { - "type": "object", - "description": "Present only for trial users; carries used-quota info." - } - } - }, - "BuildGroup": { - "type": "object", - "description": "A run group (build group). Serialized with non-null fields only; `state` is computed (ACTIVE until closed, then CLOSED).", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "project_id": { - "type": "integer", - "description": "TestHub project id the group belongs to." - }, - "group_id": { - "type": "integer" - }, - "created_by": { - "type": "integer" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "isClosed": { - "type": "boolean" - }, - "closedAt": { - "type": "string", - "format": "date-time" - }, - "closedBy": { - "type": "integer" - }, - "closedByName": { - "type": "string" - }, - "state": { - "type": "string", - "description": "Computed from isClosed.", - "enum": [ - "ACTIVE", - "CLOSED" - ] - } - } - }, - "SplitBuildLegs": { - "type": "object", - "description": "Split-build legs payload built by BuildService.getSplitBuildDetails (a ProjectDto subset). Only the fields this render actually populates are described.", - "properties": { - "id": { - "type": "integer", - "description": "Observability project id the split build belongs to." - }, - "name": { - "type": "string", - "description": "Project name." - }, - "builds": { - "type": "array", - "description": "The child legs, each a full build-summary object (same shape as BuildDetails).", - "items": { - "$schema": "BuildDetails" - } - }, - "pagingParams": { - "$schema": "PagingParams" - } - } - }, - "PagingParams": { - "type": "object", - "description": "Cursor pagination envelope. `searchAfter` is a build uuid (or list) to pass back as the next cursor; `hasNext` says whether another page exists.", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "description": "Opaque next-root cursor map (composite-agg paging); may be null." - }, - "searchAfter": { - "description": "Either a list of cursor tokens (build uuids) or a single integer, depending on the endpoint." - } - } - }, - "BuildDetails": { - "type": "object", - "description": "Full single-build detail object (BuildSummary DTO). Large flat object (~80 keys); only the non-obvious / gating fields are declared. Nulls ARE serialised (derivedName, originalName, ciBuildData, versionControlInfo, ... often null) - the NON_NULL notes on the nested aggregates do not apply to this object. `vcsInfo` is write-only and never serialised.", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string", - "description": "Live/computed status; may reflect an in-flight aggregation mid-run." - }, - "displayStatus": { - "type": "string", - "description": "Settled pipeline verdict; prefer this over `status` for the final outcome." - }, - "duration": { - "type": "integer", - "description": "Consolidated duration when present, else raw duration (the getter resolves this)." - }, - "statusStats": { - "$schema": "StatusAggregate" - }, - "issueTypeAggregate": { - "$schema": "IssueTypeAggregate", - "nullable": true, - "description": "Null when no failures have been categorised yet." - }, - "historyAggregate": { - "$schema": "HistoryAggregate", - "description": "Smart-tag roll-up; all zeros (not null) when nothing is tagged." - }, - "showHealingReport": { - "type": "boolean", - "description": "UI gate: whether a self-healing report exists to show (S3 key-existence check)." - }, - "showAuthoringReport": { - "type": "boolean", - "description": "UI gate: whether an authoring-agent report exists (S3 check); forced true on demo-sandbox envs." - }, - "appAccessibilityEnabled": { - "type": "boolean", - "description": "Per-group feature flag; fails CLOSED (false) if the flag lookup errors." - }, - "qualityGateStatus": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "tcmTestPlans": { - "type": "array", - "description": "Cross-product test-management plan links, if any.", - "items": { - "type": "object" - } - }, - "workflowStatusStats": { - "type": "object", - "description": "Workflow-status roll-up." - }, - "runInfos": { - "type": "array", - "items": { - "type": "object" - } - }, - "isArchived": { - "type": "boolean", - "nullable": true - }, - "isLocked": { - "type": "boolean", - "nullable": true - }, - "buildNumber": { - "type": "integer", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true, - "description": "TestHub project id." - }, - "tcmTestRunIdentifier": { - "type": "string", - "nullable": true, - "description": "Linked Test Management run (TR-...), when any." - }, - "issueTypes": { - "type": "array", - "nullable": true, - "items": { - "type": "object" - } - }, - "aiRcaCompletedCount": { - "type": "integer", - "nullable": true - }, - "selfHealedCount": { - "type": "integer", - "nullable": true - }, - "testCaseCount": { - "type": "integer", - "nullable": true - }, - "state": { - "type": "string", - "nullable": true, - "description": "Lifecycle state, e.g. NEW or CLOSED." - } - } - }, - "BuildNotificationSummary": { - "type": "object", - "description": "Compact build-summary email/notification card (BuildSummaryEmail DTO). `startedAt` is @JsonIgnore; the payload exposes formatted date/time/duration helpers instead. `accessibility` is present only for APP_ALLY_AUT builds.", - "properties": { - "uuid": { - "type": "string" - }, - "name": { - "type": "string" - }, - "projectName": { - "type": "string" - }, - "projectNormalisedName": { - "type": "string" - }, - "normalisedName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "displayStatus": { - "type": "string", - "description": "Computed display status derived from status + statusStats." - }, - "duration": { - "type": "integer" - }, - "formattedDuration": { - "type": "string", - "description": "Human-readable duration helper (e.g. '2m 5s')." - }, - "startTime": { - "type": "string" - }, - "startDate": { - "type": "string" - }, - "qualityGateStatus": { - "type": "string" - }, - "totalCount": { - "type": "integer" - }, - "mutedCount": { - "type": "integer" - }, - "statusStats": { - "$schema": "StatusAggregate" - }, - "issueTypeAggregate": { - "$schema": "IssueTypeAggregate" - }, - "historyAggregate": { - "$schema": "HistoryAggregate" - }, - "alerts": { - "$schema": "BuildAlerts__w4_build2" - }, - "buildNumber": { - "type": "integer" - }, - "colorMapping": { - "type": "object", - "description": "Map of status/category -> hex color for rendering the card." - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "accessibility": { - "type": "object", - "description": "APP_ALLY_AUT builds only; omitted (NON_NULL) otherwise. Populated fail-open.", - "properties": { - "totalIssues": { - "type": "integer" - }, - "totalScans": { - "type": "integer" - }, - "a11yScore": { - "type": "number" - }, - "severityBreakdown": { - "type": "object", - "properties": { - "critical": { - "type": "integer" - }, - "serious": { - "type": "integer" - }, - "moderate": { - "type": "integer" - }, - "minor": { - "type": "integer" - } - } - } - } - } - } - }, - "BuildStabilityTile": { - "type": "object", - "description": "Stability tile for a build (BuildStability DTO). `count` on the DTO is @JsonIgnore; the surfaced number lives in `overview.count`.", - "properties": { - "data": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Per-run stability display series." - }, - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Bucket keys aligned to `data`." - }, - "median": { - "type": "number" - }, - "commentCount": { - "type": "integer" - }, - "overview": { - "type": "object", - "description": "Headline verdict for the tile.", - "properties": { - "count": { - "type": "string", - "description": "Current stability as a formatted percentage string (e.g. '92.3')." - }, - "inPercentage": { - "type": "boolean" - }, - "meta": { - "type": "string", - "description": "e.g. 'based on last 30 runs'." - }, - "insights": { - "type": "object", - "description": "Positive/negative/default verdict vs recent builds; only populated with >= 10 comparison runs.", - "properties": { - "type": { - "type": "string", - "values": [ - "positive", - "negative", - "default" - ] - }, - "text": { - "type": "string" - } + }, + "UniqueErrorPlatformBreakdownResponse": { + "description": "Flat list of platform buckets for one unique error.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "browser": { + "type": "object", + "description": "Formatted browser object that also carries a `device`." + }, + "os": { + "type": "object", + "description": "Formatted platform object." + }, + "chartData": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Per-day occurrence counts (no timestamps)." + }, + "totalFailure": { + "type": "integer", + "description": "Occurrence doc count for this combination." + }, + "errorCount": { + "type": "integer", + "description": "Distinct impacted-test count for this combination." + } + } + } } - } - } - } - } - }, - "BuildSummaryStats": { - "type": "object", - "description": "Headline test-count strip for a build (BuildSummaryStats DTO).", - "properties": { - "data": { - "type": "array", - "description": "Colored metric tiles (total/passed/failed/skipped/pending roll-ups).", - "items": { - "$schema": "ColoredMetric" - } - }, - "aiRcaStats": { - "$schema": "AiRcaStats" - }, - "isArchived": { - "type": "boolean" - }, - "commentCount": { - "type": "integer" - }, - "hooksVisibility": { - "type": "string", - "description": "Whether hook rows are shown/hidden for this project." - }, - "manuallyOverriddenCount": { - "type": "integer" - }, - "sessionStats": { - "$schema": "SessionStats" - } - } - }, - "BuildTimelineStats": { - "type": "object", - "description": "Build execution timeline (BuildTimelineAggregate DTO).", - "properties": { - "data": { - "type": "array", - "description": "Three chart series with ids `passed`, `failed` and `pending` (pending = tests still running at that instant). Each point is [x, count] where x is MILLISECONDS since the build's first test started (relative, not epoch time).", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Series key: passed, failed or pending." - }, - "name": { - "type": "string" - }, - "color": { - "type": "string" - }, - "type": { - "type": "string", - "description": "Chart type, default 'column'." - }, - "data": { - "type": "array", - "description": "Array of [msSinceFirstTestStart, count] pairs.", - "items": { + }, + "UniqueErrorImpactedTestsResponse": { + "description": "Bare array of impacted tests behind one error cluster (no envelope).", + "schema": { "type": "array", "items": { - "type": "integer" + "$schema": "UniqueErrorImpactedTest" } - } } - } - } - } - } - }, - "BuildFailureCategoryStats": { - "type": "object", - "description": "Failure-category (defect) breakdown (DefectStats DTO). On a PENDING build, `data` is empty and `message` explains why (still HTTP 200).", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "DefectStatsData" - } - }, - "aiRcaStats": { - "$schema": "AiRcaStats" - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string", - "description": "Set to a 'available after your build finishes' notice while pending; otherwise typically absent." - } - } - }, - "DefectStatsData": { - "type": "object", - "description": "One failure-category bucket.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "value": { - "type": "integer", - "description": "Count of failures in this category." - }, - "color": { - "type": "string" - }, - "categories": { - "type": "array", - "description": "Optional nested sub-buckets.", - "items": { - "$schema": "ColoredMetric" - } - } - } - }, - "ColoredMetric": { - "type": "object", - "description": "A single colored metric tile / bucket.", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string", - "description": "Serialised from the DTO's `label` field via @JsonProperty(\"name\")." - }, - "displayName": { - "type": "string", - "description": "Present only when non-null." - }, - "value": { - "type": "integer" - }, - "color": { - "type": "string" - }, - "filterCount": { - "type": "integer" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" - } - } - }, - "BuildFailuresByModule": { - "type": "object", - "description": "Failures grouped by test module / file path (FailureByModuleStats DTO).", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "A module/folder node.", - "properties": { - "id": { - "type": "string", - "description": "Full module/file path." - }, - "name": { - "type": "string" - }, - "value": { - "type": "integer", - "description": "Total tests in the module." - }, - "colorValue": { - "type": "integer" - }, - "failedTests": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "parent": { - "type": "string", - "description": "Parent node id for tree reconstruction." - }, - "customPathSplit": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Path split by the inferred separator." - }, - "separator": { - "type": "string" - }, - "joinSeparator": { - "type": "string" + }, + "UniqueErrorTimelineChartsResponse": { + "description": "Three Suite Health charts keyed uniqueErrorChart / uniqueTestChart / impactedTestChart.", + "schema": { + "type": "object", + "properties": { + "uniqueErrorChart": { + "$schema": "SPChart__w3_sp_error" + }, + "uniqueTestChart": { + "$schema": "SPChart__w3_sp_error" + }, + "impactedTestChart": { + "$schema": "SPChart__w3_sp_error" + } + } } - } - } - }, - "commentCount": { - "type": "integer" - }, - "isConflicting": { - "type": "boolean" - }, - "sourceFencingFlags": { - "type": "object", - "description": "Present (NON_NULL) only when source-fencing excludes the build's product; names which products' tests were skipped.", - "properties": { - "automateTestsSkipped": { - "type": "boolean" - }, - "appAutomateTestsSkipped": { - "type": "boolean" - }, - "lcncTestsSkipped": { - "type": "boolean" - } - } - } - } - }, - "BuildFilterValues": { - "type": "object", - "description": "Older compact build-filter shape (FilterValues DTO): a single `filters` map keyed by facet name.", - "properties": { - "filters": { - "type": "object", - "description": "Map keyed by facet (browser, os, issueType, tags, status, folder, history, flaky). Each value is a list of options. `history` and `flaky` are static option lists always present.", - "properties": { - "browser": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "os": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "issueType": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "tags": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "status": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "folder": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "history": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - }, - "flaky": { - "type": "array", - "items": { - "$schema": "FilterStruct__w4_build2" - } - } - } - } - } - }, - "BuildTestFolders": { - "type": "object", - "description": "Build test-listing folder tree. Two shapes depending on folder_path: root-folders (RootFoldersResponse) or folder-detail (FolderDetailsResponse). Both are @JsonInclude(NON_NULL). Fields from both are shown; only one branch's fields are populated per call.", - "properties": { - "project_id": { - "type": "integer" - }, - "folders": { - "type": "array", - "description": "ROOT branch only: the root folders.", - "items": { - "$schema": "SubFolderDetails" - } - }, - "name": { - "type": "string", - "description": "DETAIL branch only: the folder's name (last path segment)." - }, - "folder_path": { - "type": "string", - "description": "DETAIL branch only: full folder path." - }, - "cases_count": { - "type": "integer", - "description": "DETAIL branch: unique cases directly in this folder." - }, - "total_cases_count": { - "type": "integer", - "description": "DETAIL branch: unique cases including nested subfolders." - }, - "sub_folders_count": { - "type": "integer", - "description": "DETAIL branch: immediate subfolder count." - }, - "sub_folders": { - "type": "array", - "description": "DETAIL branch only: immediate subfolders.", - "items": { - "$schema": "SubFolderDetails" - } - }, - "pagingParams": { - "$schema": "PagingParams" - }, - "after_key": { - "type": "string", - "description": "ROOT branch composite-agg cursor field; observed returned as null \u2014 page via pagingParams instead." - } - } - }, - "SubFolderDetails": { - "type": "object", - "description": "One folder node in the build's test-listing tree (NON_NULL).", - "properties": { - "name": { - "type": "string" - }, - "folder_path": { - "type": "string" - }, - "cases_count": { - "type": "integer" - }, - "total_cases_count": { - "type": "integer" - }, - "sub_folders_count": { - "type": "integer" - } - } - }, - "BuildNonSdkTestCounts": { - "type": "string", - "description": "IMPORTANT: the body is a JSON *string* (Gson JsonObject.toString()), not a JSON object. After parsing the string it is `{untitled_test_count:int, unmarked_test_count:int, missing_failure_reason_count:int}`, counts spanning the parent build and its split child builds." - }, - "StatusAggregate": { - "type": "object", - "description": "Test-status roll-up (NON_NULL). Keys are lower-case status names.", - "properties": { - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "pending": { - "type": "integer", - "description": "Running/pending count (DTO field runningCount)." - }, - "skipped": { - "type": "integer" - }, - "unknown": { - "type": "integer" - }, - "aggregate": { - "type": "integer", - "nullable": true, - "description": "Aggregate (total) count across statuses." - } - } - }, - "IssueTypeAggregate": { - "type": "object", - "description": "Issue-type roll-up (NON_NULL). Keys are the human-readable issue-type labels, not camelCase.", - "properties": { - "To be Investigated": { - "type": "integer" - }, - "Automation Bug": { - "type": "integer" - }, - "Product Bug": { - "type": "integer" - }, - "No Defect": { - "type": "integer" - }, - "Environment Issue": { - "type": "integer" - } - } - }, - "HistoryAggregate": { - "type": "object", - "description": "Smart-tag history roll-up (NON_NULL).", - "properties": { - "isFlaky": { - "type": "integer" - }, - "isAlwaysFailing": { - "type": "integer" - }, - "isNewFailure": { - "type": "integer" - }, - "isPerformanceAnomaly": { - "type": "integer" - } - } - }, - "SessionStats": { - "type": "object", - "description": "Session-level counters (SessionStats DTO).", - "properties": { - "totalSessions": { - "type": "integer" - }, - "passed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "error": { - "type": "integer" - }, - "unmarked": { - "type": "integer" - }, - "idletimeout": { - "type": "integer" - }, - "running": { - "type": "integer" - }, - "done": { - "type": "integer" - }, - "timeout": { - "type": "integer" - }, - "unknown": { - "type": "integer" - } - } - }, - "AiRcaStats": { - "type": "object", - "description": "AI RCA availability counter.", - "properties": { - "completedCount": { - "type": "integer", - "description": "How many failures already have AI RCA completed." - } - } - }, - "FilterStruct__w4_build2": { - "type": "object", - "description": "A single filter dropdown option. Folder labels embed the count, e.g. 'auth.test.js(3)'.", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true - }, - "pId": { - "type": "string", - "description": "Serialised from the DTO's `pid` field via @JsonProperty(\"pId\").", - "nullable": true - }, - "id": { - "type": "string", - "description": "Option id (same as value)." - } - } - }, - "SingleMetricSummary__w4_build2": { - "type": "object", - "description": "Shared single-metric tile (SingleMetricSummary DTO) used by the flaky / muted / new-failure tiles.", - "properties": { - "count": { - "type": "integer", - "description": "The metric count (flaky / muted / new-failure depending on endpoint)." - }, - "meta": { - "type": "string", - "description": "Context string, typically the 'out of N total' text." - }, - "commentCount": { - "type": "integer" - }, - "autoUnmuteCount": { - "type": "integer", - "description": "Most meaningful for the muted tile; how many are scheduled to auto-unmute." - } - } - }, - "BuildAlerts__w4_build2": { - "type": "object", - "description": "Build alerts panel (BuildAlerts DTO).", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Individual alert entries." - }, - "alertsConfigured": { - "type": "boolean" - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string" - } - } - }, - "ExternalBuildDetails": { - "type": "object", - "description": "Single external build-details object returned by get_latest_build (BuildDetails / BuildDetailsV2 by the new-build-listing flag). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are plan-gated by the external JsonView wrapper.", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "id": { - "type": "integer", - "description": "Integer build id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "status_stats": { - "type": "object", - "description": "Per-status counts (V2: workflow-status counts), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 shape only." - }, - "failure_categories": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "smart_tags": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "unique_errors": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "alerts": { - "type": "array", - "description": "Pro-only (JsonView)." - }, - "re_run": { - "type": "boolean" - }, - "build_error": { - "type": "object" - }, - "is_archived": { - "type": "boolean" - }, - "observability_version": { - "type": "object" - }, - "vcs_info": { - "type": "object" - }, - "ci_info": { - "type": "object" - }, - "host_info": { - "type": "object" - }, - "run_information": { - "type": "array" - }, - "observability_url": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "app_details": { - "type": "object" - } - } - }, - "ExternalProjectBuildListing": { - "type": "object", - "description": "Public-API build listing (BuildListingResponse / BuildListingResponseV2, selected by the new-build-listing group flag). Keys are snake_case except tcmTestRunIdentifier; the two versions differ only in what status_stats holds and V2's is_manually_overridden.", - "properties": { - "id": { - "type": "integer", - "description": "Project id." - }, - "name": { - "type": "string", - "description": "Project name." - }, - "builds": { - "type": "array", - "description": "The fetched page, reversed before returning (Lists.reverse), so oldest-first within a page.", - "items": { - "type": "object", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid (40 chars); use it for per-build calls. There is no `uuid` or integer `id` key (id is @JsonIgnore)." - }, - "name": { - "type": "string" - }, - "original_name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "build_number": { - "type": "integer" - }, - "is_archived": { - "type": "boolean" - }, - "observability_url": { - "type": "string", - "description": "Deep link to the build in the TRA UI." - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "status_stats": { - "type": "object", - "description": "Per-status counts. Same key on both shapes: v1 StatusAggregate, V2 WorkflowStatusAggregate." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 items only." + }, + "UniqueErrorFiltersResponse": { + "description": "The Unique Errors filter sidebar: applied selections, static option sets, and default hooks visibility.", + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "object", + "description": "Caller's current selections resolved into display objects. Scalar keys (search, isFlaky, isNewFailure, isAlwaysFailing, isManuallyOverridden, isMuted, hooksVisibility, dateRange) plus option-set keys (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList, osList, browserList, pmToolStatus, branches), each a list of {id, value, label, count, pId} option objects." + }, + "staticFilters": { + "type": "object", + "description": "Selectable option lists for the page: osList, browserList, status, folders, failureCategories, deviceList, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan \u2014 each a list of {id, value, label, count, pId} option objects. Any list can be empty when its upstream (ES / TestHub) times out." + }, + "hooksVisibility": { + "type": "string", + "description": "The project's default hooks-visibility setting." + } + } } - } - } - }, - "pagination": { - "type": "object", - "description": "snake_case keys (models/external/Pagination).", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." - } - } - } - } - }, - "ProjectBuildListing": { - "type": "object", - "description": "Internal build-listing payload (ProjectDto) returned by list_project_builds. builds[] are full BuildSummary objects.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "builds": { - "type": "array", - "description": "Rich BuildSummary rows (uuid, id, name, status, statusStats, duration, session/run counts, tags, qualityGateStatus, isArchived, displayStatus, parallelsUsed, tcmTestRunIdentifier, ...).", - "items": { - "type": "object" - } - }, - "pagingParams": { - "type": "object", - "description": "Cursor paging state (hasNext + searchAfter) for the next page." - }, - "activeCount": { - "type": "integer" - }, - "closedCount": { - "type": "integer" - }, - "manualBuildCount": { - "type": "integer" - }, - "automationBuildCount": { - "type": "integer" - }, - "isArchiving": { - "type": "boolean" - }, - "isClosing": { - "type": "boolean" - } - } - }, - "RunningBuildsOnDevice": { - "type": "object", - "description": "Builds currently queued/running on a device+OS combination (PQRunningBuildsResponse). Empty data list when nothing matches.", - "properties": { - "data": { - "type": "array", - "description": "BuildSummary rows for pending builds on the combination; each carries parallelsUsed (unique parallel sessions consumed).", - "items": { - "type": "object", - "properties": { - "uuid": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "projectId": { - "type": "integer" - }, - "parallelsUsed": { - "type": "integer", - "description": "Number of unique parallel sessions this build is consuming on the device+OS combination." + }, + "FieldSearchResponse__w3_sp_error": { + "description": "Typeahead result: a bounded set of matching filter options.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "SPFilterField" + } + } + } } - } - } - } - } - }, - "BuildInsights__w4_build4": { - "type": "object", - "description": "Agent-oriented build failure summary returned by get_build_insights (BuildInsightsResponse).", - "properties": { - "buildId": { - "type": "string" - }, - "buildName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer" - }, - "summary": { - "type": "object", - "properties": { - "statusMetrics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "testsCount": { - "type": "integer" - }, - "hooksCount": { - "type": "integer" + }, + "TestRunsRcaDataResponse": { + "description": "A test run's AI RCA (state + analysis). Top-level object; there is NO `data` wrapper.", + "schema": { + "$schema": "TestRunsRcaData" + } + }, + "BulkTestRcaResponse": { + "description": "Acknowledgement that a bulk test-RCA batch was queued. bulkRcaUuid is the batch handle; the analyses arrive later.", + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "buildUuid": { + "type": "string" + }, + "bulkRcaUuid": { + "type": "string", + "description": "UUID identifying the queued bulk batch." + }, + "trialQuota": { + "type": "object", + "description": "Present only for trial users." + } + } + } + }, + "BuildGroupListResponse": { + "description": "Paged run-group listing. Groups at top-level `data`; paging under `info`. No `success` wrapper.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "BuildGroup" + } + }, + "info": { + "$schema": "PageInfo" + } + } + } + }, + "BuildGroupOperationResponse": { + "description": "Result of a create/update/delete/close on a run group. Serialized non-null only, so the exact key set depends on the operation: create/update carry {success, data, message}; delete carries {success, message}; close carries {success, message, build_group_id, closed_builds}.", + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$schema": "BuildGroup" + }, + "message": { + "type": "string" + }, + "build_group_id": { + "type": "integer", + "description": "Echoed on close." + }, + "closed_builds": { + "type": "integer", + "description": "Number of builds flipped to closed (close only)." + } + } + } + }, + "AccessibilityTestRunsResponse": { + "description": "Build-level a11y test listing assembled by TRA. NOTE: for App-Accessibility groups whose build source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", + "schema": { + "type": "object", + "properties": { + "testCases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Test case id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "file": { + "type": "string" + }, + "thBtcerId": { + "type": "string", + "description": "The btcer UUID for this test." + }, + "osData": { + "type": "object", + "description": "OS name/version/icon." + }, + "browserData": { + "type": "object", + "description": "Browser name/version/icon." + }, + "summary": { + "type": "object", + "properties": { + "pageCount": { + "type": "integer" + }, + "componentCount": { + "type": "integer" + }, + "score": { + "type": "number" + }, + "issueCount": { + "type": "integer" + }, + "severityBreakdown": { + "type": "object", + "description": "Counts keyed by critical/serious/moderate/minor." + } + } + } + } + } + }, + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "nextRoot": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "Next page, re-based to 0-index for the FE (upstream next page - 1)." + } + } + } + } + } } - } } - }, - "totalTests": { - "type": "integer" - }, - "totalHooks": { - "type": "integer" - } - } - }, - "failureCategorization": { - "type": "object", - "properties": { - "newFailures": { - "type": "integer" - }, - "alwaysFailing": { - "type": "integer" - }, - "flaky": { - "type": "integer" - } - } - }, - "failureContext": { - "type": "object", - "description": "ALWAYS null on this build-keyed route (no test run supplied). Present only on the test-run variant: { file:{path,testCount,failedCount,failureRate}, parentModule:{...} }.", - "nullable": true - }, - "topErrorsAnalysis": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "ANALYSIS_PENDING | ANALYSIS_COMPLETE | ANALYSIS_NOT_STARTED. uniqueErrors may be empty until COMPLETE." - }, - "uniqueErrorCount": { - "type": "integer" - }, - "uniqueErrors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "clusterId": { - "type": "string" - }, - "occurrences": { - "type": "integer" - }, - "truncatedError": { - "type": "string", - "description": "First ~200 chars of the error only." - } - } - } - } - } - }, - "defectStats": { - "type": "array", - "items": { - "type": "object", - "properties": { - "issueType": { - "type": "string" - }, - "totalFailureTestsCount": { - "type": "integer" - }, - "totalFailureHooksCount": { - "type": "integer" - }, - "categories": { - "type": "array", - "items": { + }, + "AccessibilityTestMetaResponse": { + "description": "Per-test a11y detail. NOTE: for App-Accessibility groups whose run source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", + "schema": { "type": "object", "properties": { - "category": { - "type": "string" - }, - "failureTestCount": { - "type": "integer" - }, - "failureHooksCount": { - "type": "integer" - } + "totalIssues": { + "type": "integer" + }, + "severityBreakdown": { + "type": "object", + "description": "Counts keyed by critical/serious/moderate/minor." + }, + "reportURL": { + "type": "string", + "description": "URL of the a11y report to fetch elsewhere." + }, + "score": { + "type": "number" + }, + "testCaseUuid": { + "type": "string" + } } - } - } - } - } - } - } - }, - "ProjectWithAggregates": { - "type": "object", - "description": "A project enriched with Elasticsearch-derived build activity aggregates, as returned by the aggregate project-list routes.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "integer" - }, - "groupId": { - "type": "integer" - }, - "normalisedName": { - "type": "string" - }, - "buildCount": { - "type": "integer", - "description": "Distinct build count for the project in the caller's product context." - }, - "projectStability": { - "type": "number", - "description": "Aggregate stability score for the project." - }, - "stabilityChartData": { - "type": "array", - "items": { - "type": "number" - }, - "description": "Series of stability values for a mini chart." - }, - "lastRanAt": { - "type": "string", - "format": "date-time", - "description": "When the project's most recent build finished." - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastRanBy": { - "type": "integer", - "description": "User id who triggered the most recent build." - }, - "thProjectId": { - "type": "integer" - } - } - }, - "SuiteHealthTestListItem": { - "type": "object", - "description": "One test row in the Stability & Performance / Suite/Test Health listing (com.browserstack.observability.models.response.SuiteHealth.TestStabilityAndPerformance).", - "properties": { - "id": { - "type": "string", - "description": "The test's stabilityHash \u2014 the identifier to carry into the detail endpoints (serialized from testHash via @JsonProperty(\"id\"))." - }, - "title": { - "type": "string", - "description": "Test name." - }, - "filePath": { - "type": "string" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "isPlatformError": { - "type": "boolean", - "description": "True when the sampled run's error was a platform (infra) error." - }, - "reliability": { - "type": "number", - "description": "Failure percentage (failed*100/total). HIGHER = less stable." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "totalFailures": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "browserList": { - "type": "array", - "description": "Browser/device combinations the test ran on.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "string" } - } - } - }, - "platformList": { - "type": "array", - "description": "OS/platform combinations the test ran on.", - "items": { - "type": "object" - } - }, - "browsers": { - "type": "array", - "description": "Always EMPTY on this listing path (populated only when the internal browserString is set, which this path never sets). Read browserList instead.", - "items": { - "type": "object" - } - }, - "platforms": { - "type": "array", - "description": "Always EMPTY on this listing path. Read platformList instead.", - "items": { - "type": "object" - } - } - } - }, - "SuiteHealthPlatformBreakdownItem": { - "type": "object", - "description": "One OS/browser/device row of a test's platform breakdown (TestStabilityAndPerformanceBreakdown).", - "properties": { - "id": { - "type": "string", - "description": "Composite \"-\" key (lower-cased)." - }, - "reliability": { - "type": "number", - "description": "Failure percentage for this combination (higher = worse)." - }, - "duration": { - "type": "number", - "description": "Average run duration for this combination." - }, - "totalFailures": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "os": { - "type": "object", - "description": "Platform display object {name, version}." - }, - "browser": { - "type": "object", - "description": "Browser display object with device as a plain string.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "string" - } - } - }, - "browserDevice": { - "type": "object", - "description": "Browser display object with device as a nested object.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "object" - } - } - } - } - }, - "SuiteHealthTrendPoint": { - "type": "object", - "description": "One execution point in the Suite Health trend series (TrendDataPoint, @JsonInclude NON_NULL \u2014 null fields are omitted).", - "properties": { - "color": { - "type": "string", - "description": "Status colour for the run." - }, - "workflowStatus": { - "type": "string", - "description": "Workflow status (workflowStatusV2), or \"Unknown\"." - }, - "y": { - "type": "integer", - "description": "Run duration (ms); 0 when absent." - }, - "id": { - "type": "integer", - "description": "Test-run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id (display build number)." - }, - "buildName": { - "type": "string" - }, - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewlyFailed": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - }, - "flakyReason": { - "type": "integer", - "description": "Flaky-status code; 0 when absent." - }, - "creationType": { - "type": "string", - "description": "Upper-cased creation type (defaults to AUTOMATION)." - } - } - }, - "SuiteHealthTestRun": { - "type": "object", - "description": "One test execution in the Suite Health run-history table (TestStabilityAndPerformanceDetails). Smart-tag flags are under historyAggregate, not top-level.", - "properties": { - "id": { - "type": "integer", - "description": "Test-run id." - }, - "name": { - "type": "string", - "description": "Composed display name (build name + re-run/manual suffix + #serialId)." - }, - "originalName": { - "type": "string" - }, - "status": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "workflowStatusEditedBy": { - "type": "string" - }, - "state": { - "type": "string" - }, - "startedAt": { - "type": "string", - "format": "date-time" - }, - "finishedAt": { - "type": "string", - "format": "date-time" - }, - "duration": { - "type": "integer", - "description": "Run duration (ms)." - }, - "buildNumber": { - "type": "integer" - }, - "normalisedName": { - "type": "string", - "description": "URL-encoded build name." - }, - "flakyReason": { - "type": "integer" - }, - "creationType": { - "type": "string" - }, - "testCreationType": { - "type": "string" - }, - "isAutoDetectedName": { - "type": "boolean" - }, - "isPlatformError": { - "type": "boolean" - }, - "thProjectId": { - "type": "integer" - }, - "tcmTestRunId": { - "type": "integer" - }, - "tcmTestRunIdentifier": { - "type": "string" - }, - "historyAggregate": { - "type": "object", - "description": "Smart-tag flags for the run.", - "properties": { - "isFlaky": { - "type": "boolean" - }, - "isAlwaysFailing": { - "type": "boolean" - }, - "isNewFailure": { - "type": "boolean" - }, - "isPerformanceAnomaly": { - "type": "boolean" - } - } - }, - "browser": { - "type": "object", - "description": "Browser display object." - }, - "os": { - "type": "object", - "description": "Platform display object." - }, - "device": { - "type": "string" - }, - "ciBuildData": { - "type": "object", - "description": "CI context (best-effort; empty when CI info absent).", - "properties": { - "buildUrl": { - "type": "string" - }, - "buildNumber": { - "type": "string" - }, - "jobName": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "versionControlInfo": { - "type": "object", - "description": "VCS context (best-effort).", - "properties": { - "url": { - "type": "string" - }, - "commitId": { - "type": "string" - } - } - }, - "pmToolDetails": { - "type": "array", - "description": "Linked PM-tool defects.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "url": { - "type": "string" - }, - "status": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" + }, + "SplitBuildLegsResponse": { + "description": "Paginated child legs of a split/parallel build (cursor by build uuid).", + "schema": { + "$schema": "SplitBuildLegs" } - } - } - }, - "smartTagSettings": { - "type": "object", - "description": "The smart-tag settings resolved for this run from its build's setting version." - } - } - }, - "ObsSessionVideo": { - "type": "object", - "description": "Session video descriptor with playback offsets. Sourced from SessionVideo in observability-api. Offsets/flags can be null when the video is not yet processed; status_code 0 is normal.", - "properties": { - "status_code": { - "type": "integer" - }, - "url": { - "type": "string", - "nullable": true - }, - "offsetUrl": { - "type": "string", - "nullable": true - }, - "startTime": { - "type": "number", - "nullable": true - }, - "startTimeStamp": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "startOffset": { - "type": "integer", - "description": "ms offset of video start relative to the test/session, for aligning playback with the log timeline.", - "nullable": true - }, - "finishOffset": { - "type": "integer", - "nullable": true - }, - "launchingAppTime": { - "type": "integer", - "description": "VM/browser boot lead-in in seconds.", - "nullable": true - }, - "isIos": { - "type": "boolean", - "nullable": true - }, - "isV2": { - "type": "boolean", - "nullable": true - } - } - }, - "ObsHistoryTab": { - "type": "object", - "description": "One past execution of the same test across builds. Sourced from HistoryTab in observability-api; startedAt is used for ordering and is not serialized.", - "properties": { - "testRunId": { - "type": "integer", - "description": "Integer id of the historical run (serialized from the internal Id field)." - }, - "status": { - "type": "string" - }, - "serialId": { - "type": "integer" - }, - "isHittingRetentionQuota": { - "type": "boolean" - }, - "testRunUuid": { - "type": "string" - }, - "usageQuotaStatus": { - "type": "string" - }, - "workflowStatus": { - "type": "string" - }, - "creationType": { - "type": "string" - }, - "buildUuid": { - "type": "string" - }, - "thProjectId": { - "type": "integer" - } - } - }, - "TestRunHistoryEntry": { - "type": "object", - "description": "One test run's (or hook run's) execution-history entry from get_test_runs_history_details. Only the keys present for that run appear.", - "properties": { - "buildId": { - "type": "integer" - }, - "serialId": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "issueType": { - "$schema": "TestRunIssueType", - "nullable": true, - "description": "Null on passed runs." - }, - "duration": { - "type": "integer", - "description": "Run duration." - }, - "isFlaky": { - "type": "boolean", - "nullable": true - }, - "isAlwaysFailing": { - "type": "boolean", - "nullable": true - }, - "isNewlyFailed": { - "type": "boolean", - "nullable": true - }, - "isPerformanceAnomaly": { - "type": "boolean", - "nullable": true - }, - "flakyReason": { - "type": "integer", - "nullable": true - }, - "flakySetting": { - "type": "object", - "description": "Flaky configuration for the run (opaque).", - "nullable": true - }, - "creationType": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "object" - }, - "description": "Test tags." - }, - "logs": { - "$schema": "TestRunRetryLogStruct" - }, - "workflowStatus": { - "type": "string", - "nullable": true - }, - "workflowStatusEditedBy": { - "type": "string", - "nullable": true - }, - "workflowStatusEditedAt": { - "type": "string", - "format": "date-time", - "nullable": true - } - } - }, - "TestRunRetryLogStruct": { - "type": "object", - "description": "Serialized log/attachment structure for a run or retry attempt. Each key is present only when that evidence exists.", - "properties": { - "TEST_FAILURE": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Failure text: the message followed by the stack, as an array of strings." - }, - "TEST_LOG": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Screenshot / attachment URLs; fetch each directly from storage (not proxied through this API)." - }, - "BROWSERSTACK_ERROR": { - "type": "object", - "description": "BrowserStack-side error log, when the run failed on infra." - } - } - }, - "TestRunSdkLogEntry": { - "type": "object", - "description": "One test run in a build that has SDK-captured logs/attachments.", - "properties": { - "logId": { - "type": "integer" - }, - "url": { - "type": "string", - "description": "Direct attachment/log URL, fetched from storage." - }, - "testRunId": { - "type": "integer" - }, - "testRunName": { - "type": "string" - }, - "attachmentsCount": { - "type": "integer" - }, - "screenshotsCount": { - "type": "integer" - } - } - }, - "TestRunCountEntry": { - "type": "object", - "description": "Per-unique-test rerun count within a build.", - "properties": { - "testHash": { - "type": "string", - "description": "Stable hash identifying the unique test across runs." - }, - "count": { - "type": "integer", - "description": "Number of times the test ran across the build and its re-runs." - }, - "isDifferentPlatformsUsed": { - "type": "boolean", - "description": "True when those runs spanned more than one platform." - } - } - }, - "TestRunIssueType": { - "type": "object", - "description": "A failure category (issue type or sub-type) as returned on test-run endpoints.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "locator": { - "type": "string", - "description": "Stable locator code for the issue type (e.g. a category code)." - }, - "abbreviation": { - "type": "string" - }, - "color": { - "type": "string" - }, - "groupId": { - "type": "integer" - }, - "userId": { - "type": "integer" - }, - "displayName": { - "type": "string", - "description": "Present only when set (JsonInclude NON_NULL)." - }, - "issueGroup": { - "type": "object", - "description": "Parent issue group (opaque; carries the group/enum this type sits under)." - } - } - }, - "BuildTopErrorStatsPanel": { - "type": "object", - "description": "InsightStatsPanel for a build's top unique errors.", - "properties": { - "overview": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "insights": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "text": { - "type": "string" - } - } - } - }, - "description": "Defaults to the 'Analysis yet to run' overview when analysis has not completed." - }, - "data": { - "type": "array", - "description": "Error clusters sorted by count desc; each: id, title (the error), count, impactedTests, testRuns[], aiRcaTestRunIds[]. Empty while clustering is incomplete." - }, - "ready": { - "type": "boolean", - "description": "False while the auto-analyser is still running." - }, - "commentCount": { - "type": "integer" - }, - "message": { - "type": "string", - "description": "Set (e.g. 'Unique Errors are being detected') when not ready; the only meaningful field in the not-ready state.", - "nullable": true - } - } - }, - "ResolvedBuildUuid": { - "type": "object", - "description": "Build uuid resolved from identifiers.", - "properties": { - "uuid": { - "type": "string", - "description": "The resolved build uuid, or null (with HTTP 200) when nothing matched." - } - } - }, - "SidebarBuildRuns": { - "type": "object", - "description": "A page of unique build runs for the sidebar rail with bidirectional cursor paging.", - "properties": { - "data": { - "type": "array", - "description": "SidebarBuildSummary rows: uuid, id, name, originalName, normalisedName, status, displayStatus, startedAt, finishedAt, buildNumber, tags[], isArchived, usageQuotaStatus (retention vs pro-quota), qualityGateStatus, creationType, statusStats, source, selfHealedCount, etc." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasPrev": { - "type": "boolean" - }, - "hasNext": { - "type": "boolean" - }, - "beforeKey": { - "type": "string" - }, - "afterKey": { - "type": "string" - } - } - } - } - }, - "BuildQualityGateRecalcAck": { - "type": "object", - "description": "Acknowledgement that quality-gate recalculation was queued (QualityGateSummary with only message set).", - "properties": { - "message": { - "type": "string", - "description": "'Quality Gate result will be available after recalculation finishes'." - } - } - }, - "BuildReportPolling": { - "type": "object", - "description": "BuildReport polling envelope (@JsonInclude NON_NULL). May be a NULL body when the build cannot be resolved.", - "properties": { - "reportStatus": { - "type": "string", - "description": "IN_PROGRESS | TEST_AVAILABLE | COMPLETED | NOT_AVAILABLE | DATA_NOT_AVAILABLE." - }, - "retryCount": { - "type": "integer", - "description": "How many more times to poll (present with IN_PROGRESS)." - }, - "pollingInterval": { - "type": "integer", - "description": "Interval to wait before the next POLL request." - }, - "report": { - "type": "object", - "properties": { - "richHtml": { - "type": "string" - }, - "richCss": { - "type": "string" - }, - "basicHtml": { - "type": "string" - }, - "plainText": { - "type": "string" - } - }, - "description": "Populated only on terminal statuses." - }, - "errorReason": { - "type": "string", - "description": "BUILD_NOT_FOUND | MULTIPLE_BUILD_FOUND | DATA_NOT_AVAILABLE | SOMETHING_WENT_WRONG." - } - } - }, - "BuildHistoryWithDefectCounts": { - "type": "object", - "description": "Agent-facing build history: anchor build + prior same-name runs, each with status and defect count summaries.", - "properties": { - "builds": { - "type": "array", - "description": "Newest first; each entry: buildUuid, buildNumber (serialId), originalName, status, startedAt, finishedAt, durationMs, current (true for the anchor build), statusStats{passed,failed,skipped,total}, defectStats{automationBug,productBug,toBeInvestigated,notDetermined,environmentIssue}, flakyCount, alwaysFailingCount, newFailureCount." - } - } - }, - "PublicBuildDetails": { - "type": "object", - "description": "Public build record (models/external BuildDetails, or BuildDetailsV2 when new build listing is enabled). Keys are snake_case except tcmTestRunIdentifier; pro-only fields are JsonView-gated, so the exact key set varies by flag and plan.", - "properties": { - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "id": { - "type": "integer", - "description": "Integer build id." - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "status": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "started_at": { - "type": "string", - "format": "date-time" - }, - "finished_at": { - "type": "string", - "format": "date-time" - }, - "user": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "status_stats": { - "type": "object", - "description": "Per-status counts (V2: workflow-status counts), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "V2 shape only." - }, - "failure_categories": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "smart_tags": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "unique_errors": { - "type": "object", - "description": "Pro-only (JsonView)." - }, - "alerts": { - "type": "array", - "description": "Pro-only (JsonView)." - }, - "re_run": { - "type": "boolean" - }, - "build_error": { - "type": "object" - }, - "is_archived": { - "type": "boolean" - }, - "observability_version": { - "type": "object" - }, - "vcs_info": { - "type": "object" - }, - "ci_info": { - "type": "object" - }, - "host_info": { - "type": "object" - }, - "run_information": { - "type": "array" - }, - "observability_url": { - "type": "string" - }, - "tcmTestRunIdentifier": { - "type": "string", - "description": "The one camelCase key." - }, - "app_details": { - "type": "object" - } - } - } - }, - "responses": { - "Unauthorized": { - "description": "401 - no valid credential was accepted by the authenticator chain.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Forbidden": { - "description": "403 - authenticated but lacks the required permission/plan/role, or access-denied.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BadRequest": { - "description": "400 - invalid or missing parameters; also the catch-all for many client errors.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "NotFound": { - "description": "404 - the addressed resource does not exist or is not visible to this group.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "InternalServerError": { - "description": "500 - unhandled server error; carries only errorTraceId + message.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "Conflict": { - "description": "409 - the request conflicts with current state, e.g. an operation already in progress.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "BuildSummaryListResponse": { - "description": "Top-level array of build summaries.", - "schema": { - "type": "array", - "items": { - "$schema": "BuildSummary" - } - } - }, - "BuildAggregateMapResponse": { - "description": "Top-level object keyed by build name; each value is a BuildAggregate.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "BuildAggregate" - } - } - }, - "OverallDailySummaryResponse": { - "description": "Aggregated daily-summary report across projects.", - "schema": { - "$schema": "OverallDailySummary" - } - }, - "PublicLinkSettingResponse": { - "description": "Public-link on/off setting for a dashboard or project. Built inline by the handler; defaults to false when no token row exists yet.", - "schema": { - "type": "object", - "properties": { - "publicLink": { - "type": "boolean", - "description": "Whether public link sharing is enabled. false is the default for a dashboard/project that has never been shared." - } - } - } - }, - "QualityGateSettings": { - "description": "A project's quality-gate settings and mapped profiles.", - "schema": { - "$schema": "QualityGateResponse" - } - }, - "QualityProfileDetail": { - "description": "One quality profile in full (rules, filters, status). Null fields omitted.", - "schema": { - "$schema": "QualityProfile" - } - }, - "BuildQualityGateSummary": { - "description": "Readiness-aware build gate summary; exactly one of qgResult/message/state is present.", - "schema": { - "$schema": "QualityGateSummary" - } - }, - "BuildQualityProfileResults": { - "description": "Bare JSON array of per-profile result summaries for a build (no wrapper).", - "schema": { - "type": "array", - "items": { - "$schema": "QualityProfileResultLiteDto" - } - } - }, - "BuildQualityProfileResultDetail": { - "description": "One profile's per-rule results for a build.", - "schema": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "$schema": "QualityProfileRuleResult" - } - }, - "ruleStatus": { - "type": "string", - "enum": [ - "pass", - "fail" - ] - } - } - } - }, - "CiQualityGateResult": { - "description": "CI-facing pass/fail quality gate result for a build. Null fields omitted while running.", - "schema": { - "$schema": "ExtQualityGateResult" - } - }, - "QualityProfileFilterValues": { - "description": "Filter values for the quality-profile filter builder, with an afterKey cursor.", - "schema": { - "$schema": "QGFilterResponseData" - } - }, - "PercentTimelineCardResponse": { - "description": "A Testing Trends card: timeline series plus optional headline insight.", - "schema": { - "$schema": "PercentTimelineCard" - } - }, - "FailureCategoriesResponse": { - "description": "Failure counts broken down by defect category over time.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FailureCategorySeries" - } - } - } - } - }, - "CbtResponse": { - "description": "Cross-browser coverage: primary platform groups plus their drill-down breakdowns.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "CbtDataPoint" - } - }, - "drillDownData": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Primary group key this drill-down belongs to." - }, - "data": { - "type": "array", - "items": { - "$schema": "CbtDataPoint" - } - } + }, + "BuildNotificationSummaryResponse": { + "description": "Compact build notification/email summary. HTTP 200 with a NULL body when the build id is unknown.", + "schema": { + "$schema": "BuildNotificationSummary" } - } - } - } - } - }, - "BuildFrequencyResponse": { - "description": "Run counts per build name for the window. sourceFencingFlags exists on the model but is not populated on this path.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildFrequencyStats" - } - } - } - } - }, - "BuildNamesResponse": { - "description": "Distinct build names within the plan retention window, wrapped under data.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "StabilityLeaderboardResponse": { - "description": "Paged stability leaderboard. Pagination is nested under `pagingParam` (singular).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "StabilityLeaderboardRow" - } - }, - "pagingParam": { - "$schema": "TrendPagingParams" - } - } - } - }, - "PerformanceLeaderboardResponse": { - "description": "Paged performance leaderboard. Pagination is nested under `pagingParam` (singular).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "PerformanceLeaderboardRow" - } - }, - "pagingParam": { - "$schema": "TrendPagingParams" - } - } - } - }, - "StabilityChartResponse": { - "description": "Stability-over-time series with the window median.", - "schema": { - "type": "object", - "properties": { - "median": { - "type": "number", - "description": "Overall pass-rate percentage for the window." - }, - "data": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - } - } - } - }, - "PerformanceChartResponse": { - "description": "Performance-over-time: barData is average duration per bucket, lineData is test count per bucket (lineData pointRange is null).", - "schema": { - "type": "object", - "properties": { - "barData": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - }, - "lineData": { - "type": "array", - "items": { - "$schema": "TrendTimelineDataPoint" - } - } - } - } - }, - "RecentRunsResponse": { - "description": "Recent builds picker. Pagination is nested under `pagingParams` (plural).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TrendBuildSummary" - } - }, - "pagingParams": { - "$schema": "TrendPagingParams" - }, - "currentQGOverrideStatus": { - "type": "boolean", - "description": "Project's quality-gate override flag." - } - } - } - }, - "WebhookProjectMappingResponse": { - "description": "Project picker / mapping list for the webhook editor.", - "schema": { - "type": "object", - "properties": { - "projectMapping": { - "$schema": "WebhookProjectMapping" - } - } - } - }, - "RcaRetriggerResponse": { - "description": "New RCA conversation turn id (and trial quota for trial users).", - "schema": { - "$schema": "RcaRetrigger" - } - }, - "BuildAccessibilityMetaResponse": { - "description": "Build accessibility summary header (web/Automate shape).", - "schema": { - "$schema": "BuildAccessibilityMeta" - } - }, - "SimilarTestRunsResponse": { - "description": "Reference run and the runs with a similar failure.", - "schema": { - "$schema": "SimilarTestRunRs" - } - }, - "SingleMetricSummaryResponse": { - "description": "A single build-insight tile metric.", - "schema": { - "$schema": "SingleMetricSummary" - } - }, - "BuildAISummaryResponse": { - "description": "AI-generated build failure summary; soft-fails with status NOT_AVAILABLE.", - "schema": { - "$schema": "BuildAISummary" - } - }, - "BuildAttemptRunStatsResponse": { - "description": "Per-attempt result breakdown plus an aggregate summary.", - "schema": { - "$schema": "BuildAttemptRunStatsResponse" - } - }, - "BuildHistoryResponse": { - "description": "Trend over the ~10 most recent builds of the same name.", - "schema": { - "$schema": "BuildHistory" - } - }, - "BuildIdFromCoordinatesResponse": { - "description": "The resolved build UUID for the given coordinates.", - "schema": { - "$schema": "BuildIdFromCoordinates" - } - }, - "BuildCoordinatesResponse": { - "description": "URL-safe coordinates and project ids for a build UUID.", - "schema": { - "$schema": "BuildCoordinates" - } - }, - "BuildReRunResponse": { - "description": "Flat (legacy) re-run summary for a build.", - "schema": { - "$schema": "BuildReRun" - } - }, - "BuildReRunV2Response": { - "description": "Split-build-aware grouped re-run summary with an aggregate.", - "schema": { - "$schema": "BuildReRunV2" - } - }, - "FieldSearchResponse": { - "description": "Type-ahead facet options for a per-project build filter. `data` is a set of {id, value, label} option objects.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterField" - } - } - } - } - }, - "SidebarSearchResponse": { - "description": "Sidebar facet options. `data` is a list of BARE VALUES \u2014 plain strings for branches, build names and tags. Capped at 100 entries by the underlying ES aggregation.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "BuildListFiltersResponse": { - "description": "Per-project build-listing filter bar: applied selection resolved plus the static option catalogue.", - "schema": { - "type": "object", - "properties": { - "applied": { - "$schema": "BuildFilterApplied" - }, - "staticFilters": { - "$schema": "BuildFilterStatic" - } - } - } - }, - "BuildSidebarFiltersResponse": { - "description": "Cross-project build-listing sidebar filters: applied selection resolved (with the projects it was scoped to) plus the static option catalogue. frameworkList here is the hardcoded master list, not a per-project presence list.", - "schema": { - "type": "object", - "properties": { - "applied": { - "allOf": [ - { - "$schema": "BuildFilterApplied" - } - ], - "properties": { - "projects": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "The project ids this filter view was scoped to." - } - } - }, - "staticFilters": { - "$schema": "BuildFilterStatic" - } - } - } - }, - "BuildTestFiltersResponse": { - "description": "Filter panel for a build's TEST listing. `staticFilters` is a map keyed by filter name to a list of {label, value} options. `proFilters` names plan-locked filter keys (non-empty only for freemium).", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "The caller's applied test filters, echoed back resolved (folders, testTags, status, priority, caseType, automationStatus, owner, lastExecutedBy, deviceList, osList, browserList, failureCategories, dateRange, and the boolean history/flaky flags)." - }, - "staticFilters": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "FilterStruct__w2_build_filter1" - } - }, - "description": "Catalogue of selectable test-filter values, keyed by filter name (browserList, osList, status, testTags, folders, runs, deviceList, failureCategories, history, flaky, ...)." - }, - "hooksVisibility": { - "type": "string", - "description": "One of all / failed / beforeFailed / none." - }, - "proFilters": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Filter keys locked behind a paid plan; empty for a paid workspace, else names keys like isFlaky, isNewFailure, isAlwaysFailing, hasPerformanceAnomaly, isMuted." - } - } - } - }, - "RunGroupFilterOptionsResponse": { - "description": "Run-group (build-group) sidebar facet. `data` is a list of slim build-group rows; `info` is page metadata but paging is fixed at page 1, size 100 and cannot be advanced through this endpoint.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "description": "Slim build-group row. Casing is mixed: `updated_at` is snake_case.", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string", - "nullable": true - }, - "isClosed": { - "type": "boolean" - }, - "state": { - "type": "string", - "nullable": true - }, - "updated_at": { - "type": "string" - } - } - } - }, - "info": { - "type": "object", - "description": "Page metadata (fixed page 1 / size 100).", - "properties": { - "page": { - "type": "integer" - }, - "pageSize": { - "type": "integer" - }, - "count": { - "type": "integer" - }, - "prev": { - "type": "integer", - "nullable": true - }, - "next": { - "type": "integer", - "nullable": true + }, + "BuildStabilityTileResponse": { + "description": "Build stability tile.", + "schema": { + "$schema": "BuildStabilityTile" } - } - } - } - } - }, - "DashboardResponse": { - "description": "A single dashboard, wrapped in `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "Dashboard" - } - } - } - }, - "DashboardsListResponse": { - "description": "List of dashboards, wrapped in `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "Dashboard" - } - } - } - } - }, - "DashboardMetaResponse": { - "description": "Ids resolved to display names.", - "schema": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "$schema": "MetaItem" - } - }, - "users": { - "type": "array", - "items": { - "$schema": "MetaItem" - } - } - } - } - }, - "UniqueErrorSearchResponse": { - "description": "Top-level JSON array of unique-error search hits (max 10). There is no wrapper object.", - "schema": { - "type": "array", - "items": { - "$schema": "ErrorSearchResult" - } - } - }, - "WidgetCustomFieldFiltersResponse": { - "description": "Custom-field filters. Shape depends on the request: with NO page/per_page/search it is a bare JSON ARRAY of FilterDetails; with any of them it is the paginated object {data, info}.", - "schema": { - "oneOf": [ - { - "type": "array", - "items": { - "$schema": "FilterDetails" - }, - "description": "Unpaginated: returned when page/per_page/search are all omitted." }, - { - "type": "object", - "description": "Paginated: returned when any of page/per_page/search is present.", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "FilterDetails" - } - }, - "info": { - "$schema": "DashboardPageInfo" + "BuildSummaryStatsResponse": { + "description": "Build headline test-count strip.", + "schema": { + "$schema": "BuildSummaryStats" } - } - } - ] - } - }, - "ReportListResponse": { - "description": "Reports/dashboards listing. On an invalid `source` this same 200 carries a top-level `error` string and empty `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "ReportSummary" - } - }, - "my_report_count": { - "type": "integer" - }, - "total_report_count": { - "type": "integer" - }, - "info": { - "$schema": "ReportListPageInfo" - }, - "error": { - "type": "string", - "description": "Present only on the invalid-source 200 refusal." - } - } - } - }, - "ProjectAutomationCoverageResponse": { - "description": "All-time automation-coverage figures for one project. Null fields are omitted (NON_NULL).", - "schema": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "project_name": { - "type": "string" - }, - "total_test_case_count": { - "type": "integer" - }, - "automated_test_case_count": { - "type": "integer" - }, - "automation_coverage_percent": { - "type": "number" - }, - "calculated_at": { - "type": "string", - "format": "date-time", - "description": "When this figure was computed." - } - } - } - }, - "ProjectListingExternalResponse": { - "description": "One page (up to 10) of the caller's projects, plus the cursor for the next page.", - "schema": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "$schema": "ProjectExternal" - } - }, - "pagination": { - "$schema": "ExternalPagination" - } - } - } - }, - "HomeUniqueBuildsResponse": { - "description": "Latest runs grouped by unique build name, with dual offset/cursor pagination params.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildSummary" - }, - "description": "One BuildSummary per unique build name (its most recent run)." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "pageNumber": { - "type": "integer" - }, - "afterKey": { - "type": "string", - "description": "search_after cursor to carry into the next request." + }, + "BuildTimelineStatsResponse": { + "description": "Build execution timeline series.", + "schema": { + "$schema": "BuildTimelineStats" } - } - } - } - } - }, - "HomeRecentViewsResponse": { - "description": "The user's up-to-5 recently-viewed items.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "RecentView" - } - } - } - } - }, - "HomeTakeawaysResponse": { - "description": "Older Home takeaways variant: aggregate time-saved metric. The v2 timeSavingV2Metric key is present but null on this path.", - "schema": { - "type": "object", - "properties": { - "timeSavingsData": { - "$schema": "TimeSavingMetric" - }, - "takeAwayData": { - "$schema": "TakeAwayData" - }, - "timeSavingV2Metric": { - "description": "Always null on this older variant (the key is present, the value is not).", - "nullable": true - } - } - } - }, - "HomeTakeawaysNoiseBreakdownResponse": { - "description": "Newer Home takeaways variant: failure-noise breakdown. timeSavingsData is explicitly null on this path.", - "schema": { - "type": "object", - "properties": { - "timeSavingV2Metric": { - "$schema": "TimeSavingV2Metric" - }, - "timeSavingsData": { - "type": "null", - "description": "Always null through this endpoint." - }, - "takeAwayData": { - "$schema": "TakeAwayData" - } - } - } - }, - "ProjectBuildGroupListingResponse": { - "description": "A project's builds consolidated by build group, plus per-group aggregates and cursor pagination.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "isHittingRetentionQuota": { - "type": "boolean" - }, - "builds": { - "type": "array", - "items": { - "$schema": "BuildSummary" - }, - "nullable": true, - "description": "Never populated on this route (always null); the per-group rows are in buildGroupAggResult." - }, - "buildGroupAggResult": { - "type": "array", - "items": { - "$schema": "BuildGroupAggResult" - }, - "description": "Per-build-group rolled-up aggregates." - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "searchAfter": { - "description": "Cursor (array or integer) to carry into the next request." + }, + "BuildDetailsResponse": { + "description": "Full single-build detail with UI gates.", + "schema": { + "$schema": "BuildDetails" } - }, - "nullable": true, - "description": "Never populated on this route (always null); all groups come back in one response." }, - "activeCount": { - "type": "integer" + "BuildFailureCategoryStatsResponse": { + "description": "Failure-category breakdown. Empty data + explanatory message (still 200) while the build is pending.", + "schema": { + "$schema": "BuildFailureCategoryStats" + } }, - "closedCount": { - "type": "integer" + "BuildFailuresByModuleResponse": { + "description": "Failures grouped by module/file path.", + "schema": { + "$schema": "BuildFailuresByModule" + } }, - "isArchiving": { - "type": "boolean" + "BuildFilterValuesResponse": { + "description": "Older compact build filter-values map.", + "schema": { + "$schema": "BuildFilterValues" + } }, - "isClosing": { - "type": "boolean" + "BuildTestFoldersResponse": { + "description": "Build test-listing folder tree; root-folders or folder-detail shape depending on folder_path.", + "schema": { + "$schema": "BuildTestFolders" + } }, - "manualBuildCount": { - "type": "integer" + "BuildNonSdkTestCountsResponse": { + "description": "Data-hygiene counters. The body is a JSON STRING that must be parsed to read the three count keys.", + "schema": { + "$schema": "BuildNonSdkTestCounts" + } }, - "automationBuildCount": { - "type": "integer" + "SingleMetricSummaryResponse__w4_build2": { + "description": "Single-metric tile (flaky / muted / new-failure).", + "schema": { + "$schema": "SingleMetricSummary__w4_build2" + } }, - "invalidQgBuildCount": { - "type": "integer" + "SidebarUserFilterResponse": { + "description": "Users offered by the build-list sidebar Users facet. `data` is an unordered, de-duplicated set capped at 100.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "User id." + }, + "name": { + "type": "string", + "description": "User's full name (the wire field is `name`, populated from the user's full name)." + } + } + } + } + } + } }, - "currentQGOverrideStatus": { - "type": "boolean", - "nullable": true - } - } - } - }, - "SPTestFilterOptions": { - "description": "Suite Health test-list filter sidebar: the applied filters resolved into display fields, the static option catalogue, and the project default hooks visibility.", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "The currently applied filters echoed back: list filters resolved into Field objects (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList/osList/browserList, branches, pmToolStatus), scalar/tri-state flags (search, isFlaky, isNewFailure, isAlwaysFailing, hasPmToolDefects, isManuallyOverridden, isCiRun, isLatest), dateRange as {lowerBound, upperBound}, isMuted and hooksVisibility. Filters you did not send come back null or [] (inconsistently per key)." - }, - "staticFilters": { - "type": "object", - "description": "The choosable option catalogue: osList, browserList, deviceList, status, folders, failureCategories, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan - each a list of Field objects. The run-level lists (typeOfRun, assignedTo, state, testPlan) come from an upstream service and are null or [] when it has nothing or times out, rather than erroring." - }, - "hooksVisibility": { - "type": "string", - "description": "The project's default hooks visibility (all / failed / beforeFailed / none)." - } - } - } - }, - "FieldSearchResults": { - "description": "A set of filter suggestions/options. Shared by the branch, build-name, build-tag, host-name and test-tag type-ahead routes.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "description": "Suggestion items (deduplicated set).", - "items": { - "$schema": "Field" - } - } - } - } - }, - "SPTestMetricsChart": { - "description": "Suite Health page charts. A top-level map keyed by chart name (no data wrapper).", - "schema": { - "type": "object", - "properties": { - "failureChart": { - "$schema": "SPChart" - }, - "durationChart": { - "$schema": "SPChart" - }, - "failureRateChart": { - "$schema": "SPChart" - } - } - } - }, - "SPTestBaseInfo": { - "description": "Identifying header info for one test in the Test Health detail view. Flat object, no data wrapper.", - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Test display name; empty string when the hash matches nothing in the window." - }, - "combinations": { - "type": "array", - "description": "OS/browser/device environment combinations the test ran on.", - "items": { - "type": "object", - "properties": { - "os": { + "SidebarTeamFilterResponse": { + "description": "Teams offered by the build-list sidebar Teams facet, resolved via the BrowserStack teams service. `data` is unordered and capped at 100 owning sub-groups.", + "schema": { "type": "object", - "description": "Formatted platform descriptor." - }, - "browser": { + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Team id (BrowserStack team / sub-group id)." + }, + "name": { + "type": "string", + "description": "Team name, from central/rails team data." + } + } + } + } + } + } + }, + "SidebarTestCaseStatusesResponse": { + "description": "Raw pass-through of Test Management's custom-fields API for entity `TestResult` (the configured manual test-result status options) for the selected projects. Returned verbatim, NOT wrapped in the {data:[...]} envelope the other sidebar facets use.", + "schema": { "type": "object", - "description": "Formatted browser descriptor, with a nested `device` field." - } + "description": "Opaque TestHub custom-fields JSON; keys and nesting are defined by Test Management, not by this service, and are returned as received." } - } - } - } - } - }, - "SPTestEnvironmentBreakdown": { - "description": "Per-environment stability breakdown for one test. A BARE JSON array (no object wrapper).", - "schema": { - "type": "array", - "items": { - "$schema": "SPTestBreakdownRow" - } - } - }, - "TestRunDetailResponse": { - "description": "Full detail of a single test run.", - "schema": { - "$schema": "TestRunDetail" - } - }, - "TestRunDetailMapResponse": { - "description": "A MAP keyed by test run id; each value is that run's detail ({ testCode, testMetadata }). Empty object when nothing matched \u2014 not an error.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestRunDetail" - } - } - }, - "BuildInsightsResponse": { - "description": "Build-level insights surrounding a test run.", - "schema": { - "$schema": "BuildInsights" - } - }, - "BuildTestRunListingResponse": { - "description": "A build's test runs as a nested hierarchy, plus pagination. Keys are snake_case (TestListingResponse / V2 by account flag); V2 adds is_manually_overridden and test_summary holds workflow-status counts.", - "schema": { - "type": "object", - "properties": { - "hierarchy": { - "type": "array", - "description": "Nested nodes (suites/folders/tests), each with details and children. On freemium/source-fenced builds analysis keys are stripped from node details.", - "items": { - "type": "object" - } - }, - "project_id": { - "type": "integer" - }, - "name": { - "type": "string", - "description": "Project name." - }, - "group_id": { - "type": "integer" - }, - "build_id": { - "type": "string", - "description": "The build's hashed uuid." - }, - "build_name": { - "type": "string" - }, - "build_number": { - "type": "integer" - }, - "original_name": { - "type": "string" - }, - "test_summary": { - "type": "object", - "description": "Per-status counts: StatusAggregate (older envelope) or WorkflowStatusAggregate (newer), same key." - }, - "is_manually_overridden": { - "type": "boolean", - "description": "Newer envelope only." - }, - "is_archived": { - "type": "boolean" - }, - "app_details": { - "type": "object" - }, - "pagination": { - "type": "object", - "description": "snake_case keys (models/external/Pagination).", - "properties": { - "has_next": { - "type": "boolean" - }, - "next_page": { - "type": "string", - "description": "Opaque base64 cursor; pass back unchanged as the next_page query param." + }, + "HomeUserFilterOptionsResponse": { + "description": "Selectable values for one home/overview personal-filter dropdown, keyed by the requested `key`.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "meta": { + "type": "string", + "nullable": true, + "description": "Null on this route." + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + }, + "count": { + "type": "integer", + "nullable": true, + "description": "Occurrence count; null for buildStatus/sources." + }, + "pId": { + "type": "string", + "nullable": true + } + } + } + } + } } - } - } - } - } - }, - "BuildTestRunsWithAttachmentsResponse": { - "description": "Trimmed test-run rows that have attachments/screenshots, with a numeric cursor.", - "schema": { - "type": "object", - "properties": { - "test_runs": { - "type": "array", - "items": { - "$schema": "BuildTestRunWithAttachments" - } - }, - "next_cursor": { - "type": "integer", - "description": "Numeric cursor; null on the last page. Pass back as `cursor`." - } - } - } - }, - "BugReportDraftResponse": { - "description": "Pre-filled bug-report draft composed from a failed run. Read-only; not a stored bug.", - "schema": { - "type": "object", - "properties": { - "buildName": { - "type": "string" - }, - "buildId": { - "type": "integer", - "description": "The build NUMBER (e.g. 1), not the numeric build_id." - }, - "ciInfo": { - "type": "object", - "nullable": true - }, - "testDetails": { - "type": "string", - "description": "Composed test hierarchy/description text for the ticket body." - }, - "testUrl": { - "type": "string", - "nullable": true - }, - "videoUrl": { - "type": "string", - "nullable": true - }, - "status": { - "type": "string" - }, - "platform": { - "type": "string", - "nullable": true - }, - "browser": { - "type": "string", - "nullable": true - }, - "hostInfo": { - "type": "object", - "nullable": true - }, - "logStruct": { - "type": "object", - "description": "Image/log attachment references keyed by log type (e.g. TEST_FAILURE: array of strings); empty when the run has no screenshots." - }, - "retries": { - "type": "array", - "items": { - "type": "object" - } - }, - "issueType": { - "type": "string", - "nullable": true - }, - "isAutoAnalyzed": { - "type": "boolean" - }, - "duration": { - "type": "integer" - }, - "aiRca": { - "type": "object", - "description": "AI RCA snippet ({data: ...}); null when no RCA is available.", - "nullable": true - } - } - } - }, - "LinkBugToTestRunResponse": { - "description": "Result of linking a filed bug to a run. 200 carries the created integration id; a 204 (no body) means the bug key was already tracked.", - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Created integration id; null if none was recorded." - } - } - } - }, - "FlatTestRunListingResponse": { - "description": "Flat list of a build's test runs with search_after pagination.", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "$schema": "FlatTestRun" - } - }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object" - }, - "searchAfter": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Cursor to pass back as searchAfter for the next page." - } - } - } - } - } - }, - "BuildAIFailuresResponse": { - "description": "Build-level AI failure analysis: failure themes and workflows, sorted by failure share. Serialized from AIFailuresResponse; trialQuota is omitted (NON_NULL) unless the account is on the build-level trial.", - "schema": { - "type": "object", - "properties": { - "buildId": { - "type": "integer" - }, - "buildThemes": { - "type": "array", - "description": "Failure themes, sorted by testPercentage descending.", - "items": { - "type": "object", - "properties": { - "themeId": { - "type": "string" - }, - "buildFailureThemeId": { - "type": "integer", - "description": "Integer id used to drill into this theme's test runs." - }, - "themeData": { + }, + "WebhookFilterOptionsResponse": { + "description": "Webhook rule-builder filter options for the requested `type`, with a composite-aggregation paging cursor.", + "schema": { "type": "object", - "description": "Opaque theme detail map." - }, - "affectedWorkflows": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "Display label; for `folder` it is 'name(count)'." + }, + "value": { + "type": "string", + "description": "The value to store in the rule; for `folder` it is the full path." + }, + "pId": { + "type": "string", + "description": "For `folder`, the parent folder path; for other types it echoes the value." + }, + "count": { + "type": "integer", + "description": "Matching document count for the value.", + "nullable": true + }, + "id": { + "type": "string", + "description": "Option id (duplicates value)." + }, + "meta": { + "type": "string", + "nullable": true + }, + "projectId": { + "type": "integer", + "nullable": true + }, + "projectName": { + "type": "string", + "nullable": true + }, + "thProjectId": { + "type": "integer", + "nullable": true + } + } + } }, - "workflowData": { - "type": "object" - } - } - } - }, - "affectedWorkflowCount": { - "type": "integer" - }, - "testRunCount": { - "type": "integer" - }, - "testPercentage": { - "type": "string", - "description": "Share of failures as a string (may include a % sign)." - }, - "isNewTheme": { - "type": "boolean" - } - } - } - }, - "buildWorkflows": { - "type": "array", - "description": "Failure workflows, sorted by testPercentage descending.", - "items": { - "type": "object", - "properties": { - "workflowIdentifier": { - "type": "string" - }, - "buildFailureWorkflowId": { - "type": "integer", - "description": "Integer id used to drill into this workflow's test runs." - }, - "workflowData": { + "afterKey": { + "type": "string", + "description": "The last key of this page. It is still set on the final page, so stop when a page returns fewer rows than the page size (10; 1000 for folder) or an empty `data`.", + "nullable": true + } + } + } + }, + "SuiteHealthTestListResponse": { + "description": "Stability & Performance test listing (Test Health v2 / Suite Health v3 \u2014 identical envelope).", + "schema": { "type": "object", - "description": "Opaque workflow detail map." - }, - "testRunCount": { - "type": "integer" - }, - "testPercentage": { - "type": "string" - }, - "themesIdentified": { - "type": "array", - "items": { - "type": "object", - "properties": { - "buildRunThemeId": { - "type": "integer" + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "pageNumber": { + "type": "integer", + "description": "Next page index." + }, + "hasNext": { + "type": "boolean" + } + } + }, + "tests": { + "type": "array", + "items": { + "$schema": "SuiteHealthTestListItem" + } }, - "themeData": { - "type": "object" + "automateTestsSkipped": { + "type": "boolean", + "description": "True when Automate tests were hidden by source feature-fencing." }, - "isNewTheme": { - "type": "boolean" + "appAutomateTestsSkipped": { + "type": "boolean", + "description": "True when App Automate tests were fenced out." + }, + "lcncTestsSkipped": { + "type": "boolean", + "description": "True when Low-Code/No-Code tests were fenced out." } - } } - }, - "themeIdentifiedCount": { - "type": "integer" - } } - } }, - "buildThemeWorkflow": { - "type": "object", - "description": "Analysis-run status envelope.", - "properties": { - "status": { - "type": "string", - "description": "e.g. SUCCESS, PENDING, RETRIED_AND_FAILED. Non-terminal means results still being produced." - }, - "summary": { - "type": "string" + "SuiteHealthStatsResponse": { + "description": "Aggregate stat tiles for one test.", + "schema": { + "type": "object", + "properties": { + "reliability": { + "type": "number", + "description": "Failure percentage (higher = worse)." + }, + "duration": { + "type": "number", + "description": "Average run duration." + }, + "failureOccurrence": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } } - } }, - "stats": { - "type": "object", - "properties": { - "totalThemes": { - "type": "integer" - }, - "newThemes": { - "type": "integer" - }, - "existingThemes": { - "type": "integer" - }, - "totalWorkflows": { - "type": "integer" - }, - "totalTestRuns": { - "type": "integer" + "TestHealthTrendResponse": { + "description": "Legacy Test Health per-execution trend. data points are minimal (no smart-tag flags / workflow status).", + "schema": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "startedAt epochs, parallel to data." + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "color": { + "type": "string" + }, + "y": { + "type": "integer", + "description": "Run duration (ms); 0 when absent." + }, + "id": { + "type": "integer", + "description": "Test-run id." + }, + "buildId": { + "type": "integer", + "description": "Build serial id." + }, + "buildName": { + "type": "string" + } + } + } + } + } } - } }, - "trialQuota": { - "type": "object", - "description": "Present only for build-level trial accounts.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - } - }, - "BuildAIFailureTriggerResponse": { - "description": "Get-or-trigger response for build-level AI failure analysis. The AIFailuresResponse fields are unwrapped at the TOP level (JsonUnwrapped) alongside triggered; when analysis was freshly triggered they are absent/null and only triggered (and trialQuota) are present.", - "schema": { - "type": "object", - "properties": { - "triggered": { - "type": "boolean", - "description": "true when analysis was enqueued (results not in this response); false when existing terminal analysis is returned inline." - }, - "buildId": { - "type": "integer", - "description": "Present only when existing analysis is returned; null/absent on a fresh trigger." - }, - "buildThemes": { - "type": "array", - "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildThemes.", - "items": { - "type": "object" - } - }, - "buildWorkflows": { - "type": "array", - "description": "Present only when existing analysis is returned. Same item shape as BuildAIFailuresResponse.buildWorkflows.", - "items": { - "type": "object" - } - }, - "buildThemeWorkflow": { - "type": "object", - "properties": { - "status": { - "type": "string", - "description": "Set to PENDING when a non-terminal analysis is retriggered." - }, - "summary": { - "type": "string" + "SuiteHealthTrendResponse": { + "description": "Suite Health per-execution trend with rich points.", + "schema": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "startedAt epochs, parallel to data." + }, + "data": { + "type": "array", + "items": { + "$schema": "SuiteHealthTrendPoint" + } + } + } } - } - }, - "stats": { - "type": "object", - "description": "Same shape as BuildAIFailuresResponse.stats." }, - "trialQuota": { - "type": "object", - "description": "Present only for build-level trial accounts.", - "properties": { - "used": { - "type": "integer" - } - } - } - } - } - }, - "FlatAIFailureTestRunsResponse": { - "description": "Flat, cursor-paginated list of the test runs behind an AI failure cluster (or an explicit id set). Serialized from FlatTestResponse. Detail fields are null when the run has no such data (os/browser/issueType on SDK-less runs, the analyser flags before analysis).", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "scopeList": { - "type": "array", - "items": { - "type": "string" - } - }, - "details": { + "SuiteHealthTestInfoResponse": { + "description": "Header metadata for one test's Suite Health detail page.", + "schema": { "type": "object", "properties": { - "id": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "os": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } - }, - "nullable": true - }, - "browser": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - } + "name": { + "type": "string", + "description": "Test display name; empty string when no run matched." }, - "nullable": true - }, - "device": { - "type": "string", - "nullable": true - }, - "isRealDevice": { - "type": "boolean", - "nullable": true - }, - "filePath": { - "type": "string", - "nullable": true - }, - "vcFileUrl": { - "type": "string", - "nullable": true - }, - "issueType": { - "type": "object", - "nullable": true - }, - "aiRcaState": { - "type": "string", - "nullable": true - }, - "isFlaky": { - "type": "boolean", - "nullable": true - }, - "isPerformanceAnomaly": { - "type": "boolean", - "nullable": true - }, - "isNewFailure": { - "type": "boolean", - "nullable": true - }, - "isAlwaysFailing": { - "type": "boolean", - "nullable": true - }, - "isMuted": { - "type": "boolean", - "nullable": true - }, - "isAutoAnalyzed": { - "type": "boolean", - "nullable": true - }, - "isAutoAnalyzerRunning": { - "type": "boolean", - "nullable": true - }, - "isUniqueErrorsRunning": { - "type": "boolean", - "nullable": true - }, - "isFailingLast5Builds": { - "type": "boolean", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "type": "string" + "combinations": { + "type": "array", + "description": "OS/browser/device combinations the test ran on (non-displayable combos filtered out).", + "items": { + "type": "object", + "properties": { + "os": { + "type": "object", + "description": "Platform display object." + }, + "browser": { + "type": "object", + "description": "Browser display object; device nested as an object.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "device": { + "type": "object" + } + } + } + } + } } - } } - } } - } }, - "pagingParams": { - "type": "object", - "description": "Cursor paging state. Feed searchAfter back to fetch the next page; stop when hasNext is false.", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "description": "Opaque map, may be null.", - "nullable": true - }, - "searchAfter": { - "description": "Opaque cursor: an array (or integer) to pass back as the searchAfter query param." + "SuiteHealthPlatformBreakdownResponse": { + "description": "Per OS/browser/device breakdown for one test \u2014 a BARE ARRAY, sorted by reliability descending.", + "schema": { + "type": "array", + "items": { + "$schema": "SuiteHealthPlatformBreakdownItem" + } } - } - } - } - } - }, - "QueuedSessionsResponse": { - "description": "Sessions currently sitting in the automation queue for a user and product.", - "schema": { - "type": "object", - "properties": { - "sessions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "buildUuid": { - "type": "string" - }, - "sessionName": { - "type": "string" - }, - "buildName": { - "type": "string", - "description": "Null when the build is outside the caller's group." - }, - "projectName": { - "type": "string", - "description": "Null when the build is outside the caller's group." - }, - "queueReason": { - "type": "string", - "description": "Why the session is queued, e.g. no_terminal_available." - }, - "capabilities": { + }, + "SuiteHealthTestRunsResponse": { + "description": "One page of a test's executions across builds, cursor-paginated by start.", + "schema": { "type": "object", - "description": "Resolved caps; shape depends on caps_format (selenium / appium / default)." - }, - "duration": { - "type": "string", - "description": "How long the session has waited, computed at read time." - } - } - } - } - } - } - }, - "SessionAppProfilingResponse": { - "description": "Opaque upstream app-profiling JSON (CPU/memory/etc.). An empty object means the report is still generating or was never captured.", - "schema": { - "type": "object", - "description": "Opaque app-profiling payload from the App Automate product API; {} when not yet available." - } - }, - "SessionCapabilitiesResponse": { - "description": "Opaque session capabilities JSON. Normally the upstream sessionParams object; on upstream failure a fallback {outputCapabilities:{capabilities:...}} built from stored session caps.", - "schema": { - "type": "object", - "description": "Opaque capabilities object; key shape varies between the upstream sessionParams and the stored-caps fallback." - } - }, - "SessionDetailsResponse": { - "description": "Session header/metadata for the Test Details page (serialized from TestLogsInfo). customMetadata and buildId are always null through this path.", - "schema": { - "type": "object", - "properties": { - "videoLogs": { - "$schema": "ObsSessionVideo", - "nullable": true, - "description": "Session video handle (url, offsetUrl, status_code, startTime/startTimeStamp, startOffset/finishOffset, launchingAppTime, isIos, isV2); null if the media fetch failed." - }, - "audioLogs": { - "type": "object", - "description": "Opaque audio log node; null if unavailable.", - "nullable": true - }, - "browserstackSessionUrl": { - "type": "string", - "description": "Deep link to the session on the Automate/App-Automate dashboard." - }, - "percyBuildUrl": { - "type": "string", - "description": "Linked Percy build URL, extracted from build metadata; may be null.", - "nullable": true - }, - "isSdk": { - "type": "boolean", - "description": "Whether the build was produced by the observability SDK." - }, - "device": { - "type": "string", - "description": "Device name; an EMPTY string for desktop sessions (\"NA\" on some other routes)." - }, - "framework": { - "type": "string", - "description": "Short framework name, e.g. \"mocha\" (not the build's full framework/version string)." - }, - "customMetadata": { - "type": "object", - "description": "Always null through this path.", - "nullable": true - }, - "testStartedAt": { - "type": "string", - "description": "ISO-8601 session start time; drives the network-logs 30-day-expired empty state. May be null.", - "nullable": true - }, - "buildId": { - "type": "string", - "description": "Always null through this path despite the field name.", - "nullable": true - } - } - } - }, - "SessionTestCodeResponse": { - "description": "Test source code captured for an Automate session, one entry per test executed.", - "schema": { - "type": "object", - "properties": { - "session_id": { - "type": "string" - }, - "build_uuid": { - "type": "string" - }, - "tests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "test_run_id": { - "type": "integer" - }, - "code": { - "type": "string", - "description": "Captured source; may be truncated server-side without any flag in the response." - }, - "language": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "type": { - "type": "string" - } - } - } - } - } - } - }, - "BuildLinkNotReady": { - "description": "Retryable 425: the session is not yet linked to its build. Retry shortly rather than treating as fatal.", - "schema": { - "$schema": "ErrorResponse" - } - }, - "CheckConfigurationResponse": { - "description": "A repository's check-run configurations. On the internal empty-fallback path the repoName/ownerName fields may be populated in swapped order.", - "schema": { - "type": "object", - "properties": { - "repoName": { - "type": "string" - }, - "ownerName": { - "type": "string" - }, - "checks": { - "type": "array", - "items": { - "$schema": "CheckConfigurationItem" - } - } - } - } - }, - "CheckConfigurationItemResponseWrap": { - "description": "A single check-run configuration (returned by update and toggle on success).", - "schema": { - "$schema": "CheckConfigurationItem" - } - }, - "VcsCheckMappingResponse": { - "description": "A single registered PR-check mapping (vcs_ingestion row with its joined check configuration).", - "schema": { - "$schema": "VcsCheckMapping" - } - }, - "ViewResponse": { - "description": "A single saved view.", - "schema": { - "$schema": "View" - } - }, - "ViewListResponse": { - "description": "A bare JSON array of saved views (no envelope, not paginated).", - "schema": { - "type": "array", - "items": { - "$schema": "View" - } - } - }, - "DashboardViewResponse": { - "description": "Single dashboard view wrapped under `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "DashboardView" - } - } - } - }, - "WidgetFilterValuesResponse": { - "description": "Paginated widget filter-value picker results (WidgetEnhancedFilterResp).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "description": "Map keyed by bucket - `suggested` and `others` - each a list of value structs (not keyed by filter name).", - "additionalProperties": { - "type": "array", - "items": { - "$schema": "WidgetFilterValue" - } - } - }, - "fullList": { - "type": "boolean", - "description": "True when the whole value set fit in this page.", - "nullable": true - }, - "info": { - "type": "object", - "description": "Page metadata (PageInfo).", - "nullable": true - }, - "suggestedAfterKey": { - "type": "string", - "nullable": true - }, - "othersAfterKey": { - "type": "string", - "nullable": true - } - } - } - }, - "IgnoredFilterValuesResponse": { - "description": "Report of which saved widget filter values are still valid vs ignored (TestHub IgnoredFilterValuesResponse).", - "schema": { - "type": "object", - "properties": { - "filterMatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "filterType": { - "type": "string" - }, - "matchedValuesCount": { - "type": "integer" - }, - "ignoredValuesCount": { - "type": "integer" - }, - "isMismatched": { - "type": "boolean" - } - } - } - }, - "ignoredFiltersCount": { - "type": "integer", - "description": "Count of filters completely ignored." - }, - "isMismatched": { - "type": "boolean" - } - } - } - }, - "WidgetDataResponse": { - "description": "Computed data for one dashboard-view widget. The concrete shape is untyped and depends entirely on the widgetType requested \u2014 inspect per widget type.", - "schema": { - "type": "object", - "description": "Widget-type-specific payload (charts, stat cards, trend series, drilldown data, etc.)." - } - }, - "ReportRecordResponse": { - "description": "A single report record wrapped under `data`.", - "schema": { - "type": "object", - "properties": { - "data": { - "$schema": "ReportRecord" - } - } - } - }, - "ReportRecordListResponse": { - "description": "A flat list of report records under `data` (no pagination).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "ReportRecord" - } - } - } - } - }, - "ReportDatasetResponse": { - "description": "Report dataset rows (or static build rows) with a paging summary. Can be null when the report type is not TABULAR_TEST_RUNS.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunReportRow" - } - }, - "summary": { - "$schema": "PaginationSummary" - }, - "sourceFencingFlags": { - "type": "object" - } - } - } - }, - "ReportStatTilesResponse": { - "description": "Aggregate stat tiles for a report widget. `data` is null when the report type is not TABULAR_TEST_RUNS.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "StatTile" - } - }, - "sourceFencingFlags": { - "type": "object" - } - } - } - }, - "ReportCustomFieldsResponse": { - "description": "Custom-field / column definitions plus page info.", - "schema": { - "type": "object", - "properties": { - "customFields": { - "type": "array", - "items": { - "$schema": "ReportCustomFieldItem" - } - }, - "info": { - "$schema": "ReportPageInfo" - } - } - } - }, - "CreateHealingPrAccepted": { - "description": "Healing-PR job accepted (async). Only healingPrId + status (IN_PROGRESS) are set.", - "schema": { - "$schema": "CreateHealingPrResponse" - } - }, - "HealingPrStatusResponse": { - "description": "Current state of a healing-PR job; PR fields populated once terminal.", - "schema": { - "$schema": "CreateHealingPrResponse" - } - }, - "SubmitHealingVerdictResponse": { - "description": "The verdict as recorded.", - "schema": { - "$schema": "HealingVerdictResponse" - } - }, - "HealingVerdictListResponse": { - "description": "Recorded verdicts for the build. Empty array (200) when the HITL healing feature is off for the group.", - "schema": { - "type": "array", - "items": { - "$schema": "HealingVerdictResponse" - } - } - }, - "SelfHealSummaryResponse": { - "description": "Build self-heal headline numbers.", - "schema": { - "$schema": "SelfHealSummaryStats" - } - }, - "BuildComparisonAggregationResponse": { - "description": "Build-comparison headline counts.", - "schema": { - "$schema": "BuildComparisonAggregation" - } - }, - "BuildComparisonCheckResponse": { - "description": "Comparison availability pre-check.", - "schema": { - "$schema": "BuildComparisonAvailable" - } - }, - "BuildComparisonActualResponse": { - "description": "base/target resolved to parent build UUIDs.", - "schema": { - "$schema": "BuildComparisonStringEnvelope" - } - }, - "BuildComparisonListResponse": { - "description": "One bucket's grouped tests.", - "schema": { - "$schema": "BuildComparisonList" - } - }, - "BuildComparisonMetaResponse": { - "description": "base/target build summary headers.", - "schema": { - "$schema": "BuildComparisonMetaEnvelope" - } - }, - "CommentListResponse": { - "description": "A bare array of comments (commentNode, no content HTML).", - "schema": { - "type": "array", - "items": { - "$schema": "CommentRes" - } - } - }, - "CommentThreadsSidePanelResponse": { - "description": "A bare array of thread summaries for the build side panel.", - "schema": { - "type": "array", - "items": { - "$schema": "BuildRunComment" - } - } - }, - "UniqueErrorListResponse": { - "description": "Suite Health unique-errors list. An empty `errors` with a 200 is the honest state for a demo tenant or a buildless project.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "pageNumber": { - "type": "integer", - "description": "The NEXT page index to request (requested pageNumber + 1)." - }, - "hasNext": { - "type": "boolean", - "description": "Whether the page just returned had rows; false marks the end." + "properties": { + "pagingParams": { + "type": "object", + "properties": { + "hasNext": { + "type": "boolean" + }, + "start": { + "type": "integer", + "description": "Cursor for the next (older) page \u2014 pass back as the start query param." + }, + "offset": { + "type": "integer", + "description": "Fixed placeholder (20) kept for FE contract; not honoured by the backend." + } + } + }, + "builds": { + "type": "array", + "items": { + "$schema": "SuiteHealthTestRun" + } + } + } } - } }, - "errors": { - "type": "array", - "items": { - "$schema": "UniqueErrorClusterRow" - } - }, - "automateTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded Automate tests." + "TestRunHistoryDetailsResponse": { + "description": "Execution-history map keyed by the requested id. Top-level keys are the test run ids (or hook run ids when isHook=true); an id with no history is omitted.", + "schema": { + "type": "object", + "additionalProperties": { + "$schema": "TestRunHistoryEntry" + } + } }, - "appAutomateTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded App Automate tests." + "TestRunRetryLogsResponse": { + "description": "The retry attempt's screenshot/log structure. Throws (not 200) when no screenshot data exists for the attempt.", + "schema": { + "$schema": "TestRunRetryLogStruct" + } }, - "lcncTestsSkipped": { - "type": "boolean", - "description": "Source fencing silently excluded low-code / no-code tests." - } - } - } - }, - "UniqueErrorSummaryResponse": { - "description": "Header/summary block for one unique error.", - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Sample failing test name." - }, - "error": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sample error text split on newlines." - }, - "combinations": { - "type": "array", - "description": "Platform combos the error was seen on; combos NA on both os and browser are dropped.", - "items": { - "type": "object", - "properties": { - "os": { - "$schema": "SPFilterField" - }, - "browser": { + "TestRunSdkLogsListResponse": { + "description": "One fixed-size (15) page of test runs with SDK logs/attachments. totalAttachments is present only on the first (cursor-absent) page.", + "schema": { "type": "object", - "description": "Formatted browser object that also carries a `device`." - } + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunSdkLogEntry" + } + }, + "cursorInfo": { + "type": "object", + "properties": { + "nextCursor": { + "type": "integer", + "description": "Pass back as `cursor` for the next page." + }, + "hasMore": { + "type": "boolean" + } + } + }, + "totalAttachments": { + "type": "integer", + "description": "Per-build attachment total; only on the first page." + } + } } - } - } - } - } - }, - "UniqueErrorOccurrenceCountResponse": { - "description": "Total occurrences of one unique error plus a per-bucket count series.", - "schema": { - "type": "object", - "properties": { - "errorCount": { - "type": "integer", - "description": "Total matching-run count for the window." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence doc counts with NO timestamps \u2014 reconstruct the x-axis from the window." - } - } - } - }, - "UniqueErrorBuildTrendResponse": { - "description": "Occurrence trend of one unique error grouped into status series (hard-capped at 5000 runs).", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Status series name." - }, - "color": { - "type": "string", - "description": "Series colour (defaults to #999999)." - }, - "data": { + }, + "AssignTestRunFailureCategoriesResponse": { + "description": "Flat array of the resulting issue types after bulk categorisation.", + "schema": { "type": "array", "items": { - "type": "object" - }, - "description": "Enriched run/build info entries for that series." - } + "$schema": "TestRunIssueType" + } } - } }, - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Parallel list of run start timestamps (epoch)." - } - } - } - }, - "UniqueErrorBuildsResponse": { - "description": "Cursor-paginated list of builds in which a unique error occurred.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean", - "description": "Set to false only on an empty page." - }, - "searchAfter": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Cursor to send back as the next request's searchAfter." - }, - "nextRoot": { - "type": "object", - "description": "Usually absent for this endpoint." + "BstackErrorSessionsResponse": { + "description": "Raw array of BrowserStack-error session objects straight from the session repository (opaque element shape). Empty for non-SDK builds and in demo-sandbox.", + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Opaque session object." + } } - } }, - "builds": { - "type": "array", - "items": { - "$schema": "UniqueErrorBuild" - } - } - } - } - }, - "UniqueErrorPlatformBreakdownResponse": { - "description": "Flat list of platform buckets for one unique error.", - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "browser": { - "type": "object", - "description": "Formatted browser object that also carries a `device`." - }, - "os": { - "type": "object", - "description": "Formatted platform object." - }, - "chartData": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "Per-day occurrence counts (no timestamps)." - }, - "totalFailure": { - "type": "integer", - "description": "Occurrence doc count for this combination." - }, - "errorCount": { - "type": "integer", - "description": "Distinct impacted-test count for this combination." - } - } - } - } - }, - "UniqueErrorImpactedTestsResponse": { - "description": "Bare array of impacted tests behind one error cluster (no envelope).", - "schema": { - "type": "array", - "items": { - "$schema": "UniqueErrorImpactedTest" - } - } - }, - "UniqueErrorTimelineChartsResponse": { - "description": "Three Suite Health charts keyed uniqueErrorChart / uniqueTestChart / impactedTestChart.", - "schema": { - "type": "object", - "properties": { - "uniqueErrorChart": { - "$schema": "SPChart__w3_sp_error" - }, - "uniqueTestChart": { - "$schema": "SPChart__w3_sp_error" - }, - "impactedTestChart": { - "$schema": "SPChart__w3_sp_error" - } - } - } - }, - "UniqueErrorFiltersResponse": { - "description": "The Unique Errors filter sidebar: applied selections, static option sets, and default hooks visibility.", - "schema": { - "type": "object", - "properties": { - "applied": { - "type": "object", - "description": "Caller's current selections resolved into display objects. Scalar keys (search, isFlaky, isNewFailure, isAlwaysFailing, isManuallyOverridden, isMuted, hooksVisibility, dateRange) plus option-set keys (buildTags, uniqueBuildNames, folders, testTags, status, hostNames, users, lastExecutedBy, teams, failureCategories, deviceList, osList, browserList, pmToolStatus, branches), each a list of {id, value, label, count, pId} option objects." - }, - "staticFilters": { - "type": "object", - "description": "Selectable option lists for the page: osList, browserList, status, folders, failureCategories, deviceList, pmToolStatus, customMetaData, sources, lastExecutedBy, typeOfRun, assignedTo, state, testPlan \u2014 each a list of {id, value, label, count, pId} option objects. Any list can be empty when its upstream (ES / TestHub) times out." - }, - "hooksVisibility": { - "type": "string", - "description": "The project's default hooks-visibility setting." - } - } - } - }, - "FieldSearchResponse__w3_sp_error": { - "description": "Typeahead result: a bounded set of matching filter options.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "SPFilterField" - } - } - } - } - }, - "TestRunsRcaDataResponse": { - "description": "A test run's AI RCA (state + analysis). Top-level object; there is NO `data` wrapper.", - "schema": { - "$schema": "TestRunsRcaData" - } - }, - "BulkTestRcaResponse": { - "description": "Acknowledgement that a bulk test-RCA batch was queued. bulkRcaUuid is the batch handle; the analyses arrive later.", - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string" - }, - "buildUuid": { - "type": "string" - }, - "bulkRcaUuid": { - "type": "string", - "description": "UUID identifying the queued bulk batch." - }, - "trialQuota": { - "type": "object", - "description": "Present only for trial users." - } - } - } - }, - "BuildGroupListResponse": { - "description": "Paged run-group listing. Groups at top-level `data`; paging under `info`. No `success` wrapper.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "BuildGroup" - } - }, - "info": { - "$schema": "PageInfo" - } - } - } - }, - "BuildGroupOperationResponse": { - "description": "Result of a create/update/delete/close on a run group. Serialized non-null only, so the exact key set depends on the operation: create/update carry {success, data, message}; delete carries {success, message}; close carries {success, message, build_group_id, closed_builds}.", - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$schema": "BuildGroup" - }, - "message": { - "type": "string" - }, - "build_group_id": { - "type": "integer", - "description": "Echoed on close." - }, - "closed_builds": { - "type": "integer", - "description": "Number of builds flipped to closed (close only)." - } - } - } - }, - "AccessibilityTestRunsResponse": { - "description": "Build-level a11y test listing assembled by TRA. NOTE: for App-Accessibility groups whose build source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", - "schema": { - "type": "object", - "properties": { - "testCases": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Test case id." - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "file": { - "type": "string" - }, - "thBtcerId": { - "type": "string", - "description": "The btcer UUID for this test." - }, - "osData": { + "FlatTestRunListResponse": { + "description": "Flat, cursor-paged test-run listing (V2 flat surface).", + "schema": { "type": "object", - "description": "OS name/version/icon." - }, - "browserData": { - "type": "object", - "description": "Browser name/version/icon." - }, - "summary": { + "properties": { + "testRuns": { + "type": "array", + "items": { + "type": "object", + "description": "Flattened test detail (per-run row: id, name, status, platform, flags, etc.)." + } + }, + "pagingParams": { + "type": "object", + "description": "Cursor state (hasNext, nextRoot, searchAfter) to feed back for the next page." + }, + "isArchived": { + "type": "boolean" + }, + "smartTagSettings": { + "type": "object" + }, + "status": { + "type": "object", + "description": "Status aggregate over the result." + }, + "workflowStatus": { + "type": "object", + "description": "Workflow-status aggregate over the result." + }, + "folder_path": { + "type": "string" + } + } + } + }, + "HierarchyTestRunListResponse": { + "description": "Hierarchical (suite/file/test) test-run tree with cursor paging over top-level roots.", + "schema": { "type": "object", "properties": { - "pageCount": { - "type": "integer" - }, - "componentCount": { - "type": "integer" - }, - "score": { - "type": "number" - }, - "issueCount": { - "type": "integer" - }, - "severityBreakdown": { - "type": "object", - "description": "Counts keyed by critical/serious/moderate/minor." - } + "hierarchy": { + "type": "array", + "items": { + "type": "object", + "description": "A suite/file/test node (nested children under the node)." + } + }, + "pagingParams": { + "type": "object", + "description": "Cursor state (hasNext, nextRoot, searchAfter); paging is by top-level roots (5 per page, 1 for builds over 2000 runs)." + }, + "status": { + "type": "object", + "description": "Status aggregate." + }, + "workflowStatus": { + "type": "object", + "description": "Workflow-status aggregate." + }, + "isArchived": { + "type": "boolean" + }, + "smartTagSettings": { + "type": "object" + }, + "source": { + "type": "string" + }, + "folder_path": { + "type": "string" + } } - } } - } }, - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "nextRoot": { - "type": "object", - "properties": { - "page": { - "type": "integer", - "description": "Next page, re-based to 0-index for the FE (upstream next page - 1)." + "TestRunCountListResponse": { + "description": "Per-unique-test rerun counts for a build.", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$schema": "TestRunCountEntry" + } + } } - } } - } - } - } - } - }, - "AccessibilityTestMetaResponse": { - "description": "Per-test a11y detail. NOTE: for App-Accessibility groups whose run source is the App-Accessibility automation source, the call is proxied and the body is the Accessibility service's shape instead.", - "schema": { - "type": "object", - "properties": { - "totalIssues": { - "type": "integer" - }, - "severityBreakdown": { - "type": "object", - "description": "Counts keyed by critical/serious/moderate/minor." - }, - "reportURL": { - "type": "string", - "description": "URL of the a11y report to fetch elsewhere." - }, - "score": { - "type": "number" - }, - "testCaseUuid": { - "type": "string" - } - } - } - }, - "SplitBuildLegsResponse": { - "description": "Paginated child legs of a split/parallel build (cursor by build uuid).", - "schema": { - "$schema": "SplitBuildLegs" - } - }, - "BuildNotificationSummaryResponse": { - "description": "Compact build notification/email summary. HTTP 200 with a NULL body when the build id is unknown.", - "schema": { - "$schema": "BuildNotificationSummary" - } - }, - "BuildStabilityTileResponse": { - "description": "Build stability tile.", - "schema": { - "$schema": "BuildStabilityTile" - } - }, - "BuildSummaryStatsResponse": { - "description": "Build headline test-count strip.", - "schema": { - "$schema": "BuildSummaryStats" - } - }, - "BuildTimelineStatsResponse": { - "description": "Build execution timeline series.", - "schema": { - "$schema": "BuildTimelineStats" - } - }, - "BuildDetailsResponse": { - "description": "Full single-build detail with UI gates.", - "schema": { - "$schema": "BuildDetails" - } - }, - "BuildFailureCategoryStatsResponse": { - "description": "Failure-category breakdown. Empty data + explanatory message (still 200) while the build is pending.", - "schema": { - "$schema": "BuildFailureCategoryStats" - } - }, - "BuildFailuresByModuleResponse": { - "description": "Failures grouped by module/file path.", - "schema": { - "$schema": "BuildFailuresByModule" - } - }, - "BuildFilterValuesResponse": { - "description": "Older compact build filter-values map.", - "schema": { - "$schema": "BuildFilterValues" - } - }, - "BuildTestFoldersResponse": { - "description": "Build test-listing folder tree; root-folders or folder-detail shape depending on folder_path.", - "schema": { - "$schema": "BuildTestFolders" - } - }, - "BuildNonSdkTestCountsResponse": { - "description": "Data-hygiene counters. The body is a JSON STRING that must be parsed to read the three count keys.", - "schema": { - "$schema": "BuildNonSdkTestCounts" - } - }, - "SingleMetricSummaryResponse__w4_build2": { - "description": "Single-metric tile (flaky / muted / new-failure).", - "schema": { - "$schema": "SingleMetricSummary__w4_build2" - } - }, - "SidebarUserFilterResponse": { - "description": "Users offered by the build-list sidebar Users facet. `data` is an unordered, de-duplicated set capped at 100.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "User id." - }, - "name": { - "type": "string", - "description": "User's full name (the wire field is `name`, populated from the user's full name)." - } - } - } - } - } - } - }, - "SidebarTeamFilterResponse": { - "description": "Teams offered by the build-list sidebar Teams facet, resolved via the BrowserStack teams service. `data` is unordered and capped at 100 owning sub-groups.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Team id (BrowserStack team / sub-group id)." - }, - "name": { - "type": "string", - "description": "Team name, from central/rails team data." - } - } - } - } - } - } - }, - "SidebarTestCaseStatusesResponse": { - "description": "Raw pass-through of Test Management's custom-fields API for entity `TestResult` (the configured manual test-result status options) for the selected projects. Returned verbatim, NOT wrapped in the {data:[...]} envelope the other sidebar facets use.", - "schema": { - "type": "object", - "description": "Opaque TestHub custom-fields JSON; keys and nesting are defined by Test Management, not by this service, and are returned as received." - } - }, - "HomeUserFilterOptionsResponse": { - "description": "Selectable values for one home/overview personal-filter dropdown, keyed by the requested `key`.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "meta": { - "type": "string", - "nullable": true, - "description": "Null on this route." - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - }, - "count": { - "type": "integer", - "nullable": true, - "description": "Occurrence count; null for buildStatus/sources." - }, - "pId": { - "type": "string", - "nullable": true - } - } - } - } - } - } - }, - "WebhookFilterOptionsResponse": { - "description": "Webhook rule-builder filter options for the requested `type`, with a composite-aggregation paging cursor.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string", - "description": "Display label; for `folder` it is 'name(count)'." - }, - "value": { - "type": "string", - "description": "The value to store in the rule; for `folder` it is the full path." - }, - "pId": { - "type": "string", - "description": "For `folder`, the parent folder path; for other types it echoes the value." - }, - "count": { - "type": "integer", - "description": "Matching document count for the value.", - "nullable": true - }, - "id": { - "type": "string", - "description": "Option id (duplicates value)." - }, - "meta": { - "type": "string", - "nullable": true - }, - "projectId": { - "type": "integer", - "nullable": true - }, - "projectName": { - "type": "string", - "nullable": true - }, - "thProjectId": { - "type": "integer", - "nullable": true - } - } - } - }, - "afterKey": { - "type": "string", - "description": "The last key of this page. It is still set on the final page, so stop when a page returns fewer rows than the page size (10; 1000 for folder) or an empty `data`.", - "nullable": true - } - } - } - }, - "SuiteHealthTestListResponse": { - "description": "Stability & Performance test listing (Test Health v2 / Suite Health v3 \u2014 identical envelope).", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "pageNumber": { - "type": "integer", - "description": "Next page index." - }, - "hasNext": { - "type": "boolean" + }, + "BuildTopErrorStatsResponse": { + "description": "Top-unique-errors insight panel; may be not-ready (ready:false + message) while analysis/clustering runs.", + "schema": { + "$schema": "BuildTopErrorStatsPanel" } - } }, - "tests": { - "type": "array", - "items": { - "$schema": "SuiteHealthTestListItem" - } + "BuildQualityGateRecalcAckResponse": { + "description": "Async acknowledgement; poll the quality-gate summary for the outcome.", + "schema": { + "$schema": "BuildQualityGateRecalcAck" + } }, - "automateTestsSkipped": { - "type": "boolean", - "description": "True when Automate tests were hidden by source feature-fencing." + "ResolvedBuildUuidResponse": { + "description": "Resolved build uuid, or {uuid:null} with HTTP 200 when nothing matched.", + "schema": { + "$schema": "ResolvedBuildUuid" + } }, - "appAutomateTestsSkipped": { - "type": "boolean", - "description": "True when App Automate tests were fenced out." + "SidebarBuildRunsResponse": { + "description": "A page of unique build runs for the sidebar rail with cursor paging params.", + "schema": { + "$schema": "SidebarBuildRuns" + } }, - "lcncTestsSkipped": { - "type": "boolean", - "description": "True when Low-Code/No-Code tests were fenced out." - } - } - } - }, - "SuiteHealthStatsResponse": { - "description": "Aggregate stat tiles for one test.", - "schema": { - "type": "object", - "properties": { - "reliability": { - "type": "number", - "description": "Failure percentage (higher = worse)." - }, - "duration": { - "type": "number", - "description": "Average run duration." - }, - "failureOccurrence": { - "type": "object", - "properties": { - "failed": { - "type": "integer" - }, - "total": { - "type": "integer" + "BuildReportPollingResponse": { + "description": "Build-report polling envelope; IN_PROGRESS carries retryCount + pollingInterval. May be a NULL body when the build cannot be resolved. Demo-sandbox returns 404 'NOT FOUND'.", + "schema": { + "$schema": "BuildReportPolling" + } + }, + "PublicBuildDetailsResponse": { + "description": "Full public build record. Shape is feature-flagged (legacy vs V2) and pro-only fields are JsonView-gated by plan.", + "schema": { + "$schema": "PublicBuildDetails" + } + }, + "BuildHistoryWithDefectCountsResponse": { + "description": "Anchor build + up to 10 prior same-name runs, each with status and defect count summaries.", + "schema": { + "$schema": "BuildHistoryWithDefectCounts" } - } - } - } - } - }, - "TestHealthTrendResponse": { - "description": "Legacy Test Health per-execution trend. data points are minimal (no smart-tag flags / workflow status).", - "schema": { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "startedAt epochs, parallel to data." - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "color": { - "type": "string" - }, - "y": { - "type": "integer", - "description": "Run duration (ms); 0 when absent." - }, - "id": { - "type": "integer", - "description": "Test-run id." - }, - "buildId": { - "type": "integer", - "description": "Build serial id." - }, - "buildName": { - "type": "string" - } - } - } - } - } - } - }, - "SuiteHealthTrendResponse": { - "description": "Suite Health per-execution trend with rich points.", - "schema": { - "type": "object", - "properties": { - "keys": { - "type": "array", - "items": { - "type": "integer" - }, - "description": "startedAt epochs, parallel to data." - }, - "data": { - "type": "array", - "items": { - "$schema": "SuiteHealthTrendPoint" - } } - } - } - }, - "SuiteHealthTestInfoResponse": { - "description": "Header metadata for one test's Suite Health detail page.", - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Test display name; empty string when no run matched." - }, - "combinations": { - "type": "array", - "description": "OS/browser/device combinations the test ran on (non-displayable combos filtered out).", - "items": { - "type": "object", - "properties": { - "os": { - "type": "object", - "description": "Platform display object." - }, - "browser": { - "type": "object", - "description": "Browser display object; device nested as an object.", - "properties": { - "name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "device": { - "type": "object" - } + }, + "paging": { + "GET /api/v1/projects/{projectName}/settings/quality-gates/filters": { + "page": "afterKey", + "max": 1000 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/stability": { + "page": "pageNumber", + "max": 8 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/performance": { + "page": "pageNumber", + "max": 8 + }, + "GET /api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns": { + "page": "pageNumber", + "max": 5 + }, + "GET /api/v1/settings/webhooks/projects": { + "page": "page", + "max": 30 + }, + "GET /api/v1/dashboards/filters/{widgetType}": { + "page": "page" + }, + "GET /api/v1/reports/list": { + "size": "size", + "max": 100 + }, + "GET /ext/v1/projects": { + "max": 10 + }, + "GET /api/v1/home/uniqueBuilds": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{normalisedName}/build-groups-listing": { + "page": "searchAfter" + }, + "GET /ext/v1/builds/{build_id}/testRuns": {}, + "GET /ext/v1/builds/{build_id}/testRuns/withAttachments": { + "max": 100 + }, + "GET /api/v1/builds/{buildUuid}/testRuns/flat": { + "page": "searchAfter", + "max": 10000 + }, + "GET /api/v1/ai/failures/{buildUUID}/flat": { + "page": "searchAfter", + "size": "limit" + }, + "GET /ext/v1/ai/failures/{buildUUID}/flat": { + "page": "searchAfter", + "size": "limit" + }, + "POST /api/v1/dashboards/views/widgets/filters": { + "page": "page" + }, + "POST /api/v1/reports/{id}/datasets": { + "page": "page" + }, + "GET /api/v1/reports/custom-fields": { + "page": "page", + "max": 300 + }, + "GET /api/v1/reports/{projectNormalisedName}/builds": { + "page": "page" + }, + "GET /api/v1/reports/view/{globalTestHash}/details/testsLists": { + "page": "page" + }, + "POST /api/v1/reports/view/{id}/search": { + "page": "page" + }, + "POST /api/v1/dashboards/widgets/filters": { + "page": "page" + }, + "POST /api/v1/search": { + "max": 30 + }, + "GET /api/v1/projects/{projectName}/snp/v2/errors": { + "page": "pageNumber", + "max": 15 + }, + "GET /api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds": { + "page": "searchAfter", + "max": 15 + }, + "GET /api/v1/projects/{projectId}/build-groups": { + "page": "page", + "size": "pageSize" + }, + "GET /api/v1/builds/v2/{buildId}/a11y-testRuns": { + "page": "page", + "size": "limit" + }, + "GET /api/v1/testRuns/{btcerId}/a11y-testMeta": { + "page": "page" + }, + "GET /api/v1/builds/{uuid}/getSplitBuildDetails": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/folder": { + "size": "size" + }, + "GET /ext/v1/projects/{project_id}/builds": { + "max": 10 + }, + "GET /api/v1/projects/{normalisedName}/builds/v2": { + "page": "searchAfter" + }, + "GET /api/v1/projects/{projectName}/settings/webhooks/filters": { + "page": "afterKey", + "max": 1000 + }, + "GET /api/v1/settings/webhooks/filters": { + "page": "afterKey", + "max": 1000 + }, + "POST /api/v2/projects/lite": { + "max": 10 + }, + "GET /api/v2/projects/lite/v2": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v2/projects/v2": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v1/projects/{projectName}/snp/v2/tests": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{projectName}/snp/v3/tests": { + "page": "pageNumber", + "max": 10 + }, + "GET /api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds": { + "max": 20 + }, + "GET /api/v1/testRuns/{testRunId}/testCases": { + "page": "searchAfter", + "max": 100 + }, + "GET /api/v1/builds/{buildId}/testRunLogs": { + "max": 15 + }, + "GET /api/v1/builds/v2/{buildId}/flatTestRuns": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/testRuns": { + "page": "searchAfter" + }, + "GET /api/v1/builds/v2/{buildId}/testRuns/hierarchy": { + "page": "searchAfter" + }, + "POST /api/v2/builds": {} + }, + "entities": { + "notification": { + "entity": "notification", + "title": "Notification (and notifier)", + "description": "A project's alerting config: recipients (email/Slack notifiers) and their daily-summary, build-insight and alert subscriptions.", + "aliases": [ + "notification", + "notifications", + "notifier", + "notification settings", + "notification recipient", + "recipient", + "subscription", + "email notification", + "slack notification", + "daily summary", + "daily summary email", + "build insight notification", + "alert subscription", + "notification preferences" + ], + "id_convention": "A notifier id is a plain integer, discovered from the project's recipient list (there is no prefixed identifier). The per-alert and per-build-name mapping ids used when toggling a single subscription are also plain integers, found by reading one notifier's configuration first.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "scoped to one project" + }, + { + "entity": "alert", + "via": "a notifier subscribes to a project's alerts by alert id" + }, + { + "entity": "build", + "via": "build-insight subscriptions are keyed by build name" + } + ], + "capabilities": [ + "add_notification_recipients", + "delete_notification_subscriptions", + "delete_slack_notifiers", + "get_build_name_status_aggregation", + "get_daily_summary_report", + "get_my_email_notification_subscription", + "get_notifier_notification_config", + "get_project_email_notification_preferences", + "list_build_summaries_by_ids", + "list_home_notification_feed", + "list_latest_build_summaries", + "list_project_alerts_for_subscription", + "list_project_notification_recipients", + "resolve_notification_recipient_emails", + "send_slack_test_notification", + "set_user_email_notification_preferences", + "toggle_notification_subscriptions", + "toggle_notifier_channel_status", + "update_notifier_subscriptions", + "update_slack_notifier_channels" + ], + "key_facts": [ + "Every project has two independent notifier channels, email and Slack, and almost every read and write acts on ONE channel that you must name; the same project can have a completely different set of email recipients and Slack recipients.", + "Reading the recipient list or a single recipient's configuration needs only project read access; every change to recipients or subscriptions needs the project's notification-settings permission, and a change to a recipient owned by another group is refused.", + "Editing one recipient's subscriptions is a FULL REPLACE, not a patch: subscriptions you omit are removed, and there is no version history, so a prior configuration cannot be restored once overwritten.", + "Bulk enabling or disabling of subscriptions is applied one entry at a time with no overall transaction, so a single bad entry aborts partway and leaves the earlier entries already changed.", + "A Test Management caller addresses a project by its Test Management project id, which is translated to the underlying observability project id; an id with no mapping reads as project-not-found rather than empty.", + "In a recipient-list summary, a subscription count of -1 is a sentinel meaning the recipient is subscribed to all builds or all alerts, not a real count.", + "The daily-summary email content builders select builds themselves (the latest few in a time window) unless you hand them explicit build ids; the fully enriched daily report also merges several aggregations plus best-effort AI summaries that are silently dropped when they fail, so those fields can be absent.", + "Two of the notification settings operations address the project by its normalised NAME instead of its numeric id.", + "Validating typed-in recipient email addresses is not a side-effect-free lookup: for each address that resolves to an in-group user it also provisions that user's email-integration records if they do not exist yet.", + "The home notification feed is capped to roughly the first fifty entries however deep you page, and when the feature is switched off for the group it returns an empty payload rather than an error.", + "Sending a test Slack notification posts a fixed sample message to the channel and stores nothing; only the daily-summary, build-insight and alert sample types are supported and it is suppressed in the demo-sandbox environment." + ] + }, + "public_link": { + "entity": "public_link", + "title": "Public link & status badge", + "description": "Anonymous share credentials: public-link tokens for dashboards and project report pages, plus signed keys for build-status badges.", + "aliases": [ + "public link", + "share link", + "shareable link", + "anyone with the link", + "public token", + "share token", + "public sharing", + "status badge", + "build badge", + "badge", + "readme badge", + "shields badge", + "badge key" + ], + "id_convention": "A public-link token is an opaque server-generated string, discovered only by calling the get-token operation for a dashboard or project (there is no list). A status-badge key is a signed JWT minted on demand. Dashboards are addressed by UUID; projects by their TestHub project id.", + "parents": [ + "project", + "dashboard" + ], + "relations": [ + { + "entity": "dashboard", + "via": "one token per dashboard (page type CUSTOM_DASHBOARD)" + }, + { + "entity": "project", + "via": "one token per (project, page type)" + } + ], + "capabilities": [ + "get_dashboard_public_link_setting", + "get_dashboard_public_link_token", + "get_project_public_link_setting", + "get_project_public_link_token", + "get_status_badge_key", + "update_dashboard_public_link_setting", + "update_project_public_link_setting", + "validate_public_link_token" + ], + "key_facts": [ + "Two kinds of share credential live here: a public-link token that unlocks a dashboard or a project report page for anyone with the URL, and a status-badge key (a signed JWT) that exposes one build's high-level status for embedding.", + "Asking for a token is itself what starts sharing: the fetch creates the token on first request and leaves it enabled. There is no separate create step, so a plain read has a side effect the first time.", + "The public-link setting defaults to OFF. A dashboard or project whose setting has never been touched reads as not shared rather than as an error.", + "Turning sharing off immediately breaks every share URL already handed out and clears the token's cached validation, so revocation takes effect at once. Turning it back on reactivates the SAME token, so it is reversible but disruptive; there is no separate undo.", + "A project token is scoped to a single report page type (build insights, test runs, test health, unique errors or testing trends). Custom dashboards are a distinct page type reached only through the dashboard token operation.", + "A status-badge key is a bearer credential: once minted it cannot be revoked or listed, it exposes that build's status to anyone holding it, and it is refused for free-plan users.", + "Projects are named here by their TestHub project id, which is not the same as the observability project id." + ] + }, + "quality_gate": { + "entity": "quality_gate", + "title": "Quality gate (and quality profile)", + "description": "A pass/fail policy on a build, made of quality profiles: named rule sets (thresholds on status, flakiness, failure categories, etc.) scop...", + "aliases": [ + "quality gate", + "quality gates", + "QG", + "gate", + "quality profile", + "quality profiles", + "qg profile", + "quality gate profile", + "gate profile", + "quality gate rule set", + "build quality gate" + ], + "id_convention": "A quality profile is addressed by its UUID (path `profile_id`/`profileUuid`); a project by its normalised name; a build by its UUID. There are no integer ids on these routes. The one built-in default profile has a fixed UUID and behaves specially.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "project", + "via": "profiles are mapped to a project; project attributes hold the feature enabled/override switches" + }, + { + "entity": "build", + "via": "the gate is evaluated per build; results are read from the parent build for a split build's child" + } + ], + "capabilities": [ + "create_quality_profile", + "create_quality_profile_via_public_api", + "delete_quality_profile", + "delete_quality_profile_via_public_api", + "get_build_quality_gate_result", + "get_build_quality_gate_summary", + "get_build_quality_profile_result", + "get_project_quality_gate_settings", + "get_project_quality_gate_settings_via_public_api", + "get_quality_profile", + "get_quality_profile_via_public_api", + "list_build_quality_profile_results", + "list_quality_profile_filter_values", + "toggle_project_quality_gate", + "toggle_project_quality_gate_via_public_api", + "toggle_quality_profile", + "toggle_quality_profile_via_public_api", + "update_quality_profile", + "update_quality_profile_via_public_api" + ], + "key_facts": [ + "Editing a quality profile does NOT change it in place: it writes a new version and mints a NEW UUID, which the response returns. The UUID you edited stops resolving afterwards, so always carry the returned UUID forward. (The source inventory's claim that the UUID is stable is wrong.)", + "Deleting a quality profile is not reversible through the API. The profile is retired and its project mapping removed; there is no restore call, so treat it as permanent.", + "The one built-in default profile can be neither edited nor deleted (attempts are rejected). Enabling or disabling a default profile applies only to the one project, whereas enabling or disabling a custom profile applies across every project it is attached to.", + "There are two separate switches: one turns Quality Gates on or off for a whole project (and sets whether a failed gate also marks the build failed), the other enables or disables a single profile. They are different operations even though they share a name internally.", + "On the create and edit body the two fields go on the wire as `isGlobalProfile` and `applicableBuilds`; the internal names `isRootProfile` and `buildFilters` are not accepted and, if sent, are dropped and then fail the required-field check.", + "A profile must carry between 1 and 20 rules, and every rule must include a client-generated UUID `id`; a rule with an out-of-range test status, threshold, smart tag or alert level is rejected.", + "Quality gate evaluation runs asynchronously after a build finishes, so a build's gate result reads back as 'running'/pending until it completes and a CI check must poll rather than assume a single call is final.", + "A profile's ruleStatus ('pass' vs 'fail') decides how the per-profile outcome is computed: 'pass' means every rule must hold, 'fail' means none may hold.", + "Quality Gates is a plan-gated feature: when the plan lacks it, profile reads and writes are refused as feature-inaccessible, which is a plan gap and not a missing profile. The project-level on/off switch and the build-page result reads skip that plan check.", + "For a split build's child, gate results are read from the parent build, so a child on its own can look like it has no results.", + "Failure-category rules are enriched with the project's human display names only on the single-profile detail reads, not on the list or settings reads.", + "A profile can only be deleted from the group that owns it; deleting one that belongs to another group is refused." + ] + }, + "testing_trends": { + "entity": "testing_trends", + "title": "Testing Trends", + "description": "Aggregated quality-trend cards for a project - flakiness, stability, performance, failures and coverage over a time window.", + "aliases": [ + "testing trends", + "trends", + "test trends", + "quality trends", + "trend widgets", + "trend cards", + "insights dashboard", + "flakiness", + "stability", + "cbt coverage", + "test growth", + "build frequency" + ], + "id_convention": "Not addressed by its own id. Scoped by the project's normalised name in the path and narrowed by build name. Chart and leaderboard rows are addressed by build name as the identifier, or by the literal \"all\" for the project-wide series.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "buildName filter / row identifier" + } + ], + "capabilities": [ + "get_always_failing_tests_trend", + "get_build_frequency", + "get_build_performance_chart", + "get_build_stability_chart", + "get_cross_browser_coverage", + "get_failure_categories_trend", + "get_flakiness_trend", + "get_new_failures_trend", + "get_project_performance_chart", + "get_project_stability_chart", + "get_test_executions_trend", + "get_test_growth_trend", + "list_build_names", + "list_performance_leaderboard", + "list_recent_runs", + "list_stability_leaderboard" + ], + "key_facts": [ + "Every card is scoped to one project and a time window given as two epoch-millisecond bounds. When a bound is omitted it defaults to the plan's data-retention window, so a call with no window still returns the retention window's data.", + "The window is silently clamped to the plan's data retention: asking for data older than the plan retains returns retention-bounded results with no error and nothing in the body to say the window was narrowed.", + "Time-bucket granularity (hour / day / week) is chosen automatically from the width of the window, so the series interval changes as the window widens; the caller cannot set it.", + "The flakiness, new-failures and always-failing cards are plan-feature-gated: on a plan without that widget the call is refused with a 403, it does not return empty data.", + "Build frequency is the one card that requires both window bounds and does NOT clamp them to retention; its counts come from the relational build store rather than the analytics index, so they can differ slightly from the other cards.", + "Opening the recent-runs picker records the project as subscribed for the calling user - a persisted user preference, so that read has a lasting side effect.", + "Any card can be narrowed to a single build by its build name; the leaderboards and charts address a specific build by that name, or use the literal \"all\" for the project-wide series.", + "Leaderboards return a fixed number of builds per page and expose only a zero-based page number, not a page size; a hasNext flag in the response tells you whether to fetch the next page.", + "The always-failing card also records the trends view in the calling user's recently-viewed list for non-superusers, a minor convenience side effect that does not change any test data.", + "Test-growth counts distinct test cases (suite size) over the window, not test executions; test-executions counts how many tests ran. They answer different questions despite looking similar." + ] + }, + "webhook": { + "entity": "webhook", + "title": "Webhook rule", + "description": "A stored rule that fires an outbound HTTP request to a target URL when a matching build/test event occurs.", + "aliases": [ + "webhook", + "webhooks", + "webhook rule", + "webhook rules", + "hook", + "callback", + "notification", + "notifications", + "webhook notification", + "event webhook", + "outbound notification" + ], + "id_convention": "Addressed by the rule's UUID; there is no prefixed display id. Discover the UUID from a list call \u2014 it is not the integer database id.", + "parents": [ + "project", + "group" + ], + "relations": [ + { + "entity": "project", + "via": "project mapping (a group rule links up to 30 projects; a project rule is scoped to one)" + } + ], + "capabilities": [ + "create_group_webhook", + "create_project_webhook", + "delete_group_webhook", + "delete_project_webhook", + "get_group_webhook", + "get_project_webhook", + "list_group_webhook_projects", + "list_group_webhooks", + "list_project_webhooks", + "test_group_webhook", + "test_project_webhook", + "toggle_group_webhook", + "toggle_project_webhook", + "update_group_webhook", + "update_project_webhook" + ], + "key_facts": [ + "A webhook rule is addressed by its UUID, which you read from a list call; there is no prefixed display id and it is not the integer database id.", + "There are two parallel surfaces: project-scoped rules and group-scoped rules that can span many projects. A rule created on one surface is managed on that surface, and the two lists do not overlap.", + "Listing or reading webhook rules needs no special permission, but creating, editing, toggling, deleting or testing one requires the webhook settings permission \u2014 so an account can see rules it is not allowed to change.", + "Editing a rule replaces its definition in place and keeps no history: an overwritten URL, headers, auth or trigger conditions cannot be recovered. There is no undo short of re-entering the previous values.", + "Deleting a rule is permanent and also removes its project mappings; disable the rule instead if the intent is only to pause it.", + "Every create, edit, toggle, delete and test is refused in the demo-sandbox environment.", + "Testing a rule sends a real outbound HTTP request to the target URL (a server-side call whose side effects land on the target system), and it always reports success even when the target failed \u2014 read the isError flag in the body, never the HTTP status, to know if it worked.", + "A list of rules returns only each rule's summary (name, target URL, event type, enabled flag); the headers, request body and stored auth are returned only when you fetch a single rule by id.", + "A group rule links at most 30 projects. The 'all projects' scope is available only to Test Management callers; other callers are refused if they try to enter it.", + "Newly created project-scoped rules are always enabled regardless of the enabled flag sent; use the toggle operation to disable one afterwards.", + "A rule's target auth can be none, basic (username/password) or bearer token, and the request method is limited to POST or PUT." + ] + }, + "ai_insight": { + "entity": "ai_insight", + "title": "AI insight (RCA & code-fix)", + "description": "AI-generated root-cause analysis for a failed test run or build, plus the code-fix guidance and session tokens that drive it.", + "aliases": [ + "rca", + "root cause analysis", + "ai rca", + "ai insight", + "ai analysis", + "test failure analysis", + "tfa", + "ai code fix", + "code fix preference", + "failure analysis", + "ai token" + ], + "id_convention": "No single id. RCA is addressed by the test-run integer id or the build uuid; code-fix preferences by a plain integer id discovered through the get-preference call; RCA conversation turns by a UUID message id.", + "parents": [ + "test_run", + "build", + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "per-test-run RCA and improve-analysis" + }, + { + "entity": "build", + "via": "build-level RCA and RCA chat" + }, + { + "entity": "project", + "via": "code-fix preferences per build name" + } + ], + "capabilities": [ + "create_code_fix_preference", + "delete_code_fix_preference", + "get_ai_orchestration_capabilities", + "get_ai_session_token", + "get_code_fix_preference", + "get_rca_context_attachment_download_url", + "get_release_readiness_connect_state", + "get_release_readiness_prs", + "get_release_readiness_summary", + "get_release_readiness_workflows", + "presign_build_chat_upload", + "presign_code_fix_preference_upload", + "presign_rca_context_upload", + "retrigger_rca_analysis", + "retrigger_rca_analysis_via_public_api", + "submit_ai_workflow_callback", + "trigger_release_readiness", + "trigger_release_readiness_via_public_api", + "update_code_fix_preference" + ], + "key_facts": [ + "AI features are plan-gated: on a group without the AI test-failure-analysis plan the code-fix and RCA-context calls are refused with 403 PLAN_FEATURE_NOT_ALLOWED, and the session-token call returns empty token and feature list rather than erroring.", + "Some AI actions also require the calling user to have AI enabled individually, separate from the group plan; the per-user check applies to saving and presigning code-fix guidance but not to deleting it.", + "Improving (retriggering) an RCA is asynchronous: the call returns a message id and the refreshed analysis is delivered later, so poll the RCA conversation for the result rather than expecting it in the response.", + "Attaching a file is always a two-step flow: first mint an upload credential, upload the file straight to S3 with it, then quote the returned key when saving a preference or retriggering. The credential step persists nothing on its own.", + "Uploaded files land in an unconfirmed area and are kept only when a later save or turn submission references their key; an upload that is never referenced is discarded, and there are caps on how many uploads can be pending.", + "Only agentic RCAs accept extra context uploads; a non-agentic analysis rejects them.", + "RCA context uploads are refused for test runs older than an allowed age.", + "Code-fix guidance is keyed by build name within a project, one preference per build name; creating a second for the same build name is refused as already existing, so change it with an update instead.", + "Deleting code-fix guidance also removes its uploaded files and cannot be undone; the RCA analyses themselves keep no version history." + ] + }, + "build": { + "entity": "build", + "title": "Build", + "description": "A single CI/automation run reported to Test Observability, grouping the test runs executed together with their aggregate results.", + "aliases": [ + "build", + "ci build", + "ci run", + "automation build", + "test build", + "build run", + "launch", + "ci run id", + "build run id" + ], + "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier. A build ALSO carries a CI run id (`buildRunId`, a string set by the CI/upload client), which is NOT the serial number (#N): users often say \"run \" meaning buildRunId. Resolve a bare run number with search_entities (entity=\"Build\", days<=90): each hit carries both `buildRunId` and `serialId` plus the project; get_build_id_from_build_coordinates only understands the serial.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "a build contains the test runs executed in it" + }, + { + "entity": "project", + "via": "each build belongs to one project" + } + ], + "capabilities": [ + "archive_project_builds", + "bulk_close_builds", + "close_build", + "delete_build_via_public_api", + "delete_project_builds", + "generate_build_report_via_public_api", + "get_build_accessibility_meta", + "get_build_ai_failure_summary", + "get_build_alerts", + "get_build_always_failing_stats", + "get_build_attempt_run_stats", + "get_build_authoring_report_url", + "get_build_coordinates_from_uuid", + "get_build_details", + "get_build_details_via_public_api", + "get_build_failure_category_stats", + "get_build_failures_by_module", + "get_build_filter_values", + "get_build_flaky_stats", + "get_build_history", + "get_build_history_via_public_api", + "get_build_id_from_build_coordinates", + "get_build_insights", + "get_build_muted_stats", + "get_build_new_failure_stats", + "get_build_nl_automation_agent_report_url", + "get_build_non_sdk_test_counts", + "get_build_notification_summary", + "get_build_optimization_data", + "get_build_rerun_stats", + "get_build_self_healing_report_url", + "get_build_self_healing_report_url_via_public_api", + "get_build_smart_test_selection_report", + "get_build_stability_tile", + "get_build_summary_stats", + "get_build_timeline_stats", + "get_build_top_error_stats", + "get_latest_build", + "get_split_build_rerun_stats", + "list_build_test_folders", + "list_project_builds", + "list_project_builds_via_public_api", + "list_running_builds_on_device", + "list_sidebar_build_runs", + "list_similar_test_runs", + "list_split_build_legs", + "mark_build_rerun_expected", + "recalculate_build_quality_gate", + "resolve_build_uuid_from_identifiers", + "stop_build", + "submit_sdk_instrumentation", + "trigger_build_rerun", + "update_build_metadata", + "update_build_metadata_via_public_api" + ], + "key_facts": [ + "Most build endpoints are keyed by the build UUID, a string. A few instead take the internal numeric build id (the AI failure summary is one), and passing a UUID where a numeric id is expected simply fails to find the build.", + "A build locked by plan fencing is refused on most insight reads with a plan-feature-not-allowed error even though the build exists; treat that as an entitlement gap, not a missing build.", + "An unknown build UUID comes back as a build-not-found error on most reads.", + "Cross-group access is blocked: a build whose owning group does not match the caller reads as not-found on most routes (and as a forbidden error on the coordinate lookups).", + "A build can be reached by its human coordinates from the dashboard URL (project name + build name + build serial number), which resolve to the UUID; the reverse lookup rebuilds those coordinates from a UUID.", + "Closing a build cannot be undone through the API \u2014 there is no reopen operation \u2014 so treat a close as final.", + "Many insight tiles and history reads accept the same standard build filter set (search text, status, folders, tags, browser/OS, priority, owner, flaky/new-failure/always-failing toggles, and so on); the filter narrows which of the build's tests the metric is computed over.", + "On demo-sandbox environments some report reads (the authoring report, the optimisation widget) return canned data instead of touching the real backend.", + "Re-run statistics come in two shapes: a flat legacy summary and a split-build-aware grouped summary; the grouped one is the one to use for split or parent/child builds." + ] + }, + "build_filter": { + "entity": "build_filter", + "title": "Build filter", + "description": "Filter-option sources for the build listing: the static option catalogue plus per-facet type-ahead value lists.", + "aliases": [ + "build filter", + "build filters", + "build list filter", + "build listing filter", + "filter options", + "filter values", + "filter bar", + "sidebar filter", + "facet", + "filter dropdown", + "branch filter", + "build name filter", + "run group filter" + ], + "id_convention": "Not an addressable record \u2014 there is no filter id. A filter view is fetched by scope: one project by its normalised name (a URL-safe slug, discovered from a project read/list), or a set of integer project ids sent in a POST filter body.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "option values are aggregated from the builds the filter scope resolves to" + }, + { + "entity": "project", + "via": "scoped by project normalised name or project id set" + } + ], + "capabilities": [ + "get_build_list_filters", + "get_build_sidebar_filters", + "get_build_test_filters", + "get_home_personalization_filters", + "list_build_branch_filter_options", + "list_build_tag_filter_options", + "list_build_team_filter_options", + "list_build_user_filter_options", + "list_group_webhook_filter_options", + "list_home_filter_options", + "list_project_webhook_filter_options", + "list_sidebar_branch_filter_options", + "list_sidebar_build_name_filter_options", + "list_sidebar_run_group_filter_options", + "list_sidebar_tag_filter_options", + "list_sidebar_team_filter_options", + "list_sidebar_test_case_status_filter_options", + "list_sidebar_user_filter_options", + "save_home_personalization_filters" + ], + "key_facts": [ + "Filters are not stored objects you address by id. You fetch either a filter catalogue or a single facet's option list, scoped to one project (by its normalised-name slug) or to a set of project ids passed in a filter body.", + "Two families exist. A per-project set is scoped by the project's normalised name and searched with a simple prefix. A cross-project 'sidebar' set takes a full build filter body and returns only the option values that actually occur under the currently applied selection across the chosen projects.", + "The per-project facet lists return option objects that carry separate id, value and label (id is what you send back as the filter selection, label is for display). The cross-project sidebar facet lists return bare values instead (plain strings for branches, build names and tags).", + "Facet option values come from an Elasticsearch terms aggregation capped at 100 entries, so a facet list is a type-ahead suggestion set, not a guaranteed-complete inventory of every value in the project.", + "A filter view echoes the caller's currently applied selection back in a resolved 'applied' block (user and team ids turned into names, date range, tags, branches, statuses) alongside a static catalogue of all selectable option lists.", + "On a freemium workspace some filters are locked behind a paid plan; the build test-filter payload names those locked keys in a separate list rather than dropping them, so a caller can grey them out instead of losing them.", + "For a split build, the test-filter payload is resolved across the parent build and all of its child legs together, not just the single leg addressed.", + "The framework filter carries raw values: branded SDK names and pass-through non-SDK values such as 'appium' or 'selenium-4'. Matching is case-insensitive, so send the value exactly as the static catalogue offers it." + ] + }, + "dashboard": { + "entity": "dashboard", + "title": "Custom dashboard", + "description": "A saved, shareable analytics page of widgets and global filters over test-observability and test-management data.", + "aliases": [ + "dashboard", + "dashboards", + "custom dashboard", + "custom dashboards", + "analytics dashboard", + "TO dashboard", + "TCM dashboard", + "report", + "reports", + "dashboard listing", + "reports listing" + ], + "id_convention": "UUID (e.g. 07c9f7be-0cd7-4669-8d3d-a1adff0b2aec); discover ids from the dashboard listing \u2014 a dashboard name is never accepted in place of the id.", + "parents": [], + "relations": [ + { + "entity": "widget", + "via": "widgets" + } + ], + "capabilities": [ + "clone_dashboard", + "clone_dashboard_via_public_api", + "create_dashboard", + "create_dashboard_via_public_api", + "delete_dashboard", + "delete_dashboard_via_public_api", + "get_dashboard", + "get_dashboard_via_public_api", + "list_dashboards", + "list_dashboards_via_public_api", + "list_reports", + "list_widget_custom_field_filters", + "resolve_dashboard_filter_names", + "search_unique_errors", + "update_dashboard", + "update_dashboard_via_public_api" + ], + "key_facts": [ + "A dashboard is addressed by its UUID id; get the id from the dashboard listing first \u2014 a dashboard name cannot be used as the identifier.", + "The dashboard listing and the internal dashboard reads require the Custom Dashboard feature entitlement; without it the listing is refused as feature-inaccessible rather than returning an empty list.", + "Test Management (TCM) dashboards are hidden from the default listing, which is scoped to Test Observability (source TO); ask for source TCM to see them (or the group must have GCD enabled).", + "Dashboards keep no version history: an overwritten name, visibility level, widget layout or filter set cannot be restored, and a deleted dashboard is gone permanently along with its widget layout.", + "A dashboard's visibility level is 1 or 2 and decides whether only the owner or the whole group can see it.", + "Scheduled reports are stored as dashboards but are excluded from the ordinary dashboard listing; they have their own reports listing, which spans both TO and TCM.", + "On the public-API write surface a treemap widget's visualization override is silently dropped (not rejected) and widget configs are validated for render-safety up front; the internal write surface does neither.", + "In demo-sandbox environments the seeded default dashboard cannot be updated or deleted \u2014 those calls are rejected with a 400 whose body shape differs from a normal success." + ] + }, + "project": { + "entity": "project", + "title": "Project", + "description": "A Test Observability container mirroring a TestHub project; groups the builds, runs and dashboards for one app or repo.", + "aliases": [ + "project", + "projects", + "TO project", + "observability project", + "testhub project", + "th project", + "app project" + ], + "id_convention": "Plain integer project id on most paths; many read/listing paths instead take the project's normalisedName (a URL-safe slug of the project name). A project also carries a separate TestHub project id (thProjectId) used for cross-product linkage. Ids and names are discovered through a project listing.", + "parents": [], + "relations": [ + { + "entity": "test_plan", + "via": "project groups its test plans" + }, + { + "entity": "build", + "via": "project contains its builds" + } + ], + "capabilities": [ + "assign_issue_types_to_test_runs", + "claim_extended_free_trial", + "create_project", + "delete_project", + "get_default_project", + "get_home_takeaways", + "get_home_takeaways_with_noise_breakdown", + "get_project_automation_coverage_via_public_api", + "get_project_by_id_or_name", + "get_project_non_sdk_build_count", + "get_project_non_sdk_summary", + "get_user_access_details", + "list_home_recent_views", + "list_home_unique_builds", + "list_project_build_names", + "list_project_builds_by_group", + "list_projects_for_sidebar", + "list_projects_full", + "list_projects_lite", + "list_projects_lite_paginated", + "list_projects_via_public_api", + "list_projects_with_build_aggregates", + "list_projects_with_build_aggregates_paginated", + "meter_feature_usage", + "rename_project", + "whitelist_group_for_observability" + ], + "key_facts": [ + "A project is addressed two different ways depending on the operation: by its integer id, or by its normalised name (a URL-safe slug of the display name). Carry whichever the capability you picked declares - they are not interchangeable.", + "Deleting a project is irreversible and cascades in TestHub: every build, run and result under it is removed with it. There is no restore.", + "A project keeps a separate TestHub project id alongside its own id. Some lookups and the delete path can resolve a TestHub/TCM id to the mirrored observability project, so passing the wrong id family can silently act on a different project.", + "In the demo-sandbox environment, creating and deleting projects is refused, and the app-bootstrap identity fields (user id and group id) come back null.", + "Projects are only ever created as a service-to-service mirror of a TestHub project, keyed on the TestHub project id and idempotent on it - there is no customer-facing 'new project' create that a user session can call.", + "The public project listing is scoped to the caller's group and does not report a total count; it is walked page by page until an empty page is returned.", + "Automation-coverage figures for a project are computed over all time as an aggregation, not stored on the project, so a read recomputes them and carries the timestamp it was calculated at." + ] + }, + "sp_test": { + "entity": "sp_test", + "title": "Suite Health test (Stability & Performance)", + "description": "A unique automated test tracked across runs in Suite Health, keyed by a stability hash, carrying stability, failure and duration metrics.", + "aliases": [ + "test", + "suite health test", + "test health", + "stability test", + "sp test", + "snp test", + "flaky test", + "test stability", + "stability and performance", + "unique test" + ], + "id_convention": "Identified by an opaque stabilityHash string, not a numeric id. The hash is discovered from the Suite Health test listing; the same hash addresses every Test Health detail read.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "aggregated over the test's executions across builds" + } + ], + "capabilities": [ + "get_suite_health_stats", + "get_suite_health_test_info", + "get_suite_health_test_platform_breakdown", + "get_suite_health_trend", + "get_test_base_info", + "get_test_environment_breakdown", + "get_test_filter_options", + "get_test_health_stats", + "get_test_health_trend", + "get_test_metrics_chart", + "list_suite_health_test_runs", + "list_suite_health_tests", + "list_test_health_tests", + "search_test_filter_branches", + "search_test_filter_build_names", + "search_test_filter_build_tags", + "search_test_filter_host_names", + "search_test_filter_test_tags" + ], + "key_facts": [ + "A test is identified by an opaque stabilityHash string, not a numeric id. You must discover the hash from the Suite Health test listing first; the same hash is what every Test Health detail read takes.", + "Every Suite Health read is scoped to a date window, and a two-element millisecond [from, to] range is mandatory: results only ever cover that window, and omitting the range is rejected.", + "The filter type-ahead suggestions are capped server-side and are suggestions, not the complete set: git branches cap at ~100, while build tags, test tags and host names cap at ~10.", + "Metric counts for the very same query can differ depending on an account feature flag: with the new build listing enabled and the query not restricted to automation-latest, failure counts are sourced from workflow status rather than the raw recorded status, so the same window can report different failure numbers.", + "Muted-test visibility is an explicit, required input on the Test Health detail reads (show all, only muted, or only unmuted) with no default value.", + "Suite Health metrics and the environment breakdown exclude archived builds.", + "The Suite Health filter sidebar degrades gracefully: run-level dropdown options are fetched from an upstream service under a timeout, and on timeout those specific dropdowns come back empty instead of failing the whole request." + ] + }, + "test_detail": { + "entity": "test_detail", + "title": "Test detail (logs, hooks, capabilities)", + "description": "The logs, hooks, capabilities and error detail attached to one test run, shown on the Test Details page.", + "aliases": [ + "test details", + "test detail", + "logs tab", + "session logs", + "network logs", + "har", + "app logs", + "application logs", + "hooks", + "hook logs", + "capabilities tab", + "behaviour tab", + "console logs", + "consolidated logs", + "test run logs" + ], + "id_convention": "Not its own id. Most tabs are addressed by the parent test run's INTEGER id (testRunId); session-scoped log tabs by the session's HASHED string id; the app-log stream by the test run's UUID. Discover these from a test-run / session read.", + "parents": [ + "test_run" + ], + "relations": [ + { + "entity": "test_run", + "via": "detail of a test run" + }, + { + "entity": "session", + "via": "session hashed id" + } + ], + "capabilities": [ + "get_session_log_urls", + "get_session_network_log_url", + "get_test_run_app_logs", + "get_test_run_app_logs_raw", + "get_test_run_auto_unmute_config", + "get_test_run_behaviour", + "get_test_run_capabilities", + "get_test_run_hooks", + "get_test_run_log_bundle", + "get_test_run_log_urls", + "get_test_run_media_logs", + "get_test_run_merged_logs", + "get_test_run_meta", + "get_test_run_network_log_url", + "get_test_run_network_logs", + "get_test_run_overview", + "get_test_run_test_code", + "list_test_run_detail_tabs", + "list_test_run_execution_history", + "list_test_run_linked_test_cases" + ], + "key_facts": [ + "The detail is derived, not stored as its own record: it has no id of its own and no history to restore. Everything here is read-only and nothing mutates the run.", + "The same detail is addressed three different ways depending on the tab: most tabs by the test run's integer id, the session-scoped log tabs by the session's hashed string id, and the application-log stream by the test run's UUID. Carry whichever id the capability you picked declares.", + "Log tabs come in two flavours. One downloads and merges the log text server-side and returns it inline; the other returns only URLs for the caller to fetch. The inline-merge form is slow for long sessions, so prefer the URL form there.", + "Application logs are filtered to abnormal (error-level) lines only, capped at 5000 lines, and bounded to the run's own start/finish window (padded five seconds each side). A clean run legitimately returns an empty list.", + "Retention-locked or plan-locked builds refuse every detail read with a forbidden / plan-feature-not-allowed error rather than returning empty; treat that as a plan gap, not a missing run.", + "Hook attribution is heuristic: hooks are matched by same build, filename, OS, browser and device, latest run only. It is not a stored parent/child link, so hooks from a differently-configured session never appear.", + "On the demo-sandbox environment several of these reads return canned or empty payloads (an empty log envelope, a demo HAR, or logs chosen by test name) instead of real data." + ] + }, + "test_run": { + "entity": "test_run", + "title": "Test run", + "description": "One execution of a single test within a build \u2014 its status, platform, code, retries and failure analysis.", + "aliases": [ + "test", + "test result", + "run", + "test execution", + "test case execution", + "session test", + "btcer", + "tr", + "spec run", + "automated test" + ], + "id_convention": "Most routes take the INTEGER test run id (Long). A few public routes are scoped by the parent BUILD's UUID instead (a string). Discover run ids from a build's test-run listing rather than guessing them; the TR-NNN prefixed identifier some surfaces show is for display, not for these numeric paths.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildId" + }, + { + "entity": "test_plan", + "via": "groups" + } + ], + "capabilities": [ + "assign_test_run_failure_categories", + "cancel_test_run_auto_unmute", + "get_test_run_baseline", + "get_test_run_bug_report_draft", + "get_test_run_build_insights", + "get_test_run_details", + "get_test_run_retry_logs", + "get_test_runs_history_details", + "link_bug_to_test_run", + "list_build_bstack_error_sessions", + "list_build_test_run_counts", + "list_build_test_run_sdk_logs", + "list_build_test_runs", + "list_build_test_runs_flat", + "list_build_test_runs_flat_filtered", + "list_build_test_runs_hierarchy", + "list_build_test_runs_hierarchy_without_public_link", + "list_build_test_runs_with_attachments", + "list_test_run_attachment_urls", + "list_test_runs_sharing_error", + "unlink_bug_from_test_run", + "update_test_run_mute_status" + ], + "key_facts": [ + "A test run is found by its own numeric id and the caller's group; when a route's path also carries a build id, some routes use it and some ignore it entirely, so a run still resolves even if the build segment is wrong.", + "The baseline, similar-error and hooks reads return a MAP keyed by test run id, not a single object, and the map is EMPTY (not an error) when nothing qualifies \u2014 an empty body means 'none found', not a failure.", + "The baseline look-back is a fixed 30 days before the run's start time; a last-known-good run older than that is invisible, so a returned empty map does not prove the test never passed.", + "Downloading a run's attachments or screenshots is gated by an account feature; when it is off the attachment read is refused even for a run that clearly has attachments.", + "Attachment and screenshot downloads come back as presigned URLs that expire roughly two hours after they are issued \u2014 fetch them promptly and re-request rather than caching the URL.", + "A test can be muted with an automatic unmute condition (after N passes, or after a date); making that mute permanent is reversible by muting again with a condition, so it is a change, not a deletion.", + "The bug-report read composes a fresh, pre-filled ticket draft from the failure; it does NOT fetch a bug that was already filed.", + "Linking a filed bug to a run is idempotent on the issue key: linking the same key twice is a silent no-op, and the link can be removed again afterwards.", + "Retries of a test are separate executions, each with its own status; a run carries its retry history rather than overwriting the previous attempt.", + "Public listings are plan- and source-aware: a freemium or source-fenced build yields a stripped or empty result rather than the analysis fields, and smart-tag filters on such a build are refused outright.", + "The status a run reports is lowercased on the public /ext detail routes but may come back uppercase from other surfaces \u2014 compare case-insensitively.", + "In the flat build listing the top-level id of each row is the test's hash (a string), while the numeric run id lives under that row's details.id." + ] + }, + "ai_failure": { + "entity": "ai_failure", + "title": "AI failure analysis (build-level)", + "description": "A build-level AI cluster of related test failures: a failure theme or a workflow grouping the runs that failed for the same reason.", + "aliases": [ + "ai failure", + "ai failure analysis", + "failure theme", + "failure cluster", + "failure workflow", + "build failure theme", + "build failure workflow", + "build rca", + "build level rca", + "ai clustered failures", + "rca theme" + ], + "id_convention": "The build is addressed by its build UUID (an opaque string) in the path. Individual clusters are identified by an integer buildFailureThemeId or buildFailureWorkflowId, which are discovered from the build AI-failures read, not guessable.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildUUID" + }, + { + "entity": "test_run", + "via": "flat test-run drill-down under a theme/workflow" } - } - } - } - } - } - } - }, - "SuiteHealthPlatformBreakdownResponse": { - "description": "Per OS/browser/device breakdown for one test \u2014 a BARE ARRAY, sorted by reliability descending.", - "schema": { - "type": "array", - "items": { - "$schema": "SuiteHealthPlatformBreakdownItem" - } - } - }, - "SuiteHealthTestRunsResponse": { - "description": "One page of a test's executions across builds, cursor-paginated by start.", - "schema": { - "type": "object", - "properties": { - "pagingParams": { - "type": "object", - "properties": { - "hasNext": { - "type": "boolean" - }, - "start": { - "type": "integer", - "description": "Cursor for the next (older) page \u2014 pass back as the start query param." - }, - "offset": { - "type": "integer", - "description": "Fixed placeholder (20) kept for FE contract; not honoured by the backend." - } - } + ], + "capabilities": [ + "get_build_ai_failures", + "get_build_ai_failures_via_public_api", + "list_build_ai_failure_cluster_test_runs_via_public_api", + "list_build_ai_failure_test_runs_flat", + "trigger_build_ai_failure_analysis", + "trigger_build_ai_failure_analysis_via_public_api" + ], + "key_facts": [ + "AI failure clustering is computed asynchronously. Requesting analysis on a build that has none returns immediately with a triggered flag and NO themes or workflows yet; the results arrive later, so re-read to collect them.", + "The read side returns only what has already been computed and never starts analysis. Starting analysis is a separate trigger operation.", + "Themes and workflows always come back sorted by their share of failures, highest first.", + "Build-level AI analysis is gated by AI opt-in consent, a per-account AI-enabled flag, and the build-level RCA feature; a trial account also has a usage quota that returns a plan-not-allowed error once exhausted, and each fresh trigger consumes one unit of it.", + "A build whose source is excluded by source-fencing is refused analysis.", + "The trial-quota figure is present only for accounts on the build-level AI trial; a paid account never sees it, so its absence does not mean zero usage.", + "Drilling from a cluster to its test runs is cursor-paginated, and the per-page size is a fixed backend default that the caller cannot change." + ] }, - "builds": { - "type": "array", - "items": { - "$schema": "SuiteHealthTestRun" - } - } - } - } - }, - "TestRunHistoryDetailsResponse": { - "description": "Execution-history map keyed by the requested id. Top-level keys are the test run ids (or hook run ids when isHook=true); an id with no history is omitted.", - "schema": { - "type": "object", - "additionalProperties": { - "$schema": "TestRunHistoryEntry" - } - } - }, - "TestRunRetryLogsResponse": { - "description": "The retry attempt's screenshot/log structure. Throws (not 200) when no screenshot data exists for the attempt.", - "schema": { - "$schema": "TestRunRetryLogStruct" - } - }, - "TestRunSdkLogsListResponse": { - "description": "One fixed-size (15) page of test runs with SDK logs/attachments. totalAttachments is present only on the first (cursor-absent) page.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunSdkLogEntry" - } - }, - "cursorInfo": { - "type": "object", - "properties": { - "nextCursor": { - "type": "integer", - "description": "Pass back as `cursor` for the next page." - }, - "hasMore": { - "type": "boolean" - } - } + "session": { + "entity": "session", + "title": "Session", + "description": "A single automation run on BrowserStack (Automate or App Automate) recorded against a build, with its config, timings, logs and status.", + "aliases": [ + "session", + "automate session", + "app automate session", + "test session", + "run session", + "browser session", + "device session", + "live session", + "queued session" + ], + "id_convention": "Most session reads take the session's hashed id, an opaque alphanumeric string (not a numeric id). The stop control takes that hashed id in the body. One list read is the exception: it takes the NUMERIC build id even though its slot looks like a session id.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildId" + }, + { + "entity": "test_run", + "via": "a session executes the build's test runs" + } + ], + "capabilities": [ + "get_session_app_profiling", + "get_session_capabilities", + "get_session_details", + "get_session_test_code_via_public_api", + "list_build_sessions", + "list_queued_sessions", + "stop_session" + ], + "key_facts": [ + "A session is identified by its hashed id (a string), not a numeric id, except the per-build session list, which takes the build's numeric id even though the value slots where the other routes expect a session id.", + "App profiling exists only for App Automate sessions; asking for it on an Automate session is rejected, and an empty object means the report is still generating or was never captured, not an error.", + "Session detail can come back with a retryable not-ready status while the session is still being linked to its build; treat that as retry-shortly, not a failure.", + "A session that belongs to a plan-locked build is refused with a plan-not-allowed error even though the session exists.", + "A public-link viewer does not see still-running sessions in the per-build session list.", + "Stopping a session ends the live run and cannot be undone, but removes nothing: the session record and its logs remain.", + "The public test-code read reports a session that exists but captured no code identically to a missing session, so a not-found there does not prove the id is wrong.", + "On the demo sandbox environment, capabilities, session details and app profiling are served from a canned dataset rather than real data." + ] }, - "totalAttachments": { - "type": "integer", - "description": "Per-build attachment total; only on the first page." - } - } - } - }, - "AssignTestRunFailureCategoriesResponse": { - "description": "Flat array of the resulting issue types after bulk categorisation.", - "schema": { - "type": "array", - "items": { - "$schema": "TestRunIssueType" - } - } - }, - "BstackErrorSessionsResponse": { - "description": "Raw array of BrowserStack-error session objects straight from the session repository (opaque element shape). Empty for non-SDK builds and in demo-sandbox.", - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Opaque session object." - } - } - }, - "FlatTestRunListResponse": { - "description": "Flat, cursor-paged test-run listing (V2 flat surface).", - "schema": { - "type": "object", - "properties": { - "testRuns": { - "type": "array", - "items": { - "type": "object", - "description": "Flattened test detail (per-run row: id, name, status, platform, flags, etc.)." - } + "check_config": { + "entity": "check_config", + "title": "Check configuration (SCM check-run)", + "description": "A repo's SCM check-run rule: which BrowserStack build/project a commit or PR check reports on, as quality-gate or build status.", + "aliases": [ + "check", + "checks", + "check run", + "check-run", + "check config", + "check configuration", + "pr check", + "pr checks", + "vcs check", + "github check", + "quality gate check", + "commit check", + "scm check", + "checks mapping", + "check mapping", + "pr checks mapping" + ], + "id_convention": "Check-configuration id is a plain integer (discover it from the repo's check-runs listing). A checks-mapping registration has its OWN integer ingestion id, which is neither the check-configuration id nor the GitHub check id.", + "capabilities": [ + "create_check_configurations", + "create_checks_mapping", + "delete_check_configuration", + "get_checks_mapping", + "list_check_configurations", + "toggle_check_configuration", + "update_check_configuration" + ], + "key_facts": [ + "The whole feature is gated by the CHECKS_EVALUATION entitlement: create, read, list, update, delete and toggle all refuse when the workspace lacks it, and that refusal is flattened into a generic bad-request error rather than a typed one.", + "Check configurations are scoped to the caller's group AND user together: you only see and can change checks you created, and a teammate's checks are invisible and cannot be edited, toggled or deleted.", + "A repository may hold at most 10 check configurations; a configuration is unique by the combination of its name, repository, group, build name and target project.", + "checkType is one of exactly two values: quality-gate status or build status.", + "The target project named on a check must already exist in the workspace, or the write is refused as project-not-found.", + "Deleting a check configuration removes the row for good; there is no undo and no bin.", + "A checks-mapping registration is a separate record from the check configuration it points at: it captures one pull request's commit, callback URL and evaluation state (starting at 'Not Started'), and is addressed by its own ingestion id.", + "A checks-mapping registration only succeeds for check ids that already match an existing check configuration in the caller's group for that repository and organisation; ids that match nothing are rejected." + ] + }, + "view": { + "entity": "view", + "title": "Saved view", + "description": "A named saved filter preset for a listing screen, private to its creator or shared with the whole group.", + "aliases": [ + "view", + "views", + "saved view", + "saved views", + "custom view", + "custom saved view", + "filter preset", + "view preset", + "saved filter", + "named filter", + "filter view" + ], + "id_convention": "A view id is a UUID (discover it from a views listing). The path viewType is a SCREEN name, not an id: one of builds, testRuns, testHealth, uniqueErrors or buildInsights.", + "capabilities": [ + "create_view", + "create_view_via_public_api", + "delete_view", + "list_views", + "list_views_via_public_api", + "update_view", + "update_view_via_public_api" + ], + "key_facts": [ + "A view belongs to exactly one screen type (builds, test runs, test health, unique errors or build insights); the same name can be reused across different screen types.", + "Views are either private (level 1, visible only to the creator) or shared/public (level 2, visible to the whole group).", + "A view name must be unique within its screen type: within the group for shared views, within the creator for private views. A clash is refused with a name-exists error rather than overwriting.", + "Deleting a view is a hard, unrecoverable removal, and the view disappears from every group member's view switcher immediately.", + "The creator and group of a view are always taken from the caller's session, never from the request body; a createdBy or groupId sent in the body is ignored.", + "On a shared public link only public views are returned; private views are never exposed through a shared link.", + "Editing a view is a partial update (omitted fields keep their stored values), but views keep no revision history, so an overwritten name, filter query or visibility cannot be restored.", + "The external (public-API) surface enforces stricter input than the internal one: name must be non-blank and at most 100 characters, query non-blank and at most 4000, and it has no delete operation at all." + ] }, - "pagingParams": { - "type": "object", - "description": "Cursor state (hasNext, nextRoot, searchAfter) to feed back for the next page." + "dashboard_view": { + "entity": "dashboard_view", + "title": "Dashboard view", + "description": "A saved dashboard configuration (name, global filters, widget layout) for the Test Observability or Test Management dashboard page.", + "aliases": [ + "dashboard", + "dashboard view", + "view", + "dashboard page", + "custom dashboard", + "dashboard tab", + "testing trends", + "testing-trends", + "automation trend", + "automation-trend", + "default dashboard", + "builds dashboard", + "test runs dashboard", + "test health dashboard", + "unique errors dashboard", + "build insights dashboard" + ], + "id_convention": "UUID. Custom views' UUIDs are discovered from the list capability. On READS only, three built-in views are also addressable by alias: `default` resolves to the TCM view for source=TCM and to the TO 'testing trends' view for source=TO, and `automation-trend` resolves to the automation-trend view. Writes (update/delete/clone-source) require the raw UUID; the aliases are rejected there.", + "parents": [ + "project" + ], + "relations": [], + "capabilities": [ + "check_dashboard_widget_ignored_filters", + "clone_dashboard_view", + "create_dashboard_view", + "delete_dashboard_view", + "get_dashboard_view", + "get_dashboard_view_widget_data", + "list_dashboard_view_custom_field_filters", + "list_dashboard_view_widget_types", + "list_dashboard_views", + "list_dashboard_widget_filter_values", + "update_dashboard_view" + ], + "key_facts": [ + "There are three built-in dashboards: the default TCM dashboard, the default TO dashboard (shown as 'testing trends'), and 'automation-trend'. None of the three can be renamed, edited, cloned into, or deleted; an attempt to update or delete one is refused, and the built-ins never appear as normal editable views.", + "A dashboard is addressed by its UUID everywhere. Only when reading a single dashboard may you also use the aliases `default` and `automation-trend` in place of the UUID; every write needs the raw UUID.", + "Opening a built-in default dashboard forces its date range to the last 30 days, ignoring whatever range was stored, and injects the current filter values into its widgets.", + "Reading a single dashboard records it in your 'recently viewed' dashboards list (the same list the home page shows); there is no read that avoids leaving that trace. The trace is skipped for super-users unless they are in group 2.", + "Dashboards keep no version history: an overwritten name, filter set, or widget layout cannot be restored to a previous revision, and a delete is permanent.", + "Dashboard names must be unique within the project; creating or cloning with a name already in use is rejected.", + "Every operation on this entity must name a project (by id, name, or identifier). The named project both scopes the dashboard and is the resource the create/update/delete/clone permission check runs against, even for calls that otherwise ignore the project.", + "Visibility is governed by the numeric `level` field on the dashboard; the default level is 2 (group / shared).", + "Custom (private/group) dashboards are only visible to their owner and their group; a UUID that belongs to someone else's private dashboard reads as not-found rather than forbidden.", + "A dashboard name is limited to 1-100 characters." + ] + }, + "report": { + "entity": "report", + "title": "Report (saved report)", + "description": "A saved, shareable analytics view over test runs with persisted global filters and widgets; private or group-visible.", + "aliases": [ + "report", + "saved report", + "reports", + "test report", + "test execution report", + "analytics report", + "tm report", + "test management report", + "custom report" + ], + "id_convention": "UUID (the report id). Reports mirrored from Test Management are ALSO addressable by their TM-side numeric external report id through the sync surface. Discover a report's UUID from the report listing.", + "parents": [], + "relations": [ + { + "entity": "project", + "via": "selected projects in the report's global filters" + }, + { + "entity": "test_run", + "via": "the tabular test-runs report widget" + } + ], + "capabilities": [ + "bulk_sync_tm_reports", + "clone_report", + "create_report", + "delete_report", + "delete_tm_report", + "get_report", + "get_report_dataset_rows", + "get_report_print_download_token", + "get_report_stat_tiles", + "list_report_custom_fields", + "list_report_static_builds", + "list_saved_reports", + "sync_tm_report", + "update_report", + "update_tm_report" + ], + "key_facts": [ + "Reports and dashboards are the same stored object under the hood and share one storage, name space and permission family; a report is just the report-typed view of it, so its visibility, filters and widgets behave exactly like a dashboard's.", + "A report is addressed two ways for the same record: end-user reports by a UUID, and reports mirrored from Test Management additionally by their TM-side numeric external id on the sync surface.", + "Visibility level is 1 = private (only the owner sees it) or 2 = group (everyone in the group sees it). A report you own at any level, or any group-level (level 2) report in your group, is yours to delete.", + "Report names must be unique within the same type and visibility level; a clashing name is rejected on create, on clone, and on an edit that changes the name, type or level.", + "Reports keep no version history \u2014 a rename, filter change or widget replacement overwrites the previous definition with no way to restore it.", + "A report stores its columns and filters by numeric custom-field id, so rendering or editing a saved report means resolving those ids back to display names first; the same field name can exist at both the test-case and test-execution level, distinguished only by entity type.", + "Only the tabular test-runs report type has live data behind it; asking a report's tiles or dataset for any other report type returns a 200 with a null body rather than an error.", + "Reports mirrored from Test Management are stamped with a TCM source and are meant to be written only by the Test Management service (identity taken from the request body, no permission check); the end-user builder creates reports under the TO source.", + "In the demo-sandbox environment there is a built-in default report that cannot be edited or deleted.", + "Creating or listing reports requires the custom-dashboard plan feature, and minting a report's PDF-print token requires the download-report plan feature; without the feature the call is refused even for a user who holds report permissions." + ] }, - "isArchived": { - "type": "boolean" + "report_view": { + "entity": "report_view", + "title": "Report view", + "description": "A configured analytics report (currently the tabular test-runs report) run on demand to return its table, summary tiles and per-test detail.", + "aliases": [ + "report", + "reports view", + "tabular report", + "test-runs report", + "report table", + "analytics report", + "tabular test runs report" + ], + "id_convention": "No stored id: the report is defined by the definition sent in the request body. Per-test detail is keyed by a test's globalTestHash (a string hash identifying one test case across runs), taken from a report-table row.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "report rows / per-test detail" + } + ], + "capabilities": [ + "get_report_test_stats", + "get_report_test_trend", + "get_tabular_test_runs_report_tiles", + "list_report_test_runs", + "run_tabular_test_runs_report" + ], + "key_facts": [ + "Only the tabular test-runs report is implemented. Asking for any other report type returns an empty or null body with a 200, not an error, so check the body rather than the status.", + "The report is defined entirely by the definition you send (datasets, segments, global filters); the id in the address is not read at all, so any value satisfies it.", + "Running the report has no side effects even though the request is write-shaped: the body is the query, not saved state.", + "The report table and its summary tiles are two separate calls that take the same definition; the tiles call has no paging.", + "Per-test detail (stats, run list, trend) is keyed by a test's global hash, which you read from a row of the report table.", + "A test with no matching runs in the window returns an empty object for its stats rather than zeroed counts.", + "Trend point durations are in milliseconds, and each point carries the build it came from.", + "When every dataset in the definition mutually conflicts, the table comes back empty and the tiles come back with all zero/false fencing flags rather than an error." + ] }, - "smartTagSettings": { - "type": "object" + "widget": { + "entity": "widget", + "title": "Dashboard widget", + "description": "A single configured tile on a dashboard (chart, stat card or distribution) whose data is computed on demand from filters and a time window.", + "aliases": [ + "widget", + "dashboard widget", + "tile", + "chart", + "dashboard tile", + "widget filter", + "dashboard chart" + ], + "id_convention": "Addressed on these routes by a widget-type enum plus the definition in the body, not by a stored widget id. The dashboard id in the address is not read on the data and drill-down calls.", + "parents": [ + "dashboard" + ], + "relations": [ + { + "entity": "dashboard", + "via": "dashboardId (unused on data routes)" + } + ], + "capabilities": [ + "get_org_dashboard_widget_data", + "get_widget_drilldown_records", + "list_widget_filter_values", + "search_widget_filter_values" + ], + "key_facts": [ + "Widget data is computed from the definition and filters you send, not read from stored widget state; the dashboard id in the address is ignored on the org-level data and drill-down calls.", + "Filter-value lookups come in two forms: a flat list of values for one filter key, and an enhanced form that splits values into suggested and others with search and paging.", + "For ClickHouse-powered widget types, the project ids you send are automatically translated to TestHub project ids before the query runs.", + "Group 2 asking for the active-builds widget is forced to a seven-day window regardless of the range requested.", + "These reads have no side effects even though the requests are write-shaped.", + "The widget-data response shape depends on the widget type requested, so read the type you asked for before assuming a chart vs card vs distribution envelope." + ] }, - "status": { - "type": "object", - "description": "Status aggregate over the result." + "parallel_queue": { + "entity": "parallel_queue", + "title": "Parallel queue usage", + "description": "A view of a group's parallel-session concurrency for a product: parallels in use vs allowed and queued sessions vs the queue limit.", + "aliases": [ + "parallel queue", + "parallels", + "queue", + "concurrency", + "parallel sessions", + "queue usage", + "session queue" + ], + "id_convention": "Scoped to the authenticated caller's group; the running-builds view additionally takes a numeric user id in the address.", + "parents": [], + "relations": [], + "capabilities": [ + "get_parallel_queue_usage_summary", + "list_parallel_queue_team_usage", + "list_user_running_builds" + ], + "key_facts": [ + "Usage is always for the signed-in caller's group and the product named in the request (automate or app-automate). A product value other than automate is treated as app-automate on the running-builds view.", + "The team breakdown and running-builds views pass through to the billing backend using the caller's own BrowserStack credentials, so an unrecognised product fails downstream there.", + "The summary view only works for session/JWT callers because it reads the user from the security context; omit the product and you get back only the test-execution usage block, with no live queue numbers.", + "The running-builds view takes a user id in the address and does not check that it belongs to the caller's own group.", + "The summary view strips the internal user-details block out before returning." + ] }, - "workflowStatus": { - "type": "object", - "description": "Workflow-status aggregate over the result." + "usage": { + "entity": "usage", + "title": "Usage & entitlement", + "description": "Test-execution consumption and plan/tier entitlement for a group or user: counts run, limits, and how much of the plan is used.", + "aliases": [ + "usage", + "consumption", + "test executions", + "billing usage", + "plan usage", + "tier usage", + "entitlement", + "device tier" + ], + "id_convention": "Group/user scoping comes from query params or the authenticated session; the device-tier and plan-details views use the caller's own group with no id argument.", + "parents": [], + "relations": [], + "capabilities": [ + "get_device_tier_usage", + "get_group_plan_usage_details", + "get_test_execution_usage_count" + ], + "key_facts": [ + "The raw execution-count view takes the user id or group id straight from the request and never checks they belong to the caller, so any authenticated caller can read another user's or group's counts.", + "That same count view returns a 200 with the plain text body 'Missing userId or groupId' when both are omitted, instead of a 400.", + "The plan/usage-details view returns every count as a compacted display string (e.g. '1.2K'), not a number, and nulls out every usage field when the group has no execution limit configured.", + "The device-tier view lists only mobile tiers; non-mobile tiers are filtered out, and a missing product is a 400.", + "Execution counts are split into all tests reported and the subset that ran on BrowserStack infrastructure (those carrying a session id)." + ] }, - "folder_path": { - "type": "string" - } - } - } - }, - "HierarchyTestRunListResponse": { - "description": "Hierarchical (suite/file/test) test-run tree with cursor paging over top-level roots.", - "schema": { - "type": "object", - "properties": { - "hierarchy": { - "type": "array", - "items": { - "type": "object", - "description": "A suite/file/test node (nested children under the node)." - } + "search": { + "entity": "search", + "title": "Global search", + "description": "A global free-text search across product entities (builds, tests, projects) for the search bar and quick overlay.", + "aliases": [ + "search", + "global search", + "find", + "search bar", + "quick search", + "entity search" + ], + "id_convention": "No stored entity; each result carries its own numeric id and a type discriminator.", + "parents": [], + "relations": [], + "capabilities": [ + "search_entities" + ], + "key_facts": [ + "Search covers a fixed set of entity types and rejects any other value; the accepted values are case-sensitive (Build, BuildTestCaseExecutionResult, Project, All).", + "The query must be at least three characters and not blank, or the request is rejected.", + "Results are always sorted by most-recently-updated; the sort order cannot be changed by the caller.", + "The quick-overlay mode returns a smaller page and omits the paging and total-count fields that the full mode includes.", + "Each result is enriched with the matching record's user name, resolved from its user id." + ] }, - "pagingParams": { - "type": "object", - "description": "Cursor state (hasNext, nextRoot, searchAfter); paging is by top-level roots (5 per page, 1 for builds over 2000 runs)." + "self_healing": { + "entity": "self_healing", + "title": "Self-healing (AI-healed locators)", + "description": "AI-suggested replacement selectors for tests whose locators broke, plus the review verdicts and PRs that apply them.", + "aliases": [ + "self heal", + "self-heal", + "ai healing", + "aiHealing", + "healed locator", + "healed selector", + "locator healing", + "auto heal", + "healing pr", + "heal pr", + "healing verdict" + ], + "id_convention": "A build is addressed by its build UUID (^[a-zA-Z0-9-]{1,64}$). A healing-PR job is a random UUID (8-4-4-4-12 hex) returned by create_self_healing_pr; it is NOT discoverable through any list call and lives only in Redis with a short TTL, so capture it from the create response.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "healed locators are computed per build; summary and verdicts are build-scoped" + }, + { + "entity": "test_run", + "via": "each healed selector carries the sessionId of the run it broke in" + } + ], + "capabilities": [ + "create_self_healing_pr", + "get_build_self_heal_summary", + "get_self_healing_pr_status", + "list_self_healing_verdicts", + "submit_self_healing_verdict" + ], + "key_facts": [ + "Opening a healing PR is asynchronous: the create call returns immediately with a job id and an IN_PROGRESS state, and the actual GitHub PR is opened later by a background worker. Poll the status separately; the PR URL only appears once the job reaches a terminal state.", + "A healing-PR job's terminal state can be SUCCEEDED, PARTIAL (some files changed, some skipped), FAILED, or NO_CHANGES_NEEDED. PARTIAL and NO_CHANGES_NEEDED are successful outcomes, not errors.", + "Healing-PR job state is kept only briefly and only for the group that started it; once it expires the id reads as not-found rather than as a finished state, so a job whose status was never polled in time is unrecoverable.", + "Opening a healing PR is GitHub-only and needs the build to carry version-control info (origin remote, branch) and the group to have a GitHub source-code integration connected; a GitHub Enterprise or non-standard host must pass an explicit owner/repo.", + "The human-in-the-loop verdict flow (recording whether a healed locator was accurate) is an optional feature per group. When it is off, reading verdicts returns an empty list while submitting a verdict is refused outright.", + "Only reviewers with a full product role can record a verdict; view-only members cannot.", + "A recorded verdict can carry over from an earlier build: a build shows verdicts first captured against a previous build, flagged as carryover, so the same healed element is not re-reviewed every build.", + "Self-heal summary numbers require the build to be within the account's plan quota; a build locked by quota is refused rather than returning zeroes.", + "The healing report itself (the list of individual healed selectors, with their original and healed values) is delivered as a downloadable report artefact, separate from these APIs; the summary here is only the headline counts." + ] }, - "status": { - "type": "object", - "description": "Status aggregate." + "build_comparison": { + "entity": "build_comparison", + "title": "Build comparison", + "description": "A side-by-side diff of two builds (a base and a target) showing which tests newly failed, started passing, were added or dropped.", + "aliases": [ + "compare builds", + "build diff", + "build vs build", + "compare two builds", + "buildComparison", + "comparison", + "base vs target" + ], + "id_convention": "Not a stored entity with its own id: a comparison is identified by the pair of build UUIDs passed as the `base` and `target` query params. Comparison is always done at the top-level parent build, so a re-run/child UUID should be normalised first via resolve_build_comparison_parent_uuids.", + "parents": [ + "build" + ], + "relations": [ + { + "entity": "build", + "via": "base and target are both build UUIDs" + } + ], + "capabilities": [ + "check_build_comparison_available", + "get_build_comparison_aggregation", + "get_build_comparison_meta", + "list_build_comparison_bucket_tests", + "resolve_build_comparison_parent_uuids" + ], + "key_facts": [ + "Every comparison endpoint takes exactly two required query params, `base` and `target`, each a build UUID; an empty or missing one comes back as build-not-found.", + "Comparison is defined only between two top-level parent builds. Child builds, archived builds and plan-locked builds are rejected as unsupported, so normalise a pasted re-run UUID to its parent before comparing.", + "The two builds must come from compatible sources. An incompatible pair is not an error: the response comes back with source-fencing flags set and its actual comparison payload empty, so check for those flags before trusting empty counts.", + "Comparing is a paid feature: the aggregation, the per-bucket listing and the availability pre-check are all refused when the plan does not include build comparison. Resolving parent UUIDs and fetching the two build headers are NOT plan-gated and work regardless.", + "The side-by-side header counts are recomputed with the same de-duplication the comparison buckets use, so the header agrees with the buckets; this makes the header fetch heavier than a plain build summary and it silently falls back to raw counts if that recompute fails.", + "The whole flow is heavy: it resolves both builds' child builds and runs search + aggregation across them, with a large per-build ceiling on the test rows it will pull, so treat these as expensive reads.", + "A typical page flow is: normalise both UUIDs to parents, check the pair is comparable, fetch the two headers and the headline counts, then drill into one bucket's tests." + ] }, - "workflowStatus": { - "type": "object", - "description": "Workflow-status aggregate." + "comment": { + "entity": "comment", + "title": "Comment", + "description": "A rich-text note left on a build, test run or test, threaded and able to @mention group users, used for review discussion.", + "aliases": [ + "comments", + "note", + "discussion", + "thread", + "reply", + "feedback", + "annotation" + ], + "id_convention": "A comment id is a random UUID string generated on create and returned in the create response. A comment belongs to a thread identified by threadId (also a UUID; a new top-level comment starts its own thread). There is no list-by-id call: ids are discovered through the build/test comment reads.", + "parents": [ + "project", + "build" + ], + "relations": [ + { + "entity": "build", + "via": "buildUuid / buildSerialId" + }, + { + "entity": "test_run", + "via": "runUuid / testHash" + }, + { + "entity": "project", + "via": "scoped by project normalised name in the path" + } + ], + "capabilities": [ + "create_comment", + "delete_comment", + "list_build_comment_threads", + "list_comments", + "list_test_run_comments_via_public_api", + "update_comment" + ], + "key_facts": [ + "Comments are a paid feature: creating, editing and deleting are refused when the plan does not include comments.", + "A comment can only be edited or deleted by the person who wrote it; another user's comment reads as not-found on those operations even inside the same group.", + "Comments have no version history. Editing a comment overwrites its body in place with no way to recover the previous text.", + "Reads are bounded by the plan's data-retention window, so older comments silently fall out of results rather than erroring.", + "@mentioning a group user notifies them shortly after the comment is posted; the notification is a side effect that fires even though the create call has already returned.", + "Comment reads are keyed to a build's numeric serial id and look back over a fixed window of preceding builds (so recurring discussion on the same test carries across re-runs), not over all history.", + "The public /ext read is view-only: there is no external endpoint to post a comment, only the group-authenticated internal ones can create." + ] }, - "isArchived": { - "type": "boolean" + "sp_error": { + "entity": "sp_error", + "title": "Unique error (Suite Health)", + "description": "A cluster of test failures sharing one error signature in a project, the unit of the Suite Health Unique Errors view.", + "aliases": [ + "unique error", + "unique errors", + "error cluster", + "error signature", + "suite health error", + "sp error", + "failure cluster", + "errorClusterId", + "clustered failure" + ], + "id_convention": "Numeric cluster id (errorClusterId), a plain integer discovered from the unique-errors list (each row's `id`, returned as a numeric string). Every detail view takes it in the path as errorId / clusterId.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "test_run", + "via": "impacted tests hitting the error (breakdown / platforms)" + }, + { + "entity": "build", + "via": "builds in which the error occurred (details/builds)" + } + ], + "capabilities": [ + "get_unique_error_build_trend", + "get_unique_error_filters", + "get_unique_error_impacted_tests", + "get_unique_error_occurrence_count", + "get_unique_error_platform_breakdown", + "get_unique_error_summary", + "get_unique_error_timeline_charts", + "list_unique_error_builds", + "list_unique_errors", + "search_unique_error_filter_branches", + "search_unique_error_filter_build_names", + "search_unique_error_filter_build_tags", + "search_unique_error_filter_host_names", + "search_unique_error_filter_test_tags" + ], + "key_facts": [ + "A cluster id is a plain integer. Get it from the unique-errors list (each row's `id`, a numeric string); every per-error detail view takes that same value in its path.", + "Every Unique Errors call needs a date window of EXACTLY two epoch-millisecond bounds; omitting it or sending one bound is rejected. A single lowerBound/upperBound pair is the only fallback.", + "These views only ever consider FAILED test runs. Archived builds and usage-locked builds are always excluded, and manual (Test Management) runs are excluded from the list and per-error queries whatever you send.", + "On a demo tenant, or a real project that has no builds yet, the list answers with an empty set and a success status, not a not-found. Only a genuinely absent project is a 404.", + "Free / non-pro accounts are capped: the list returns only the single highest-occurrence error and is force-sorted by occurrence count descending, and per-error filtering is skipped.", + "Source feature-fencing can silently drop Automate, App-Automate or low-code tests from results; the list response carries automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped booleans that say when this happened.", + "The window is clamped to the account's data-retention range before querying, so a range wider than retention silently returns only the retained slice.", + "Every Unique Errors endpoint is read-only and accepts central-auth, obs token, service key, rails-basic, public-link and speedboat tokens; none of them accepts username + access-key auth.", + "Occurrence bar/chart data comes back as bare per-bucket counts with no timestamps on some views, so the time axis has to be reconstructed from the requested window; buckets are one calendar day." + ] }, - "smartTagSettings": { - "type": "object" + "test_rca": { + "entity": "test_rca", + "title": "Test RCA (AI root-cause analysis)", + "description": "The AI root-cause analysis stored for a single failed test run, with its generation state and thinking data.", + "aliases": [ + "rca", + "test rca", + "ai rca", + "root cause analysis", + "root-cause analysis", + "failure analysis", + "ai failure analysis", + "why did this test fail" + ], + "id_convention": "No id of its own in paths: an RCA is addressed by the integer test-run id it belongs to (one RCA per test run). The stored row does carry an integer id and a bulkRcaId (UUID) linking runs analysed together in one bulk request.", + "parents": [ + "test_run" + ], + "relations": [ + { + "entity": "test_run", + "via": "one RCA per test run, keyed by test run id" + } + ], + "capabilities": [ + "get_test_rca", + "get_test_rca_data", + "get_test_rca_via_public_api", + "save_test_rca", + "trigger_bulk_test_rca", + "trigger_test_rca_via_public_api" + ], + "key_facts": [ + "Generating an RCA is asynchronous: a request only queues the work, and the finished analysis arrives later. The immediate reply carries a state field, not the completed analysis; the terminal states are Completed (success) and the error states (LLM_SERVICE_ERROR, LOG_FETCH_ERROR, AI_SYSTEM_ERROR, UNKNOWN_ERROR, TIMEOUT).", + "Fetching an RCA is get-or-create by default: asking for one that does not exist yet creates the record and queues a generation, so the plain fetch is NOT a safe read. There is a separate pure-read that never has side effects, and a peek option on the public route.", + "Both fetch and trigger need the caller's own BrowserStack username and Selenium access key; these are resolved server-side from the caller's user record, and a caller whose record lacks them is refused before the AI pipeline runs.", + "The feature is gated three ways: the group must have accepted the AI opt-in consent, the group's plan/AI token must carry the RCA feature, and the build's source must not be fenced off \u2014 any of these missing turns every RCA call into a refusal rather than a result.", + "There is no version history for an RCA: a fresh generation overwrites the stored analysis for that test run, and the previous one is gone.", + "The completed analysis body is opaque model output whose internal shape can change; treat it as display content rather than parsing fixed fields out of it.", + "The analysis is written back by the AI service itself through an ingestion callback; a user-facing caller normally only reads it or triggers it, and the reading side reflects whatever the service last wrote." + ] }, - "source": { - "type": "string" + "build_group": { + "entity": "build_group", + "title": "Build group (run group)", + "description": "A named grouping of builds within a project, used to collect the builds of one release or test cycle.", + "aliases": [ + "run group", + "run-group", + "build group", + "test run group", + "trg", + "release group", + "group of builds", + "build set" + ], + "id_convention": "Plain integer build-group id, discovered from the group listing. Note: the project id in these paths is the TestHub project id, not the observability project id.", + "parents": [ + "project" + ], + "relations": [ + { + "entity": "build", + "via": "builds are linked into a group (a build belongs to at most one group)" + } + ], + "capabilities": [ + "apply_build_bulk_action", + "close_build_group", + "create_build_group", + "delete_build_group", + "list_build_groups", + "update_build_group" + ], + "key_facts": [ + "Groups live in TestHub; this service is a pass-through that mirrors what TestHub returns, so writes here take effect in TestHub and are not undoable from this API.", + "A group has a name (required, up to 255 chars) and an optional description (up to 1000 chars); the name 'Ungrouped' is reserved and rejected.", + "A group's state is derived: it reads ACTIVE until it is closed, then CLOSED. Closing freezes the group's builds and test runs (they become non-editable / locked) but deletes no data.", + "Deleting a group removes the group and its build associations but never the underlying builds; the builds are detached asynchronously afterwards, so they may still show the group for a short window.", + "Bulk actions across many builds and the group delete both flip to asynchronous background work above a size threshold, returning an acknowledgement rather than a finished result \u2014 re-read to confirm the outcome.", + "Only one bulk action can run per project at a time: while one is in flight a second is refused, and the caller should retry rather than treat it as bad input.", + "The permission to add builds to a run group governs creating, editing, deleting and closing a group; the bulk-action operations are gated per action instead (linking needs that same run-group permission, while assigning a test plan or an assignee needs the automated-run edit permission)." + ] }, - "folder_path": { - "type": "string" - } - } - } - }, - "TestRunCountListResponse": { - "description": "Per-unique-test rerun counts for a build.", - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$schema": "TestRunCountEntry" - } + "accessibility": { + "entity": "accessibility", + "title": "Accessibility (a11y) data", + "description": "Accessibility scan results for a build's and a test run's tests, plus the group's a11y plan and free-trial entitlement.", + "aliases": [ + "a11y", + "accessibility testing", + "a11y testing", + "accessibility scan", + "accessibility results", + "ally", + "a11y report" + ], + "id_convention": "No id of its own: build-level a11y is addressed by the build UUID; test-run-level a11y is addressed by the TestHub build-test-case-execution-result (btcer) id, a plain integer. Plan/trial reads take no id and use the session's group/user.", + "parents": [ + "build", + "test_run" + ], + "relations": [ + { + "entity": "build", + "via": "a11y test-run listing scoped by build UUID" + }, + { + "entity": "test_run", + "via": "per-test a11y detail by btcer id" + } + ], + "key_facts": [ + "For groups on the App-Accessibility feature, an accessibility read whose build/test source is the App-Accessibility automation source is proxied to a separate Accessibility service and comes back in that service's shape, not the shape assembled here \u2014 the same route can therefore return two different bodies depending on the group flag and the source.", + "Several a11y reads are thin pass-throughs to a separate Accessibility service; on an upstream parse failure they return a plain text error body with a 500 status instead of the usual structured error envelope.", + "Severity of accessibility issues is always broken down into the four buckets critical, serious, moderate and minor.", + "Starting the accessibility free trial is a state change on the Accessibility product's entitlement even though it reads like a details fetch; checking trial status is a plain read.", + "The build must have finished (or timed out) and actually carry accessibility data, otherwise the a11y test-run listing returns a typed 4xx rather than an empty list.", + "Tests with no accessibility summary are silently dropped from the build's a11y listing, so its rows can be fewer than the build's total tests." + ], + "capabilities": [ + "get_a11y_plan_details", + "get_accessibility_free_trial_status", + "get_test_run_accessibility_meta", + "list_build_accessibility_test_runs", + "start_accessibility_free_trial" + ] } - } - } - }, - "BuildTopErrorStatsResponse": { - "description": "Top-unique-errors insight panel; may be not-ready (ready:false + message) while analysis/clustering runs.", - "schema": { - "$schema": "BuildTopErrorStatsPanel" - } - }, - "BuildQualityGateRecalcAckResponse": { - "description": "Async acknowledgement; poll the quality-gate summary for the outcome.", - "schema": { - "$schema": "BuildQualityGateRecalcAck" - } - }, - "ResolvedBuildUuidResponse": { - "description": "Resolved build uuid, or {uuid:null} with HTTP 200 when nothing matched.", - "schema": { - "$schema": "ResolvedBuildUuid" - } - }, - "SidebarBuildRunsResponse": { - "description": "A page of unique build runs for the sidebar rail with cursor paging params.", - "schema": { - "$schema": "SidebarBuildRuns" - } - }, - "BuildReportPollingResponse": { - "description": "Build-report polling envelope; IN_PROGRESS carries retryCount + pollingInterval. May be a NULL body when the build cannot be resolved. Demo-sandbox returns 404 'NOT FOUND'.", - "schema": { - "$schema": "BuildReportPolling" - } - }, - "PublicBuildDetailsResponse": { - "description": "Full public build record. Shape is feature-flagged (legacy vs V2) and pro-only fields are JsonView-gated by plan.", - "schema": { - "$schema": "PublicBuildDetails" - } - }, - "BuildHistoryWithDefectCountsResponse": { - "description": "Anchor build + up to 10 prior same-name runs, each with status and defect count summaries.", - "schema": { - "$schema": "BuildHistoryWithDefectCounts" - } - } - }, - "paging": { - "GET /api/v1/projects/{projectName}/settings/quality-gates/filters": { - "page": "afterKey", - "max": 1000 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/stability": { - "page": "pageNumber", - "max": 8 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/performance": { - "page": "pageNumber", - "max": 8 - }, - "GET /api/v1/projects/{projectNormalisedName}/testingTrends/uniqueRuns": { - "page": "pageNumber", - "max": 5 - }, - "GET /api/v1/settings/webhooks/projects": { - "page": "page", - "max": 30 - }, - "GET /api/v1/dashboards/filters/{widgetType}": { - "page": "page" - }, - "GET /api/v1/reports/list": { - "size": "size", - "max": 100 - }, - "GET /ext/v1/projects": { - "max": 10 - }, - "GET /api/v1/home/uniqueBuilds": { - "page": "pageNumber", - "max": 10 - }, - "GET /api/v1/projects/{normalisedName}/build-groups-listing": { - "page": "searchAfter" - }, - "GET /ext/v1/builds/{build_id}/testRuns": {}, - "GET /ext/v1/builds/{build_id}/testRuns/withAttachments": { - "max": 100 - }, - "GET /api/v1/builds/{buildUuid}/testRuns/flat": { - "page": "searchAfter", - "max": 10000 - }, - "GET /api/v1/ai/failures/{buildUUID}/flat": { - "page": "searchAfter", - "size": "limit" - }, - "GET /ext/v1/ai/failures/{buildUUID}/flat": { - "page": "searchAfter", - "size": "limit" - }, - "POST /api/v1/dashboards/views/widgets/filters": { - "page": "page" - }, - "POST /api/v1/reports/{id}/datasets": { - "page": "page" - }, - "GET /api/v1/reports/custom-fields": { - "page": "page", - "max": 300 - }, - "GET /api/v1/reports/{projectNormalisedName}/builds": { - "page": "page" - }, - "GET /api/v1/reports/view/{globalTestHash}/details/testsLists": { - "page": "page" - }, - "POST /api/v1/reports/view/{id}/search": { - "page": "page" - }, - "POST /api/v1/dashboards/widgets/filters": { - "page": "page" - }, - "POST /api/v1/search": { - "max": 30 - }, - "GET /api/v1/projects/{projectName}/snp/v2/errors": { - "page": "pageNumber", - "max": 15 - }, - "GET /api/v1/projects/{projectName}/snp/v2/errors/{errorId}/details/builds": { - "page": "searchAfter", - "max": 15 - }, - "GET /api/v1/projects/{projectId}/build-groups": { - "page": "page", - "size": "pageSize" - }, - "GET /api/v1/builds/v2/{buildId}/a11y-testRuns": { - "page": "page", - "size": "limit" - }, - "GET /api/v1/testRuns/{btcerId}/a11y-testMeta": { - "page": "page" - }, - "GET /api/v1/builds/{uuid}/getSplitBuildDetails": { - "page": "searchAfter" - }, - "GET /api/v1/builds/v2/{buildId}/folder": { - "size": "size" - }, - "GET /ext/v1/projects/{project_id}/builds": { - "max": 10 - }, - "GET /api/v1/projects/{normalisedName}/builds/v2": { - "page": "searchAfter" - }, - "GET /api/v1/projects/{projectName}/settings/webhooks/filters": { - "page": "afterKey", - "max": 1000 - }, - "GET /api/v1/settings/webhooks/filters": { - "page": "afterKey", - "max": 1000 - }, - "POST /api/v2/projects/lite": { - "max": 10 - }, - "GET /api/v2/projects/lite/v2": { - "page": "searchAfter", - "max": 100 - }, - "GET /api/v2/projects/v2": { - "page": "searchAfter", - "max": 100 - }, - "GET /api/v1/projects/{projectName}/snp/v2/tests": { - "page": "pageNumber", - "max": 10 - }, - "GET /api/v1/projects/{projectName}/snp/v3/tests": { - "page": "pageNumber", - "max": 10 - }, - "GET /api/v1/projects/{projectName}/snp/v3/tests/{stabilityHash}/details/builds": { - "max": 20 - }, - "GET /api/v1/testRuns/{testRunId}/testCases": { - "page": "searchAfter", - "max": 100 - }, - "GET /api/v1/builds/{buildId}/testRunLogs": { - "max": 15 - }, - "GET /api/v1/builds/v2/{buildId}/flatTestRuns": { - "page": "searchAfter" - }, - "GET /api/v1/builds/v2/{buildId}/testRuns": { - "page": "searchAfter" - }, - "GET /api/v1/builds/v2/{buildId}/testRuns/hierarchy": { - "page": "searchAfter" - }, - "POST /api/v2/builds": {} - }, - "entities": { - "notification": { - "entity": "notification", - "title": "Notification (and notifier)", - "description": "A project's alerting config: recipients (email/Slack notifiers) and their daily-summary, build-insight and alert subscriptions.", - "aliases": [ - "notification", - "notifications", - "notifier", - "notification settings", - "notification recipient", - "recipient", - "subscription", - "email notification", - "slack notification", - "daily summary", - "daily summary email", - "build insight notification", - "alert subscription", - "notification preferences" - ], - "id_convention": "A notifier id is a plain integer, discovered from the project's recipient list (there is no prefixed identifier). The per-alert and per-build-name mapping ids used when toggling a single subscription are also plain integers, found by reading one notifier's configuration first.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "scoped to one project" - }, - { - "entity": "alert", - "via": "a notifier subscribes to a project's alerts by alert id" - }, - { - "entity": "build", - "via": "build-insight subscriptions are keyed by build name" - } - ], - "capabilities": [ - "add_notification_recipients", - "delete_notification_subscriptions", - "delete_slack_notifiers", - "get_build_name_status_aggregation", - "get_daily_summary_report", - "get_my_email_notification_subscription", - "get_notifier_notification_config", - "get_project_email_notification_preferences", - "list_build_summaries_by_ids", - "list_home_notification_feed", - "list_latest_build_summaries", - "list_project_alerts_for_subscription", - "list_project_notification_recipients", - "resolve_notification_recipient_emails", - "send_slack_test_notification", - "set_user_email_notification_preferences", - "toggle_notification_subscriptions", - "toggle_notifier_channel_status", - "update_notifier_subscriptions", - "update_slack_notifier_channels" - ], - "key_facts": [ - "Every project has two independent notifier channels, email and Slack, and almost every read and write acts on ONE channel that you must name; the same project can have a completely different set of email recipients and Slack recipients.", - "Reading the recipient list or a single recipient's configuration needs only project read access; every change to recipients or subscriptions needs the project's notification-settings permission, and a change to a recipient owned by another group is refused.", - "Editing one recipient's subscriptions is a FULL REPLACE, not a patch: subscriptions you omit are removed, and there is no version history, so a prior configuration cannot be restored once overwritten.", - "Bulk enabling or disabling of subscriptions is applied one entry at a time with no overall transaction, so a single bad entry aborts partway and leaves the earlier entries already changed.", - "A Test Management caller addresses a project by its Test Management project id, which is translated to the underlying observability project id; an id with no mapping reads as project-not-found rather than empty.", - "In a recipient-list summary, a subscription count of -1 is a sentinel meaning the recipient is subscribed to all builds or all alerts, not a real count.", - "The daily-summary email content builders select builds themselves (the latest few in a time window) unless you hand them explicit build ids; the fully enriched daily report also merges several aggregations plus best-effort AI summaries that are silently dropped when they fail, so those fields can be absent.", - "Two of the notification settings operations address the project by its normalised NAME instead of its numeric id.", - "Validating typed-in recipient email addresses is not a side-effect-free lookup: for each address that resolves to an in-group user it also provisions that user's email-integration records if they do not exist yet.", - "The home notification feed is capped to roughly the first fifty entries however deep you page, and when the feature is switched off for the group it returns an empty payload rather than an error.", - "Sending a test Slack notification posts a fixed sample message to the channel and stores nothing; only the daily-summary, build-insight and alert sample types are supported and it is suppressed in the demo-sandbox environment." - ] - }, - "public_link": { - "entity": "public_link", - "title": "Public link & status badge", - "description": "Anonymous share credentials: public-link tokens for dashboards and project report pages, plus signed keys for build-status badges.", - "aliases": [ - "public link", - "share link", - "shareable link", - "anyone with the link", - "public token", - "share token", - "public sharing", - "status badge", - "build badge", - "badge", - "readme badge", - "shields badge", - "badge key" - ], - "id_convention": "A public-link token is an opaque server-generated string, discovered only by calling the get-token operation for a dashboard or project (there is no list). A status-badge key is a signed JWT minted on demand. Dashboards are addressed by UUID; projects by their TestHub project id.", - "parents": [ - "project", - "dashboard" - ], - "relations": [ - { - "entity": "dashboard", - "via": "one token per dashboard (page type CUSTOM_DASHBOARD)" - }, - { - "entity": "project", - "via": "one token per (project, page type)" - } - ], - "capabilities": [ - "get_dashboard_public_link_setting", - "get_dashboard_public_link_token", - "get_project_public_link_setting", - "get_project_public_link_token", - "get_status_badge_key", - "update_dashboard_public_link_setting", - "update_project_public_link_setting", - "validate_public_link_token" - ], - "key_facts": [ - "Two kinds of share credential live here: a public-link token that unlocks a dashboard or a project report page for anyone with the URL, and a status-badge key (a signed JWT) that exposes one build's high-level status for embedding.", - "Asking for a token is itself what starts sharing: the fetch creates the token on first request and leaves it enabled. There is no separate create step, so a plain read has a side effect the first time.", - "The public-link setting defaults to OFF. A dashboard or project whose setting has never been touched reads as not shared rather than as an error.", - "Turning sharing off immediately breaks every share URL already handed out and clears the token's cached validation, so revocation takes effect at once. Turning it back on reactivates the SAME token, so it is reversible but disruptive; there is no separate undo.", - "A project token is scoped to a single report page type (build insights, test runs, test health, unique errors or testing trends). Custom dashboards are a distinct page type reached only through the dashboard token operation.", - "A status-badge key is a bearer credential: once minted it cannot be revoked or listed, it exposes that build's status to anyone holding it, and it is refused for free-plan users.", - "Projects are named here by their TestHub project id, which is not the same as the observability project id." - ] - }, - "quality_gate": { - "entity": "quality_gate", - "title": "Quality gate (and quality profile)", - "description": "A pass/fail policy on a build, made of quality profiles: named rule sets (thresholds on status, flakiness, failure categories, etc.) scop...", - "aliases": [ - "quality gate", - "quality gates", - "QG", - "gate", - "quality profile", - "quality profiles", - "qg profile", - "quality gate profile", - "gate profile", - "quality gate rule set", - "build quality gate" - ], - "id_convention": "A quality profile is addressed by its UUID (path `profile_id`/`profileUuid`); a project by its normalised name; a build by its UUID. There are no integer ids on these routes. The one built-in default profile has a fixed UUID and behaves specially.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "project", - "via": "profiles are mapped to a project; project attributes hold the feature enabled/override switches" - }, - { - "entity": "build", - "via": "the gate is evaluated per build; results are read from the parent build for a split build's child" - } - ], - "capabilities": [ - "create_quality_profile", - "create_quality_profile_via_public_api", - "delete_quality_profile", - "delete_quality_profile_via_public_api", - "get_build_quality_gate_result", - "get_build_quality_gate_summary", - "get_build_quality_profile_result", - "get_project_quality_gate_settings", - "get_project_quality_gate_settings_via_public_api", - "get_quality_profile", - "get_quality_profile_via_public_api", - "list_build_quality_profile_results", - "list_quality_profile_filter_values", - "toggle_project_quality_gate", - "toggle_project_quality_gate_via_public_api", - "toggle_quality_profile", - "toggle_quality_profile_via_public_api", - "update_quality_profile", - "update_quality_profile_via_public_api" - ], - "key_facts": [ - "Editing a quality profile does NOT change it in place: it writes a new version and mints a NEW UUID, which the response returns. The UUID you edited stops resolving afterwards, so always carry the returned UUID forward. (The source inventory's claim that the UUID is stable is wrong.)", - "Deleting a quality profile is not reversible through the API. The profile is retired and its project mapping removed; there is no restore call, so treat it as permanent.", - "The one built-in default profile can be neither edited nor deleted (attempts are rejected). Enabling or disabling a default profile applies only to the one project, whereas enabling or disabling a custom profile applies across every project it is attached to.", - "There are two separate switches: one turns Quality Gates on or off for a whole project (and sets whether a failed gate also marks the build failed), the other enables or disables a single profile. They are different operations even though they share a name internally.", - "On the create and edit body the two fields go on the wire as `isGlobalProfile` and `applicableBuilds`; the internal names `isRootProfile` and `buildFilters` are not accepted and, if sent, are dropped and then fail the required-field check.", - "A profile must carry between 1 and 20 rules, and every rule must include a client-generated UUID `id`; a rule with an out-of-range test status, threshold, smart tag or alert level is rejected.", - "Quality gate evaluation runs asynchronously after a build finishes, so a build's gate result reads back as 'running'/pending until it completes and a CI check must poll rather than assume a single call is final.", - "A profile's ruleStatus ('pass' vs 'fail') decides how the per-profile outcome is computed: 'pass' means every rule must hold, 'fail' means none may hold.", - "Quality Gates is a plan-gated feature: when the plan lacks it, profile reads and writes are refused as feature-inaccessible, which is a plan gap and not a missing profile. The project-level on/off switch and the build-page result reads skip that plan check.", - "For a split build's child, gate results are read from the parent build, so a child on its own can look like it has no results.", - "Failure-category rules are enriched with the project's human display names only on the single-profile detail reads, not on the list or settings reads.", - "A profile can only be deleted from the group that owns it; deleting one that belongs to another group is refused." - ] - }, - "testing_trends": { - "entity": "testing_trends", - "title": "Testing Trends", - "description": "Aggregated quality-trend cards for a project - flakiness, stability, performance, failures and coverage over a time window.", - "aliases": [ - "testing trends", - "trends", - "test trends", - "quality trends", - "trend widgets", - "trend cards", - "insights dashboard", - "flakiness", - "stability", - "cbt coverage", - "test growth", - "build frequency" - ], - "id_convention": "Not addressed by its own id. Scoped by the project's normalised name in the path and narrowed by build name. Chart and leaderboard rows are addressed by build name as the identifier, or by the literal \"all\" for the project-wide series.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "buildName filter / row identifier" - } - ], - "capabilities": [ - "get_always_failing_tests_trend", - "get_build_frequency", - "get_build_performance_chart", - "get_build_stability_chart", - "get_cross_browser_coverage", - "get_failure_categories_trend", - "get_flakiness_trend", - "get_new_failures_trend", - "get_project_performance_chart", - "get_project_stability_chart", - "get_test_executions_trend", - "get_test_growth_trend", - "list_build_names", - "list_performance_leaderboard", - "list_recent_runs", - "list_stability_leaderboard" - ], - "key_facts": [ - "Every card is scoped to one project and a time window given as two epoch-millisecond bounds. When a bound is omitted it defaults to the plan's data-retention window, so a call with no window still returns the retention window's data.", - "The window is silently clamped to the plan's data retention: asking for data older than the plan retains returns retention-bounded results with no error and nothing in the body to say the window was narrowed.", - "Time-bucket granularity (hour / day / week) is chosen automatically from the width of the window, so the series interval changes as the window widens; the caller cannot set it.", - "The flakiness, new-failures and always-failing cards are plan-feature-gated: on a plan without that widget the call is refused with a 403, it does not return empty data.", - "Build frequency is the one card that requires both window bounds and does NOT clamp them to retention; its counts come from the relational build store rather than the analytics index, so they can differ slightly from the other cards.", - "Opening the recent-runs picker records the project as subscribed for the calling user - a persisted user preference, so that read has a lasting side effect.", - "Any card can be narrowed to a single build by its build name; the leaderboards and charts address a specific build by that name, or use the literal \"all\" for the project-wide series.", - "Leaderboards return a fixed number of builds per page and expose only a zero-based page number, not a page size; a hasNext flag in the response tells you whether to fetch the next page.", - "The always-failing card also records the trends view in the calling user's recently-viewed list for non-superusers, a minor convenience side effect that does not change any test data.", - "Test-growth counts distinct test cases (suite size) over the window, not test executions; test-executions counts how many tests ran. They answer different questions despite looking similar." - ] - }, - "webhook": { - "entity": "webhook", - "title": "Webhook rule", - "description": "A stored rule that fires an outbound HTTP request to a target URL when a matching build/test event occurs.", - "aliases": [ - "webhook", - "webhooks", - "webhook rule", - "webhook rules", - "hook", - "callback", - "notification", - "notifications", - "webhook notification", - "event webhook", - "outbound notification" - ], - "id_convention": "Addressed by the rule's UUID; there is no prefixed display id. Discover the UUID from a list call \u2014 it is not the integer database id.", - "parents": [ - "project", - "group" - ], - "relations": [ - { - "entity": "project", - "via": "project mapping (a group rule links up to 30 projects; a project rule is scoped to one)" - } - ], - "capabilities": [ - "create_group_webhook", - "create_project_webhook", - "delete_group_webhook", - "delete_project_webhook", - "get_group_webhook", - "get_project_webhook", - "list_group_webhook_projects", - "list_group_webhooks", - "list_project_webhooks", - "test_group_webhook", - "test_project_webhook", - "toggle_group_webhook", - "toggle_project_webhook", - "update_group_webhook", - "update_project_webhook" - ], - "key_facts": [ - "A webhook rule is addressed by its UUID, which you read from a list call; there is no prefixed display id and it is not the integer database id.", - "There are two parallel surfaces: project-scoped rules and group-scoped rules that can span many projects. A rule created on one surface is managed on that surface, and the two lists do not overlap.", - "Listing or reading webhook rules needs no special permission, but creating, editing, toggling, deleting or testing one requires the webhook settings permission \u2014 so an account can see rules it is not allowed to change.", - "Editing a rule replaces its definition in place and keeps no history: an overwritten URL, headers, auth or trigger conditions cannot be recovered. There is no undo short of re-entering the previous values.", - "Deleting a rule is permanent and also removes its project mappings; disable the rule instead if the intent is only to pause it.", - "Every create, edit, toggle, delete and test is refused in the demo-sandbox environment.", - "Testing a rule sends a real outbound HTTP request to the target URL (a server-side call whose side effects land on the target system), and it always reports success even when the target failed \u2014 read the isError flag in the body, never the HTTP status, to know if it worked.", - "A list of rules returns only each rule's summary (name, target URL, event type, enabled flag); the headers, request body and stored auth are returned only when you fetch a single rule by id.", - "A group rule links at most 30 projects. The 'all projects' scope is available only to Test Management callers; other callers are refused if they try to enter it.", - "Newly created project-scoped rules are always enabled regardless of the enabled flag sent; use the toggle operation to disable one afterwards.", - "A rule's target auth can be none, basic (username/password) or bearer token, and the request method is limited to POST or PUT." - ] - }, - "ai_insight": { - "entity": "ai_insight", - "title": "AI insight (RCA & code-fix)", - "description": "AI-generated root-cause analysis for a failed test run or build, plus the code-fix guidance and session tokens that drive it.", - "aliases": [ - "rca", - "root cause analysis", - "ai rca", - "ai insight", - "ai analysis", - "test failure analysis", - "tfa", - "ai code fix", - "code fix preference", - "failure analysis", - "ai token" - ], - "id_convention": "No single id. RCA is addressed by the test-run integer id or the build uuid; code-fix preferences by a plain integer id discovered through the get-preference call; RCA conversation turns by a UUID message id.", - "parents": [ - "test_run", - "build", - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "per-test-run RCA and improve-analysis" - }, - { - "entity": "build", - "via": "build-level RCA and RCA chat" - }, - { - "entity": "project", - "via": "code-fix preferences per build name" - } - ], - "capabilities": [ - "create_code_fix_preference", - "delete_code_fix_preference", - "get_ai_orchestration_capabilities", - "get_ai_session_token", - "get_code_fix_preference", - "get_rca_context_attachment_download_url", - "get_release_readiness_connect_state", - "get_release_readiness_prs", - "get_release_readiness_summary", - "get_release_readiness_workflows", - "presign_build_chat_upload", - "presign_code_fix_preference_upload", - "presign_rca_context_upload", - "retrigger_rca_analysis", - "retrigger_rca_analysis_via_public_api", - "submit_ai_workflow_callback", - "trigger_release_readiness", - "trigger_release_readiness_via_public_api", - "update_code_fix_preference" - ], - "key_facts": [ - "AI features are plan-gated: on a group without the AI test-failure-analysis plan the code-fix and RCA-context calls are refused with 403 PLAN_FEATURE_NOT_ALLOWED, and the session-token call returns empty token and feature list rather than erroring.", - "Some AI actions also require the calling user to have AI enabled individually, separate from the group plan; the per-user check applies to saving and presigning code-fix guidance but not to deleting it.", - "Improving (retriggering) an RCA is asynchronous: the call returns a message id and the refreshed analysis is delivered later, so poll the RCA conversation for the result rather than expecting it in the response.", - "Attaching a file is always a two-step flow: first mint an upload credential, upload the file straight to S3 with it, then quote the returned key when saving a preference or retriggering. The credential step persists nothing on its own.", - "Uploaded files land in an unconfirmed area and are kept only when a later save or turn submission references their key; an upload that is never referenced is discarded, and there are caps on how many uploads can be pending.", - "Only agentic RCAs accept extra context uploads; a non-agentic analysis rejects them.", - "RCA context uploads are refused for test runs older than an allowed age.", - "Code-fix guidance is keyed by build name within a project, one preference per build name; creating a second for the same build name is refused as already existing, so change it with an update instead.", - "Deleting code-fix guidance also removes its uploaded files and cannot be undone; the RCA analyses themselves keep no version history." - ] - }, - "build": { - "entity": "build", - "title": "Build", - "description": "A single CI/automation run reported to Test Observability, grouping the test runs executed together with their aggregate results.", - "aliases": [ - "build", - "ci build", - "ci run", - "automation build", - "test build", - "build run", - "launch", - "ci run id", - "build run id" - ], - "id_convention": "Addressed by build UUID (an opaque string) on most routes. A few reads take the internal NUMERIC build id instead (e.g. the AI failure summary), and the two are not interchangeable. A build can also be located by its human coordinates (project normalised name + build normalised name + build serial number), which resolve to the UUID. There is no prefixed display identifier. A build ALSO carries a CI run id (`buildRunId`, a string set by the CI/upload client), which is NOT the serial number (#N): users often say \"run \" meaning buildRunId. Resolve a bare run number with search_entities (entity=\"Build\", days<=90): each hit carries both `buildRunId` and `serialId` plus the project; get_build_id_from_build_coordinates only understands the serial.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "a build contains the test runs executed in it" - }, - { - "entity": "project", - "via": "each build belongs to one project" - } - ], - "capabilities": [ - "archive_project_builds", - "bulk_close_builds", - "close_build", - "delete_build_via_public_api", - "delete_project_builds", - "generate_build_report_via_public_api", - "get_build_accessibility_meta", - "get_build_ai_failure_summary", - "get_build_alerts", - "get_build_always_failing_stats", - "get_build_attempt_run_stats", - "get_build_authoring_report_url", - "get_build_coordinates_from_uuid", - "get_build_details", - "get_build_details_via_public_api", - "get_build_failure_category_stats", - "get_build_failures_by_module", - "get_build_filter_values", - "get_build_flaky_stats", - "get_build_history", - "get_build_history_via_public_api", - "get_build_id_from_build_coordinates", - "get_build_insights", - "get_build_muted_stats", - "get_build_new_failure_stats", - "get_build_nl_automation_agent_report_url", - "get_build_non_sdk_test_counts", - "get_build_notification_summary", - "get_build_optimization_data", - "get_build_rerun_stats", - "get_build_self_healing_report_url", - "get_build_self_healing_report_url_via_public_api", - "get_build_smart_test_selection_report", - "get_build_stability_tile", - "get_build_summary_stats", - "get_build_timeline_stats", - "get_build_top_error_stats", - "get_latest_build", - "get_split_build_rerun_stats", - "list_build_test_folders", - "list_project_builds", - "list_project_builds_via_public_api", - "list_running_builds_on_device", - "list_sidebar_build_runs", - "list_similar_test_runs", - "list_split_build_legs", - "mark_build_rerun_expected", - "recalculate_build_quality_gate", - "resolve_build_uuid_from_identifiers", - "stop_build", - "submit_sdk_instrumentation", - "trigger_build_rerun", - "update_build_metadata", - "update_build_metadata_via_public_api" - ], - "key_facts": [ - "Most build endpoints are keyed by the build UUID, a string. A few instead take the internal numeric build id (the AI failure summary is one), and passing a UUID where a numeric id is expected simply fails to find the build.", - "A build locked by plan fencing is refused on most insight reads with a plan-feature-not-allowed error even though the build exists; treat that as an entitlement gap, not a missing build.", - "An unknown build UUID comes back as a build-not-found error on most reads.", - "Cross-group access is blocked: a build whose owning group does not match the caller reads as not-found on most routes (and as a forbidden error on the coordinate lookups).", - "A build can be reached by its human coordinates from the dashboard URL (project name + build name + build serial number), which resolve to the UUID; the reverse lookup rebuilds those coordinates from a UUID.", - "Closing a build cannot be undone through the API \u2014 there is no reopen operation \u2014 so treat a close as final.", - "Many insight tiles and history reads accept the same standard build filter set (search text, status, folders, tags, browser/OS, priority, owner, flaky/new-failure/always-failing toggles, and so on); the filter narrows which of the build's tests the metric is computed over.", - "On demo-sandbox environments some report reads (the authoring report, the optimisation widget) return canned data instead of touching the real backend.", - "Re-run statistics come in two shapes: a flat legacy summary and a split-build-aware grouped summary; the grouped one is the one to use for split or parent/child builds." - ] - }, - "build_filter": { - "entity": "build_filter", - "title": "Build filter", - "description": "Filter-option sources for the build listing: the static option catalogue plus per-facet type-ahead value lists.", - "aliases": [ - "build filter", - "build filters", - "build list filter", - "build listing filter", - "filter options", - "filter values", - "filter bar", - "sidebar filter", - "facet", - "filter dropdown", - "branch filter", - "build name filter", - "run group filter" - ], - "id_convention": "Not an addressable record \u2014 there is no filter id. A filter view is fetched by scope: one project by its normalised name (a URL-safe slug, discovered from a project read/list), or a set of integer project ids sent in a POST filter body.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "option values are aggregated from the builds the filter scope resolves to" - }, - { - "entity": "project", - "via": "scoped by project normalised name or project id set" - } - ], - "capabilities": [ - "get_build_list_filters", - "get_build_sidebar_filters", - "get_build_test_filters", - "get_home_personalization_filters", - "list_build_branch_filter_options", - "list_build_tag_filter_options", - "list_build_team_filter_options", - "list_build_user_filter_options", - "list_group_webhook_filter_options", - "list_home_filter_options", - "list_project_webhook_filter_options", - "list_sidebar_branch_filter_options", - "list_sidebar_build_name_filter_options", - "list_sidebar_run_group_filter_options", - "list_sidebar_tag_filter_options", - "list_sidebar_team_filter_options", - "list_sidebar_test_case_status_filter_options", - "list_sidebar_user_filter_options", - "save_home_personalization_filters" - ], - "key_facts": [ - "Filters are not stored objects you address by id. You fetch either a filter catalogue or a single facet's option list, scoped to one project (by its normalised-name slug) or to a set of project ids passed in a filter body.", - "Two families exist. A per-project set is scoped by the project's normalised name and searched with a simple prefix. A cross-project 'sidebar' set takes a full build filter body and returns only the option values that actually occur under the currently applied selection across the chosen projects.", - "The per-project facet lists return option objects that carry separate id, value and label (id is what you send back as the filter selection, label is for display). The cross-project sidebar facet lists return bare values instead (plain strings for branches, build names and tags).", - "Facet option values come from an Elasticsearch terms aggregation capped at 100 entries, so a facet list is a type-ahead suggestion set, not a guaranteed-complete inventory of every value in the project.", - "A filter view echoes the caller's currently applied selection back in a resolved 'applied' block (user and team ids turned into names, date range, tags, branches, statuses) alongside a static catalogue of all selectable option lists.", - "On a freemium workspace some filters are locked behind a paid plan; the build test-filter payload names those locked keys in a separate list rather than dropping them, so a caller can grey them out instead of losing them.", - "For a split build, the test-filter payload is resolved across the parent build and all of its child legs together, not just the single leg addressed.", - "The framework filter carries raw values: branded SDK names and pass-through non-SDK values such as 'appium' or 'selenium-4'. Matching is case-insensitive, so send the value exactly as the static catalogue offers it." - ] - }, - "dashboard": { - "entity": "dashboard", - "title": "Custom dashboard", - "description": "A saved, shareable analytics page of widgets and global filters over test-observability and test-management data.", - "aliases": [ - "dashboard", - "dashboards", - "custom dashboard", - "custom dashboards", - "analytics dashboard", - "TO dashboard", - "TCM dashboard", - "report", - "reports", - "dashboard listing", - "reports listing" - ], - "id_convention": "UUID (e.g. 07c9f7be-0cd7-4669-8d3d-a1adff0b2aec); discover ids from the dashboard listing \u2014 a dashboard name is never accepted in place of the id.", - "parents": [], - "relations": [ - { - "entity": "widget", - "via": "widgets" - } - ], - "capabilities": [ - "clone_dashboard", - "clone_dashboard_via_public_api", - "create_dashboard", - "create_dashboard_via_public_api", - "delete_dashboard", - "delete_dashboard_via_public_api", - "get_dashboard", - "get_dashboard_via_public_api", - "list_dashboards", - "list_dashboards_via_public_api", - "list_reports", - "list_widget_custom_field_filters", - "resolve_dashboard_filter_names", - "search_unique_errors", - "update_dashboard", - "update_dashboard_via_public_api" - ], - "key_facts": [ - "A dashboard is addressed by its UUID id; get the id from the dashboard listing first \u2014 a dashboard name cannot be used as the identifier.", - "The dashboard listing and the internal dashboard reads require the Custom Dashboard feature entitlement; without it the listing is refused as feature-inaccessible rather than returning an empty list.", - "Test Management (TCM) dashboards are hidden from the default listing, which is scoped to Test Observability (source TO); ask for source TCM to see them (or the group must have GCD enabled).", - "Dashboards keep no version history: an overwritten name, visibility level, widget layout or filter set cannot be restored, and a deleted dashboard is gone permanently along with its widget layout.", - "A dashboard's visibility level is 1 or 2 and decides whether only the owner or the whole group can see it.", - "Scheduled reports are stored as dashboards but are excluded from the ordinary dashboard listing; they have their own reports listing, which spans both TO and TCM.", - "On the public-API write surface a treemap widget's visualization override is silently dropped (not rejected) and widget configs are validated for render-safety up front; the internal write surface does neither.", - "In demo-sandbox environments the seeded default dashboard cannot be updated or deleted \u2014 those calls are rejected with a 400 whose body shape differs from a normal success." - ] - }, - "project": { - "entity": "project", - "title": "Project", - "description": "A Test Observability container mirroring a TestHub project; groups the builds, runs and dashboards for one app or repo.", - "aliases": [ - "project", - "projects", - "TO project", - "observability project", - "testhub project", - "th project", - "app project" - ], - "id_convention": "Plain integer project id on most paths; many read/listing paths instead take the project's normalisedName (a URL-safe slug of the project name). A project also carries a separate TestHub project id (thProjectId) used for cross-product linkage. Ids and names are discovered through a project listing.", - "parents": [], - "relations": [ - { - "entity": "test_plan", - "via": "project groups its test plans" - }, - { - "entity": "build", - "via": "project contains its builds" - } - ], - "capabilities": [ - "assign_issue_types_to_test_runs", - "claim_extended_free_trial", - "create_project", - "delete_project", - "get_default_project", - "get_home_takeaways", - "get_home_takeaways_with_noise_breakdown", - "get_project_automation_coverage_via_public_api", - "get_project_by_id_or_name", - "get_project_non_sdk_build_count", - "get_project_non_sdk_summary", - "get_user_access_details", - "list_home_recent_views", - "list_home_unique_builds", - "list_project_build_names", - "list_project_builds_by_group", - "list_projects_for_sidebar", - "list_projects_full", - "list_projects_lite", - "list_projects_lite_paginated", - "list_projects_via_public_api", - "list_projects_with_build_aggregates", - "list_projects_with_build_aggregates_paginated", - "meter_feature_usage", - "rename_project", - "whitelist_group_for_observability" - ], - "key_facts": [ - "A project is addressed two different ways depending on the operation: by its integer id, or by its normalised name (a URL-safe slug of the display name). Carry whichever the capability you picked declares - they are not interchangeable.", - "Deleting a project is irreversible and cascades in TestHub: every build, run and result under it is removed with it. There is no restore.", - "A project keeps a separate TestHub project id alongside its own id. Some lookups and the delete path can resolve a TestHub/TCM id to the mirrored observability project, so passing the wrong id family can silently act on a different project.", - "In the demo-sandbox environment, creating and deleting projects is refused, and the app-bootstrap identity fields (user id and group id) come back null.", - "Projects are only ever created as a service-to-service mirror of a TestHub project, keyed on the TestHub project id and idempotent on it - there is no customer-facing 'new project' create that a user session can call.", - "The public project listing is scoped to the caller's group and does not report a total count; it is walked page by page until an empty page is returned.", - "Automation-coverage figures for a project are computed over all time as an aggregation, not stored on the project, so a read recomputes them and carries the timestamp it was calculated at." - ] - }, - "sp_test": { - "entity": "sp_test", - "title": "Suite Health test (Stability & Performance)", - "description": "A unique automated test tracked across runs in Suite Health, keyed by a stability hash, carrying stability, failure and duration metrics.", - "aliases": [ - "test", - "suite health test", - "test health", - "stability test", - "sp test", - "snp test", - "flaky test", - "test stability", - "stability and performance", - "unique test" - ], - "id_convention": "Identified by an opaque stabilityHash string, not a numeric id. The hash is discovered from the Suite Health test listing; the same hash addresses every Test Health detail read.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "aggregated over the test's executions across builds" - } - ], - "capabilities": [ - "get_suite_health_stats", - "get_suite_health_test_info", - "get_suite_health_test_platform_breakdown", - "get_suite_health_trend", - "get_test_base_info", - "get_test_environment_breakdown", - "get_test_filter_options", - "get_test_health_stats", - "get_test_health_trend", - "get_test_metrics_chart", - "list_suite_health_test_runs", - "list_suite_health_tests", - "list_test_health_tests", - "search_test_filter_branches", - "search_test_filter_build_names", - "search_test_filter_build_tags", - "search_test_filter_host_names", - "search_test_filter_test_tags" - ], - "key_facts": [ - "A test is identified by an opaque stabilityHash string, not a numeric id. You must discover the hash from the Suite Health test listing first; the same hash is what every Test Health detail read takes.", - "Every Suite Health read is scoped to a date window, and a two-element millisecond [from, to] range is mandatory: results only ever cover that window, and omitting the range is rejected.", - "The filter type-ahead suggestions are capped server-side and are suggestions, not the complete set: git branches cap at ~100, while build tags, test tags and host names cap at ~10.", - "Metric counts for the very same query can differ depending on an account feature flag: with the new build listing enabled and the query not restricted to automation-latest, failure counts are sourced from workflow status rather than the raw recorded status, so the same window can report different failure numbers.", - "Muted-test visibility is an explicit, required input on the Test Health detail reads (show all, only muted, or only unmuted) with no default value.", - "Suite Health metrics and the environment breakdown exclude archived builds.", - "The Suite Health filter sidebar degrades gracefully: run-level dropdown options are fetched from an upstream service under a timeout, and on timeout those specific dropdowns come back empty instead of failing the whole request." - ] - }, - "test_detail": { - "entity": "test_detail", - "title": "Test detail (logs, hooks, capabilities)", - "description": "The logs, hooks, capabilities and error detail attached to one test run, shown on the Test Details page.", - "aliases": [ - "test details", - "test detail", - "logs tab", - "session logs", - "network logs", - "har", - "app logs", - "application logs", - "hooks", - "hook logs", - "capabilities tab", - "behaviour tab", - "console logs", - "consolidated logs", - "test run logs" - ], - "id_convention": "Not its own id. Most tabs are addressed by the parent test run's INTEGER id (testRunId); session-scoped log tabs by the session's HASHED string id; the app-log stream by the test run's UUID. Discover these from a test-run / session read.", - "parents": [ - "test_run" - ], - "relations": [ - { - "entity": "test_run", - "via": "detail of a test run" - }, - { - "entity": "session", - "via": "session hashed id" - } - ], - "capabilities": [ - "get_session_log_urls", - "get_session_network_log_url", - "get_test_run_app_logs", - "get_test_run_app_logs_raw", - "get_test_run_auto_unmute_config", - "get_test_run_behaviour", - "get_test_run_capabilities", - "get_test_run_hooks", - "get_test_run_log_bundle", - "get_test_run_log_urls", - "get_test_run_media_logs", - "get_test_run_merged_logs", - "get_test_run_meta", - "get_test_run_network_log_url", - "get_test_run_network_logs", - "get_test_run_overview", - "get_test_run_test_code", - "list_test_run_detail_tabs", - "list_test_run_execution_history", - "list_test_run_linked_test_cases" - ], - "key_facts": [ - "The detail is derived, not stored as its own record: it has no id of its own and no history to restore. Everything here is read-only and nothing mutates the run.", - "The same detail is addressed three different ways depending on the tab: most tabs by the test run's integer id, the session-scoped log tabs by the session's hashed string id, and the application-log stream by the test run's UUID. Carry whichever id the capability you picked declares.", - "Log tabs come in two flavours. One downloads and merges the log text server-side and returns it inline; the other returns only URLs for the caller to fetch. The inline-merge form is slow for long sessions, so prefer the URL form there.", - "Application logs are filtered to abnormal (error-level) lines only, capped at 5000 lines, and bounded to the run's own start/finish window (padded five seconds each side). A clean run legitimately returns an empty list.", - "Retention-locked or plan-locked builds refuse every detail read with a forbidden / plan-feature-not-allowed error rather than returning empty; treat that as a plan gap, not a missing run.", - "Hook attribution is heuristic: hooks are matched by same build, filename, OS, browser and device, latest run only. It is not a stored parent/child link, so hooks from a differently-configured session never appear.", - "On the demo-sandbox environment several of these reads return canned or empty payloads (an empty log envelope, a demo HAR, or logs chosen by test name) instead of real data." - ] - }, - "test_run": { - "entity": "test_run", - "title": "Test run", - "description": "One execution of a single test within a build \u2014 its status, platform, code, retries and failure analysis.", - "aliases": [ - "test", - "test result", - "run", - "test execution", - "test case execution", - "session test", - "btcer", - "tr", - "spec run", - "automated test" - ], - "id_convention": "Most routes take the INTEGER test run id (Long). A few public routes are scoped by the parent BUILD's UUID instead (a string). Discover run ids from a build's test-run listing rather than guessing them; the TR-NNN prefixed identifier some surfaces show is for display, not for these numeric paths.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildId" - }, - { - "entity": "test_plan", - "via": "groups" - } - ], - "capabilities": [ - "assign_test_run_failure_categories", - "cancel_test_run_auto_unmute", - "get_test_run_baseline", - "get_test_run_bug_report_draft", - "get_test_run_build_insights", - "get_test_run_details", - "get_test_run_retry_logs", - "get_test_runs_history_details", - "link_bug_to_test_run", - "list_build_bstack_error_sessions", - "list_build_test_run_counts", - "list_build_test_run_sdk_logs", - "list_build_test_runs", - "list_build_test_runs_flat", - "list_build_test_runs_flat_filtered", - "list_build_test_runs_hierarchy", - "list_build_test_runs_hierarchy_without_public_link", - "list_build_test_runs_with_attachments", - "list_test_run_attachment_urls", - "list_test_runs_sharing_error", - "unlink_bug_from_test_run", - "update_test_run_mute_status" - ], - "key_facts": [ - "A test run is found by its own numeric id and the caller's group; when a route's path also carries a build id, some routes use it and some ignore it entirely, so a run still resolves even if the build segment is wrong.", - "The baseline, similar-error and hooks reads return a MAP keyed by test run id, not a single object, and the map is EMPTY (not an error) when nothing qualifies \u2014 an empty body means 'none found', not a failure.", - "The baseline look-back is a fixed 30 days before the run's start time; a last-known-good run older than that is invisible, so a returned empty map does not prove the test never passed.", - "Downloading a run's attachments or screenshots is gated by an account feature; when it is off the attachment read is refused even for a run that clearly has attachments.", - "Attachment and screenshot downloads come back as presigned URLs that expire roughly two hours after they are issued \u2014 fetch them promptly and re-request rather than caching the URL.", - "A test can be muted with an automatic unmute condition (after N passes, or after a date); making that mute permanent is reversible by muting again with a condition, so it is a change, not a deletion.", - "The bug-report read composes a fresh, pre-filled ticket draft from the failure; it does NOT fetch a bug that was already filed.", - "Linking a filed bug to a run is idempotent on the issue key: linking the same key twice is a silent no-op, and the link can be removed again afterwards.", - "Retries of a test are separate executions, each with its own status; a run carries its retry history rather than overwriting the previous attempt.", - "Public listings are plan- and source-aware: a freemium or source-fenced build yields a stripped or empty result rather than the analysis fields, and smart-tag filters on such a build are refused outright.", - "The status a run reports is lowercased on the public /ext detail routes but may come back uppercase from other surfaces \u2014 compare case-insensitively.", - "In the flat build listing the top-level id of each row is the test's hash (a string), while the numeric run id lives under that row's details.id." - ] - }, - "ai_failure": { - "entity": "ai_failure", - "title": "AI failure analysis (build-level)", - "description": "A build-level AI cluster of related test failures: a failure theme or a workflow grouping the runs that failed for the same reason.", - "aliases": [ - "ai failure", - "ai failure analysis", - "failure theme", - "failure cluster", - "failure workflow", - "build failure theme", - "build failure workflow", - "build rca", - "build level rca", - "ai clustered failures", - "rca theme" - ], - "id_convention": "The build is addressed by its build UUID (an opaque string) in the path. Individual clusters are identified by an integer buildFailureThemeId or buildFailureWorkflowId, which are discovered from the build AI-failures read, not guessable.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildUUID" - }, - { - "entity": "test_run", - "via": "flat test-run drill-down under a theme/workflow" - } - ], - "capabilities": [ - "get_build_ai_failures", - "get_build_ai_failures_via_public_api", - "list_build_ai_failure_cluster_test_runs_via_public_api", - "list_build_ai_failure_test_runs_flat", - "trigger_build_ai_failure_analysis", - "trigger_build_ai_failure_analysis_via_public_api" - ], - "key_facts": [ - "AI failure clustering is computed asynchronously. Requesting analysis on a build that has none returns immediately with a triggered flag and NO themes or workflows yet; the results arrive later, so re-read to collect them.", - "The read side returns only what has already been computed and never starts analysis. Starting analysis is a separate trigger operation.", - "Themes and workflows always come back sorted by their share of failures, highest first.", - "Build-level AI analysis is gated by AI opt-in consent, a per-account AI-enabled flag, and the build-level RCA feature; a trial account also has a usage quota that returns a plan-not-allowed error once exhausted, and each fresh trigger consumes one unit of it.", - "A build whose source is excluded by source-fencing is refused analysis.", - "The trial-quota figure is present only for accounts on the build-level AI trial; a paid account never sees it, so its absence does not mean zero usage.", - "Drilling from a cluster to its test runs is cursor-paginated, and the per-page size is a fixed backend default that the caller cannot change." - ] - }, - "session": { - "entity": "session", - "title": "Session", - "description": "A single automation run on BrowserStack (Automate or App Automate) recorded against a build, with its config, timings, logs and status.", - "aliases": [ - "session", - "automate session", - "app automate session", - "test session", - "run session", - "browser session", - "device session", - "live session", - "queued session" - ], - "id_convention": "Most session reads take the session's hashed id, an opaque alphanumeric string (not a numeric id). The stop control takes that hashed id in the body. One list read is the exception: it takes the NUMERIC build id even though its slot looks like a session id.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildId" - }, - { - "entity": "test_run", - "via": "a session executes the build's test runs" - } - ], - "capabilities": [ - "get_session_app_profiling", - "get_session_capabilities", - "get_session_details", - "get_session_test_code_via_public_api", - "list_build_sessions", - "list_queued_sessions", - "stop_session" - ], - "key_facts": [ - "A session is identified by its hashed id (a string), not a numeric id, except the per-build session list, which takes the build's numeric id even though the value slots where the other routes expect a session id.", - "App profiling exists only for App Automate sessions; asking for it on an Automate session is rejected, and an empty object means the report is still generating or was never captured, not an error.", - "Session detail can come back with a retryable not-ready status while the session is still being linked to its build; treat that as retry-shortly, not a failure.", - "A session that belongs to a plan-locked build is refused with a plan-not-allowed error even though the session exists.", - "A public-link viewer does not see still-running sessions in the per-build session list.", - "Stopping a session ends the live run and cannot be undone, but removes nothing: the session record and its logs remain.", - "The public test-code read reports a session that exists but captured no code identically to a missing session, so a not-found there does not prove the id is wrong.", - "On the demo sandbox environment, capabilities, session details and app profiling are served from a canned dataset rather than real data." - ] - }, - "check_config": { - "entity": "check_config", - "title": "Check configuration (SCM check-run)", - "description": "A repo's SCM check-run rule: which BrowserStack build/project a commit or PR check reports on, as quality-gate or build status.", - "aliases": [ - "check", - "checks", - "check run", - "check-run", - "check config", - "check configuration", - "pr check", - "pr checks", - "vcs check", - "github check", - "quality gate check", - "commit check", - "scm check", - "checks mapping", - "check mapping", - "pr checks mapping" - ], - "id_convention": "Check-configuration id is a plain integer (discover it from the repo's check-runs listing). A checks-mapping registration has its OWN integer ingestion id, which is neither the check-configuration id nor the GitHub check id.", - "capabilities": [ - "create_check_configurations", - "create_checks_mapping", - "delete_check_configuration", - "get_checks_mapping", - "list_check_configurations", - "toggle_check_configuration", - "update_check_configuration" - ], - "key_facts": [ - "The whole feature is gated by the CHECKS_EVALUATION entitlement: create, read, list, update, delete and toggle all refuse when the workspace lacks it, and that refusal is flattened into a generic bad-request error rather than a typed one.", - "Check configurations are scoped to the caller's group AND user together: you only see and can change checks you created, and a teammate's checks are invisible and cannot be edited, toggled or deleted.", - "A repository may hold at most 10 check configurations; a configuration is unique by the combination of its name, repository, group, build name and target project.", - "checkType is one of exactly two values: quality-gate status or build status.", - "The target project named on a check must already exist in the workspace, or the write is refused as project-not-found.", - "Deleting a check configuration removes the row for good; there is no undo and no bin.", - "A checks-mapping registration is a separate record from the check configuration it points at: it captures one pull request's commit, callback URL and evaluation state (starting at 'Not Started'), and is addressed by its own ingestion id.", - "A checks-mapping registration only succeeds for check ids that already match an existing check configuration in the caller's group for that repository and organisation; ids that match nothing are rejected." - ] - }, - "view": { - "entity": "view", - "title": "Saved view", - "description": "A named saved filter preset for a listing screen, private to its creator or shared with the whole group.", - "aliases": [ - "view", - "views", - "saved view", - "saved views", - "custom view", - "custom saved view", - "filter preset", - "view preset", - "saved filter", - "named filter", - "filter view" - ], - "id_convention": "A view id is a UUID (discover it from a views listing). The path viewType is a SCREEN name, not an id: one of builds, testRuns, testHealth, uniqueErrors or buildInsights.", - "capabilities": [ - "create_view", - "create_view_via_public_api", - "delete_view", - "list_views", - "list_views_via_public_api", - "update_view", - "update_view_via_public_api" - ], - "key_facts": [ - "A view belongs to exactly one screen type (builds, test runs, test health, unique errors or build insights); the same name can be reused across different screen types.", - "Views are either private (level 1, visible only to the creator) or shared/public (level 2, visible to the whole group).", - "A view name must be unique within its screen type: within the group for shared views, within the creator for private views. A clash is refused with a name-exists error rather than overwriting.", - "Deleting a view is a hard, unrecoverable removal, and the view disappears from every group member's view switcher immediately.", - "The creator and group of a view are always taken from the caller's session, never from the request body; a createdBy or groupId sent in the body is ignored.", - "On a shared public link only public views are returned; private views are never exposed through a shared link.", - "Editing a view is a partial update (omitted fields keep their stored values), but views keep no revision history, so an overwritten name, filter query or visibility cannot be restored.", - "The external (public-API) surface enforces stricter input than the internal one: name must be non-blank and at most 100 characters, query non-blank and at most 4000, and it has no delete operation at all." - ] - }, - "dashboard_view": { - "entity": "dashboard_view", - "title": "Dashboard view", - "description": "A saved dashboard configuration (name, global filters, widget layout) for the Test Observability or Test Management dashboard page.", - "aliases": [ - "dashboard", - "dashboard view", - "view", - "dashboard page", - "custom dashboard", - "dashboard tab", - "testing trends", - "testing-trends", - "automation trend", - "automation-trend", - "default dashboard", - "builds dashboard", - "test runs dashboard", - "test health dashboard", - "unique errors dashboard", - "build insights dashboard" - ], - "id_convention": "UUID. Custom views' UUIDs are discovered from the list capability. On READS only, three built-in views are also addressable by alias: `default` resolves to the TCM view for source=TCM and to the TO 'testing trends' view for source=TO, and `automation-trend` resolves to the automation-trend view. Writes (update/delete/clone-source) require the raw UUID; the aliases are rejected there.", - "parents": [ - "project" - ], - "relations": [], - "capabilities": [ - "check_dashboard_widget_ignored_filters", - "clone_dashboard_view", - "create_dashboard_view", - "delete_dashboard_view", - "get_dashboard_view", - "get_dashboard_view_widget_data", - "list_dashboard_view_custom_field_filters", - "list_dashboard_view_widget_types", - "list_dashboard_views", - "list_dashboard_widget_filter_values", - "update_dashboard_view" - ], - "key_facts": [ - "There are three built-in dashboards: the default TCM dashboard, the default TO dashboard (shown as 'testing trends'), and 'automation-trend'. None of the three can be renamed, edited, cloned into, or deleted; an attempt to update or delete one is refused, and the built-ins never appear as normal editable views.", - "A dashboard is addressed by its UUID everywhere. Only when reading a single dashboard may you also use the aliases `default` and `automation-trend` in place of the UUID; every write needs the raw UUID.", - "Opening a built-in default dashboard forces its date range to the last 30 days, ignoring whatever range was stored, and injects the current filter values into its widgets.", - "Reading a single dashboard records it in your 'recently viewed' dashboards list (the same list the home page shows); there is no read that avoids leaving that trace. The trace is skipped for super-users unless they are in group 2.", - "Dashboards keep no version history: an overwritten name, filter set, or widget layout cannot be restored to a previous revision, and a delete is permanent.", - "Dashboard names must be unique within the project; creating or cloning with a name already in use is rejected.", - "Every operation on this entity must name a project (by id, name, or identifier). The named project both scopes the dashboard and is the resource the create/update/delete/clone permission check runs against, even for calls that otherwise ignore the project.", - "Visibility is governed by the numeric `level` field on the dashboard; the default level is 2 (group / shared).", - "Custom (private/group) dashboards are only visible to their owner and their group; a UUID that belongs to someone else's private dashboard reads as not-found rather than forbidden.", - "A dashboard name is limited to 1-100 characters." - ] - }, - "report": { - "entity": "report", - "title": "Report (saved report)", - "description": "A saved, shareable analytics view over test runs with persisted global filters and widgets; private or group-visible.", - "aliases": [ - "report", - "saved report", - "reports", - "test report", - "test execution report", - "analytics report", - "tm report", - "test management report", - "custom report" - ], - "id_convention": "UUID (the report id). Reports mirrored from Test Management are ALSO addressable by their TM-side numeric external report id through the sync surface. Discover a report's UUID from the report listing.", - "parents": [], - "relations": [ - { - "entity": "project", - "via": "selected projects in the report's global filters" - }, - { - "entity": "test_run", - "via": "the tabular test-runs report widget" - } - ], - "capabilities": [ - "bulk_sync_tm_reports", - "clone_report", - "create_report", - "delete_report", - "delete_tm_report", - "get_report", - "get_report_dataset_rows", - "get_report_print_download_token", - "get_report_stat_tiles", - "list_report_custom_fields", - "list_report_static_builds", - "list_saved_reports", - "sync_tm_report", - "update_report", - "update_tm_report" - ], - "key_facts": [ - "Reports and dashboards are the same stored object under the hood and share one storage, name space and permission family; a report is just the report-typed view of it, so its visibility, filters and widgets behave exactly like a dashboard's.", - "A report is addressed two ways for the same record: end-user reports by a UUID, and reports mirrored from Test Management additionally by their TM-side numeric external id on the sync surface.", - "Visibility level is 1 = private (only the owner sees it) or 2 = group (everyone in the group sees it). A report you own at any level, or any group-level (level 2) report in your group, is yours to delete.", - "Report names must be unique within the same type and visibility level; a clashing name is rejected on create, on clone, and on an edit that changes the name, type or level.", - "Reports keep no version history \u2014 a rename, filter change or widget replacement overwrites the previous definition with no way to restore it.", - "A report stores its columns and filters by numeric custom-field id, so rendering or editing a saved report means resolving those ids back to display names first; the same field name can exist at both the test-case and test-execution level, distinguished only by entity type.", - "Only the tabular test-runs report type has live data behind it; asking a report's tiles or dataset for any other report type returns a 200 with a null body rather than an error.", - "Reports mirrored from Test Management are stamped with a TCM source and are meant to be written only by the Test Management service (identity taken from the request body, no permission check); the end-user builder creates reports under the TO source.", - "In the demo-sandbox environment there is a built-in default report that cannot be edited or deleted.", - "Creating or listing reports requires the custom-dashboard plan feature, and minting a report's PDF-print token requires the download-report plan feature; without the feature the call is refused even for a user who holds report permissions." - ] - }, - "report_view": { - "entity": "report_view", - "title": "Report view", - "description": "A configured analytics report (currently the tabular test-runs report) run on demand to return its table, summary tiles and per-test detail.", - "aliases": [ - "report", - "reports view", - "tabular report", - "test-runs report", - "report table", - "analytics report", - "tabular test runs report" - ], - "id_convention": "No stored id: the report is defined by the definition sent in the request body. Per-test detail is keyed by a test's globalTestHash (a string hash identifying one test case across runs), taken from a report-table row.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "report rows / per-test detail" - } - ], - "capabilities": [ - "get_report_test_stats", - "get_report_test_trend", - "get_tabular_test_runs_report_tiles", - "list_report_test_runs", - "run_tabular_test_runs_report" - ], - "key_facts": [ - "Only the tabular test-runs report is implemented. Asking for any other report type returns an empty or null body with a 200, not an error, so check the body rather than the status.", - "The report is defined entirely by the definition you send (datasets, segments, global filters); the id in the address is not read at all, so any value satisfies it.", - "Running the report has no side effects even though the request is write-shaped: the body is the query, not saved state.", - "The report table and its summary tiles are two separate calls that take the same definition; the tiles call has no paging.", - "Per-test detail (stats, run list, trend) is keyed by a test's global hash, which you read from a row of the report table.", - "A test with no matching runs in the window returns an empty object for its stats rather than zeroed counts.", - "Trend point durations are in milliseconds, and each point carries the build it came from.", - "When every dataset in the definition mutually conflicts, the table comes back empty and the tiles come back with all zero/false fencing flags rather than an error." - ] - }, - "widget": { - "entity": "widget", - "title": "Dashboard widget", - "description": "A single configured tile on a dashboard (chart, stat card or distribution) whose data is computed on demand from filters and a time window.", - "aliases": [ - "widget", - "dashboard widget", - "tile", - "chart", - "dashboard tile", - "widget filter", - "dashboard chart" - ], - "id_convention": "Addressed on these routes by a widget-type enum plus the definition in the body, not by a stored widget id. The dashboard id in the address is not read on the data and drill-down calls.", - "parents": [ - "dashboard" - ], - "relations": [ - { - "entity": "dashboard", - "via": "dashboardId (unused on data routes)" - } - ], - "capabilities": [ - "get_org_dashboard_widget_data", - "get_widget_drilldown_records", - "list_widget_filter_values", - "search_widget_filter_values" - ], - "key_facts": [ - "Widget data is computed from the definition and filters you send, not read from stored widget state; the dashboard id in the address is ignored on the org-level data and drill-down calls.", - "Filter-value lookups come in two forms: a flat list of values for one filter key, and an enhanced form that splits values into suggested and others with search and paging.", - "For ClickHouse-powered widget types, the project ids you send are automatically translated to TestHub project ids before the query runs.", - "Group 2 asking for the active-builds widget is forced to a seven-day window regardless of the range requested.", - "These reads have no side effects even though the requests are write-shaped.", - "The widget-data response shape depends on the widget type requested, so read the type you asked for before assuming a chart vs card vs distribution envelope." - ] - }, - "parallel_queue": { - "entity": "parallel_queue", - "title": "Parallel queue usage", - "description": "A view of a group's parallel-session concurrency for a product: parallels in use vs allowed and queued sessions vs the queue limit.", - "aliases": [ - "parallel queue", - "parallels", - "queue", - "concurrency", - "parallel sessions", - "queue usage", - "session queue" - ], - "id_convention": "Scoped to the authenticated caller's group; the running-builds view additionally takes a numeric user id in the address.", - "parents": [], - "relations": [], - "capabilities": [ - "get_parallel_queue_usage_summary", - "list_parallel_queue_team_usage", - "list_user_running_builds" - ], - "key_facts": [ - "Usage is always for the signed-in caller's group and the product named in the request (automate or app-automate). A product value other than automate is treated as app-automate on the running-builds view.", - "The team breakdown and running-builds views pass through to the billing backend using the caller's own BrowserStack credentials, so an unrecognised product fails downstream there.", - "The summary view only works for session/JWT callers because it reads the user from the security context; omit the product and you get back only the test-execution usage block, with no live queue numbers.", - "The running-builds view takes a user id in the address and does not check that it belongs to the caller's own group.", - "The summary view strips the internal user-details block out before returning." - ] - }, - "usage": { - "entity": "usage", - "title": "Usage & entitlement", - "description": "Test-execution consumption and plan/tier entitlement for a group or user: counts run, limits, and how much of the plan is used.", - "aliases": [ - "usage", - "consumption", - "test executions", - "billing usage", - "plan usage", - "tier usage", - "entitlement", - "device tier" - ], - "id_convention": "Group/user scoping comes from query params or the authenticated session; the device-tier and plan-details views use the caller's own group with no id argument.", - "parents": [], - "relations": [], - "capabilities": [ - "get_device_tier_usage", - "get_group_plan_usage_details", - "get_test_execution_usage_count" - ], - "key_facts": [ - "The raw execution-count view takes the user id or group id straight from the request and never checks they belong to the caller, so any authenticated caller can read another user's or group's counts.", - "That same count view returns a 200 with the plain text body 'Missing userId or groupId' when both are omitted, instead of a 400.", - "The plan/usage-details view returns every count as a compacted display string (e.g. '1.2K'), not a number, and nulls out every usage field when the group has no execution limit configured.", - "The device-tier view lists only mobile tiers; non-mobile tiers are filtered out, and a missing product is a 400.", - "Execution counts are split into all tests reported and the subset that ran on BrowserStack infrastructure (those carrying a session id)." - ] - }, - "search": { - "entity": "search", - "title": "Global search", - "description": "A global free-text search across product entities (builds, tests, projects) for the search bar and quick overlay.", - "aliases": [ - "search", - "global search", - "find", - "search bar", - "quick search", - "entity search" - ], - "id_convention": "No stored entity; each result carries its own numeric id and a type discriminator.", - "parents": [], - "relations": [], - "capabilities": [ - "search_entities" - ], - "key_facts": [ - "Search covers a fixed set of entity types and rejects any other value; the accepted values are case-sensitive (Build, BuildTestCaseExecutionResult, Project, All).", - "The query must be at least three characters and not blank, or the request is rejected.", - "Results are always sorted by most-recently-updated; the sort order cannot be changed by the caller.", - "The quick-overlay mode returns a smaller page and omits the paging and total-count fields that the full mode includes.", - "Each result is enriched with the matching record's user name, resolved from its user id." - ] - }, - "self_healing": { - "entity": "self_healing", - "title": "Self-healing (AI-healed locators)", - "description": "AI-suggested replacement selectors for tests whose locators broke, plus the review verdicts and PRs that apply them.", - "aliases": [ - "self heal", - "self-heal", - "ai healing", - "aiHealing", - "healed locator", - "healed selector", - "locator healing", - "auto heal", - "healing pr", - "heal pr", - "healing verdict" - ], - "id_convention": "A build is addressed by its build UUID (^[a-zA-Z0-9-]{1,64}$). A healing-PR job is a random UUID (8-4-4-4-12 hex) returned by create_self_healing_pr; it is NOT discoverable through any list call and lives only in Redis with a short TTL, so capture it from the create response.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "healed locators are computed per build; summary and verdicts are build-scoped" - }, - { - "entity": "test_run", - "via": "each healed selector carries the sessionId of the run it broke in" - } - ], - "capabilities": [ - "create_self_healing_pr", - "get_build_self_heal_summary", - "get_self_healing_pr_status", - "list_self_healing_verdicts", - "submit_self_healing_verdict" - ], - "key_facts": [ - "Opening a healing PR is asynchronous: the create call returns immediately with a job id and an IN_PROGRESS state, and the actual GitHub PR is opened later by a background worker. Poll the status separately; the PR URL only appears once the job reaches a terminal state.", - "A healing-PR job's terminal state can be SUCCEEDED, PARTIAL (some files changed, some skipped), FAILED, or NO_CHANGES_NEEDED. PARTIAL and NO_CHANGES_NEEDED are successful outcomes, not errors.", - "Healing-PR job state is kept only briefly and only for the group that started it; once it expires the id reads as not-found rather than as a finished state, so a job whose status was never polled in time is unrecoverable.", - "Opening a healing PR is GitHub-only and needs the build to carry version-control info (origin remote, branch) and the group to have a GitHub source-code integration connected; a GitHub Enterprise or non-standard host must pass an explicit owner/repo.", - "The human-in-the-loop verdict flow (recording whether a healed locator was accurate) is an optional feature per group. When it is off, reading verdicts returns an empty list while submitting a verdict is refused outright.", - "Only reviewers with a full product role can record a verdict; view-only members cannot.", - "A recorded verdict can carry over from an earlier build: a build shows verdicts first captured against a previous build, flagged as carryover, so the same healed element is not re-reviewed every build.", - "Self-heal summary numbers require the build to be within the account's plan quota; a build locked by quota is refused rather than returning zeroes.", - "The healing report itself (the list of individual healed selectors, with their original and healed values) is delivered as a downloadable report artefact, separate from these APIs; the summary here is only the headline counts." - ] - }, - "build_comparison": { - "entity": "build_comparison", - "title": "Build comparison", - "description": "A side-by-side diff of two builds (a base and a target) showing which tests newly failed, started passing, were added or dropped.", - "aliases": [ - "compare builds", - "build diff", - "build vs build", - "compare two builds", - "buildComparison", - "comparison", - "base vs target" - ], - "id_convention": "Not a stored entity with its own id: a comparison is identified by the pair of build UUIDs passed as the `base` and `target` query params. Comparison is always done at the top-level parent build, so a re-run/child UUID should be normalised first via resolve_build_comparison_parent_uuids.", - "parents": [ - "build" - ], - "relations": [ - { - "entity": "build", - "via": "base and target are both build UUIDs" - } - ], - "capabilities": [ - "check_build_comparison_available", - "get_build_comparison_aggregation", - "get_build_comparison_meta", - "list_build_comparison_bucket_tests", - "resolve_build_comparison_parent_uuids" - ], - "key_facts": [ - "Every comparison endpoint takes exactly two required query params, `base` and `target`, each a build UUID; an empty or missing one comes back as build-not-found.", - "Comparison is defined only between two top-level parent builds. Child builds, archived builds and plan-locked builds are rejected as unsupported, so normalise a pasted re-run UUID to its parent before comparing.", - "The two builds must come from compatible sources. An incompatible pair is not an error: the response comes back with source-fencing flags set and its actual comparison payload empty, so check for those flags before trusting empty counts.", - "Comparing is a paid feature: the aggregation, the per-bucket listing and the availability pre-check are all refused when the plan does not include build comparison. Resolving parent UUIDs and fetching the two build headers are NOT plan-gated and work regardless.", - "The side-by-side header counts are recomputed with the same de-duplication the comparison buckets use, so the header agrees with the buckets; this makes the header fetch heavier than a plain build summary and it silently falls back to raw counts if that recompute fails.", - "The whole flow is heavy: it resolves both builds' child builds and runs search + aggregation across them, with a large per-build ceiling on the test rows it will pull, so treat these as expensive reads.", - "A typical page flow is: normalise both UUIDs to parents, check the pair is comparable, fetch the two headers and the headline counts, then drill into one bucket's tests." - ] - }, - "comment": { - "entity": "comment", - "title": "Comment", - "description": "A rich-text note left on a build, test run or test, threaded and able to @mention group users, used for review discussion.", - "aliases": [ - "comments", - "note", - "discussion", - "thread", - "reply", - "feedback", - "annotation" - ], - "id_convention": "A comment id is a random UUID string generated on create and returned in the create response. A comment belongs to a thread identified by threadId (also a UUID; a new top-level comment starts its own thread). There is no list-by-id call: ids are discovered through the build/test comment reads.", - "parents": [ - "project", - "build" - ], - "relations": [ - { - "entity": "build", - "via": "buildUuid / buildSerialId" - }, - { - "entity": "test_run", - "via": "runUuid / testHash" - }, - { - "entity": "project", - "via": "scoped by project normalised name in the path" - } - ], - "capabilities": [ - "create_comment", - "delete_comment", - "list_build_comment_threads", - "list_comments", - "list_test_run_comments_via_public_api", - "update_comment" - ], - "key_facts": [ - "Comments are a paid feature: creating, editing and deleting are refused when the plan does not include comments.", - "A comment can only be edited or deleted by the person who wrote it; another user's comment reads as not-found on those operations even inside the same group.", - "Comments have no version history. Editing a comment overwrites its body in place with no way to recover the previous text.", - "Reads are bounded by the plan's data-retention window, so older comments silently fall out of results rather than erroring.", - "@mentioning a group user notifies them shortly after the comment is posted; the notification is a side effect that fires even though the create call has already returned.", - "Comment reads are keyed to a build's numeric serial id and look back over a fixed window of preceding builds (so recurring discussion on the same test carries across re-runs), not over all history.", - "The public /ext read is view-only: there is no external endpoint to post a comment, only the group-authenticated internal ones can create." - ] - }, - "sp_error": { - "entity": "sp_error", - "title": "Unique error (Suite Health)", - "description": "A cluster of test failures sharing one error signature in a project, the unit of the Suite Health Unique Errors view.", - "aliases": [ - "unique error", - "unique errors", - "error cluster", - "error signature", - "suite health error", - "sp error", - "failure cluster", - "errorClusterId", - "clustered failure" - ], - "id_convention": "Numeric cluster id (errorClusterId), a plain integer discovered from the unique-errors list (each row's `id`, returned as a numeric string). Every detail view takes it in the path as errorId / clusterId.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "test_run", - "via": "impacted tests hitting the error (breakdown / platforms)" - }, - { - "entity": "build", - "via": "builds in which the error occurred (details/builds)" - } - ], - "capabilities": [ - "get_unique_error_build_trend", - "get_unique_error_filters", - "get_unique_error_impacted_tests", - "get_unique_error_occurrence_count", - "get_unique_error_platform_breakdown", - "get_unique_error_summary", - "get_unique_error_timeline_charts", - "list_unique_error_builds", - "list_unique_errors", - "search_unique_error_filter_branches", - "search_unique_error_filter_build_names", - "search_unique_error_filter_build_tags", - "search_unique_error_filter_host_names", - "search_unique_error_filter_test_tags" - ], - "key_facts": [ - "A cluster id is a plain integer. Get it from the unique-errors list (each row's `id`, a numeric string); every per-error detail view takes that same value in its path.", - "Every Unique Errors call needs a date window of EXACTLY two epoch-millisecond bounds; omitting it or sending one bound is rejected. A single lowerBound/upperBound pair is the only fallback.", - "These views only ever consider FAILED test runs. Archived builds and usage-locked builds are always excluded, and manual (Test Management) runs are excluded from the list and per-error queries whatever you send.", - "On a demo tenant, or a real project that has no builds yet, the list answers with an empty set and a success status, not a not-found. Only a genuinely absent project is a 404.", - "Free / non-pro accounts are capped: the list returns only the single highest-occurrence error and is force-sorted by occurrence count descending, and per-error filtering is skipped.", - "Source feature-fencing can silently drop Automate, App-Automate or low-code tests from results; the list response carries automateTestsSkipped / appAutomateTestsSkipped / lcncTestsSkipped booleans that say when this happened.", - "The window is clamped to the account's data-retention range before querying, so a range wider than retention silently returns only the retained slice.", - "Every Unique Errors endpoint is read-only and accepts central-auth, obs token, service key, rails-basic, public-link and speedboat tokens; none of them accepts username + access-key auth.", - "Occurrence bar/chart data comes back as bare per-bucket counts with no timestamps on some views, so the time axis has to be reconstructed from the requested window; buckets are one calendar day." - ] - }, - "test_rca": { - "entity": "test_rca", - "title": "Test RCA (AI root-cause analysis)", - "description": "The AI root-cause analysis stored for a single failed test run, with its generation state and thinking data.", - "aliases": [ - "rca", - "test rca", - "ai rca", - "root cause analysis", - "root-cause analysis", - "failure analysis", - "ai failure analysis", - "why did this test fail" - ], - "id_convention": "No id of its own in paths: an RCA is addressed by the integer test-run id it belongs to (one RCA per test run). The stored row does carry an integer id and a bulkRcaId (UUID) linking runs analysed together in one bulk request.", - "parents": [ - "test_run" - ], - "relations": [ - { - "entity": "test_run", - "via": "one RCA per test run, keyed by test run id" - } - ], - "capabilities": [ - "get_test_rca", - "get_test_rca_data", - "get_test_rca_via_public_api", - "save_test_rca", - "trigger_bulk_test_rca", - "trigger_test_rca_via_public_api" - ], - "key_facts": [ - "Generating an RCA is asynchronous: a request only queues the work, and the finished analysis arrives later. The immediate reply carries a state field, not the completed analysis; the terminal states are Completed (success) and the error states (LLM_SERVICE_ERROR, LOG_FETCH_ERROR, AI_SYSTEM_ERROR, UNKNOWN_ERROR, TIMEOUT).", - "Fetching an RCA is get-or-create by default: asking for one that does not exist yet creates the record and queues a generation, so the plain fetch is NOT a safe read. There is a separate pure-read that never has side effects, and a peek option on the public route.", - "Both fetch and trigger need the caller's own BrowserStack username and Selenium access key; these are resolved server-side from the caller's user record, and a caller whose record lacks them is refused before the AI pipeline runs.", - "The feature is gated three ways: the group must have accepted the AI opt-in consent, the group's plan/AI token must carry the RCA feature, and the build's source must not be fenced off \u2014 any of these missing turns every RCA call into a refusal rather than a result.", - "There is no version history for an RCA: a fresh generation overwrites the stored analysis for that test run, and the previous one is gone.", - "The completed analysis body is opaque model output whose internal shape can change; treat it as display content rather than parsing fixed fields out of it.", - "The analysis is written back by the AI service itself through an ingestion callback; a user-facing caller normally only reads it or triggers it, and the reading side reflects whatever the service last wrote." - ] - }, - "build_group": { - "entity": "build_group", - "title": "Build group (run group)", - "description": "A named grouping of builds within a project, used to collect the builds of one release or test cycle.", - "aliases": [ - "run group", - "run-group", - "build group", - "test run group", - "trg", - "release group", - "group of builds", - "build set" - ], - "id_convention": "Plain integer build-group id, discovered from the group listing. Note: the project id in these paths is the TestHub project id, not the observability project id.", - "parents": [ - "project" - ], - "relations": [ - { - "entity": "build", - "via": "builds are linked into a group (a build belongs to at most one group)" - } - ], - "capabilities": [ - "apply_build_bulk_action", - "close_build_group", - "create_build_group", - "delete_build_group", - "list_build_groups", - "update_build_group" - ], - "key_facts": [ - "Groups live in TestHub; this service is a pass-through that mirrors what TestHub returns, so writes here take effect in TestHub and are not undoable from this API.", - "A group has a name (required, up to 255 chars) and an optional description (up to 1000 chars); the name 'Ungrouped' is reserved and rejected.", - "A group's state is derived: it reads ACTIVE until it is closed, then CLOSED. Closing freezes the group's builds and test runs (they become non-editable / locked) but deletes no data.", - "Deleting a group removes the group and its build associations but never the underlying builds; the builds are detached asynchronously afterwards, so they may still show the group for a short window.", - "Bulk actions across many builds and the group delete both flip to asynchronous background work above a size threshold, returning an acknowledgement rather than a finished result \u2014 re-read to confirm the outcome.", - "Only one bulk action can run per project at a time: while one is in flight a second is refused, and the caller should retry rather than treat it as bad input.", - "The permission to add builds to a run group governs creating, editing, deleting and closing a group; the bulk-action operations are gated per action instead (linking needs that same run-group permission, while assigning a test plan or an assignee needs the automated-run edit permission)." - ] - }, - "accessibility": { - "entity": "accessibility", - "title": "Accessibility (a11y) data", - "description": "Accessibility scan results for a build's and a test run's tests, plus the group's a11y plan and free-trial entitlement.", - "aliases": [ - "a11y", - "accessibility testing", - "a11y testing", - "accessibility scan", - "accessibility results", - "ally", - "a11y report" - ], - "id_convention": "No id of its own: build-level a11y is addressed by the build UUID; test-run-level a11y is addressed by the TestHub build-test-case-execution-result (btcer) id, a plain integer. Plan/trial reads take no id and use the session's group/user.", - "parents": [ - "build", - "test_run" - ], - "relations": [ - { - "entity": "build", - "via": "a11y test-run listing scoped by build UUID" - }, - { - "entity": "test_run", - "via": "per-test a11y detail by btcer id" - } - ], - "key_facts": [ - "For groups on the App-Accessibility feature, an accessibility read whose build/test source is the App-Accessibility automation source is proxied to a separate Accessibility service and comes back in that service's shape, not the shape assembled here \u2014 the same route can therefore return two different bodies depending on the group flag and the source.", - "Several a11y reads are thin pass-throughs to a separate Accessibility service; on an upstream parse failure they return a plain text error body with a 500 status instead of the usual structured error envelope.", - "Severity of accessibility issues is always broken down into the four buckets critical, serious, moderate and minor.", - "Starting the accessibility free trial is a state change on the Accessibility product's entitlement even though it reads like a details fetch; checking trial status is a plain read.", - "The build must have finished (or timed out) and actually carry accessibility data, otherwise the a11y test-run listing returns a typed 4xx rather than an empty list.", - "Tests with no accessibility summary are silently dropped from the build's a11y listing, so its rows can be fewer than the build's total tests." - ], - "capabilities": [ - "get_a11y_plan_details", - "get_accessibility_free_trial_status", - "get_test_run_accessibility_meta", - "list_build_accessibility_test_runs", - "start_accessibility_free_trial" - ] - } - }, - "version": "1.7" - } + }, + "version": "1.7" + } } diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts index 7202ad0c..caf7c4bc 100644 --- a/src/tools/capability-registry/bind.ts +++ b/src/tools/capability-registry/bind.ts @@ -257,6 +257,15 @@ function coerceType( return value; } if (expected === "file") { + // NEVER ON THE HOSTED SERVER. `local_only` is what keeps file capabilities off the + // remote surface, and that is a flag an index author has to remember; this is the net + // under it. A hosted server has no business reading its own disk on a caller's behalf, + // whatever the index says. + if (appConfig.REMOTE_MCP) + throw new InvocationError( + `'${label}' is a file on the caller's own machine, which this server cannot read. ` + + "Use the form of this capability that takes a public link, or upload by hand.", + ); // CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path // that does not exist — or a base directory nobody configured — is refused before the // user is asked to approve an upload that was never going to happen. diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts index 8185ec4e..d51c221e 100644 --- a/src/tools/capability-registry/egress.ts +++ b/src/tools/capability-registry/egress.ts @@ -40,8 +40,25 @@ export type Transport = ( headers: Record, query: Record, body?: unknown, + options?: { timeoutMs?: number }, ) => Promise; +/** + * What a cut-off read is told, as opposed to a refused connection. + * + * Both used to be "the product could not be reached", and for a request that was SENT + * that is the wrong advice: a build upload or a device allocation cut off after the last + * byte may well have taken effect, and a caller told it was unreachable retries — and + * uploads twice, or holds two devices. + */ +export function timedOutMessage(timeoutMs: number): string { + return ( + `no answer within ${Math.round(timeoutMs / 1000)}s. The request was sent, so it may ` + + "have taken effect — check (list_app_builds, get_session, or the dashboard) before " + + "retrying." + ); +} + /** What this server can put into a credential template. Nothing else is fillable. */ const PLACEHOLDERS = ["username", "access_key"] as const; @@ -233,8 +250,9 @@ function truncate(text: string): string { } /** A fetch-based transport. Redirects are NOT followed. */ -export function fetchTransport(timeoutMs = 45_000): Transport { - return async (method, url, headers, query, body) => { +export function fetchTransport(defaultTimeoutMs = 45_000): Transport { + return async (method, url, headers, query, body, options) => { + const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs; const target = new URL(url); for (const [key, value] of Object.entries(query || {})) { if (value !== undefined && value !== null) @@ -266,12 +284,16 @@ export function fetchTransport(timeoutMs = 45_000): Transport { status: response.status, body: await readBody(response), }; - } catch { - // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call. + } catch (error) { + // Upstream detail stays out of the reply; the resolver treats status 0 as a failed + // call. A cut-off is told apart from a refused connection, because the advice differs. + const timedOut = error instanceof Error && error.name === "AbortError"; return { status: 0, body: null, - error: "the product could not be reached", + error: timedOut + ? timedOutMessage(timeoutMs) + : "the product could not be reached", }; } finally { clearTimeout(timer); diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index f4e35b62..3a7dac90 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -148,6 +148,45 @@ function asReference(capability: Capability): Capability { * The object form is flattened away here, so no reader downstream sees anything but a plain * array, and which surface this is stays ours to know rather than a fact the caller acts on. */ +/** read < write < destructive; anything the gate does not recognise ranks highest. */ +function modeRank(mode: unknown): number { + return mode === "read" ? 0 : mode === "write" ? 1 : 2; +} + +/** + * Refuse, at load, a fallback chain the runtime would have to refuse on the first call. + * + * `invokeWithFallbacks` already refuses a destructive or uncallable step, but it never + * compared the step's mode with the original's — so a read could have fallen back to a + * write sibling and run it without the consent prompt a write gets. Today's only chain + * (hide_keyboard → dismiss_keyboard_key) is write → write; this keeps the next index from + * opening that door, and reports an unknown target here rather than after a request has + * already gone out. + */ +function checkFallbacks(product: string, capabilities: Capability[]): void { + const byName = new Map(capabilities.map((c) => [c.name, c])); + for (const capability of capabilities) { + for (const step of capability.fallbacks ?? []) { + const target = byName.get(step.capability); + if (!target) + throw new IndexError( + `${product}.${capability.name} falls back to '${step.capability}', which the ` + + "index does not define", + ); + if (target.mode === "destructive") + throw new IndexError( + `${product}.${capability.name} falls back to '${step.capability}', which is destructive`, + ); + if (modeRank(target.mode) > modeRank(capability.mode)) + throw new IndexError( + `${product}.${capability.name} (${capability.mode}) falls back to ` + + `'${step.capability}' (${target.mode}): a fallback may not need more consent ` + + "than the call that triggered it", + ); + } + } +} + function withSurfaceGuidance(capability: Capability): Capability { const g = capability.guidance; if (!g || Array.isArray(g)) return capability; @@ -379,13 +418,19 @@ export class CapabilityRegistry { this.byEndpoint.set(product, lookup); this.byName.set(product, names); this.withheld.set(product, withheld); - products[product] = withheld.size - ? { - ...bundle, - capabilities: enabled, - entities: stripDisabledFromEntities(bundle, withheld), - } - : bundle; + checkFallbacks(product, bundle.capabilities); + // ALWAYS THE PROCESSED PRODUCT. This used to fall back to the raw bundle when nothing + // was disabled, which quietly dropped the per-surface guidance (`withSurfaceGuidance` + // runs on the way into `enabled`) and the reference-only form from search, + // listProducts and describeEntity for any product with no `disabled` entry — exactly + // the first tm/tra export to use `local_only`. + products[product] = { + ...bundle, + capabilities: enabled, + entities: withheld.size + ? stripDisabledFromEntities(bundle, withheld) + : bundle.entities, + }; } this.index = { ...index, products }; diff --git a/src/tools/capability-registry/narrow.ts b/src/tools/capability-registry/narrow.ts index dc92c2d1..b3fb4cdc 100644 --- a/src/tools/capability-registry/narrow.ts +++ b/src/tools/capability-registry/narrow.ts @@ -133,24 +133,40 @@ function assertKnownFields( * Returns `undefined` when there is nothing to do — no selection, or a capability that * declares none — so the caller hands back the original body and the default holds. */ -export function narrowResponse( +/** + * Refuse a `select` the capability cannot honour — BEFORE anything is sent. + * + * This used to run only on the way back, inside `narrowResponse`, so a `create_session` + * carrying a bad `select` really allocated the device and then came back as an error with + * the session id dropped on the floor; the retry allocated a second one. It now runs with + * the other pre-flight checks, ahead of the write-consent prompt, and `narrowResponse` + * calls it again for the no-request path (`select` empty, or body not the declared shape). + */ +export function assertSelectable( capability: Capability, - body: unknown, selection: Selection | undefined, -): { body: unknown; narrowing: Narrowing } | undefined { - if (!selection || isSelectionEmpty(selection)) return undefined; - +): void { + if (!selection || isSelectionEmpty(selection)) return; const declared = capability.narrowable; if (!declared) throw new InvocationError( "that capability does not support `select`: it returns the product's response as " + "sent. describeCapability names the ones that do, under `narrowable`.", ); - const allowed = declared.fields; assertKnownFields(Object.keys(selection.where ?? {}), allowed, "`where`"); assertKnownFields(selection.distinct ?? [], allowed, "`distinct`"); assertKnownFields(selection.fields ?? [], allowed, "`fields`"); +} + +export function narrowResponse( + capability: Capability, + body: unknown, + selection: Selection | undefined, +): { body: unknown; narrowing: Narrowing } | undefined { + if (!selection || isSelectionEmpty(selection)) return undefined; + assertSelectable(capability, selection); + const declared = capability.narrowable!; const rows = rowsAt(body, declared.path); // A body that is not the shape the capability promised is handed back whole. Narrowing diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 904779da..349138ae 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -35,7 +35,7 @@ import { ResponseSelection, resolveResponses, } from "./index-loader.js"; -import { narrowResponse } from "./narrow.js"; +import { assertSelectable, narrowResponse } from "./narrow.js"; import { invokeWithFallbacks } from "./fallback.js"; import { invoke } from "./resolve.js"; import { @@ -159,6 +159,19 @@ export function addCapabilityRegistryToolsFromConfig( ); } +/** + * Titles for products whose index does not carry one yet. + * + * The infra indexes are authored here and declare `title`; tm, tra and loadtesting come + * out of the products-os export, which does not emit it yet, and re-formatting three + * 80k-line files to add one key was the wrong trade. These go away when the export does. + */ +const DEFAULT_TITLES: Record = { + tm: "Test Management", + tra: "Test Reporting & Analytics", + loadtesting: "Load Testing", +}; + function ok(payload: unknown): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(payload) }] }; } @@ -352,7 +365,7 @@ export function addCapabilityRegistryTools( : undefined; /** The product's title for the question put to a person; the key stays the handle. */ const label = (name: string) => - registry.index.products[name]?.title ?? name; + registry.index.products[name]?.title ?? DEFAULT_TITLES[name] ?? name; const routing = ambiguity.products.length > 1 ? { @@ -433,8 +446,11 @@ export function addCapabilityRegistryTools( ...(routing ? { routing } : {}), products: registry.productNames().map((name) => ({ name, - ...(registry.index.products[name].title - ? { title: registry.index.products[name].title } + ...((registry.index.products[name].title ?? DEFAULT_TITLES[name]) + ? { + title: + registry.index.products[name].title ?? DEFAULT_TITLES[name], + } : {}), summary: registry.index.products[name].summary, // THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a @@ -1181,10 +1197,8 @@ export function addCapabilityRegistryTools( }) .optional() .describe( - "ONLY for a capability whose contract carries `narrowable`, which names the " + - "fields this may use; on any other capability it is refused. Those endpoints " + - "take no filtering parameters of their own, so this is applied to the response " + - "here, and the result reports what it narrowed and from how many rows.", + "Only for a capability whose contract carries `narrowable`: narrows the response " + + "to those fields and reports what it narrowed.", ), }, { @@ -1261,6 +1275,11 @@ export function addCapabilityRegistryTools( ); } + // A `select` the capability cannot honour is refused HERE, with the other + // pre-flight checks: refusing it on the way back meant the request had already + // been sent — a device allocated, a build uploaded — and the answer thrown away. + assertSelectable(capability, input.select); + if (capability.mode === "destructive") { // Refused before binding, so consent is never sought for something that cannot run. return refuse( diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index 833e0031..aaeffcf1 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -150,7 +150,11 @@ export async function invoke( (bound.body === undefined && capability.send_empty_body ? {} : bound.body); - return transport(capability.method, url, headers, bound.query, payload); + return transport(capability.method, url, headers, bound.query, payload, { + timeoutMs: capability.timeout_seconds + ? capability.timeout_seconds * 1000 + : undefined, + }); }; let response = await send(); diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 7588f2fb..7dafccac 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -318,6 +318,14 @@ export interface Capability { * the capability's guidance to state. Every attempt is reported with its status. */ fallbacks?: Fallback[]; + /** + * How long to wait for this one capability's answer, in seconds. + * + * The transport's default (45 s) fits a JSON API and not a build upload or a device + * allocation, both of which routinely run longer. A capability that can legitimately + * take minutes says so here; everything else keeps the default. + */ + timeout_seconds?: number; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; diff --git a/tests/tools/capabilityRegistryDisabled.test.ts b/tests/tools/capabilityRegistryDisabled.test.ts index 80e3f746..a3d3f2e4 100644 --- a/tests/tools/capabilityRegistryDisabled.test.ts +++ b/tests/tools/capabilityRegistryDisabled.test.ts @@ -92,14 +92,19 @@ describe("a disabled capability is absent from the loaded model", () => { expect(registryWith(product()).disabledCounts()).toEqual({ tm: 1 }); }); - it("reports nothing, and shares the bundle, when no capability is disabled", () => { + it("reports nothing, and keeps every capability, when none is disabled", () => { const products = { tm: { summary: "s", capabilities: [LIVE], entities: {} }, }; const registry = registryWith(products); expect(registry.disabledCounts()).toEqual({}); - // Untouched products keep object identity — the filter allocates only where it must. - expect(registry.index.products.tm).toBe(products.tm); + // The product is always the processed one now — the raw bundle used to be shared + // when nothing was disabled, which dropped the per-surface guidance for exactly those + // products — so equality is by content, not identity. + expect(registry.index.products.tm).toEqual(products.tm); + expect(registry.index.products.tm.capabilities.map((c) => c.name)).toEqual([ + LIVE.name, + ]); }); }); diff --git a/tests/tools/capabilityRegistryFallback.test.ts b/tests/tools/capabilityRegistryFallback.test.ts index 0a1684a3..0f73fdfa 100644 --- a/tests/tools/capabilityRegistryFallback.test.ts +++ b/tests/tools/capabilityRegistryFallback.test.ts @@ -239,25 +239,24 @@ describe("a capability with fallbacks", () => { expect(out.fallback).toBeUndefined(); }); - it("refuses a step that names a destructive capability", async () => { - const { sent, call } = harness( - [400, 200], - [{ when: { status: 400 }, capability: "delete_everything" }], - ); - const out = await call({}); - expect(out.ok).toBe(false); - expect(out.error).toMatch(/delete_everything.*destructive/); - expect(sent).toHaveLength(1); + it("refuses, at load, a step that names a destructive capability", () => { + // The loader checks every chain before a request can go out; the runtime guard in + // invokeWithFallbacks stays as the backstop behind it. + expect(() => + harness( + [400, 200], + [{ when: { status: 400 }, capability: "delete_everything" }], + ), + ).toThrow(/delete_everything.*destructive/); }); - it("refuses a step that names a capability the index does not have", async () => { - const { call } = harness( - [400, 200], - [{ when: { status: 400 }, capability: "no_such_thing" }], - ); - const out = await call({}); - expect(out.ok).toBe(false); - expect(out.error).toMatch(/unknown_capability: no_such_thing/); + it("refuses, at load, a step that names a capability the index does not define", () => { + expect(() => + harness( + [400, 200], + [{ when: { status: 400 }, capability: "no_such_thing" }], + ), + ).toThrow(/no_such_thing.*does not define/); }); }); diff --git a/tests/tools/capabilityRegistryProductTitle.test.ts b/tests/tools/capabilityRegistryProductTitle.test.ts index fd45d0b8..b39edee3 100644 --- a/tests/tools/capabilityRegistryProductTitle.test.ts +++ b/tests/tools/capabilityRegistryProductTitle.test.ts @@ -79,11 +79,15 @@ describe("the routing question names products by title", () => { ]); }); - it("falls back to the key for a product that declares no title", async () => { + it("falls back to the key for a product that declares no title, unless a default exists", async () => { + // tm's index does not carry a title yet (the export does not emit one), so the + // server supplies "Test Management"; mobiledevicecontrol has no default and reads as + // its key when the index gives none. const out = await listProducts(false)("end my session"); expect(out.routing.question).toBe( - "Which product do you mean — mobiledevicecontrol or tm?", + "Which product do you mean — mobiledevicecontrol or Test Management?", ); expect(out.products[0].title).toBeUndefined(); + expect(out.products[1].title).toBe("Test Management"); }); }); diff --git a/tests/tools/capabilityRegistryReviewFixes.test.ts b/tests/tools/capabilityRegistryReviewFixes.test.ts new file mode 100644 index 00000000..c3c5f881 --- /dev/null +++ b/tests/tools/capabilityRegistryReviewFixes.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; +import { fetchTransport } from "../../src/tools/capability-registry/egress.js"; + +// The five findings from the review of PR #491, each pinned so it cannot come back. + +function product(capabilities: unknown[], extra: Record = {}) { + return { + summary: "s", + base_url: "https://p.example", + capabilities, + entities: { + thing: { + description: "A thing", + capabilities: (capabilities as { name: string }[]).map((c) => c.name), + }, + }, + ...extra, + }; +} + +function harness(index: unknown) { + const sent: unknown[] = []; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async (...args: unknown[]) => { + sent.push(args); + return { status: 200, body: { value: { sessionId: "abc" } } } as never; + }, + } as never, + ); + const call = async (tool: string, input: unknown) => + JSON.parse((await handlers[tool](input, {} as never)).content[0].text); + return { sent, call }; +} + +describe("M1: a bad `select` is refused before anything is sent", () => { + it("sends nothing for a capability that declares no narrowable", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "create_session", + method: "POST", + path: "/session", + mode: "write", + entity: "thing", + intent: "Start.", + }, + ]), + }, + }); + const out = await call("invokeCapability", { + name: "create_session", + product: "p", + user_permission: "granted", + change_summary: "start", + select: { limit: 1 }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/does not support `select`/); + expect(sent).toHaveLength(0); + }); + + it("sends nothing for a field the contract does not declare", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "list_things", + method: "GET", + path: "/things", + mode: "read", + entity: "thing", + intent: "List.", + narrowable: { fields: ["os"] }, + }, + ]), + }, + }); + const out = await call("invokeCapability", { + name: "list_things", + product: "p", + select: { where: { platform: "x" } }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/'platform'/); + expect(sent).toHaveLength(0); + }); +}); + +describe("M2: a capability may ask for longer, and a cut-off is told apart from unreachable", () => { + it("passes the capability's timeout to the transport", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "upload", + method: "POST", + path: "/upload", + mode: "write", + entity: "thing", + intent: "Upload.", + timeout_seconds: 600, + }, + { + name: "quick", + method: "GET", + path: "/quick", + mode: "read", + entity: "thing", + intent: "Quick.", + }, + ]), + }, + }); + await call("invokeCapability", { + name: "upload", + product: "p", + user_permission: "granted", + change_summary: "up", + }); + await call("invokeCapability", { name: "quick", product: "p" }); + expect((sent[0] as unknown[])[5]).toEqual({ timeoutMs: 600_000 }); + expect((sent[1] as unknown[])[5]).toEqual({ timeoutMs: undefined }); + }); + + it("says the request may have taken effect when the read was cut off", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation((_url, init) => { + return new Promise((_resolve, reject) => { + (init?.signal as AbortSignal).addEventListener("abort", () => { + const e = new Error("aborted"); + e.name = "AbortError"; + reject(e); + }); + }); + }); + try { + const transport = fetchTransport(10); + const out = await transport("POST", "https://p.example/x", {}, {}, {}); + expect(out.status).toBe(0); + expect(out.error).toMatch(/no answer within 0s/); + expect(out.error).toMatch(/may have taken effect/); + expect(out.error).not.toMatch(/could not be reached/); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("still says unreachable when the connection itself failed", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new TypeError("fetch failed")); + try { + const out = await fetchTransport()("GET", "https://p.example/x", {}, {}); + expect(out.error).toBe("the product could not be reached"); + } finally { + fetchSpy.mockRestore(); + } + }); +}); + +describe("M3: a fallback chain is checked when the index loads", () => { + const index = (fallbackTo: string, targetMode = "write", mode = "read") => ({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "a", + method: "POST", + path: "/a", + mode, + entity: "thing", + intent: "A.", + fallbacks: [{ when: { status: 400 }, capability: fallbackTo }], + }, + { + name: "b", + method: "POST", + path: "/b", + mode: targetMode, + entity: "thing", + intent: "B.", + }, + ]), + }, + }); + it("refuses a read that would fall back to a write", () => { + expect( + () => new CapabilityRegistry(index("b", "write", "read") as never), + ).toThrow(/may not need more consent/); + }); + it("refuses a fallback to a destructive capability", () => { + expect( + () => new CapabilityRegistry(index("b", "destructive", "write") as never), + ).toThrow(/destructive/); + }); + it("refuses a fallback the index does not define", () => { + expect(() => new CapabilityRegistry(index("nope") as never)).toThrow( + /does not define/, + ); + }); + it("accepts write → write, which is the shipped chain", () => { + expect( + () => new CapabilityRegistry(index("b", "write", "write") as never), + ).not.toThrow(); + }); +}); + +describe("M4: the processed product is used even when nothing is disabled", () => { + it("keeps the surface guidance for a product with no withheld capability", () => { + const registry = new CapabilityRegistry({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "only", + method: "GET", + path: "/only", + mode: "read", + entity: "thing", + intent: "Only.", + guidance: { + shared: ["both"], + local: ["on a laptop"], + remote: ["on a server"], + }, + }, + ]), + }, + } as never); + const cap = registry.index.products.p.capabilities[0]; + expect(Array.isArray(cap.guidance)).toBe(true); + expect(cap.guidance).toContain("both"); + // one surface line, never both: this process is local (REMOTE_MCP unset) + expect(cap.guidance).toContain("on a laptop"); + expect(cap.guidance).not.toContain("on a server"); + }); +}); + +describe("M5: file parameters are refused on the hosted server", () => { + afterEach(() => { + vi.doUnmock("../../src/config.js"); + vi.resetModules(); + }); + it("refuses before touching the disk, even without local_only", async () => { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ + default: { REMOTE_MCP: true, UPLOAD_BASE_DIR: "/tmp" }, + })); + const { bind } = + await import("../../src/tools/capability-registry/bind.js"); + const capability = { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + encoding: "multipart", + body: [{ name: "file", type: "file", required: true }], + }; + expect(() => + bind(capability as never, { body: { file: "/tmp/does-not-matter.apk" } }), + ).toThrow(/this server cannot read/); + }); +}); + +describe("titles: products without one in the index still read as a name", () => { + it("uses the default title for tm", async () => { + const { call } = harness({ + schema_version: 1, + build_id: "t", + products: { + tm: product([ + { + name: "list_x", + method: "GET", + path: "/x", + mode: "read", + entity: "thing", + intent: "X.", + }, + ]), + }, + }); + const out = await call("listProducts", {}); + expect(out.products[0].title).toBe("Test Management"); + }); +}); From 5a777307e2c4f312d7c712d2eebf4594a8e8685f Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 27 Oct 2026 17:18:44 +0530 Subject: [PATCH 53/53] fix(capability): a reply cut off mid-read says the request was sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "may have taken effect — check before retrying" message was chosen only when the caught error was the abort itself. When the timer fires while the body is still streaming, readBody wraps that abort in a plain Error, so the one case the message was written for — a slow create_session whose device was really allocated — still read as "could not be reached". The transport now remembers whether the status line arrived; any failure after that is reported as a reply cut off, with the same advice, whatever the error's class. The body-read test asserts the message, not just status 0. Co-Authored-By: Claude Fable 5.1 --- src/tools/capability-registry/egress.ts | 30 ++++++++++++++++--- .../tools/capabilityRegistryBodyRead.test.ts | 6 ++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts index d51c221e..e154d5e7 100644 --- a/src/tools/capability-registry/egress.ts +++ b/src/tools/capability-registry/egress.ts @@ -59,6 +59,15 @@ export function timedOutMessage(timeoutMs: number): string { ); } +/** The reply had started arriving and then failed: the request was certainly sent. */ +export function replyCutOffMessage(detail: string): string { + return ( + `the reply was cut off while being read (${detail}). The request was sent, so it ` + + "may have taken effect — check (list_app_builds, get_session, or the dashboard) " + + "before retrying." + ); +} + /** What this server can put into a credential template. Nothing else is fillable. */ const PLACEHOLDERS = ["username", "access_key"] as const; @@ -260,6 +269,10 @@ export function fetchTransport(defaultTimeoutMs = 45_000): Transport { } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + // Set once `fetch` resolves, i.e. the status line has arrived: from then on any failure + // — the abort timer firing mid-body, a dropped connection — is a reply that was cut off, + // not a product that could not be reached, and the advice differs. + let replied = false; try { const response = await fetch(target.toString(), { method, @@ -280,20 +293,29 @@ export function fetchTransport(defaultTimeoutMs = 45_000): Transport { redirect: "manual", signal: controller.signal, }); + replied = true; return { status: response.status, body: await readBody(response), }; } catch (error) { // Upstream detail stays out of the reply; the resolver treats status 0 as a failed - // call. A cut-off is told apart from a refused connection, because the advice differs. + // call. A cut-off is told apart from a refused connection, because the advice + // differs — and a failure after the reply started is a cut-off whatever its class, + // since `readBody` wraps the abort in a plain Error. const timedOut = error instanceof Error && error.name === "AbortError"; return { status: 0, body: null, - error: timedOut - ? timedOutMessage(timeoutMs) - : "the product could not be reached", + error: replied + ? replyCutOffMessage( + timedOut + ? `no answer within ${Math.round(timeoutMs / 1000)}s` + : "connection lost", + ) + : timedOut + ? timedOutMessage(timeoutMs) + : "the product could not be reached", }; } finally { clearTimeout(timer); diff --git a/tests/tools/capabilityRegistryBodyRead.test.ts b/tests/tools/capabilityRegistryBodyRead.test.ts index 03bcb080..bf8009a8 100644 --- a/tests/tools/capabilityRegistryBodyRead.test.ts +++ b/tests/tools/capabilityRegistryBodyRead.test.ts @@ -27,6 +27,12 @@ describe("a response whose body cannot be read", () => { {}, ); expect(result.status).toBe(0); + // And the advice is the one for a request that was SENT: the status line arrived, so + // the device may really have been allocated. "Could not be reached" would invite the + // retry that takes a second one. + expect(result.error).toMatch(/cut off while being read/); + expect(result.error).toMatch(/may have taken effect/); + expect(result.error).not.toMatch(/could not be reached/); expect(result.body).toBeNull(); expect(result.error).toBeTruthy(); vi.unstubAllGlobals();